Skip to content

Commit 35a057e

Browse files
mrabbaniclaude
andauthored
feat: disable save button on validation errors + server-side error docs (#69)
* feat: disable save button when validation errors exist and add server-side error docs - Add `hasErrors` to SaveButtonRenderProps so consumers can disable save on errors - Add `hasScopeErrors(scopeId)` to SettingsContextValue for scope-level error checks - Block handleSave internally when scope has validation errors - Add server-side validation error documentation with examples in Settings.mdx - Add dedicated ServerSideValidation story demonstrating error flow - Update all docs and examples to use `disabled={!dirty || hasErrors}` Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: extract ServerSideValidation render into component and escape entities Fixes ESLint react-hooks/rules-of-hooks and react/no-unescaped-entities errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ef6c1c6 commit 35a057e

8 files changed

Lines changed: 136 additions & 17 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@ import { Settings } from '@wedevs/plugin-ui';
6666
// e.g. { "dokan.general.store_name": "..." }
6767
await api.post(`/settings/${scopeId}`, treeValues);
6868
}}
69-
renderSaveButton={({ dirty, onSave }) => (
70-
<Button onClick={onSave} disabled={!dirty}>Save</Button>
69+
renderSaveButton={({ dirty, hasErrors, onSave }) => (
70+
<Button onClick={onSave} disabled={!dirty || hasErrors}>Save</Button>
7171
)}
7272
hookPrefix="my_plugin" // WordPress filter hook prefix
7373
applyFilters={applyFilters} // @wordpress/hooks applyFilters for field extensibility

DEVELOPER_GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1135,8 +1135,8 @@ function SettingsPage() {
11351135
data: treeValues,
11361136
});
11371137
}}
1138-
renderSaveButton={({ dirty, onSave }) => (
1139-
<Button onClick={onSave} disabled={!dirty}>
1138+
renderSaveButton={({ dirty, hasErrors, onSave }) => (
1139+
<Button onClick={onSave} disabled={!dirty || hasErrors}>
11401140
{__('Save Changes', 'my-plugin')}
11411141
</Button>
11421142
)}

src/DeveloperGuide.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -915,8 +915,8 @@ function SettingsPage() {
915915
onSave={async (scopeId, treeValues) => {
916916
await apiFetch({ path: `/my-plugin/v1/settings/${scopeId}`, method: 'POST', data: treeValues });
917917
}}
918-
renderSaveButton={({ dirty, onSave }) => (
919-
<Button onClick={onSave} disabled={!dirty}>{__('Save Changes', 'my-plugin')}</Button>
918+
renderSaveButton={({ dirty, hasErrors, onSave }) => (
919+
<Button onClick={onSave} disabled={!dirty || hasErrors}>{__('Save Changes', 'my-plugin')}</Button>
920920
)}
921921
/>
922922
);

src/components/settings/Settings.mdx

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,8 @@ function MySettingsPage() {
8282
// flatValues: original flat dot-keyed values
8383
console.log("Saving", scopeId, treeValues, flatValues);
8484
}}
85-
renderSaveButton={({ dirty, onSave }) => (
86-
<Button onClick={onSave} disabled={!dirty}>
85+
renderSaveButton={({ dirty, hasErrors, onSave }) => (
86+
<Button onClick={onSave} disabled={!dirty || hasErrors}>
8787
{__("Save Changes", "my-plugin")}
8888
</Button>
8989
)}
@@ -571,6 +571,34 @@ Fired when the save button is clicked. Only receives values **scoped to the acti
571571
/>
572572
```
573573

574+
### Server-Side Validation Errors
575+
576+
If your API returns field-level validation errors, **throw an object** with an `errors` property
577+
from `onSave`. The keys must match field `dependency_key` values:
578+
579+
```tsx
580+
<Settings
581+
onSave={async (scopeId, treeValues, flatValues) => {
582+
const res = await fetch(`/wp-json/my-plugin/v1/settings/${scopeId}`, {
583+
method: "POST",
584+
headers: { "Content-Type": "application/json" },
585+
body: JSON.stringify(treeValues),
586+
});
587+
588+
if (!res.ok) {
589+
const data = await res.json();
590+
// Throw with { errors: { [dependency_key]: "message" } }
591+
throw { errors: data.errors };
592+
// e.g. { errors: { "dokan.general.store_name": "Store name already taken" } }
593+
}
594+
}}
595+
/>
596+
```
597+
598+
- Errors display on the matching fields in red (`text-destructive`)
599+
- The save button is disabled while errors are present (`hasErrors` becomes `true`)
600+
- Errors auto-clear when the user changes the errored field
601+
574602
### Scope ID resolution
575603

576604
The `scopeId` follows this logic:
@@ -592,8 +620,8 @@ import { __ } from "@wordpress/i18n";
592620
import { Settings, Button } from "@wedevs/plugin-ui";
593621

594622
<Settings
595-
renderSaveButton={({ scopeId, dirty, onSave }) => (
596-
<Button onClick={onSave} disabled={!dirty}>
623+
renderSaveButton={({ scopeId, dirty, hasErrors, onSave }) => (
624+
<Button onClick={onSave} disabled={!dirty || hasErrors}>
597625
{__("Save Changes", "my-text-domain")}
598626
</Button>
599627
)}
@@ -619,6 +647,11 @@ import { Settings, Button } from "@wedevs/plugin-ui";
619647
<td><code>boolean</code></td>
620648
<td><code>true</code> if any field in the scope has been modified</td>
621649
</tr>
650+
<tr>
651+
<td><code>hasErrors</code></td>
652+
<td><code>boolean</code></td>
653+
<td><code>true</code> if any field in the scope has a validation error (client-side or server-side)</td>
654+
</tr>
622655
<tr>
623656
<td><code>onSave</code></td>
624657
<td><code>{'() => void'}</code></td>

src/components/settings/Settings.stories.tsx

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,13 +1108,22 @@ function SettingsStoryWrapper({
11081108
setValues((prev) => ({ ...prev, [key]: value }));
11091109
log({ type: 'change', pageId: scopeId, key, value });
11101110
}}
1111-
onSave={(scopeId, scopeValues) => {
1111+
onSave={async (scopeId, _treeValues, flatValues) => {
11121112
// eslint-disable-next-line no-console
1113-
console.log(`Save scope "${scopeId}":`, scopeValues);
1114-
log({ type: 'save', pageId: scopeId, values: scopeValues });
1113+
console.log(`Save scope "${scopeId}":`, flatValues);
1114+
log({ type: 'save', pageId: scopeId, values: flatValues });
1115+
1116+
// Simulate server-side validation: if store_name is "test", throw a field error
1117+
if (flatValues['store_name'] === 'test') {
1118+
throw {
1119+
errors: {
1120+
store_name: 'Store name "test" is already taken. Please choose another.',
1121+
},
1122+
};
1123+
}
11151124
}}
1116-
renderSaveButton={({ dirty, onSave: save }) => (
1117-
<Button onClick={save} disabled={!dirty}>
1125+
renderSaveButton={({ dirty, hasErrors, onSave: save }) => (
1126+
<Button onClick={save} disabled={!dirty || hasErrors}>
11181127
<Save className="size-4 mr-2" />
11191128
Save Changes
11201129
</Button>
@@ -1192,6 +1201,65 @@ export const DependencyDemo: Story = {
11921201
),
11931202
};
11941203

1204+
function ServerSideValidationWrapper(args: SettingsProps) {
1205+
const [values, setValues] = useState<Record<string, unknown>>({
1206+
store_name: '',
1207+
});
1208+
const { entries, log } = useEventLog();
1209+
1210+
return (
1211+
<div className="flex flex-col gap-4">
1212+
<div className="text-sm text-muted-foreground px-1">
1213+
Navigate to <strong>General &rarr; Store Settings</strong>, type <code className="bg-muted px-1 rounded">&ldquo;test&rdquo;</code> as
1214+
the Store Name, and click Save. A server-side error will appear on the field.
1215+
Changing the field clears the error automatically.
1216+
</div>
1217+
<div className="h-[700px] flex flex-col">
1218+
<Settings
1219+
{...args}
1220+
className="flex-1"
1221+
values={values}
1222+
onChange={(scopeId, key, value) => {
1223+
setValues((prev) => ({ ...prev, [key]: value }));
1224+
log({ type: 'change', pageId: scopeId, key, value });
1225+
}}
1226+
onSave={async (scopeId, _treeValues, flatValues) => {
1227+
// Simulate network delay
1228+
await new Promise((r) => setTimeout(r, 500));
1229+
1230+
log({ type: 'save', pageId: scopeId, values: flatValues });
1231+
1232+
// Simulate server-side validation error
1233+
if (flatValues['store_name'] === 'test') {
1234+
throw {
1235+
errors: {
1236+
store_name: 'Store name "test" is already taken. Please choose another.',
1237+
},
1238+
};
1239+
}
1240+
}}
1241+
renderSaveButton={({ dirty, hasErrors, onSave: triggerSave }) => (
1242+
<Button onClick={triggerSave} disabled={!dirty || hasErrors}>
1243+
<Save className="size-4 mr-2" />
1244+
Save Changes
1245+
</Button>
1246+
)}
1247+
/>
1248+
</div>
1249+
<EventLog entries={entries} />
1250+
</div>
1251+
);
1252+
}
1253+
1254+
/** Server-side validation demo — type "test" as store name and save to see a server error. */
1255+
export const ServerSideValidation: Story = {
1256+
args: {
1257+
schema: sampleSchema,
1258+
title: 'Server-Side Validation',
1259+
},
1260+
render: (args) => <ServerSideValidationWrapper {...args} />,
1261+
};
1262+
11951263
// ============================================
11961264
// Flat Array Stories
11971265
// ============================================

src/components/settings/settings-content.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export function SettingsContent({ className }: { className?: string }) {
2020
activeTab,
2121
setActiveTab,
2222
isPageDirty,
23+
hasScopeErrors,
2324
getPageValues,
2425
save,
2526
renderSaveButton,
@@ -32,9 +33,10 @@ export function SettingsContent({ className }: { className?: string }) {
3233
// Scope ID: subpage ID if a subpage is active, otherwise page ID
3334
const scopeId = activeSubpage || activePage;
3435
const dirty = isPageDirty(scopeId);
36+
const hasErrors = hasScopeErrors(scopeId);
3537

3638
const handleSave = () => {
37-
if (!save) return;
39+
if (!save || hasErrors) return;
3840
const scopeValues = getPageValues(scopeId);
3941
save(scopeId, scopeValues);
4042
};
@@ -134,7 +136,7 @@ export function SettingsContent({ className }: { className?: string }) {
134136
data-testid={`settings-save-${scopeId}`}
135137
>
136138
{renderSaveButton
137-
? renderSaveButton({ scopeId, dirty, onSave: handleSave })
139+
? renderSaveButton({ scopeId, dirty, hasErrors, onSave: handleSave })
138140
: null}
139141
</div>
140142
)}

src/components/settings/settings-context.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ export interface SettingsContextValue {
6666
isSidebarVisible: boolean;
6767
/** Check if any field on a specific page has been modified */
6868
isPageDirty: (pageId: string) => boolean;
69+
/** Check if any field on a specific page has a validation error */
70+
hasScopeErrors: (scopeId: string) => boolean;
6971
/** Get only the values that belong to a specific page */
7072
getPageValues: (pageId: string) => Record<string, any>;
7173
/** 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({
232234
[scopeFieldKeysMap, values, initialValues]
233235
);
234236

237+
// Per-scope error check
238+
const hasScopeErrors = useCallback(
239+
(scopeId: string): boolean => {
240+
const keys = scopeFieldKeysMap.get(scopeId);
241+
if (!keys) return false;
242+
return keys.some((key) => key in errors);
243+
},
244+
[scopeFieldKeysMap, errors]
245+
);
246+
235247
// Per-scope values extraction
236248
const getPageValues = useCallback(
237249
(scopeId: string): Record<string, any> => {
@@ -510,6 +522,7 @@ export function SettingsProvider({
510522
getActiveTabs,
511523
isSidebarVisible,
512524
isPageDirty,
525+
hasScopeErrors,
513526
getPageValues,
514527
save: handleOnSave,
515528
renderSaveButton,
@@ -535,6 +548,7 @@ export function SettingsProvider({
535548
getActiveTabs,
536549
isSidebarVisible,
537550
isPageDirty,
551+
hasScopeErrors,
538552
getPageValues,
539553
handleOnSave,
540554
renderSaveButton,

src/components/settings/settings-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ export interface SaveButtonRenderProps {
125125
scopeId: string;
126126
/** Whether any field in the current scope has been modified. */
127127
dirty: boolean;
128+
/** Whether any field in the current scope has a validation error (client-side or server-side). */
129+
hasErrors: boolean;
128130
/** Call this to trigger save — internally gathers scope values and invokes the consumer's `onSave(scopeId, treeValues, flatValues)`. */
129131
onSave: () => void;
130132
}

0 commit comments

Comments
 (0)