Skip to content

Commit 9c51314

Browse files
fix(plugin-form): an ObjectForm block's showReset draws the Reset button instead of clearing the form after a save (#27599)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27247 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:ObjectForm 区块的 `showReset: true` 现在会在表单按钮栏画出「重置」按钮,点了把表单恢复到打开时的值、不保存;保存后不再因为 `showReset` 清空表单。 - **为什么改**:规范把 `showReset` 写作「显示重置按钮」,渲染器却把它当成「保存后清空」:设 `true` 没按钮、每次保存清空表单,设 `false` 没作用。 - **风险与回滚**:依赖旧的「保存后清空」副作用的表单会不再清空(应改用 `submitBehavior: { kind: 'continue' }`);回滚即 revert 本 PR,无数据或协议迁移。 - **先看**:`ui/packages/components/src/renderers/form/form.tsx`(按钮与 reset 事件处理)、`ui/packages/plugin-form/src/ObjectForm.tsx`(去掉 OR 进 `resetOnSubmit` 的那行)、`ui/packages/plugin-form/src/ModalForm.tsx`(表单外的页脚按钮经表单 id 触发重置)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/plugin-form/src/ObjectForm.showReset-27247.test.tsx`,预期 8 条全过。 - **需要你做的**:无,席位落地。 ## What changes The `object-form` block declares `showReset` as "Show the reset button" (the spec text is unchanged). The renderer OR-ed it into the form's clear-after-save flag, so `true` drew no button and emptied the form after every save, and `false` did nothing. The triage ruling: the renderer honours `showReset` as declared, and `resetOnSubmit` stays its own key. - **`@object-ui/components` (`form` renderer).** `showReset` draws a Reset button in the action bar, between Cancel and Save, labelled `resetLabel` or the session language's `common.reset` (already in all ten packs; added to the no-provider fallback). The `form` element handles its own `reset` event: it restores react-hook-form's opening values (the defaults the `defaultValues` reset keeps current), clears the submit error and the rejected-field markers, and calls `preventDefault` so the browser does not also restore the DOM inputs. So any `type="reset"` button that names the form's `id` reaches the same restore. - **`@object-ui/plugin-form`.** - `ObjectForm`: the flat path no longer reads `showReset` into `resetOnSubmit`. The flat and sectioned simple paths hand `showReset` (off in `view` mode) to the bar. - `TabbedForm` forwards it to its bar. - `DrawerForm` and `ModalForm` draw the button in their own footers as `type="reset" form={formId}`, gated like Save (no button in `view` mode or on a record closed for edit). A footer with only Reset still shows. - **`@object-ui/types`.** `FormSchema` (the renderer-internal `form` node, which `@objectstack/spec` does not declare) gains `showReset?: boolean` and `resetLabel?: string`, with JSDoc and zod rows. The seat approved this file-surface addition before any push. **Not covered (no clean slot).** `WizardForm` and `MasterDetailForm` have their own action bars. A wizard has one inner form per step plus merged step data, and a master-detail form has a header form plus line items, so neither has one form whose opening values a Reset restores. Both still read no `showReset`, and nothing there changes. **Declarations, measured.** `Clause-②: yes (widening)`: the two keys reach the published entry of `@object-ui/types` (`dist/form.d.ts` declares `showReset?: boolean` and `resetLabel?: string` on the exported `FormSchema`; `dist/zod/form.zod.d.ts` has both rows). `Metadata-protocol: none`: what a metadata author writes is unchanged. The `object-form` block's `showReset` and its text are as before, and the new keys sit on the `form` node, which is not in the spec's block vocabulary or the public-tier `sdui.manifest.json` (`check-sdui-manifest` exit 0, manifest unchanged). Changeset level `patch` for the three packages. ## Tests - `ui/packages/plugin-form/src/ObjectForm.showReset-27247.test.tsx` (8): the flat form's button restores `initialValues` and does not call `create`; a save with `showReset: true` keeps the typed value (the pin against the old clear); unset draws no button; `view` mode draws none; the sectioned form restores the opening values; the tabbed form draws one button; the modal footer's button names the form's `id` and a click restores the opening values; the drawer footer draws it with the form's `id`. - `ui/packages/components/src/renderers/form/__tests__/form-reset-button-27247.test.tsx` (3): no button unless `showReset`; `resetLabel` names it and a click restores `defaultValues` without `onSubmit`; a reset button outside the form reaches it through the form `id`. <details><summary>Ablations (each committed first, mutated through scripts/ablation-replace.mjs, restored to HEAD with git diff HEAD empty)</summary> - Put `schema.showReset ||` back at the head of the flat path's `resetOnSubmit`: `a save does not clear the form because of showReset` goes red, 1 failed and 7 passed. - Delete `onReset={handleReset}` from the `form` element: 5 red (restores the opening values; outside reset button; flat form; sectioned form; modal footer), 6 passed. </details> <details><summary>Local verification (pre-merge head 42f8831, merged head 61baeb3)</summary> - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/ packages/components/src/renderers/form/ packages/types/`: pre-merge 674 files passed, 13326 tests passed, 18 skipped; post-merge 676 files passed, 13337 tests passed, 18 skipped. - `type-check` for `@object-ui/types`, `@object-ui/components`, `@object-ui/plugin-form`: exit 0 on both heads. - `dispatch-gates --commands` (34 families) run on the merged head and reconciled with `--ran`: 34 derived, 34 run, every one exit 0, 0 NOT-MEASURED, 0 UNRUN. </details> ## Acceptance notes - `content/docs/ui/components/plugins.mdx` lists `showReset` with no description. That page is generated from `sdui.manifest.json`, from the `object-form` registration inputs in `plugin-form/src/index.tsx`. Adding a description there means regenerating the manifest and the page, which is outside this card's file surface, so it is left as is. The ui README and `content/docs/**` have no sentence that describes the old effect. - `skills/objectstack-ui/references/react-blocks.md` says "Show the reset button." for `showReset`. That is now true, so no skills line is wrong. - Found while measuring, for the seat to file: on the sectioned simple path, `ObjectForm` builds its `form` node without `resetOnSubmit`. So `submitBehavior: { kind: 'continue' }` (and `resetOnSuccess` on a create form) does not clear a sectioned form after a save, while the flat form does. A probe test reproduced this: the flat form cleared, the sectioned form kept `Alpha`. - The merge commit from `origin/main` carries no trailer pair (git's default merge message; the pre-push hook accepted it). ## Verification Open an `object-form` block with `showReset: true` in create mode, type in a field, and press Reset. The field goes back to its opening value and nothing is saved. Save, and the form keeps the saved values. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXYxRw2JZU7Ybu98id)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e270dab commit 9c51314

10 files changed

Lines changed: 353 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
'@object-ui/components': patch
4+
'@object-ui/types': patch
5+
---
6+
7+
fix(plugin-form): an ObjectForm block's `showReset` draws the Reset button it declares, instead of clearing the form after every save
8+
9+
Clause-②: yes (widening)
10+
11+
Metadata-protocol: none (the `object-form` block's `showReset` key and its spec text, "Show the reset button", are unchanged; the renderer now does what that text says. The two new keys are on `@object-ui/types`' `FormSchema`, the renderer-internal `form` node that `@objectstack/spec` does not declare)
12+
13+
`showReset` on the `object-form` block is declared "Show the reset button", but the renderer passed it to the form's clear-after-save flag. So `showReset: true` drew no button and emptied the form after each successful save, and `showReset: false` did nothing.
14+
15+
- **FROM → TO.** `showReset: true` now draws a Reset button beside Cancel and Save. Pressing it puts the form back to the values it opened with (the record being edited, or the create defaults) and saves nothing. A save no longer clears the form because of `showReset`. A form that should clear after a save declares `submitBehavior: { kind: 'continue' }`, or `resetOnSuccess: true` on a create form.
16+
- **Where the button is drawn.** The simple form (flat or sectioned), the tabbed form, and the drawer and modal footers. The wizard and the master-detail form have their own action bars and draw no Reset button yet.
17+
- **`@object-ui/types`.** `FormSchema` (the `form` node) gains two optional keys: `showReset`, which draws the button in the form's action bar, and `resetLabel`, its label (default: the session language's "Reset"). The `form` node also restores its opening values on a `reset` event, so a footer outside the form can reach it with a `type="reset"` button that names the form's `id`.
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
/**
2+
* The `form` renderer's Reset button (objectstack#27247): `showReset` draws it
3+
* in the action bar, `resetLabel` names it, and pressing it — or any reset
4+
* button naming the form by `id` from outside it — restores the values the
5+
* form opened with, without a submit.
6+
*/
7+
8+
import { describe, it, expect, vi, afterEach } from 'vitest';
9+
import React from 'react';
10+
import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';
11+
import { ComponentRegistry } from '@object-ui/core';
12+
// Module-scope import (not `beforeAll`) — objectui#3010/#3021.
13+
import '../../../renderers';
14+
15+
type Rec = Record<string, unknown>;
16+
17+
function renderForm(schema: Rec, after?: React.ReactNode, domProps: Rec = {}) {
18+
const Form = ComponentRegistry.get('form')!;
19+
return render(
20+
<>
21+
<Form
22+
schema={{
23+
type: 'form',
24+
fields: [{ name: 'title', label: 'Title', type: 'input' }],
25+
defaultValues: { title: 'Opening' },
26+
...schema,
27+
}}
28+
{...domProps}
29+
/>
30+
{after}
31+
</>,
32+
);
33+
}
34+
35+
const titleInput = () => screen.getByLabelText('Title') as HTMLInputElement;
36+
37+
describe('form renderer — showReset (objectstack#27247)', () => {
38+
afterEach(cleanup);
39+
40+
it('draws no Reset button unless showReset is set', () => {
41+
const { container } = renderForm({});
42+
expect(container.querySelector('button[type="reset"]')).toBeNull();
43+
});
44+
45+
it('restores the opening values and submits nothing', async () => {
46+
const onSubmit = vi.fn();
47+
renderForm({ showReset: true, resetLabel: 'Start over', onSubmit });
48+
fireEvent.change(titleInput(), { target: { value: 'Edited' } });
49+
expect(titleInput().value).toBe('Edited');
50+
51+
fireEvent.click(screen.getByRole('button', { name: 'Start over' }));
52+
53+
await waitFor(() => expect(titleInput().value).toBe('Opening'));
54+
expect(onSubmit).not.toHaveBeenCalled();
55+
});
56+
57+
it('a reset button outside the form reaches it through the form id', async () => {
58+
// The `id` arrives the way `SchemaRenderer` delivers a node's DOM props —
59+
// beside `schema` — which is how the drawer and modal forms get theirs.
60+
renderForm(
61+
{ showActions: false },
62+
<button type="reset" form="f-27247">Outside</button>,
63+
{ id: 'f-27247' },
64+
);
65+
fireEvent.change(titleInput(), { target: { value: 'Edited' } });
66+
fireEvent.click(screen.getByRole('button', { name: 'Outside' }));
67+
await waitFor(() => expect(titleInput().value).toBe('Opening'));
68+
});
69+
});

