Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<Button onClick={onSave} disabled={!dirty}>Save</Button>
renderSaveButton={({ dirty, hasErrors, onSave }) => (
<Button onClick={onSave} disabled={!dirty || hasErrors}>Save</Button>
)}
hookPrefix="my_plugin" // WordPress filter hook prefix
applyFilters={applyFilters} // @wordpress/hooks applyFilters for field extensibility
Expand Down
4 changes: 2 additions & 2 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1135,8 +1135,8 @@ function SettingsPage() {
data: treeValues,
});
}}
renderSaveButton={({ dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>
renderSaveButton={({ dirty, hasErrors, onSave }) => (
<Button onClick={onSave} disabled={!dirty || hasErrors}>
{__('Save Changes', 'my-plugin')}
</Button>
)}
Expand Down
4 changes: 2 additions & 2 deletions src/DeveloperGuide.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<Button onClick={onSave} disabled={!dirty}>{__('Save Changes', 'my-plugin')}</Button>
renderSaveButton={({ dirty, hasErrors, onSave }) => (
<Button onClick={onSave} disabled={!dirty || hasErrors}>{__('Save Changes', 'my-plugin')}</Button>
)}
/>
);
Expand Down
41 changes: 37 additions & 4 deletions src/components/settings/Settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,8 @@ function MySettingsPage() {
// flatValues: original flat dot-keyed values
console.log("Saving", scopeId, treeValues, flatValues);
}}
renderSaveButton={({ dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>
renderSaveButton={({ dirty, hasErrors, onSave }) => (
<Button onClick={onSave} disabled={!dirty || hasErrors}>
{__("Save Changes", "my-plugin")}
</Button>
)}
Expand Down Expand Up @@ -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
<Settings
onSave={async (scopeId, treeValues, flatValues) => {
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:
Expand All @@ -592,8 +620,8 @@ import { __ } from "@wordpress/i18n";
import { Settings, Button } from "@wedevs/plugin-ui";

<Settings
renderSaveButton={({ scopeId, dirty, onSave }) => (
<Button onClick={onSave} disabled={!dirty}>
renderSaveButton={({ scopeId, dirty, hasErrors, onSave }) => (
<Button onClick={onSave} disabled={!dirty || hasErrors}>
{__("Save Changes", "my-text-domain")}
</Button>
)}
Expand All @@ -619,6 +647,11 @@ import { Settings, Button } from "@wedevs/plugin-ui";
<td><code>boolean</code></td>
<td><code>true</code> if any field in the scope has been modified</td>
</tr>
<tr>
<td><code>hasErrors</code></td>
<td><code>boolean</code></td>
<td><code>true</code> if any field in the scope has a validation error (client-side or server-side)</td>
</tr>
<tr>
<td><code>onSave</code></td>
<td><code>{'() => void'}</code></td>
Expand Down
78 changes: 73 additions & 5 deletions src/components/settings/Settings.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<Button onClick={save} disabled={!dirty}>
renderSaveButton={({ dirty, hasErrors, onSave: save }) => (
<Button onClick={save} disabled={!dirty || hasErrors}>
<Save className="size-4 mr-2" />
Save Changes
</Button>
Expand Down Expand Up @@ -1192,6 +1201,65 @@ export const DependencyDemo: Story = {
),
};

function ServerSideValidationWrapper(args: SettingsProps) {
const [values, setValues] = useState<Record<string, unknown>>({
store_name: '',
});
const { entries, log } = useEventLog();

return (
<div className="flex flex-col gap-4">
<div className="text-sm text-muted-foreground px-1">
Navigate to <strong>General &rarr; Store Settings</strong>, type <code className="bg-muted px-1 rounded">&ldquo;test&rdquo;</code> as
the Store Name, and click Save. A server-side error will appear on the field.
Changing the field clears the error automatically.
</div>
<div className="h-[700px] flex flex-col">
<Settings
{...args}
className="flex-1"
values={values}
onChange={(scopeId, key, value) => {
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 }) => (
<Button onClick={triggerSave} disabled={!dirty || hasErrors}>
<Save className="size-4 mr-2" />
Save Changes
</Button>
)}
/>
</div>
<EventLog entries={entries} />
</div>
);
}

/** 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) => <ServerSideValidationWrapper {...args} />,
};

// ============================================
// Flat Array Stories
// ============================================
Expand Down
6 changes: 4 additions & 2 deletions src/components/settings/settings-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function SettingsContent({ className }: { className?: string }) {
activeTab,
setActiveTab,
isPageDirty,
hasScopeErrors,
getPageValues,
save,
renderSaveButton,
Expand All @@ -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);
};
Expand Down Expand Up @@ -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}
</div>
)}
Expand Down
14 changes: 14 additions & 0 deletions src/components/settings/settings-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
/** Trigger a save for the given scope. Builds treeValues from flat pageValues, then calls the consumer's onSave(scopeId, treeValues, flatValues). */
Expand Down Expand Up @@ -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<string, any> => {
Expand Down Expand Up @@ -510,6 +522,7 @@ export function SettingsProvider({
getActiveTabs,
isSidebarVisible,
isPageDirty,
hasScopeErrors,
getPageValues,
save: handleOnSave,
renderSaveButton,
Expand All @@ -535,6 +548,7 @@ export function SettingsProvider({
getActiveTabs,
isSidebarVisible,
isPageDirty,
hasScopeErrors,
getPageValues,
handleOnSave,
renderSaveButton,
Expand Down
2 changes: 2 additions & 0 deletions src/components/settings/settings-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading