Skip to content

Commit 5e2f386

Browse files
fix(plugin-form): a sectioned object-form clears after a save on submitBehavior continue and resetOnSuccess (#27663)
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 #27602 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:`object-form` 的分组(sections)表单现在与平铺表单读同一条「保存后清空」规则,`submitBehavior: { kind: 'continue' }` 或新建表单上的 `resetOnSuccess: true` 在保存后清空表单。 - 为什么改:分组表单原先不带这条规则,「保存并新增下一条」保存后仍留着上一条的值,用户再存一次就是一条近似重复记录;平铺表单早已正确。 - 风险与回滚:风险低,只是把平铺路径已有的同一表达式提成一个常量,两条路径共读;两个键都没声明的表单行为不变。回滚即 revert 本 PR。 - 先看:`ui/packages/plugin-form/src/ObjectForm.tsx` 的 `resetOnSubmit` 常量及分组路径 `form` 节点新加的一行;再看 `ObjectForm.resetOnSubmit-27602.test.tsx`。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-form/src/ObjectForm.resetOnSubmit-27602.test.tsx`,预期 6 个测试全绿(平铺、分组各 3 个)。 - 需要你做的:无,席位落地。 ## What changes `SimpleObjectForm` renders an `object-form` block through one of two `form` nodes: the flat one, and the sectioned one (explicit `sections`, or sections derived from the object's field groups). Only the flat node carried `resetOnSubmit`, so a sectioned "save and add another" form kept the saved record's values while the same declaration on a flat form cleared it. The rule is now one named const, `resetOnSubmit`, declared once beside `formLayout` and read by both nodes: ```ts const resetOnSubmit = schema.submitBehavior?.kind === 'continue' || (!schema.submitBehavior && schema.resetOnSuccess && schema.mode === 'create'); ``` The expression is unchanged from the flat path's; the flat node now reads the const instead of its own copy, and the sectioned node gains the same key. Nothing else resets the form imperatively: the `continue` arm of the submit-behaviour switch is a no-op whose comment already defers to `resetOnSubmit`, and the form renderer's single `form.reset()` call after a successful submit is the only reader. Changeset: `@object-ui/plugin-form` patch. ## Tests `ui/packages/plugin-form/src/ObjectForm.resetOnSubmit-27602.test.tsx` renders the same block flat and sectioned (`describe.each` over both paths) and saves `name: 'Alpha'`, reading the field once the submit button is enabled again (the form disables it for the whole submit, reset included): - `submitBehavior: { kind: 'continue' }` clears, on both paths; - `resetOnSuccess: true` on a create form clears, on both paths; - neither key declared keeps `Alpha`, on both paths. **Ablation**, run once, on the committed fix (`97c6b181`), through `scripts/ablation-replace.mjs`. The mutation deleted the sectioned node's `resetOnSubmit,` line (anchor 1 → 0, blob `1fa7f7aa0930` → `5c7ae6369dbf`). Result: the two sectioned clear cases went red and the other four stayed green (`Tests 2 failed | 4 passed (6)`). The restore was verified with blob == HEAD and an empty `git diff HEAD`. The test imports `./ObjectForm` from source, so no build sits between the mutation and the run. ## Acceptance notes - Not reproduced in a browser; the defect and the fix are both measured at the component layer, through the same `ObjectForm` the block registers. - Lint on the two touched files: 0 errors. The warnings are `no-explicit-any`; the new test's four come from the same `as any` schema and data-source casts its sibling `ObjectForm.showReset-27247.test.tsx` uses. ## Verification Local runs below were taken on head `57e13970`. <details><summary>Targeted tests, type-check, lint</summary> ```text pnpm --dir ui exec vitest run --maxWorkers=2 --reporter=verbose packages/plugin-form/src/ObjectForm.resetOnSubmit-27602.test.tsx packages/plugin-form/src/ObjectForm.submitBehavior.test.tsx packages/plugin-form/src/ObjectForm.showReset-27247.test.tsx packages/plugin-form/src/sectionedFormValues.test.tsx packages/components/src/renderers/form/__tests__/form-invalid-input-refuses-submit-27483.test.tsx :: exit 0 Test Files 5 passed (5) · Tests 30 passed (30) · the new file's 6 named, flat and sectioned pnpm --filter @object-ui/plugin-form run type-check :: exit 0 (tsc --noEmit && tsc -p tsconfig.test.json) pnpm --filter @object-ui/plugin-form exec eslint src/ObjectForm.tsx src/ObjectForm.resetOnSubmit-27602.test.tsx :: exit 0 (0 errors) pnpm --dir ui run check:new-line-citations :: exit 0 (0 new citations) pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/ :: exit 0 (on 97c6b18, before the main merge: Test Files 212 passed (212) · Tests 2357 passed | 1 skipped) ``` The whole-package run is from the pre-merge commit. The merge brought no change under `plugin-form`, so after it the five files above were run again, including the form renderer test the merge added beside the `resetOnSubmit` reader in `form.tsx`. </details> <details><summary>Derived gate families (dispatch-gates --commands, reconciled with --ran)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:pm-changeset-deadline-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran FILE` printed: `34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED`. Every family CI runs beyond these (lint, type-check lanes, Test UI, Build & E2E UI) is read from this PR's check-runs. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CY2n1JmZZKrHgtp9jC)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9d29223 commit 5e2f386

3 files changed

Lines changed: 118 additions & 2 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
---
4+
5+
fix(plugin-form): a sectioned `object-form` clears after a save when it declares `submitBehavior: { kind: 'continue' }` or `resetOnSuccess`, as the flat form does
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A "save and add another" form declares `submitBehavior: { kind: 'continue' }`, or `resetOnSuccess: true` on a create form. When the form's fields were grouped into sections (explicit `sections`, or sections derived from the object's field groups), the form kept the saved record's values after the save, so the next entry started from them and could be saved as a near-duplicate. The flat form already cleared. Both now read one shared rule: `continue` clears, `resetOnSuccess` clears a create form when no `submitBehavior` is declared, and a form that declares neither keeps its values.
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* "Save and add another" on the `object-form` block (objectstack#27602).
6+
* `submitBehavior: { kind: 'continue' }`, or the legacy `resetOnSuccess: true`
7+
* on a create form, clears the form after a save. `SimpleObjectForm` renders
8+
* the same block through two `form` nodes — the flat one and the sectioned
9+
* one — and only the flat node carried the rule, so a form whose fields are
10+
* grouped into sections kept the saved record's values, inviting a
11+
* near-duplicate on the next save. Both nodes now read one shared expression.
12+
*
13+
* Pinned on BOTH paths, with the same block:
14+
*
15+
* - `submitBehavior: { kind: 'continue' }` clears after a save;
16+
* - `resetOnSuccess: true` on a create form clears after a save;
17+
* - neither key declared: the saved values stay.
18+
*/
19+
import { describe, it, expect, vi, beforeEach } from 'vitest';
20+
import { render, waitFor, fireEvent } from '@testing-library/react';
21+
import React from 'react';
22+
23+
const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() }));
24+
vi.mock('@object-ui/components', async (importOriginal) => {
25+
const actual = await importOriginal<any>();
26+
return { ...actual, toast: { ...actual.toast, success: toastSuccess, error: vi.fn() } };
27+
});
28+
29+
import { ObjectForm } from './ObjectForm';
30+
import { registerAllFields } from '@object-ui/fields';
31+
32+
registerAllFields();
33+
34+
const objectSchema = {
35+
name: 'o',
36+
fields: {
37+
name: { type: 'text', label: 'Name' },
38+
note: { type: 'text', label: 'Note' },
39+
},
40+
};
41+
const makeDS = () => ({
42+
getObjectSchema: vi.fn().mockResolvedValue(objectSchema),
43+
create: vi.fn(async (_o: string, d: any) => ({ id: 'r1', ...d })),
44+
update: vi.fn(),
45+
findOne: vi.fn(),
46+
});
47+
const waitInput = (root: ParentNode, name: string) =>
48+
waitFor(() => {
49+
const el = root.querySelector(`input[name="${name}"]`) as HTMLInputElement | null;
50+
if (!el) throw new Error(`${name} not ready`);
51+
return el;
52+
});
53+
54+
const PATHS = [
55+
{ path: 'flat', shape: {} },
56+
{ path: 'sectioned', shape: { sections: [{ name: 's1', label: 'Main', fields: ['name', 'note'] }] } },
57+
] as const;
58+
59+
/**
60+
* Renders the block, types a value into `name`, saves, and returns the value
61+
* `name` holds once the save has fully settled — the form's submit button is
62+
* disabled for the whole submit, `resetOnSubmit` included, and re-enabled
63+
* only after it.
64+
*/
65+
const saveAndRead = async (schema: Record<string, unknown>) => {
66+
const ds = makeDS();
67+
const { container } = render(
68+
<ObjectForm
69+
schema={{ type: 'object-form', objectName: 'o', mode: 'create', ...schema } as any}
70+
dataSource={ds as any}
71+
/>,
72+
);
73+
fireEvent.change(await waitInput(container, 'name'), { target: { value: 'Alpha' } });
74+
fireEvent.submit(container.querySelector('form') as HTMLFormElement);
75+
await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(1));
76+
expect(ds.create.mock.calls[0][1]).toMatchObject({ name: 'Alpha' });
77+
const submit = container.querySelector('button[type="submit"]') as HTMLButtonElement;
78+
await waitFor(() => expect(submit.disabled).toBe(false));
79+
return (container.querySelector('input[name="name"]') as HTMLInputElement).value;
80+
};
81+
82+
describe.each(PATHS)('ObjectForm — clear after save, $path form (objectstack#27602)', ({ shape }) => {
83+
beforeEach(() => toastSuccess.mockClear());
84+
85+
it("submitBehavior { kind: 'continue' } clears the form after a save", async () => {
86+
expect(await saveAndRead({ ...shape, submitBehavior: { kind: 'continue' } })).toBe('');
87+
});
88+
89+
it('resetOnSuccess: true on a create form clears the form after a save', async () => {
90+
expect(await saveAndRead({ ...shape, resetOnSuccess: true })).toBe('');
91+
});
92+
93+
it('neither key declared: the saved values stay', async () => {
94+
expect(await saveAndRead({ ...shape })).toBe('Alpha');
95+
expect(toastSuccess).toHaveBeenCalled();
96+
});
97+
});

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1548,6 +1548,15 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
15481548
// the retired `inline` / `grid` to `vertical` here is retired with them.
15491549
const formLayout = schema.layout ?? 'vertical';
15501550

