Skip to content

Commit b01e29d

Browse files
os-zhuangclaude
andcommitted
fix(plugin-form): a wizard that ends on a field-less review step can finish
A wizard step with no inputs — `{ label: 'Review', fields: [] }`, the shape this component's own usage example ends on — rendered a bare hint div and NO `<form>`. The footer Next/Create buttons submit the step form natively (`type="submit" form={stepFormId}`), so on that step they pointed at a target that did not exist: clicking Create did nothing at all and the wizard could never be completed. Observed directly — `create` call count 0, no error, no feedback. This is fallout from the fix the regression suite guards: moving the buttons off `onClick={() => handleStepSubmit(formData)}` onto native submission is what made a missing form fatal. Render a real form on the field-less step; it contributes no values of its own, so the record is whatever the earlier steps accumulated. Also correct that suite's docblock, which overstated what it covers. Two redundant mechanisms carry values across steps (react-hook-form retains unmounted fields; the wizard merges `{...formData, ...stepData}`), and "submits the merged payload" passes when EITHER is broken alone — it catches a total failure to accumulate, not a broken merge. The new field-less-step case is what pins the merge down: with no react-hook-form instance on that step, `formData` is the only carrier left. Verified by mutation — breaking only the merge now fails with `expected {} to deeply equal {"name": "Alice"}`, and reverting the form fix fails with `expected null not to be null`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 2374a49 commit b01e29d

2 files changed

Lines changed: 65 additions & 3 deletions

File tree

packages/plugin-form/src/WizardForm.regression.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,18 @@
1111
* 2. the create-mode seeding effect reset `formData` to `{}` on re-render.
1212
* The buttons now submit the inner form natively (`type="submit"` + `form={id}`)
1313
* and the create seed is idempotent.
14+
*
15+
* What each case here actually pins down — the first two overlap less than they
16+
* look, so don't read either as covering the whole merge:
17+
* - "submits the merged payload" is an END-TO-END outcome check. Two redundant
18+
* mechanisms carry values across steps (react-hook-form keeps unmounted
19+
* fields, since `shouldUnregister` is false; and the wizard merges
20+
* `{...formData, ...stepData}`), and breaking EITHER ONE ALONE still passes.
21+
* It catches a total failure to accumulate, not a broken merge.
22+
* - "carries earlier steps through a field-less review step" is what pins the
23+
* merge itself: a step with no inputs has no react-hook-form instance to
24+
* retain anything, so `formData` is the only carrier left.
25+
* - "wires the footer Next/Create buttons" pins the native-submit wiring.
1426
*/
1527
import { describe, it, expect, vi } from 'vitest';
1628
import { render, screen, waitFor, fireEvent, act } from '@testing-library/react';
@@ -90,6 +102,39 @@ describe('WizardForm — multi-step create collects every step', () => {
90102
expect(payload).toEqual(expect.objectContaining({ name: 'Alice', note: 'hello' }));
91103
});
92104

105+
it('carries earlier steps through a field-less review step (and can finish it)', async () => {
106+
const ds = makeDS();
107+
// A final review/confirm step with no inputs — the shape this component's
108+
// own usage example ends on. It renders no react-hook-form instance, so
109+
// it is the one path where the wizard's own `formData` is the ONLY thing
110+
// carrying the earlier steps.
111+
const reviewSchema = {
112+
...schema,
113+
sections: [{ label: 'Step 1', fields: ['name'] }, { label: 'Review', fields: [] }],
114+
};
115+
const { container } = render(<WizardForm schema={reviewSchema as any} dataSource={ds as any} />);
116+
117+
fireEvent.change(await settledStepInput(container, 'name'), { target: { value: 'Alice' } });
118+
fireEvent.submit(container.querySelector('form') as HTMLFormElement); // → review step
119+
120+
await waitFor(() => {
121+
if (!screen.queryByText(/No fields configured/i)) throw new Error('not on the review step');
122+
});
123+
await act(async () => {});
124+
125+
// The review step must still expose a form for the footer button to submit;
126+
// without one the button targets nothing and the wizard can never finish.
127+
const createBtn = screen.getByRole('button', { name: /create/i });
128+
const reviewForm = container.querySelector('form');
129+
expect(reviewForm).not.toBeNull();
130+
expect(createBtn.getAttribute('form')).toBe(reviewForm!.getAttribute('id'));
131+
132+
await act(async () => { fireEvent.click(createBtn); });
133+
await waitFor(() => expect(ds.create).toHaveBeenCalledTimes(1));
134+
// Step 1's value survives a step that contributed nothing of its own.
135+
expect(ds.create.mock.calls[0][1]).toEqual(expect.objectContaining({ name: 'Alice' }));
136+
});
137+
93138
it('wires the footer Next/Create buttons to the step form (cannot bypass it)', async () => {
94139
const ds = makeDS();
95140
const { container } = render(<WizardForm schema={schema as any} dataSource={ds as any} />);

packages/plugin-form/src/WizardForm.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -537,9 +537,26 @@ export const WizardForm: React.FC<WizardFormProps> = ({
537537
}}
538538
/>
539539
) : (
540-
<div className="text-center py-8 text-muted-foreground">
541-
No fields configured for this step
542-
</div>
540+
// A step can legitimately have NO inputs — a final "Review"/confirm
541+
// step is the canonical case (this component's own usage example
542+
// ends on `{ label: 'Step 3: Review', fields: [] }`). The footer
543+
// Next/Create buttons submit the step form *natively* by id, so
544+
// that form has to exist even when there is nothing to fill in:
545+
// without it the buttons point at a missing target, the click does
546+
// nothing at all, and a wizard that ends on a review step can never
547+
// be completed. Submitting contributes no new values — the record
548+
// is whatever the earlier steps accumulated in `formData`.
549+
<form
550+
id={stepFormId}
551+
onSubmit={(e) => {
552+
e.preventDefault();
553+
void handleStepSubmit({});
554+
}}
555+
>
556+
<div className="text-center py-8 text-muted-foreground">
557+
No fields configured for this step
558+
</div>
559+
</form>
543560
)}
544561
</FormSection>
545562
)}

0 commit comments

Comments
 (0)