diff --git a/CLAUDE.md b/CLAUDE.md index 7985079..6219018 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,8 +66,8 @@ import { Settings } from '@wedevs/plugin-ui'; // e.g. { "dokan.general.store_name": "..." } await api.post(`/settings/${scopeId}`, treeValues); }} - renderSaveButton={({ dirty, onSave }) => ( - + renderSaveButton={({ dirty, hasErrors, onSave }) => ( + )} hookPrefix="my_plugin" // WordPress filter hook prefix applyFilters={applyFilters} // @wordpress/hooks applyFilters for field extensibility diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index f693da9..5bb5079 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1135,8 +1135,8 @@ function SettingsPage() { data: treeValues, }); }} - renderSaveButton={({ dirty, onSave }) => ( - )} diff --git a/src/DeveloperGuide.mdx b/src/DeveloperGuide.mdx index 275abb1..0013d6b 100644 --- a/src/DeveloperGuide.mdx +++ b/src/DeveloperGuide.mdx @@ -915,8 +915,8 @@ function SettingsPage() { onSave={async (scopeId, treeValues) => { await apiFetch({ path: `/my-plugin/v1/settings/${scopeId}`, method: 'POST', data: treeValues }); }} - renderSaveButton={({ dirty, onSave }) => ( - + renderSaveButton={({ dirty, hasErrors, onSave }) => ( + )} /> ); diff --git a/src/components/settings/Settings.mdx b/src/components/settings/Settings.mdx index a056bfa..c37f8d1 100644 --- a/src/components/settings/Settings.mdx +++ b/src/components/settings/Settings.mdx @@ -82,8 +82,8 @@ function MySettingsPage() { // flatValues: original flat dot-keyed values console.log("Saving", scopeId, treeValues, flatValues); }} - renderSaveButton={({ dirty, onSave }) => ( - )} @@ -571,6 +571,34 @@ Fired when the save button is clicked. Only receives values **scoped to the acti /> ``` +### Server-Side Validation Errors + +If your API returns field-level validation errors, **throw an object** with an `errors` property +from `onSave`. The keys must match field `dependency_key` values: + +```tsx + { + const res = await fetch(`/wp-json/my-plugin/v1/settings/${scopeId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(treeValues), + }); + + if (!res.ok) { + const data = await res.json(); + // Throw with { errors: { [dependency_key]: "message" } } + throw { errors: data.errors }; + // e.g. { errors: { "dokan.general.store_name": "Store name already taken" } } + } + }} +/> +``` + +- Errors display on the matching fields in red (`text-destructive`) +- The save button is disabled while errors are present (`hasErrors` becomes `true`) +- Errors auto-clear when the user changes the errored field + ### Scope ID resolution The `scopeId` follows this logic: @@ -592,8 +620,8 @@ import { __ } from "@wordpress/i18n"; import { Settings, Button } from "@wedevs/plugin-ui"; ( - )} @@ -619,6 +647,11 @@ import { Settings, Button } from "@wedevs/plugin-ui"; boolean true if any field in the scope has been modified + + hasErrors + boolean + true if any field in the scope has a validation error (client-side or server-side) + onSave {'() => void'} diff --git a/src/components/settings/Settings.stories.tsx b/src/components/settings/Settings.stories.tsx index 76d46fe..609f2a5 100644 --- a/src/components/settings/Settings.stories.tsx +++ b/src/components/settings/Settings.stories.tsx @@ -1108,13 +1108,22 @@ function SettingsStoryWrapper({ setValues((prev) => ({ ...prev, [key]: value })); log({ type: 'change', pageId: scopeId, key, value }); }} - onSave={(scopeId, scopeValues) => { + onSave={async (scopeId, _treeValues, flatValues) => { // eslint-disable-next-line no-console - console.log(`Save scope "${scopeId}":`, scopeValues); - log({ type: 'save', pageId: scopeId, values: scopeValues }); + console.log(`Save scope "${scopeId}":`, flatValues); + log({ type: 'save', pageId: scopeId, values: flatValues }); + + // Simulate server-side validation: if store_name is "test", throw a field error + if (flatValues['store_name'] === 'test') { + throw { + errors: { + store_name: 'Store name "test" is already taken. Please choose another.', + }, + }; + } }} - renderSaveButton={({ dirty, onSave: save }) => ( - @@ -1192,6 +1201,65 @@ export const DependencyDemo: Story = { ), }; +function ServerSideValidationWrapper(args: SettingsProps) { + const [values, setValues] = useState>({ + store_name: '', + }); + const { entries, log } = useEventLog(); + + return ( +
+
+ Navigate to General → Store Settings, type “test” as + the Store Name, and click Save. A server-side error will appear on the field. + Changing the field clears the error automatically. +
+
+ { + setValues((prev) => ({ ...prev, [key]: value })); + log({ type: 'change', pageId: scopeId, key, value }); + }} + onSave={async (scopeId, _treeValues, flatValues) => { + // Simulate network delay + await new Promise((r) => setTimeout(r, 500)); + + log({ type: 'save', pageId: scopeId, values: flatValues }); + + // Simulate server-side validation error + if (flatValues['store_name'] === 'test') { + throw { + errors: { + store_name: 'Store name "test" is already taken. Please choose another.', + }, + }; + } + }} + renderSaveButton={({ dirty, hasErrors, onSave: triggerSave }) => ( + + )} + /> +
+ +
+ ); +} + +/** Server-side validation demo — type "test" as store name and save to see a server error. */ +export const ServerSideValidation: Story = { + args: { + schema: sampleSchema, + title: 'Server-Side Validation', + }, + render: (args) => , +}; + // ============================================ // Flat Array Stories // ============================================ diff --git a/src/components/settings/settings-content.tsx b/src/components/settings/settings-content.tsx index bb9bafe..fffbf6f 100644 --- a/src/components/settings/settings-content.tsx +++ b/src/components/settings/settings-content.tsx @@ -20,6 +20,7 @@ export function SettingsContent({ className }: { className?: string }) { activeTab, setActiveTab, isPageDirty, + hasScopeErrors, getPageValues, save, renderSaveButton, @@ -32,9 +33,10 @@ export function SettingsContent({ className }: { className?: string }) { // Scope ID: subpage ID if a subpage is active, otherwise page ID const scopeId = activeSubpage || activePage; const dirty = isPageDirty(scopeId); + const hasErrors = hasScopeErrors(scopeId); const handleSave = () => { - if (!save) return; + if (!save || hasErrors) return; const scopeValues = getPageValues(scopeId); save(scopeId, scopeValues); }; @@ -134,7 +136,7 @@ export function SettingsContent({ className }: { className?: string }) { data-testid={`settings-save-${scopeId}`} > {renderSaveButton - ? renderSaveButton({ scopeId, dirty, onSave: handleSave }) + ? renderSaveButton({ scopeId, dirty, hasErrors, onSave: handleSave }) : null} )} diff --git a/src/components/settings/settings-context.tsx b/src/components/settings/settings-context.tsx index bb2ea9b..d8a7985 100644 --- a/src/components/settings/settings-context.tsx +++ b/src/components/settings/settings-context.tsx @@ -66,6 +66,8 @@ export interface SettingsContextValue { isSidebarVisible: boolean; /** Check if any field on a specific page has been modified */ isPageDirty: (pageId: string) => boolean; + /** Check if any field on a specific page has a validation error */ + hasScopeErrors: (scopeId: string) => boolean; /** Get only the values that belong to a specific page */ getPageValues: (pageId: string) => Record; /** Trigger a save for the given scope. Builds treeValues from flat pageValues, then calls the consumer's onSave(scopeId, treeValues, flatValues). */ @@ -232,6 +234,16 @@ export function SettingsProvider({ [scopeFieldKeysMap, values, initialValues] ); + // Per-scope error check + const hasScopeErrors = useCallback( + (scopeId: string): boolean => { + const keys = scopeFieldKeysMap.get(scopeId); + if (!keys) return false; + return keys.some((key) => key in errors); + }, + [scopeFieldKeysMap, errors] + ); + // Per-scope values extraction const getPageValues = useCallback( (scopeId: string): Record => { @@ -510,6 +522,7 @@ export function SettingsProvider({ getActiveTabs, isSidebarVisible, isPageDirty, + hasScopeErrors, getPageValues, save: handleOnSave, renderSaveButton, @@ -535,6 +548,7 @@ export function SettingsProvider({ getActiveTabs, isSidebarVisible, isPageDirty, + hasScopeErrors, getPageValues, handleOnSave, renderSaveButton, diff --git a/src/components/settings/settings-types.ts b/src/components/settings/settings-types.ts index b2cdd0e..ac04228 100644 --- a/src/components/settings/settings-types.ts +++ b/src/components/settings/settings-types.ts @@ -125,6 +125,8 @@ export interface SaveButtonRenderProps { scopeId: string; /** Whether any field in the current scope has been modified. */ dirty: boolean; + /** Whether any field in the current scope has a validation error (client-side or server-side). */ + hasErrors: boolean; /** Call this to trigger save — internally gathers scope values and invokes the consumer's `onSave(scopeId, treeValues, flatValues)`. */ onSave: () => void; }