From 23ed06dccc0d856058a97218af403fbdaed20b32 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Fri, 6 Mar 2026 12:16:34 +0600 Subject: [PATCH 1/4] fix: update onSave to pass nested tree values and handle save errors - Add dot-key-to-nested-object tree building in handleOnSave - Pass both treeValues and flatValues to onSave callback - Align onSave signature across all interfaces (SettingsContextValue, SettingsProviderProps, SettingsProps) - Only reset dirty state on successful save - Handle server-side validation errors by merging into errors state - Add setErrors to useCallback deps to prevent stale closure Co-Authored-By: Claude Opus 4.6 --- src/components/settings/settings-context.tsx | 33 +++++++++++++++++--- src/components/settings/settings-types.ts | 4 +-- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/components/settings/settings-context.tsx b/src/components/settings/settings-context.tsx index 6836c77..288d0cb 100644 --- a/src/components/settings/settings-context.tsx +++ b/src/components/settings/settings-context.tsx @@ -69,7 +69,7 @@ export interface SettingsContextValue { /** Get only the values that belong to a specific page */ getPageValues: (pageId: string) => Record; /** Consumer-provided save handler (exposed so SettingsContent can call it) */ - onSave?: (pageId: string, values: Record) => void | Promise; + onSave?: (pageId: string, treeValues: Record, flatValues: Record) => void | Promise; /** Consumer-provided render function for the save button */ renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode; } @@ -88,7 +88,7 @@ export interface SettingsProviderProps { schema: SettingsElement[]; values?: Record; onChange?: (scopeId: string, key: string, value: any) => void; - onSave?: (scopeId: string, values: Record) => void | Promise; + onSave?: (scopeId: string, treeValues: Record, flatValues: Record) => void | Promise; renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode; loading?: boolean; hookPrefix?: string; @@ -261,10 +261,33 @@ export function SettingsProvider({ const handleOnSave = useCallback( async (pageId: string, pageValues: Record) => { if (!onSave) return; - await Promise.resolve(onSave(pageId, pageValues)); - resetPageDirty(pageId); + // Build nested tree from dot-separated keys + const treeValues: Record = {}; + for (const [dotKey, val] of Object.entries(pageValues)) { + const parts = dotKey.split('.'); + let cursor: Record = treeValues; + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in cursor) || typeof cursor[parts[i]] !== 'object') { + cursor[parts[i]] = {}; + } + cursor = cursor[parts[i]]; + } + cursor[parts[parts.length - 1]] = val; + } + try { + await Promise.resolve(onSave(pageId, treeValues, pageValues)); + resetPageDirty(pageId); + } catch (error: any) { + console.error('[Settings] onSave error caught:', error); + // If the error contains field-level errors (e.g. from a 400 response), + // merge them into the errors state so they display on the relevant fields. + // Error keys should match field dependency_key values. + if (error && typeof error === 'object' && error.errors && typeof error.errors === 'object') { + setErrors((prev) => ({ ...prev, ...error.errors })); + } + } }, - [onSave, resetPageDirty] + [onSave, resetPageDirty, setErrors] ); // Update a field value diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts index e3ea641..7811460 100644 --- a/src/components/settings/settings-types.ts +++ b/src/components/settings/settings-types.ts @@ -136,8 +136,8 @@ export interface SettingsProps { values?: Record; /** Called when a field value changes. Receives the scope ID (subpage/page), field key, and new value. */ onChange?: (scopeId: string, key: string, value: any) => void; - /** Called when the save button is clicked. Receives the scope ID and that scope's values only. */ - onSave?: (scopeId: string, values: Record) => void; + /** Called when the save button is clicked. Receives the scope ID, nested tree values, and flat dot-keyed values. */ + onSave?: (scopeId: string, treeValues: Record, flatValues: Record) => void; /** * Custom render function for the save button area. * Use this to provide your own translated save button. From 50617009fb97148b3e6af0f29fd1b75319eea5e5 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Fri, 6 Mar 2026 12:18:27 +0600 Subject: [PATCH 2/4] docs: update onSave signature in all documentation Update examples and API reference tables to reflect the new onSave(scopeId, treeValues, flatValues) signature. Co-Authored-By: Claude Opus 4.6 --- DEVELOPER_GUIDE.md | 4 ++-- src/DeveloperGuide.mdx | 4 ++-- src/components/settings/Settings.mdx | 26 ++++++++++++++++---------- 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index aef3893..f693da9 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1128,11 +1128,11 @@ function SettingsPage() { onChange={(scopeId, key, value) => { setValues(prev => ({ ...prev, [key]: value })); }} - onSave={async (scopeId, pageValues) => { + onSave={async (scopeId, treeValues) => { await apiFetch({ path: `/my-plugin/v1/settings/${scopeId}`, method: 'POST', - data: pageValues, + data: treeValues, }); }} renderSaveButton={({ dirty, onSave }) => ( diff --git a/src/DeveloperGuide.mdx b/src/DeveloperGuide.mdx index 59e59b2..275abb1 100644 --- a/src/DeveloperGuide.mdx +++ b/src/DeveloperGuide.mdx @@ -912,8 +912,8 @@ function SettingsPage() { hookPrefix="my_plugin" applyFilters={applyFilters} onChange={(scopeId, key, value) => setValues(prev => ({ ...prev, [key]: value }))} - onSave={async (scopeId, pageValues) => { - await apiFetch({ path: `/my-plugin/v1/settings/${scopeId}`, method: 'POST', data: pageValues }); + onSave={async (scopeId, treeValues) => { + await apiFetch({ path: `/my-plugin/v1/settings/${scopeId}`, method: 'POST', data: treeValues }); }} renderSaveButton={({ dirty, onSave }) => ( diff --git a/src/components/settings/Settings.mdx b/src/components/settings/Settings.mdx index 61d5408..1c01890 100644 --- a/src/components/settings/Settings.mdx +++ b/src/components/settings/Settings.mdx @@ -77,9 +77,10 @@ function MySettingsPage() { onChange={(scopeId, key, value) => { setValues((prev) => ({ ...prev, [key]: value })); }} - onSave={(scopeId, pageValues) => { - // POST pageValues to your REST API - console.log("Saving", scopeId, pageValues); + onSave={(scopeId, treeValues, flatValues) => { + // treeValues: nested object from dot-separated keys + // flatValues: original flat dot-keyed values + console.log("Saving", scopeId, treeValues, flatValues); }} renderSaveButton={({ dirty, onSave }) => ( + )} + hookPrefix="my_plugin" // WordPress filter hook prefix + applyFilters={applyFilters} // @wordpress/hooks applyFilters for field extensibility +/> +``` + +### Key Concepts + +- **`dependency_key`**: Unique key on each field element, used as the key in `values` and `flatValues` +- **Dependencies**: Elements can conditionally show/hide based on other field values via `dependencies` array +- **Validation**: Per-field `validations` array with rules and error messages +- **Dirty tracking**: Per-scope (subpage/page) dirty state; resets only on successful save +- **Error handling**: If `onSave` throws `{ errors: { fieldKey: "message" } }`, errors display on the relevant fields +- **Extensibility**: `applyFilters` enables WordPress hooks like `{hookPrefix}_settings_{variant}_field` + +### Settings Hooks + +```tsx +import { useSettings } from '@wedevs/plugin-ui'; + +const { + values, // All current field values + activePage, // Current page element + activeSubpage, // Current subpage element (if any) + activeTab, // Current tab element (if any) + isPageDirty, // (pageId) => boolean + getPageValues, // (pageId) => Record + errors, // Record validation errors +} = useSettings(); +``` + +## Theme System + +```tsx +import { ThemeProvider, createTheme } from '@wedevs/plugin-ui'; + +// Use a built-in preset + + + + +// Create a custom theme +const myTheme = createTheme({ + primary: '220 90% 56%', // HSL values (without hsl() wrapper) + background: '0 0% 100%', + foreground: '0 0% 3.9%', + // ... see ThemeTokens type for all available tokens +}); +``` + +Built-in presets: `defaultTheme`, `slateTheme`, `amberMinimalTheme`, `t3ChatTheme`, `midnightBloomTheme`, `bubblegumTheme`, `cyberpunkTheme`, `twitterTheme` (each with a dark variant). + +## UI Components + +### Form +`Input`, `Textarea`, `Select`, `Combobox`, `Checkbox`, `RadioGroup`, `Switch`, `Slider`, `DatePicker`, `DateRangePicker`, `Calendar`, `CurrencyInput`, `InputOTP`, `RichTextEditor`, `FileUpload` + +Card variants: `CheckboxCard`, `RadioCard`, `SwitchCard` +Labeled variants: `LabeledCheckbox`, `LabeledRadio`, `LabeledSwitch` + +### Layout +`Card`, `Tabs`, `Separator`, `ScrollArea`, `Layout` (WordPress sidebar+content), `Sidebar` (shadcn primitives), `Field` (form control wrapper with label, description, error) + +### Data Display +`Badge`, `Avatar`, `AvatarGroup`, `Progress`, `CircularProgress`, `Skeleton`, `Spinner`, `Thumbnail`, `DataViews` (WordPress DataViews wrapper) + +### Overlay +`Modal`, `Sheet`, `Popover`, `Tooltip`, `DropdownMenu`, `AlertDialog`, `Toaster` (sonner toast) + +### Feedback +`Alert`, `Notice`, `toast()` (from sonner) + +### WordPress-Specific +`Layout` (sidebar layout with WordPress hook integration), `DataViews` (wraps `@wordpress/dataviews` with filter hooks), `LayoutMenu` (navigation menu) + +## WordPress Integration + +### Layout Component + +```tsx +import { Layout, LayoutHeader, LayoutBody, LayoutSidebar, LayoutMain } from '@wedevs/plugin-ui'; + + + Header + + + Content + + +``` + +### DataViews Component + +```tsx +import { DataViews } from '@wedevs/plugin-ui'; + + +``` + +Supports WordPress filter hooks: `{snakeNamespace}_dataviews_{elementName}` + +## Conventions + +- **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 +- **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 + +GitHub CI runs these checks on PRs to `main`. Run them locally before pushing to avoid failures: + +```bash +npm run lint # ESLint (src/**/*.{ts,tsx}) +npm run typecheck # tsc --noEmit +``` + +Both must pass. The CI pipeline (`.github/workflows/ci.yml`) runs these on `ubuntu-latest` with Node 24. + +## Documentation + +- `src/components/settings/Settings.mdx` — Full settings API reference +- `DEVELOPER_GUIDE.md` — WordPress integration guide +- `src/DeveloperGuide.mdx` — Storybook developer guide From 5ea076982e3d141bb65fca5f0a1d7eae698acd45 Mon Sep 17 00:00:00 2001 From: Md Mahbub Rabbani Date: Fri, 6 Mar 2026 14:36:01 +0600 Subject: [PATCH 4/4] fix: rename context onSave to save to disambiguate from consumer prop The context's internal save wrapper (2 args) shared the same name as the consumer's onSave prop (3 args), causing a TS2554 error in settings-content. Rename to `save` in SettingsContextValue and update JSDoc across types and docs. Co-Authored-By: Claude Opus 4.6 --- src/components/settings/Settings.mdx | 2 +- src/components/settings/settings-content.tsx | 8 ++++---- src/components/settings/settings-context.tsx | 6 +++--- src/components/settings/settings-types.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/components/settings/Settings.mdx b/src/components/settings/Settings.mdx index 1c01890..a056bfa 100644 --- a/src/components/settings/Settings.mdx +++ b/src/components/settings/Settings.mdx @@ -622,7 +622,7 @@ import { Settings, Button } from "@wedevs/plugin-ui"; onSave {'() => void'} - Call this to trigger onSave(scopeId, treeValues, flatValues) + Call this to trigger save — internally gathers scope values and calls the consumer's onSave(scopeId, treeValues, flatValues) diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index 6394c14..bb9bafe 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -21,7 +21,7 @@ export function SettingsContent({ className }: { className?: string }) { setActiveTab, isPageDirty, getPageValues, - onSave, + save, renderSaveButton, } = useSettings(); @@ -34,13 +34,13 @@ export function SettingsContent({ className }: { className?: string }) { const dirty = isPageDirty(scopeId); const handleSave = () => { - if (!onSave) return; + if (!save) return; const scopeValues = getPageValues(scopeId); - onSave(scopeId, scopeValues); + save(scopeId, scopeValues); }; // Determine whether to show a save area - const showSaveArea = Boolean(onSave); + const showSaveArea = Boolean(save); if (!contentSource) { return ( diff --git a/src/components/settings/settings-context.tsx b/src/components/settings/settings-context.tsx index 288d0cb..7f381ec 100644 --- a/src/components/settings/settings-context.tsx +++ b/src/components/settings/settings-context.tsx @@ -68,8 +68,8 @@ export interface SettingsContextValue { isPageDirty: (pageId: string) => boolean; /** Get only the values that belong to a specific page */ getPageValues: (pageId: string) => Record; - /** Consumer-provided save handler (exposed so SettingsContent can call it) */ - onSave?: (pageId: string, treeValues: Record, flatValues: Record) => void | Promise; + /** Trigger a save for the given scope. Builds treeValues from flat pageValues, then calls the consumer's onSave(scopeId, treeValues, flatValues). */ + save?: (scopeId: string, pageValues: Record) => void | Promise; /** Consumer-provided render function for the save button */ renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode; } @@ -502,7 +502,7 @@ export function SettingsProvider({ isSidebarVisible, isPageDirty, getPageValues, - onSave: handleOnSave, + save: handleOnSave, renderSaveButton, }), [ diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts index 7811460..b2cdd0e 100644 --- a/src/components/settings/settings-types.ts +++ b/src/components/settings/settings-types.ts @@ -125,7 +125,7 @@ export interface SaveButtonRenderProps { scopeId: string; /** Whether any field in the current scope has been modified. */ dirty: boolean; - /** Call this to trigger save — invokes `onSave(scopeId, scopeValues)`. */ + /** Call this to trigger save — internally gathers scope values and invokes the consumer's `onSave(scopeId, treeValues, flatValues)`. */ onSave: () => void; }