‎ui/packages/components/src/renderers/form/form.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,9 @@ const useSafeFormTranslation = createSafeTranslation(
439439
// so a form with no I18nProvider renders what it always did.
440440
'common.submit': 'Submit',
441441
'common.cancel': 'Cancel',
442+
// objectstack#27247 — the declared Reset button's copy when no
443+
// `resetLabel` is authored; the shared key, for the reason above.
444+
'common.reset': 'Reset',
442445
// objectui#3272 — the fullscreen long-text editor (`mobile_fullscreen`).
443446
// A whole dialog, not a word: title, its sr-only description, the footer's
444447
// confirm button, and the trigger's accessible name. `Cancel` in the footer
@@ -1617,7 +1620,9 @@ ComponentRegistry.register('form',
16171620
// it". An authored value still wins verbatim, including `''`.
16181621
submitLabel,
16191622
cancelLabel,
1623+
resetLabel,
16201624
showCancel = false,
1625+
showReset = false,
16211626
showSubmit = true,
16221627
layout = 'vertical',
16231628
columns = 1,
@@ -3189,6 +3194,20 @@ ComponentRegistry.register('form',
31893194
return submitThroughValidation(event);
31903195
};
31913196

3197+
// Handle reset (objectstack#27247). The form goes back to the values it
3198+
// opened with — react-hook-form's defaults, which the `defaultValues`
3199+
// reset above keeps current — and nothing is saved. Wired to the
3200+
// <form>'s own `reset` event, not only to the bar's button, so a footer
3201+
// outside the form (drawer, modal) reaches it with a `type="reset"`
3202+
// button naming the form's `id`; `preventDefault` keeps the browser from
3203+
// also restoring the DOM inputs behind react-hook-form's back.
3204+
const handleReset = (event?: React.FormEvent<HTMLFormElement>) => {
3205+
event?.preventDefault();
3206+
form.reset();
3207+
setSubmitError(null);
3208+
setRejectedFieldNames([]);
3209+
};
3210+
31923211
// Handle cancel
31933212
const handleCancel = () => {
31943213
form.reset();
@@ -3941,8 +3960,10 @@ ComponentRegistry.register('form',
39413960
// Extract schema props that should not be spread to DOM (handled separately by schema destructuring above)
39423961
submitLabel: _submitLabel,
39433962
cancelLabel: _cancelLabel,
3963+
resetLabel: _resetLabel,
39443964
showSubmit: _showSubmit,
39453965
showCancel: _showCancel,
3966+
showReset: _showReset,
39463967
resetOnSubmit: _resetOnSubmit,
39473968
defaultValues: _defaultValues,
39483969
inputType: _inputType,
@@ -3993,6 +4014,7 @@ ComponentRegistry.register('form',
39934014
<form
39944015
ref={formRef}
39954016
onSubmit={handleSubmit}
4017+
onReset={handleReset}
39964018
className={className}
39974019
{...formProps}
39984020
// Apply designer props
@@ -4176,6 +4198,16 @@ ComponentRegistry.register('form',
41764198
{cancelLabel ?? t('common.cancel')}
41774199
</Button>
41784200
)}
4201+
{showReset && (
4202+
<Button
4203+
type="reset"
4204+
variant="outline"
4205+
disabled={isSubmitting || disabled}
4206+
className="w-full sm:w-auto"
4207+
>
4208+
{resetLabel ?? t('common.reset')}
4209+
</Button>
4210+
)}
41794211
{showSubmit && (
41804212
<Button
41814213
type="submit"
@@ -4209,6 +4241,8 @@ ComponentRegistry.register('form',
42094241
{ name: 'submitLabel', type: 'string' },
42104242
{ name: 'cancelLabel', type: 'string' },
42114243
{ name: 'showCancel', type: 'boolean' },
4244+
{ name: 'showReset', type: 'boolean', description: 'Draw a Reset button that restores the opening values without saving' },
4245+
{ name: 'resetLabel', type: 'string' },
42124246
{
42134247
name: 'layout',
42144248
type: 'enum',

‎ui/packages/plugin-form/src/DrawerForm.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ const useDiscardTranslation = createSafeTranslation(
9898
'form.create': 'Create',
9999
'form.update': 'Update',
100100
'common.cancel': 'Cancel',
101+
// The footer's declared Reset button (objectstack#27247).
102+
'common.reset': 'Reset',
101103
'form.errorLoading': 'Error loading form',
102104
'publicForm.loading': 'Loading form…',
103105
},
@@ -185,6 +187,8 @@ export interface DrawerFormSchema {
185187
submitText?: string;
186188
showCancel?: boolean;
187189
cancelText?: string;
190+
/** The Reset button in the footer: restores the opening values, no save (objectstack#27247). */
191+
showReset?: boolean;
188192
initialValues?: Record<string, any>;
189193
initialData?: Record<string, any>;
190194
readOnly?: boolean;
@@ -739,6 +743,7 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
739743
});
740744
const showSubmit = schema.showSubmit !== false && schema.mode !== 'view' && !closedForRecord(closedAffordance);
741745
const showCancel = schema.showCancel !== false;
746+
const showReset = schema.showReset === true && schema.mode !== 'view' && !closedForRecord(closedAffordance);
742747
const submitLabel = schema.submitText || (schema.mode === 'create' ? t('form.create') : t('form.update'));
743748
const cancelLabel = schema.cancelText || t('common.cancel');
744749

@@ -1014,7 +1019,7 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
10141019
{/* Sticky footer — own action buttons. Cancel calls the discard guard
10151020
directly (no form.reset), so unsaved input survives "Keep editing".
10161021
Suppressed for the master-detail path, which owns its own action bar. */}
1017-
{!error && !loading && !(subforms?.length && schema.mode !== 'view') && (showSubmit || showCancel) && (
1022+
{!error && !loading && !(subforms?.length && schema.mode !== 'view') && (showSubmit || showCancel || showReset) && (
10181023
<div className="shrink-0 border-t px-4 py-3 bg-background" data-testid="drawer-form-footer">
10191024
{/* The REASON the Save below is disabled (objectui#10166). */}
10201025
<UploadInFlightNotice gate={uploadGate} />
@@ -1030,6 +1035,20 @@ const DrawerFormBody: React.FC<DrawerFormProps> = ({
10301035
{cancelLabel}
10311036
</Button>
10321037
)}
1038+
{/* The declared Reset button (objectstack#27247). It lives out
1039+
here, outside the <form>, so it names the form by `id` and
1040+
the form's own `reset` handler restores the opening values. */}
1041+
{showReset && (
1042+
<Button
1043+
type="reset"
1044+
form={formId}
1045+
variant="outline"
1046+
disabled={isSubmitting}
1047+
className="w-full sm:w-auto"
1048+
>
1049+
{t('common.reset')}
1050+
</Button>
1051+
)}
10331052
{showSubmit && (
10341053
<Button
10351054
type="submit"

‎ui/packages/plugin-form/src/ModalForm.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ const useDiscardTranslation = createSafeTranslation(
106106
'form.create': 'Create',
107107
'form.update': 'Update',
108108
'common.cancel': 'Cancel',
109+
// The footer's declared Reset button (objectstack#27247).
110+
'common.reset': 'Reset',
109111
'form.errorLoading': 'Error loading form',
110112
},
111113
'form.discardTitle',
@@ -222,6 +224,8 @@ export interface ModalFormSchema {
222224
submitText?: string;
223225
showCancel?: boolean;
224226
cancelText?: string;
227+
/** The Reset button in the footer: restores the opening values, no save (objectstack#27247). */
228+
showReset?: boolean;
225229
initialValues?: Record<string, any>;
226230
initialData?: Record<string, any>;
227231
readOnly?: boolean;
@@ -867,6 +871,7 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
867871
});
868872
const showSubmit = schema.showSubmit !== false && schema.mode !== 'view' && !closedForRecord(closedAffordance);
869873
const showCancel = schema.showCancel !== false;
874+
const showReset = schema.showReset === true && schema.mode !== 'view' && !closedForRecord(closedAffordance);
870875
const submitLabel = schema.submitText || (schema.mode === 'create' ? t('form.create') : t('form.update'));
871876
const cancelLabel = schema.cancelText || t('common.cancel');
872877

@@ -1219,7 +1224,7 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
12191224
);
12201225
}
12211226

1222-
const hasFooter = !loading && !error && (showSubmit || showCancel);
1227+
const hasFooter = !loading && !error && (showSubmit || showCancel || showReset);
12231228

12241229
return (
12251230
<Dialog
@@ -1282,6 +1287,20 @@ const ModalFormBody: React.FC<ModalFormProps> = ({
12821287
{cancelLabel}
12831288
</Button>
12841289
)}
1290+
{/* The declared Reset button (objectstack#27247). It lives out
1291+
here, outside the <form>, so it names the form by `id` and
1292+
the form's own `reset` handler restores the opening values. */}
1293+
{showReset && (
1294+
<Button
1295+
type="reset"
1296+
form={formId}
1297+
variant="outline"
1298+
disabled={isSubmitting}
1299+
className="w-full sm:w-auto"
1300+
>
1301+
{t('common.reset')}
1302+
</Button>
1303+
)}
12851304
{showSubmit && (
12861305
<Button
12871306
type="submit"

0 commit comments

Comments
 (0)