1551+
// Clear after a save ("save and add another"): `submitBehavior: { kind:
1552+
// 'continue' }`, or the legacy `resetOnSuccess` on a create form when no
1553+
// `submitBehavior` is declared. ONE rule, read by both the sectioned and the
1554+
// flat `form` node below — the sectioned node used to carry none, so a
1555+
// grouped form kept the saved record's values while the flat one cleared
1556+
// (objectstack#27602).
1557+
const resetOnSubmit = schema.submitBehavior?.kind === 'continue'
1558+
|| (!schema.submitBehavior && schema.resetOnSuccess && schema.mode === 'create');
1559+
15511560
// If sections are provided (explicitly, or derived from the object's
15521561
// `fieldGroups`) for the simple form, render them as full-width, optionally
15531562
// collapsible groups.
@@ -1745,6 +1754,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
17451754
showCancel: schema.showCancel !== false,
17461755
// The declared Reset button (objectstack#27247); see the flat path.
17471756
showReset: schema.showReset === true && schema.mode !== 'view',
1757+
resetOnSubmit,
17481758
// While an upload is in flight the Save button says so, the same
17491759
// answer ActionParamDialog gives its Confirm button. The notice
17501760
// below carries the reason in a sentence (objectui#10166).
@@ -1909,8 +1919,7 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
19091919
// submit: it used to be OR-ed into `resetOnSubmit` below, so `true` drew
19101920
// no button and emptied the form after every save (objectstack#27247).
19111921
showReset: schema.showReset === true && schema.mode !== 'view',
1912-
resetOnSubmit: schema.submitBehavior?.kind === 'continue'
1913-
|| (!schema.submitBehavior && schema.resetOnSuccess && schema.mode === 'create'),
1922+
resetOnSubmit,
19141923
defaultValues: finalDefaultValues,
19151924
previousValues,
19161925
onSubmit: handleSubmit,

0 commit comments

Comments
 (0)