-
Notifications
You must be signed in to change notification settings - Fork 10
fix: update onSave to pass nested tree values and handle save errors #65
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
23ed06d
5061700
f32dad7
5ea0769
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,201 @@ | ||
| # @wedevs/plugin-ui | ||
|
|
||
| Scoped, themeable React component library for WordPress plugins. Built on ShadCN patterns, Tailwind CSS v4, and Base-UI primitives. | ||
|
|
||
| ## Architecture | ||
|
|
||
| ``` | ||
| src/ | ||
| ├── components/ | ||
| │ ├── ui/ # Core ShadCN-style components (150+ exports) | ||
| │ ├── settings/ # Schema-driven settings system | ||
| │ └── wordpress/ # WordPress integration (Layout, DataViews) | ||
| ├── providers/ # ThemeProvider (CSS variable injection) | ||
| ├── themes/ # Built-in theme presets | ||
| ├── hooks/ # useMobile, useWindowDimensions | ||
| └── lib/ # Utilities (cn, renderIcon, WpMedia, wordpress-date) | ||
| ``` | ||
|
|
||
| ## Import Patterns | ||
|
|
||
| ```tsx | ||
| // Main entry (includes styles) | ||
| import { Settings, Button, ThemeProvider } from '@wedevs/plugin-ui'; | ||
|
|
||
| // Sub-path exports | ||
| import { Settings } from '@wedevs/plugin-ui/settings'; | ||
| import { Button, Input } from '@wedevs/plugin-ui/components/ui'; | ||
| import { ThemeProvider } from '@wedevs/plugin-ui/providers'; | ||
| import { defaultTheme, createTheme } from '@wedevs/plugin-ui/themes'; | ||
| import { cn } from '@wedevs/plugin-ui/utils'; | ||
|
|
||
| // Styles (import in your entry point) | ||
| import '@wedevs/plugin-ui/styles.css'; | ||
| ``` | ||
|
|
||
| ## CSS Setup (Tailwind v4) | ||
|
|
||
| ```css | ||
| @import "tailwindcss"; | ||
| @import "@wedevs/plugin-ui/styles.css" layer(plugin-ui); | ||
| ``` | ||
|
|
||
| ## Settings System | ||
|
|
||
| Schema-driven settings page with hierarchical navigation, dependency evaluation, validation, and WordPress hook extensibility. | ||
|
|
||
| ### Element Hierarchy | ||
|
|
||
| `page` → `subpage` → `tab` → `section` → `subsection` → `field` / `fieldgroup` | ||
|
|
||
| ### Basic Usage | ||
|
|
||
| ```tsx | ||
| import { Settings } from '@wedevs/plugin-ui'; | ||
|
|
||
| <Settings | ||
| schema={settingsSchema} // SettingsElement[] (flat or hierarchical) | ||
| values={values} // Record<string, any> keyed by dependency_key | ||
| onChange={(scopeId, key, value) => { | ||
| setValues(prev => ({ ...prev, [key]: value })); | ||
| }} | ||
| onSave={async (scopeId, treeValues, flatValues) => { | ||
| // treeValues: nested object built from dot-separated keys | ||
| // e.g. { dokan: { general: { store_name: "..." } } } | ||
| // flatValues: original flat dot-keyed values | ||
| // e.g. { "dokan.general.store_name": "..." } | ||
| await api.post(`/settings/${scopeId}`, treeValues); | ||
| }} | ||
| renderSaveButton={({ dirty, onSave }) => ( | ||
| <Button onClick={onSave} disabled={!dirty}>Save</Button> | ||
| )} | ||
| 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<string, any> | ||
| errors, // Record<string, string> validation errors | ||
| } = useSettings(); | ||
| ``` | ||
|
|
||
| ## Theme System | ||
|
|
||
| ```tsx | ||
| import { ThemeProvider, createTheme } from '@wedevs/plugin-ui'; | ||
|
|
||
| // Use a built-in preset | ||
| <ThemeProvider theme={defaultTheme}> | ||
| <App /> | ||
| </ThemeProvider> | ||
|
|
||
| // 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'; | ||
|
|
||
| <Layout namespace="my-plugin"> | ||
| <LayoutHeader>Header</LayoutHeader> | ||
| <LayoutBody> | ||
| <LayoutSidebar menuItems={menuItems} /> | ||
| <LayoutMain>Content</LayoutMain> | ||
| </LayoutBody> | ||
| </Layout> | ||
| ``` | ||
|
|
||
| ### DataViews Component | ||
|
|
||
| ```tsx | ||
| import { DataViews } from '@wedevs/plugin-ui'; | ||
|
|
||
| <DataViews | ||
| namespace="my-plugin" | ||
| data={items} | ||
| fields={fields} | ||
| actions={actions} | ||
| paginationInfo={paginationInfo} | ||
| /> | ||
| ``` | ||
|
|
||
| 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Capitalize Use the official casing here to avoid a docs/style nit slipping into a reference section. 🧰 Tools🪛 LanguageTool[uncategorized] ~195-~195: The official name of this software platform is spelled with a capital “H”. (GITHUB) 🤖 Prompt for AI Agents |
||
|
|
||
| ## Documentation | ||
|
|
||
| - `src/components/settings/Settings.mdx` — Full settings API reference | ||
| - `DEVELOPER_GUIDE.md` — WordPress integration guide | ||
| - `src/DeveloperGuide.mdx` — Storybook developer guide | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string, any>; | ||
| /** Consumer-provided save handler (exposed so SettingsContent can call it) */ | ||
| onSave?: (pageId: string, values: Record<string, any>) => void | Promise<void>; | ||
| /** 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<string, any>) => void | Promise<void>; | ||
| /** Consumer-provided render function for the save button */ | ||
| renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode; | ||
| } | ||
|
|
@@ -88,7 +88,7 @@ export interface SettingsProviderProps { | |
| schema: SettingsElement[]; | ||
| values?: Record<string, any>; | ||
| onChange?: (scopeId: string, key: string, value: any) => void; | ||
| onSave?: (scopeId: string, values: Record<string, any>) => void | Promise<void>; | ||
| onSave?: (scopeId: string, treeValues: Record<string, any>, flatValues: Record<string, any>) => void | Promise<void>; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: # First, read the settings-context.tsx file to see the context around line 91
head -100 src/components/settings/settings-context.tsx | tail -20Repository: getdokan/plugin-ui Length of output: 895 🏁 Script executed: # Get more context - read the full file or at least the type/interface definition
wc -l src/components/settings/settings-context.tsxRepository: getdokan/plugin-ui Length of output: 110 🏁 Script executed: # Search for onSave usages in the codebase
rg -n 'onSave' --type=ts --type=tsx -B 2 -A 2 | head -100Repository: getdokan/plugin-ui Length of output: 89 🏁 Script executed: # Search for onSave usages without type filters
rg -n 'onSave' -B 2 -A 2Repository: getdokan/plugin-ui Length of output: 15495 🏁 Script executed: # Also check how onSave is called/invoked in the file
rg -n 'onSave\s*\(' -B 1 -A 1Repository: getdokan/plugin-ui Length of output: 1625 🏁 Script executed: # Verify the exact line in Settings.stories.tsx to understand the context better
sed -n '1080,1095p' src/components/settings/Settings.stories.tsxRepository: getdokan/plugin-ui Length of output: 911 🏁 Script executed: # Check if there are any other invocations of onSave callback we might have missed
rg -n 'onSave\s*\(\s*[^)]*\s*\)' -B 1 -A 1Repository: getdokan/plugin-ui Length of output: 1625 Breaking change: The new signature
Update these call sites to accept the new third parameter 🤖 Prompt for AI Agents |
||
| renderSaveButton?: (props: SaveButtonRenderProps) => React.ReactNode; | ||
| loading?: boolean; | ||
| hookPrefix?: string; | ||
|
|
@@ -261,10 +261,33 @@ export function SettingsProvider({ | |
| const handleOnSave = useCallback( | ||
| async (pageId: string, pageValues: Record<string, any>) => { | ||
| if (!onSave) return; | ||
| await Promise.resolve(onSave(pageId, pageValues)); | ||
| resetPageDirty(pageId); | ||
| // Build nested tree from dot-separated keys | ||
| const treeValues: Record<string, any> = {}; | ||
| for (const [dotKey, val] of Object.entries(pageValues)) { | ||
| const parts = dotKey.split('.'); | ||
| let cursor: Record<string, any> = 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 | ||
|
|
@@ -479,7 +502,7 @@ export function SettingsProvider({ | |
| isSidebarVisible, | ||
| isPageDirty, | ||
| getPageValues, | ||
| onSave: handleOnSave, | ||
| save: handleOnSave, | ||
| renderSaveButton, | ||
| }), | ||
| [ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add a language tag to the architecture code fence.
This block is missing a fence language, which will keep tripping markdownlint (
MD040).textwould be the right fit here.🧰 Tools
🪛 markdownlint-cli2 (0.21.0)
[warning] 7-7: Fenced code blocks should have a language specified
(MD040, fenced-code-language)
🤖 Prompt for AI Agents