From e7f0a4bbb011a336024c6d24e19b1db3f587ec02 Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Thu, 23 Apr 2026 12:09:39 +0600 Subject: [PATCH 1/7] feat: integrate DataForm component and useFormValidity hook, update webpack config for module output --- .gitignore | 3 +- package.json | 25 +- src/components/ui/index.ts | 15 + src/components/wordpress/DataForm.stories.tsx | 338 ++++++++++++++++++ src/index.ts | 15 + webpack.config.js | 9 +- 6 files changed, 388 insertions(+), 17 deletions(-) create mode 100644 src/components/wordpress/DataForm.stories.tsx diff --git a/.gitignore b/.gitignore index 22a8969..6aea623 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /node_modules /dist storybook-static -debug-storybook.log \ No newline at end of file +debug-storybook.log +.vscode \ No newline at end of file diff --git a/package.json b/package.json index bde37bb..4f606aa 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,6 @@ "version": "2.0.0", "description": "Scoped, themeable UI components for WordPress plugins - ShadCN style", "main": "dist/index.js", - "module": "dist/index.js", "types": "dist/index.d.ts", "sideEffects": [ "**/*.css" @@ -11,33 +10,33 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/index.js", - "require": "./dist/index.js" + "require": "./dist/index.js", + "default": "./dist/index.js" }, "./components/ui": { "types": "./dist/components/ui/index.d.ts", - "import": "./dist/components/ui/index.js", - "require": "./dist/components/ui/index.js" + "require": "./dist/components/ui/index.js", + "default": "./dist/components/ui/index.js" }, "./providers": { "types": "./dist/providers/index.d.ts", - "import": "./dist/providers/index.js", - "require": "./dist/providers/index.js" + "require": "./dist/providers/index.js", + "default": "./dist/providers/index.js" }, "./themes": { "types": "./dist/themes/index.d.ts", - "import": "./dist/themes/index.js", - "require": "./dist/themes/index.js" + "require": "./dist/themes/index.js", + "default": "./dist/themes/index.js" }, "./utils": { "types": "./dist/utils/index.d.ts", - "import": "./dist/utils/index.js", - "require": "./dist/utils/index.js" + "require": "./dist/utils/index.js", + "default": "./dist/utils/index.js" }, "./settings": { "types": "./dist/components/settings/index.d.ts", - "import": "./dist/components/settings/index.js", - "require": "./dist/components/settings/index.js" + "require": "./dist/components/settings/index.js", + "default": "./dist/components/settings/index.js" }, "./styles.css": "./dist/styles.css" }, diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 70cd463..2619a3f 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -289,6 +289,21 @@ export { FieldTitle, } from "./field"; export { DataViews, type DataViewAction, type DataViewField, type DataViewFilterField, type DataViewFilterProps, type DataViewLayouts, type DataViewsProps, type DataViewState } from '../wordpress/dataviews'; +export { DataForm, useFormValidity } from '@wordpress/dataviews/wp'; +export type { + DataFormProps, + Form as DataFormSchema, + FormField as DataFormField, + FormValidity as DataFormValidity, + Layout as DataFormLayout, + LayoutType as DataFormLayoutType, + LabelPosition as DataFormLabelPosition, + RegularLayout as DataFormRegularLayout, + PanelLayout as DataFormPanelLayout, + CardLayout as DataFormCardLayout, + RowLayout as DataFormRowLayout, + DetailsLayout as DataFormDetailsLayout, +} from '@wordpress/dataviews'; // Calendar component (react-day-picker + WordPress timezone/locale) export { Calendar, type CalendarProps } from "./calendar"; diff --git a/src/components/wordpress/DataForm.stories.tsx b/src/components/wordpress/DataForm.stories.tsx new file mode 100644 index 0000000..fa5b540 --- /dev/null +++ b/src/components/wordpress/DataForm.stories.tsx @@ -0,0 +1,338 @@ +import type { Meta, StoryFn } from "@storybook/react"; +import { SlotFillProvider } from "@wordpress/components"; +import { useState } from "react"; +import { + DataForm, + useFormValidity, + type DataFormSchema, + type DataViewField, +} from "./../ui"; +import { Button } from "../ui/button"; + +interface SamplePost { + title: string; + status: string; + author: number; + email: string; + date: string; + sticky: boolean; + description: string; + tags: string[]; + password?: string; +} + +const fields: DataViewField[] = [ + { + id: "title", + label: "Title", + type: "text", + }, + { + id: "status", + label: "Status", + type: "text", + Edit: "radio", + elements: [ + { value: "draft", label: "Draft" }, + { value: "published", label: "Published" }, + { value: "private", label: "Private" }, + ], + }, + { + id: "author", + label: "Author", + type: "integer", + elements: [ + { value: 1, label: "Jane" }, + { value: 2, label: "John" }, + { value: 3, label: "Alice" }, + ], + setValue: ({ value }) => ({ author: Number(value) }), + }, + { + id: "email", + label: "Email", + type: "email", + }, + { + id: "date", + label: "Date", + type: "date", + }, + { + id: "sticky", + label: "Sticky", + type: "boolean", + Edit: "toggle", + }, + { + id: "description", + label: "Description", + type: "text", + Edit: "textarea", + }, + { + id: "tags", + label: "Tags", + type: "array", + placeholder: "Enter comma-separated tags", + elements: [ + { value: "astronomy", label: "Astronomy" }, + { value: "photography", label: "Photography" }, + { value: "travel", label: "Travel" }, + ], + }, + { + id: "password", + label: "Password", + type: "text", + isVisible: (item: SamplePost) => item.status !== "private", + }, +]; + +const initialPost: SamplePost = { + title: "Hello, World!", + status: "draft", + author: 1, + email: "hello@wordpress.org", + date: "2026-01-01", + sticky: false, + description: "This is a sample description.", + tags: ["photography"], +}; + +const meta: Meta = { + title: "WordPress/DataForm", + component: DataForm, + decorators: [ + (Story) => ( + +
+ +
+
+ ), + ], + parameters: { + layout: "fullscreen", + docs: { + description: { + component: ` +A schema-driven form component re-exported from \`@wordpress/dataviews\`. Fields are described once and rendered through one of several \`Layout\` types (regular, panel, card, row, details), with validation supplied via the \`useFormValidity\` hook. + +## Basic usage + +\`\`\`tsx +import { DataForm, type DataViewField, type DataFormSchema } from "@wedevs/plugin-ui"; + +const fields: DataViewField[] = [/* ... */]; +const form: DataFormSchema = { layout: { type: "regular" }, fields: ["title", "status"] }; + + + data={item} + fields={fields} + form={form} + onChange={(edits) => setItem((prev) => ({ ...prev, ...edits }))} +/> +\`\`\` + `, + }, + }, + }, + tags: ["autodocs"], +}; + +export default meta; + +/** Standard top-to-bottom form. The default layout for most edit screens. */ +export const RegularLayout: StoryFn = () => { + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: "regular", labelPosition: "top" }, + fields: ["title", "status", "author", "email", "date", "sticky", "description", "tags", "password"], + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); +}; + +/** Side-by-side label and field — useful for compact admin pages. */ +export const RegularLayoutSideLabels: StoryFn = () => { + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: "regular", labelPosition: "side" }, + fields: ["title", "status", "author", "email", "date"], + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); +}; + +/** Each field is a clickable row that opens a dropdown — mirrors the WP block inspector. */ +export const PanelLayoutDropdown: StoryFn = () => { + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: "panel", labelPosition: "side", openAs: "dropdown" }, + fields: ["title", "status", "author", "email", "date", "tags"], + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); +}; + +/** Same panel rows, but each opens a modal with apply/cancel buttons. */ +export const PanelLayoutModal: StoryFn = () => { + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { + type: "panel", + labelPosition: "top", + openAs: { type: "modal", applyLabel: "Apply", cancelLabel: "Cancel" }, + }, + fields: ["title", "status", "author", "tags"], + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); +}; + +/** Group fields into a collapsible card with a header. */ +export const CardLayout: StoryFn = () => { + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: "regular" }, + fields: [ + { + id: "details", + label: "Post details", + layout: { type: "card", withHeader: true, isCollapsible: true, isOpened: true }, + children: ["title", "status", "author"], + }, + { + id: "meta", + label: "Metadata", + layout: { type: "card", withHeader: true, isCollapsible: true, isOpened: false }, + children: ["email", "date", "tags"], + }, + ], + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); +}; + +/** Render multiple fields on a single horizontal row. */ +export const RowLayout: StoryFn = () => { + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: "regular", labelPosition: "top" }, + fields: [ + "title", + { + id: "row", + label: "Author & date", + layout: { type: "row", alignment: "start" }, + children: ["author", "date"], + }, + "description", + ], + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); +}; + +/** Validate fields with the `useFormValidity` hook. The save button stays disabled until the form is valid. */ +export const WithValidation: StoryFn = () => { + const [post, setPost] = useState({ ...initialPost, title: "", email: "" }); + + const validatedFields: DataViewField[] = fields.map((field) => { + if (field.id === "title") { + return { ...field, isValid: { required: { value: true, message: "Title is required" } } }; + } + if (field.id === "email") { + return { + ...field, + isValid: { + required: { value: true, message: "Email is required" }, + custom: (value: unknown) => + typeof value === "string" && /.+@.+\..+/.test(value) + ? undefined + : { message: "Enter a valid email address" }, + }, + }; + } + return field; + }); + + const form: DataFormSchema = { + layout: { type: "regular", labelPosition: "top" }, + fields: ["title", "email", "status"], + }; + + const { validity, isValid } = useFormValidity(post, validatedFields, form); + + return ( +
+ + data={post} + fields={validatedFields} + form={form} + validity={validity} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> +
+ + {isValid ? "Form is valid" : "Fix the highlighted fields"} + + +
+
+ ); +}; diff --git a/src/index.ts b/src/index.ts index 4306d4b..582b5b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -72,6 +72,9 @@ export { CurrencyInput, DesignSystemSection, // DataViews DataViews, + // DataForm + DataForm, + useFormValidity, // Dialog Dialog, DialogClose, @@ -292,6 +295,18 @@ export { type DataViewLayouts, type DataViewsProps, type DataViewState, + type DataFormProps, + type DataFormSchema, + type DataFormField, + type DataFormValidity, + type DataFormLayout, + type DataFormLayoutType, + type DataFormLabelPosition, + type DataFormRegularLayout, + type DataFormPanelLayout, + type DataFormCardLayout, + type DataFormRowLayout, + type DataFormDetailsLayout, type LabeledCheckboxProps, type CheckboxCardProps, type RadioGroupItemProps, diff --git a/webpack.config.js b/webpack.config.js index ae69f63..c3c7687 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,10 @@ const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); module.exports = { ...defaultConfig, + experiments: { + ...( defaultConfig.experiments || {} ), + outputModule: true, + }, entry: { index: './src/index.ts', }, @@ -12,8 +16,9 @@ module.exports = { path: path.resolve( __dirname, 'dist' ), filename: '[name].js', library: { - type: 'commonjs2', + type: 'module', }, + module: true, clean: true, }, externals: { @@ -26,8 +31,6 @@ module.exports = { '@wordpress/date': '@wordpress/date', '@wordpress/hooks': '@wordpress/hooks', '@wordpress/i18n': '@wordpress/i18n', - '@wordpress/dataviews': '@wordpress/dataviews', - '@wordpress/dataviews/wp': '@wordpress/dataviews/wp', quill: 'quill', }, module: { From 85952493eab522ff9c3fb855ab34ffd284afccb8 Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Thu, 23 Apr 2026 12:25:27 +0600 Subject: [PATCH 2/7] fix: update webpack configuration to change library type from module to commonjs2 --- package.json | 27 ++++++++++++++------------- webpack.config.js | 7 +------ 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 4f606aa..5357109 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "2.0.0", "description": "Scoped, themeable UI components for WordPress plugins - ShadCN style", "main": "dist/index.js", + "module": "dist/index.js", "types": "dist/index.d.ts", "sideEffects": [ "**/*.css" @@ -10,33 +11,33 @@ "exports": { ".": { "types": "./dist/index.d.ts", - "require": "./dist/index.js", - "default": "./dist/index.js" + "import": "./dist/index.js", + "require": "./dist/index.js" }, "./components/ui": { "types": "./dist/components/ui/index.d.ts", - "require": "./dist/components/ui/index.js", - "default": "./dist/components/ui/index.js" + "import": "./dist/components/ui/index.js", + "require": "./dist/components/ui/index.js" }, "./providers": { "types": "./dist/providers/index.d.ts", - "require": "./dist/providers/index.js", - "default": "./dist/providers/index.js" + "import": "./dist/providers/index.js", + "require": "./dist/providers/index.js" }, "./themes": { "types": "./dist/themes/index.d.ts", - "require": "./dist/themes/index.js", - "default": "./dist/themes/index.js" + "import": "./dist/themes/index.js", + "require": "./dist/themes/index.js" }, "./utils": { "types": "./dist/utils/index.d.ts", - "require": "./dist/utils/index.js", - "default": "./dist/utils/index.js" + "import": "./dist/utils/index.js", + "require": "./dist/utils/index.js" }, "./settings": { "types": "./dist/components/settings/index.d.ts", - "require": "./dist/components/settings/index.js", - "default": "./dist/components/settings/index.js" + "import": "./dist/components/settings/index.js", + "require": "./dist/components/settings/index.js" }, "./styles.css": "./dist/styles.css" }, @@ -120,4 +121,4 @@ "type": "git", "url": "https://github.com/getdokan/plugin-ui.git" } -} +} \ No newline at end of file diff --git a/webpack.config.js b/webpack.config.js index c3c7687..2c04523 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,10 +4,6 @@ const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); module.exports = { ...defaultConfig, - experiments: { - ...( defaultConfig.experiments || {} ), - outputModule: true, - }, entry: { index: './src/index.ts', }, @@ -16,9 +12,8 @@ module.exports = { path: path.resolve( __dirname, 'dist' ), filename: '[name].js', library: { - type: 'module', + type: 'commonjs2', }, - module: true, clean: true, }, externals: { From fa5fbd775c85d935f8e157dcbe70d830b77e75a1 Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Thu, 23 Apr 2026 14:58:52 +0600 Subject: [PATCH 3/7] feat: add support for @wordpress/dataviews and @wordpress/dataviews/wp in webpack configuration --- webpack.config.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/webpack.config.js b/webpack.config.js index 2c04523..ae69f63 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,6 +26,8 @@ module.exports = { '@wordpress/date': '@wordpress/date', '@wordpress/hooks': '@wordpress/hooks', '@wordpress/i18n': '@wordpress/i18n', + '@wordpress/dataviews': '@wordpress/dataviews', + '@wordpress/dataviews/wp': '@wordpress/dataviews/wp', quill: 'quill', }, module: { From 5b6fd2f540fb34ff280bf05e5e03b51dce9227df Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Thu, 23 Apr 2026 15:42:38 +0600 Subject: [PATCH 4/7] Remove dataviews externals from webpack --- webpack.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/webpack.config.js b/webpack.config.js index ae69f63..2c04523 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,8 +26,6 @@ module.exports = { '@wordpress/date': '@wordpress/date', '@wordpress/hooks': '@wordpress/hooks', '@wordpress/i18n': '@wordpress/i18n', - '@wordpress/dataviews': '@wordpress/dataviews', - '@wordpress/dataviews/wp': '@wordpress/dataviews/wp', quill: 'quill', }, module: { From 7e26c518cd42263404f3c12e81836ec8384f5989 Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Thu, 23 Apr 2026 16:44:46 +0600 Subject: [PATCH 5/7] feat: update documentation to include DataForm as a WordPress-integrated component --- CLAUDE.md | 2 +- src/components/ui/index.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6219018..90cbbff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,7 @@ Supports WordPress filter hooks: `{snakeNamespace}_dataviews_{elementName}` - **Composition pattern**: All components use compound component pattern (e.g., `Card` + `CardHeader` + `CardContent`) - **`cn()` utility**: Use for merging Tailwind classes — combines `clsx` + `tailwind-merge` - **`Field` wrapper**: Use to wrap form controls with consistent label, description, and error display -- **No WordPress dependency in UI components**: Only `Layout`, `DataViews`, and `Settings` (via `applyFilters`) touch WordPress APIs +- **No WordPress dependency in UI components**: Only `Layout`, `DataViews`, `DataForm`, and `Settings` (via `applyFilters`) touch WordPress APIs - **Externals**: React, ReactDOM, and WordPress packages (`@wordpress/components`, `@wordpress/dataviews`, `@wordpress/hooks`, `@wordpress/i18n`, `@wordpress/date`) are externalized — consumers must provide them ## Before Committing & Pushing diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 2619a3f..ba1872e 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,5 +1,7 @@ -// Core UI Components following ShadCN pattern -// All components are pure React - no WordPress dependencies +// Core UI Components following ShadCN pattern. +// Most components are pure React; the sanctioned WordPress-integrated +// exceptions (Layout, DataViews, DataForm, LayoutMenu, AdminNotice) are +// re-exported from here to keep a single UI barrel for consumers. export { Alert, AlertDescription, AlertTitle, AlertAction } from "./alert"; export { default as AdminNotice } from "../wordpress/AdminNotice"; From dfea1629a4c31ff5eab1793d334c331eb49d48de Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Fri, 24 Apr 2026 00:07:23 +0600 Subject: [PATCH 6/7] feat: update DataForm styles to use new CSS variable naming convention --- src/components/wordpress/style.css | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/components/wordpress/style.css b/src/components/wordpress/style.css index d228592..62cc24f 100644 --- a/src/components/wordpress/style.css +++ b/src/components/wordpress/style.css @@ -2,8 +2,8 @@ @import '@wordpress/dataviews/build-style/style.css'; @import '@wordpress/theme/design-tokens.css'; -:root { - --dokan-dataviews-radius: 6px; +.pui-root-dataviews { + --pui-dataviews-radius: 6px; } .pui-root-dataviews, @@ -11,25 +11,24 @@ background-color: var(--background, #ffffff); } .pui-root-dataviews:not(.custom-layout) { - border-radius: var(--dokan-dataviews-radius); + border-radius: var(--pui-dataviews-radius, 6px); border: 1px solid var(--border, #e7e7e7); box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.1019607843); color: var(--foreground, #1d1d1d); } .pui-root-dataviews .dataviews-no-results { - border-top: 1px solid var(--border, #e7e7e7); background-color: var(--background, #ffffff); } .pui-root-dataviews:not(.custom-layout) .dataviews-wrapper { - border-radius: var(--dokan-dataviews-radius); + border-radius: var(--pui-dataviews-radius, 6px); } .pui-root-dataviews:not(.custom-layout) .dataviews-layout__container { - border-top-left-radius: var(--dokan-dataviews-radius); - border-top-right-radius: var(--dokan-dataviews-radius); + border-top-left-radius: var(--pui-dataviews-radius, 6px); + border-top-right-radius: var(--pui-dataviews-radius, 6px); } .pui-root-dataviews:not(.dataviews-pagination) .dataviews-layout__container { - border-bottom-left-radius: var(--dokan-dataviews-radius); - border-bottom-right-radius: var(--dokan-dataviews-radius); + border-bottom-left-radius: var(--pui-dataviews-radius, 6px); + border-bottom-right-radius: var(--pui-dataviews-radius, 6px); } .pui-root-dataviews .dataviews-wrapper .dataviews-view-table { border-left: none; @@ -82,8 +81,8 @@ justify-content: space-between !important; padding: 16px; background-color: var(--background, #ffffff); - border-bottom-left-radius: var(--dokan-dataviews-radius); - border-bottom-right-radius: var(--dokan-dataviews-radius); + border-bottom-left-radius: var(--pui-dataviews-radius, 6px); + border-bottom-right-radius: var(--pui-dataviews-radius, 6px); } .pui-root-dataviews .dataviews-wrapper .dataviews-view-table tr td:first-child, .pui-root-dataviews .dataviews-wrapper .dataviews-view-table tr th:first-child { @@ -119,7 +118,7 @@ .dataviews-bulk-actions-footer__action-buttons .components-button.is-compact:not(:last-child) { border: 1px solid var(--border, #e7e7e7); - border-radius: var(--dokan-dataviews-radius); + border-radius: var(--pui-dataviews-radius, 6px); line-height: 1; } /* DataViews list view theming */ From 22014253f0c7869cb218237a3a52c28d10e2698d Mon Sep 17 00:00:00 2001 From: Kamruzzaman Date: Fri, 24 Apr 2026 00:23:48 +0600 Subject: [PATCH 7/7] feat: refactor DataForm stories for improved readability and structure --- src/components/wordpress/DataForm.stories.tsx | 1092 ++++++++++++----- 1 file changed, 815 insertions(+), 277 deletions(-) diff --git a/src/components/wordpress/DataForm.stories.tsx b/src/components/wordpress/DataForm.stories.tsx index fa5b540..534fdce 100644 --- a/src/components/wordpress/DataForm.stories.tsx +++ b/src/components/wordpress/DataForm.stories.tsx @@ -1,123 +1,118 @@ -import type { Meta, StoryFn } from "@storybook/react"; -import { SlotFillProvider } from "@wordpress/components"; -import { useState } from "react"; -import { - DataForm, - useFormValidity, - type DataFormSchema, - type DataViewField, -} from "./../ui"; -import { Button } from "../ui/button"; +import type { Meta, StoryFn } from '@storybook/react'; +import { SlotFillProvider } from '@wordpress/components'; +import { useState } from 'react'; +import { DataForm, useFormValidity, type DataFormSchema, type DataViewField } from './../ui'; +import { Button } from '../ui/button'; interface SamplePost { - title: string; - status: string; - author: number; - email: string; - date: string; - sticky: boolean; - description: string; - tags: string[]; - password?: string; + title: string; + status: string; + author: number; + email: string; + date: string; + sticky: boolean; + description: string; + tags: string[]; + password?: string; } const fields: DataViewField[] = [ - { - id: "title", - label: "Title", - type: "text", - }, - { - id: "status", - label: "Status", - type: "text", - Edit: "radio", - elements: [ - { value: "draft", label: "Draft" }, - { value: "published", label: "Published" }, - { value: "private", label: "Private" }, - ], - }, - { - id: "author", - label: "Author", - type: "integer", - elements: [ - { value: 1, label: "Jane" }, - { value: 2, label: "John" }, - { value: 3, label: "Alice" }, - ], - setValue: ({ value }) => ({ author: Number(value) }), - }, - { - id: "email", - label: "Email", - type: "email", - }, - { - id: "date", - label: "Date", - type: "date", - }, - { - id: "sticky", - label: "Sticky", - type: "boolean", - Edit: "toggle", - }, - { - id: "description", - label: "Description", - type: "text", - Edit: "textarea", - }, - { - id: "tags", - label: "Tags", - type: "array", - placeholder: "Enter comma-separated tags", - elements: [ - { value: "astronomy", label: "Astronomy" }, - { value: "photography", label: "Photography" }, - { value: "travel", label: "Travel" }, - ], - }, - { - id: "password", - label: "Password", - type: "text", - isVisible: (item: SamplePost) => item.status !== "private", - }, + { + id: 'title', + label: 'Title', + type: 'text' + }, + { + id: 'status', + label: 'Status', + type: 'text', + Edit: 'radio', + elements: [ + { value: 'draft', label: 'Draft' }, + { value: 'published', label: 'Published' }, + { value: 'private', label: 'Private' } + ] + }, + { + id: 'author', + label: 'Author', + type: 'integer', + elements: [ + { value: 1, label: 'Jane' }, + { value: 2, label: 'John' }, + { value: 3, label: 'Alice' } + ], + setValue: ({ value }) => ({ author: Number(value) }) + }, + { + id: 'email', + label: 'Email', + type: 'email' + }, + { + id: 'date', + label: 'Date', + type: 'date' + }, + { + id: 'sticky', + label: 'Sticky', + type: 'boolean', + Edit: 'toggle' + }, + { + id: 'description', + label: 'Description', + type: 'text', + Edit: 'textarea' + }, + { + id: 'tags', + label: 'Tags', + type: 'array', + placeholder: 'Enter comma-separated tags', + elements: [ + { value: 'astronomy', label: 'Astronomy' }, + { value: 'photography', label: 'Photography' }, + { value: 'travel', label: 'Travel' } + ] + }, + { + id: 'password', + label: 'Password', + type: 'text', + isVisible: (item: SamplePost) => item.status !== 'private' + } ]; const initialPost: SamplePost = { - title: "Hello, World!", - status: "draft", - author: 1, - email: "hello@wordpress.org", - date: "2026-01-01", - sticky: false, - description: "This is a sample description.", - tags: ["photography"], + title: 'Hello, World!', + status: 'draft', + author: 1, + email: 'hello@wordpress.org', + date: '2026-01-01', + sticky: false, + description: 'This is a sample description.', + tags: ['photography'] }; const meta: Meta = { - title: "WordPress/DataForm", - component: DataForm, - decorators: [ - (Story) => ( - -
- -
-
- ), - ], - parameters: { - layout: "fullscreen", - docs: { - description: { - component: ` + title: 'WordPress/DataForm', + component: DataForm, + decorators: [ + (Story) => ( + +
+ +
+
+ ) + ], + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: ` A schema-driven form component re-exported from \`@wordpress/dataviews\`. Fields are described once and rendered through one of several \`Layout\` types (regular, panel, card, row, details), with validation supplied via the \`useFormValidity\` hook. ## Basic usage @@ -135,204 +130,747 @@ const form: DataFormSchema = { layout: { type: "regular" }, fields: ["title", "s onChange={(edits) => setItem((prev) => ({ ...prev, ...edits }))} /> \`\`\` - `, - }, + ` + } + } }, - }, - tags: ["autodocs"], + tags: ['autodocs'] }; export default meta; /** Standard top-to-bottom form. The default layout for most edit screens. */ export const RegularLayout: StoryFn = () => { - const [post, setPost] = useState(initialPost); - - const form: DataFormSchema = { - layout: { type: "regular", labelPosition: "top" }, - fields: ["title", "status", "author", "email", "date", "sticky", "description", "tags", "password"], - }; - - return ( - - data={post} - fields={fields} - form={form} - onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} - /> - ); + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: 'regular', labelPosition: 'top' }, + fields: [ + 'title', + 'status', + 'author', + 'email', + 'date', + 'sticky', + 'description', + 'tags', + 'password' + ] + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); }; /** Side-by-side label and field — useful for compact admin pages. */ export const RegularLayoutSideLabels: StoryFn = () => { - const [post, setPost] = useState(initialPost); - - const form: DataFormSchema = { - layout: { type: "regular", labelPosition: "side" }, - fields: ["title", "status", "author", "email", "date"], - }; - - return ( - - data={post} - fields={fields} - form={form} - onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} - /> - ); + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: 'regular', labelPosition: 'side' }, + fields: ['title', 'status', 'author', 'email', 'date'] + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); }; /** Each field is a clickable row that opens a dropdown — mirrors the WP block inspector. */ export const PanelLayoutDropdown: StoryFn = () => { - const [post, setPost] = useState(initialPost); - - const form: DataFormSchema = { - layout: { type: "panel", labelPosition: "side", openAs: "dropdown" }, - fields: ["title", "status", "author", "email", "date", "tags"], - }; - - return ( - - data={post} - fields={fields} - form={form} - onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} - /> - ); + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: 'panel', labelPosition: 'side', openAs: 'dropdown' }, + fields: ['title', 'status', 'author', 'email', 'date', 'tags'] + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); }; /** Same panel rows, but each opens a modal with apply/cancel buttons. */ export const PanelLayoutModal: StoryFn = () => { - const [post, setPost] = useState(initialPost); + const [post, setPost] = useState(initialPost); - const form: DataFormSchema = { - layout: { - type: "panel", - labelPosition: "top", - openAs: { type: "modal", applyLabel: "Apply", cancelLabel: "Cancel" }, - }, - fields: ["title", "status", "author", "tags"], - }; - - return ( - - data={post} - fields={fields} - form={form} - onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} - /> - ); + const form: DataFormSchema = { + layout: { + type: 'panel', + labelPosition: 'top', + openAs: { type: 'modal', applyLabel: 'Apply', cancelLabel: 'Cancel' } + }, + fields: ['title', 'status', 'author', 'tags'] + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); }; /** Group fields into a collapsible card with a header. */ export const CardLayout: StoryFn = () => { - const [post, setPost] = useState(initialPost); - - const form: DataFormSchema = { - layout: { type: "regular" }, - fields: [ - { - id: "details", - label: "Post details", - layout: { type: "card", withHeader: true, isCollapsible: true, isOpened: true }, - children: ["title", "status", "author"], - }, - { - id: "meta", - label: "Metadata", - layout: { type: "card", withHeader: true, isCollapsible: true, isOpened: false }, - children: ["email", "date", "tags"], - }, - ], - }; - - return ( - - data={post} - fields={fields} - form={form} - onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} - /> - ); + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: 'regular' }, + fields: [ + { + id: 'details', + label: 'Post details', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: true }, + children: ['title', 'status', 'author'] + }, + { + id: 'meta', + label: 'Metadata', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: false }, + children: ['email', 'date', 'tags'] + } + ] + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); }; /** Render multiple fields on a single horizontal row. */ export const RowLayout: StoryFn = () => { - const [post, setPost] = useState(initialPost); - - const form: DataFormSchema = { - layout: { type: "regular", labelPosition: "top" }, - fields: [ - "title", - { - id: "row", - label: "Author & date", - layout: { type: "row", alignment: "start" }, - children: ["author", "date"], - }, - "description", - ], - }; - - return ( - - data={post} - fields={fields} - form={form} - onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} - /> - ); + const [post, setPost] = useState(initialPost); + + const form: DataFormSchema = { + layout: { type: 'regular', labelPosition: 'top' }, + fields: [ + 'title', + { + id: 'row', + label: 'Author & date', + layout: { type: 'row', alignment: 'start' }, + children: ['author', 'date'] + }, + 'description' + ] + }; + + return ( + + data={post} + fields={fields} + form={form} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> + ); +}; + +/** + * A realistic product-editor form modeled after the Dokan product editor + * schema. Demonstrates grouping many fields into collapsible cards with + * inline row layouts and conditional visibility driven by other fields. + */ +export const DokanFormManager: StoryFn = () => { + interface DokanProduct { + name: string; + slug: string; + type: string; + regular_price: string; + sale_price: string; + create_schedule_for_discount: boolean; + date_on_sale_from: string; + date_on_sale_to: string; + category_ids: number[]; + product_tag: number[]; + downloadable: boolean; + virtual: boolean; + short_description: string; + description: string; + sku: string; + global_unique_id: string; + manage_stock: boolean; + stock_status: string; + stock_quantity: number; + low_stock_amount: number; + backorders: string; + sold_individually: boolean; + _disable_shipping: boolean; + weight: string; + length: string; + width: string; + height: string; + shipping_class: string; + tax_status: string; + tax_class: string; + download_limit: number; + download_expiry: number; + _is_lot_discount: boolean; + _lot_discount_quantity: number; + _lot_discount_amount: number; + status: string; + catalog_visibility: string; + purchase_note: string; + reviews_allowed: boolean; + } + + const initialProduct: DokanProduct = { + name: 'My Name - Personalized Custom Name Necklace', + slug: 'my-name', + type: 'simple', + regular_price: '49.99', + sale_price: '', + create_schedule_for_discount: false, + date_on_sale_from: '', + date_on_sale_to: '', + category_ids: [20, 15], + product_tag: [32], + downloadable: false, + virtual: false, + short_description: 'Discover the perfect personalized gift with our custom name necklace.', + description: + 'Crafted from high-quality sterling silver or elegant gold plating, this bespoke piece allows you to showcase your identity.', + sku: 'DKN-NECK-001', + global_unique_id: '', + manage_stock: false, + stock_status: 'instock', + stock_quantity: 0, + low_stock_amount: 0, + backorders: 'no', + sold_individually: false, + _disable_shipping: true, + weight: '0.12', + length: '11', + width: '4', + height: '2', + shipping_class: '', + tax_status: 'taxable', + tax_class: '', + download_limit: -1, + download_expiry: -1, + _is_lot_discount: false, + _lot_discount_quantity: 0, + _lot_discount_amount: 0, + status: 'publish', + catalog_visibility: 'visible', + purchase_note: '', + reviews_allowed: true + }; + + const dokanFields: DataViewField[] = [ + { + id: 'name', + label: 'Title', + type: 'text', + placeholder: 'Enter product title...', + isValid: { required: true } + }, + { + id: 'slug', + label: 'Permalink', + type: 'text', + description: 'https://dokan.test/product/', + placeholder: 'Enter product slug...' + }, + { + id: 'type', + label: 'Product Type', + type: 'text', + description: + 'Choose Variable if your product has multiple attributes — like sizes, colors, quality etc.', + elements: [ + { value: 'simple', label: 'Simple' }, + { value: 'variable', label: 'Variable' }, + { value: 'external', label: 'External/Affiliate product' }, + { value: 'grouped', label: 'Group Product' }, + { value: 'subscription', label: 'Simple subscription' } + ] + }, + { + id: 'regular_price', + label: 'Price', + type: 'text', + placeholder: '0.00', + isVisible: (item) => item.type !== 'variable' && item.type !== 'grouped' + }, + { + id: 'sale_price', + label: 'Sale Price', + type: 'text', + placeholder: '0.00', + isVisible: (item) => item.type !== 'variable' && item.type !== 'grouped' + }, + { + id: 'create_schedule_for_discount', + label: 'Create schedule for discount', + type: 'boolean', + Edit: 'toggle', + isVisible: (item) => item.type !== 'variable' && item.type !== 'grouped' + }, + { + id: 'date_on_sale_from', + label: 'From', + type: 'datetime', + isVisible: (item) => item.create_schedule_for_discount === true + }, + { + id: 'date_on_sale_to', + label: 'To', + type: 'datetime', + isVisible: (item) => item.create_schedule_for_discount === true + }, + { + id: 'category_ids', + label: 'Categories', + type: 'array', + placeholder: 'Select product categories', + elements: [ + { value: 20, label: 'Accessories' }, + { value: 19, label: 'Hoodies' }, + { value: 18, label: 'Tshirts' }, + { value: 15, label: 'Uncategorized' }, + { value: 22, label: 'Decor' }, + { value: 21, label: 'Music' } + ] + }, + { + id: 'product_tag', + label: 'Tags', + type: 'array', + placeholder: 'Select product tags', + elements: [ + { value: 32, label: 'Dokan dummy data' }, + { value: 67, label: 'Test Tag' }, + { value: 102, label: 'asdf dfas' } + ] + }, + { + id: 'downloadable', + label: 'Downloadable', + type: 'boolean', + Edit: 'toggle', + description: 'Downloadable products give access to a file upon purchase.' + }, + { + id: 'virtual', + label: 'Virtual', + type: 'boolean', + Edit: 'toggle', + description: 'Virtual products are intangible and are not shipped.' + }, + { + id: 'short_description', + label: 'Short Description', + type: 'text', + Edit: 'textarea', + placeholder: 'Enter product short description' + }, + { + id: 'description', + label: 'Description', + type: 'text', + Edit: 'textarea', + placeholder: 'Enter product description', + isValid: { required: true } + }, + { + id: 'sku', + label: 'SKU (Stock Keeping Unit)', + type: 'text', + placeholder: 'Enter product SKU', + description: 'A unique identifier for each distinct product.' + }, + { + id: 'global_unique_id', + label: 'GTIN, UPC, EAN, or ISBN', + type: 'text', + placeholder: 'Enter code', + description: 'A barcode or other identifier unique to this product.' + }, + { + id: 'manage_stock', + label: 'Manage stock?', + type: 'boolean', + Edit: 'toggle', + description: 'Manage stock level (quantity)' + }, + { + id: 'stock_status', + label: 'Stock Status', + type: 'text', + elements: [ + { value: 'instock', label: 'In stock' }, + { value: 'outofstock', label: 'Out of stock' }, + { value: 'onbackorder', label: 'On backorder' } + ], + isVisible: (item) => !item.manage_stock + }, + { + id: 'stock_quantity', + label: 'Stock quantity', + type: 'integer', + placeholder: '1', + isVisible: (item) => item.manage_stock === true + }, + { + id: 'low_stock_amount', + label: 'Low stock threshold', + type: 'integer', + placeholder: 'Store-wide threshold (2)', + isVisible: (item) => item.manage_stock === true + }, + { + id: 'backorders', + label: 'Allow Backorders', + type: 'text', + elements: [ + { value: 'no', label: 'Do not allow' }, + { value: 'notify', label: 'Allow, but notify customer' }, + { value: 'yes', label: 'Allow' } + ], + isVisible: (item) => item.manage_stock === true + }, + { + id: 'sold_individually', + label: 'Sold individually', + type: 'boolean', + Edit: 'toggle', + description: 'Allow only one quantity of this product to be bought in a single order.' + }, + { + id: '_disable_shipping', + label: 'This product requires shipping', + type: 'boolean', + Edit: 'toggle', + isVisible: (item) => item.type !== 'grouped' && item.type !== 'external' + }, + { + id: 'weight', + label: 'Weight (kg)', + type: 'text', + placeholder: 'Weight (kg)', + isVisible: (item) => item._disable_shipping === true + }, + { + id: 'length', + label: 'Length (cm)', + type: 'text', + placeholder: 'Length (cm)', + isVisible: (item) => item._disable_shipping === true + }, + { + id: 'width', + label: 'Width (cm)', + type: 'text', + placeholder: 'Width (cm)', + isVisible: (item) => item._disable_shipping === true + }, + { + id: 'height', + label: 'Height (cm)', + type: 'text', + placeholder: 'Height (cm)', + isVisible: (item) => item._disable_shipping === true + }, + { + id: 'shipping_class', + label: 'Shipping Class', + type: 'text', + elements: [ + { value: '', label: 'No shipping class' }, + { value: 'heavy', label: 'Heavy' }, + { value: 'light', label: 'Light' } + ] + }, + { + id: 'tax_status', + label: 'Tax Status', + type: 'text', + elements: [ + { value: 'taxable', label: 'Taxable' }, + { value: 'shipping', label: 'Shipping only' }, + { value: 'none', label: 'None' } + ] + }, + { + id: 'tax_class', + label: 'Tax Class', + type: 'text', + elements: [ + { value: '', label: 'Standard' }, + { value: 'reduced-rate', label: 'Reduced rate' }, + { value: 'zero-rate', label: 'Zero rate' } + ] + }, + { + id: 'download_limit', + label: 'Download Limit', + type: 'integer', + placeholder: 'Unlimited', + description: 'Leave blank for unlimited re-downloads.', + isVisible: (item) => item.downloadable === true + }, + { + id: 'download_expiry', + label: 'Download Expiry', + type: 'integer', + placeholder: 'Never', + description: 'Number of days before a download link expires.', + isVisible: (item) => item.downloadable === true + }, + { + id: '_is_lot_discount', + label: 'Enable bulk discount', + type: 'boolean', + Edit: 'toggle' + }, + { + id: '_lot_discount_quantity', + label: 'Minimum quantity', + type: 'integer', + placeholder: '0', + isVisible: (item) => item._is_lot_discount === true + }, + { + id: '_lot_discount_amount', + label: 'Discount %', + type: 'integer', + placeholder: 'Percentage', + isVisible: (item) => item._is_lot_discount === true + }, + { + id: 'status', + label: 'Status', + type: 'text', + Edit: 'radio', + elements: [ + { value: 'publish', label: 'Publish' }, + { value: 'draft', label: 'Draft' } + ] + }, + { + id: 'catalog_visibility', + label: 'Visibility', + type: 'text', + elements: [ + { value: 'visible', label: 'Visible' }, + { value: 'catalog', label: 'Catalog' }, + { value: 'search', label: 'Search' }, + { value: 'hidden', label: 'Hidden' } + ] + }, + { + id: 'purchase_note', + label: 'Purchase Note', + type: 'text', + Edit: 'textarea', + placeholder: 'Purchase Note', + description: 'Customer will get this in order email.' + }, + { + id: 'reviews_allowed', + label: 'Enable product reviews', + type: 'boolean', + Edit: 'toggle' + } + ]; + + const [product, setProduct] = useState(initialProduct); + + const form: DataFormSchema = { + layout: { type: 'regular', labelPosition: 'top' }, + fields: [ + { + id: 'general', + label: 'General', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: true }, + children: [ + 'name', + 'slug', + 'type', + 'regular_price', + 'sale_price', + 'create_schedule_for_discount', + { + id: 'discount_schedule', + label: 'Discount schedule', + layout: { type: 'row' }, + children: ['date_on_sale_from', 'date_on_sale_to'] + }, + 'category_ids', + 'product_tag', + { + id: 'digital_options', + label: 'Digital product options', + layout: { type: 'row', alignment: 'start' }, + children: ['downloadable', 'virtual'] + } + ] + }, + { + id: 'description_section', + label: 'Description', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: true }, + children: ['short_description', 'description'] + }, + { + id: 'inventory', + label: 'Inventory', + description: 'Manage inventory for this product', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: false }, + children: [ + 'sku', + 'global_unique_id', + 'manage_stock', + 'stock_status', + 'stock_quantity', + 'low_stock_amount', + 'backorders', + 'sold_individually' + ] + }, + { + id: 'shipping', + label: 'Shipping and Tax', + description: 'Manage shipping and tax for this product', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: false }, + children: [ + '_disable_shipping', + { + id: 'shipping_dimensions', + label: 'Dimensions', + layout: { type: 'row' }, + children: ['weight', 'length', 'width', 'height'] + }, + 'shipping_class', + 'tax_status', + 'tax_class' + ] + }, + { + id: 'downloadable_options', + label: 'Downloadable Options', + description: 'Configure your downloadable product settings', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: false }, + children: ['download_limit', 'download_expiry'] + }, + { + id: 'discount', + label: 'Discount Options', + description: 'Set your discount for this product', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: false }, + children: ['_is_lot_discount', '_lot_discount_quantity', '_lot_discount_amount'] + }, + { + id: 'others', + label: 'Publish & Visibility', + description: 'Set your extra product options', + layout: { type: 'card', withHeader: true, isCollapsible: true, isOpened: true }, + children: ['status', 'catalog_visibility', 'purchase_note', 'reviews_allowed'] + } + ] + }; + + const { validity, isValid } = useFormValidity(product, dokanFields, form); + + return ( +
+ + data={product} + fields={dokanFields} + form={form} + validity={validity} + onChange={(edits) => setProduct((prev) => ({ ...prev, ...edits }))} + /> +
+ + {isValid ? 'Ready to publish' : 'Fix the highlighted fields'} + + +
+
+ ); }; /** Validate fields with the `useFormValidity` hook. The save button stays disabled until the form is valid. */ export const WithValidation: StoryFn = () => { - const [post, setPost] = useState({ ...initialPost, title: "", email: "" }); - - const validatedFields: DataViewField[] = fields.map((field) => { - if (field.id === "title") { - return { ...field, isValid: { required: { value: true, message: "Title is required" } } }; - } - if (field.id === "email") { - return { - ...field, - isValid: { - required: { value: true, message: "Email is required" }, - custom: (value: unknown) => - typeof value === "string" && /.+@.+\..+/.test(value) - ? undefined - : { message: "Enter a valid email address" }, - }, - }; - } - return field; - }); - - const form: DataFormSchema = { - layout: { type: "regular", labelPosition: "top" }, - fields: ["title", "email", "status"], - }; - - const { validity, isValid } = useFormValidity(post, validatedFields, form); - - return ( -
- - data={post} - fields={validatedFields} - form={form} - validity={validity} - onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} - /> -
- - {isValid ? "Form is valid" : "Fix the highlighted fields"} - - -
-
- ); + const [post, setPost] = useState({ ...initialPost, title: '', email: '' }); + + const validatedFields: DataViewField[] = fields.map((field) => { + if (field.id === 'title') { + return { + ...field, + isValid: { required: true } + }; + } + if (field.id === 'email') { + return { + ...field, + isValid: { + required: true, + custom: (item: SamplePost) => + /.+@.+\..+/.test(item.email) + ? null + : 'Enter a valid email address' + } + }; + } + return field; + }); + + const form: DataFormSchema = { + layout: { type: 'regular', labelPosition: 'top' }, + fields: ['title', 'email', 'status'] + }; + + const { validity, isValid } = useFormValidity(post, validatedFields, form); + + return ( +
+ + data={post} + fields={validatedFields} + form={form} + validity={validity} + onChange={(edits) => setPost((prev) => ({ ...prev, ...edits }))} + /> +
+ + {isValid ? 'Form is valid' : 'Fix the highlighted fields'} + + +
+
+ ); };