Skip to content

Commit 0d45c0e

Browse files
fix(ui): a wizard form view keeps layout horizontal, and the form page draws it as steps (#27267)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ858UZPVazGF5N8yCfayxT1g)_ Fixes #27224 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:分步表单(`type: 'wizard'`)写 `layout: 'horizontal'` 时每一步的字段标签都放到输入框左侧;`/forms/:name` 与 `/f/:slug` 表单页把分步表单画成逐步填写(步骤条、下一步/上一步、最后一步提交)。 - 为什么改:这两项都能保存发布,但分步表单把 layout 固定成 vertical,表单页把所有分节堆在一页,作者以为生效了其实没有。 - 风险与回滚:只改 WizardForm 的一处 layout 传值和表单页的 wizard 分支;simple、tabbed 与不写 layout 的表单行为不变(有对照测试);回滚即 revert 本 PR。 - 先看:`ui/apps/console/src/components/FormPage.tsx`(`renderWizardSteps`、`handleWizardNext`、提交被拒时跳到出错步骤)、`ui/packages/plugin-form/src/WizardForm.tsx`(`layout: schema.layout ?? 'vertical'`)。 - 两分钟验证:`pnpm --dir ui exec vitest run apps/console/src/components/FormPage.wizardSteps-27224.test.tsx`,预期 11 个测试全绿。 - 需要你做的:无,席位落地。 ## What changes 1. **A wizard form keeps `layout: 'horizontal'`.** `ObjectForm` hands the authored key to `WizardForm` in its `{...schema}` spread, and the step form node `WizardForm` built said `layout: 'vertical'` whatever the form view said. It now passes `schema.layout ?? 'vertical'`, the shape `TabbedForm` got in PR #27222, and declares `layout` on `WizardFormSchema` as `ObjectFormSchema['layout']`. Every field on every step draws its label beside the control from `sm` up, through the form renderer's existing horizontal arrangement. The auto-stepper path (`mobile.stepper`) spreads the same schema, so it keeps the key too. 2. **The form page draws `type: 'wizard'` as steps.** `/forms/:name` and the public `/f/:slug` read `type` only for `tabbed`, so a wizard view drew every section stacked on one page. A wizard view with more than one section now draws, with the semantics of the app's wizard form and the spec's FormView wizard contract (D4 step gate, D5 derived progress): - a step indicator, one step per section in array order, labelled by the section heading (else the `wizard.stepFallback` text), hidden by `showStepIndicator: false`; - **Next** checks the current step's fields with the same rules the final submit applies (`findSubmitRefusals` over that one section) and opens the next step only when none is refused; a refused step is marked in the indicator and the refusal message is the page's existing one; - **Back** is always free; a later step opens from the indicator once completed, or at once under `allowSkip` (`canOpenWizardStep`, the rule `WizardForm`'s `canOpenStep` applies); - **Submit** appears on the last step only and carries every step's fields (the page's one `values` map); a submit refused for a missing field, or a faulted `visibleWhen`, opens the step holding the first field it names and marks every step a refused field sits on; - Enter in a field before the last step is that step's Next; a `continue` submit behaviour starts the wizard over from step one; - every step stays mounted and only the current one is drawn (the `hidden` attribute), so a control on another step keeps its widget state, an upload in flight included; - a step with no fields shows the `wizard.emptyStep` text, as the app's wizard does; - a wizard view with a single section draws it stacked, as a single-section tabbed view does. The refusal reporting that `handleSubmit` inlined is lifted into `reportRefusals` so Next and the final submit put the same messages on the same channel. Every string the wizard draws is an existing catalogue key (`wizard.progressLabel`, `wizard.stepFallback`, `wizard.back`, `wizard.emptyStep`, `common.next`, `form.stepOf`), so no locale pack changes. `FORM_SPEC_TYPES` is left as it is; its split/drawer/modal entries belong to #27159. ## Why Both keys are authorable and saved, and both drew as if absent: a wizard form view's `layout: 'horizontal'` and the form page's `type: 'wizard'`. This is the wizard arm of the defect PR #27222 closed for simple and tabbed forms (triage 6468472559: the filer's Expected stands). ## Tests - `ui/packages/plugin-form/src/__tests__/wizardFormLayout-27224.test.tsx` (3): horizontal on step one and, after Next, on step two; CONTROLS: no `layout`, and `vertical`, draw vertical. - `ui/apps/console/src/components/FormPage.wizardSteps-27224.test.tsx` (11): steps drawn, only the current one shown, Next and no Back or Submit on step one; Next and Back, completed steps reopen from the indicator and an uncompleted one does not; Next refuses an outstanding required field and writes nothing; Enter is Next; the submit carries every step's fields; `allowSkip` opens any step and a refused submit opens the step holding the field; `showStepIndicator: false`; horizontal rows on every step; `canOpenWizardStep` unit cases. CONTROLS: a single-section wizard draws stacked with Submit and no indicator; a `simple` view draws every section stacked with Submit and no steps. Ablations, each through `scripts/ablation-replace.mjs` on the committed head `15589ba4`, restored to the HEAD blob with `git diff HEAD` empty: - `WizardForm.tsx` back to `layout: 'vertical'`: the horizontal test reds (1 failed, 2 passed: both controls stay green). - `FormPage.tsx` with `wizardView` forced to `false`: all 8 wizard tests red, the 3 controls (single section, simple view, `canOpenWizardStep`) stay green. ## Acceptance notes - The page's step gate checks what the page can check: `required`, `requiredWhen`, `readonlyWhen` and a faulted `visibleWhen`, through the rules its final submit already applies. Like the app's wizard, it is a UI admission rule, never authorization; the server re-validates the record on submit. - `nextText` / `prevText` are not FormView keys (they live on the `object-form` component props), so the page draws the catalogue's Next and Back. ## Verification Measured on `objectstack-ai/objectstack` at `15589ba4` (merge base `295699d5`). `origin/main` has since moved to `3beb2240`; none of its commits touch `ui/packages/plugin-form`, `FormPage.tsx` or the form renderer, so the branch is not merged with it. <details><summary>Builds, suites and type-checks</summary> - Closure `pnpm --workspace-concurrency=2 --filter '@object-ui/console^...' build` under the lock: VERDICT command-exit 0 (11m36s). - `pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/ apps/console/src/components/FormPage`: Test Files 243 passed (243), Tests 2792 passed, 1 skipped; VERDICT command-exit 0. - `pnpm --filter @object-ui/plugin-form run type-check && pnpm --filter @object-ui/console run type-check`: VERDICT command-exit 0. - `pnpm check:new-line-citations` (from `ui/`): 0 new citations. </details> <details><summary>Derived gates: 34 derived, 34 run, all exit 0 (dispatch-gates --ran: 0 NOT-MEASURED)</summary> - `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 --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 - `pnpm --dir ui exec vitest run scripts/` :: exit 0 </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's run. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbZ31aDwuL2615F85BS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3af6022 commit 0d45c0e

5 files changed

Lines changed: 642 additions & 29 deletions

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@object-ui/plugin-form': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(ui): a wizard form keeps `layout: 'horizontal'`, and the form page draws `type: 'wizard'` as steps (#27224)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
A wizard form view (`type: 'wizard'`) dropped two things it declares:
12+
13+
- **A wizard form** now keeps an authored `layout: 'horizontal'` on every step, drawing each label beside its field from the `sm` breakpoint up, as simple and tabbed forms already do. It used to draw its fields `vertical` whatever the form view said. `vertical`, and a form view with no `layout`, draw exactly as before.
14+
- **The form page** (`/forms/:name`, and the public `/f/:slug`) now draws a `type: 'wizard'` form view as one step per section, the way the app's wizard form does: a step indicator (hidden by `showStepIndicator: false`), **Next** checks the current step's required fields before the next step opens, **Back** is always free, a later step opens from the indicator once completed or under `allowSkip`, and the last step's **Submit** carries every step's fields. A submit refused for a missing field opens the step that holds it and marks it in the indicator. A wizard form view with a single section draws it stacked, as before. Before, the page drew every section stacked on one page with no steps.

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 235 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1026,6 +1026,23 @@ export function resolveActiveTab(
10261026
return drawn[0];
10271027
}
10281028

1029+
/**
1030+
* Whether the step indicator of a `type: 'wizard'` form view may open `step`
1031+
* (objectstack-ai/objectstack#27224) — the rule `@object-ui/plugin-form`'s
1032+
* `WizardForm` applies: any step up to the current one, and a later one only
1033+
* once it has been completed or the form view declares `allowSkip`. The step
1034+
* gate is navigation, never authorization: the final submit re-checks every
1035+
* step whichever way the user got there.
1036+
*/
1037+
export function canOpenWizardStep(
1038+
step: number,
1039+
current: number,
1040+
completed: ReadonlySet<number>,
1041+
allowSkip: boolean | undefined,
1042+
): boolean {
1043+
return step <= current || !!allowSkip || completed.has(step);
1044+
}
1045+
10291046
/**
10301047
* One field row under the form view's `layout: 'horizontal'`
10311048
* (objectstack-ai/objectstack#26843): the label in a fixed column beside its
@@ -2276,6 +2293,16 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
22762293
* view's `defaultTab` decides until then; see {@link resolveActiveTab}.
22772294
*/
22782295
const [pickedTab, setPickedTab] = useState<string | null>(null);
2296+
/**
2297+
* Where a `type: 'wizard'` form view stands (objectstack-ai/objectstack#27224):
2298+
* the step drawn, the steps whose Next passed their check (a later step opens
2299+
* from the indicator only once completed, see {@link canOpenWizardStep}), and
2300+
* the steps a refused final submit found a field on, so their indicator says
2301+
* so. The form's ONE `values` map still holds every step's fields.
2302+
*/
2303+
const [wizardStep, setWizardStep] = useState(0);
2304+
const [completedSteps, setCompletedSteps] = useState<ReadonlySet<number>>(() => new Set());
2305+
const [invalidSteps, setInvalidSteps] = useState<ReadonlySet<number>>(() => new Set());
22792306
/**
22802307
* The sonner id this form's submit OUTCOME is published under — one id for
22812308
* the confirmation and for the refusal, so the later of the two SUPERSEDES
@@ -2374,6 +2401,17 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
23742401
* carries them all.
23752402
*/
23762403
const tabbedView = loaded?.form?.type === 'tabbed' && sections.length > 1;
2404+
/**
2405+
* The form view's `type: 'wizard'` (objectstack-ai/objectstack#27224), which
2406+
* this page drew as stacked sections: one step per section, in array order,
2407+
* with the semantics of the app's wizard form. A single section has no step
2408+
* to move to, so it draws stacked, as a single-section tabbed view does.
2409+
*/
2410+
const wizardView = loaded?.form?.type === 'wizard' && sections.length > 1;
2411+
const currentStep = Math.min(wizardStep, Math.max(sections.length - 1, 0));
2412+
const onLastStep = currentStep === sections.length - 1;
2413+
/** The index of the section holding `field`, or -1. */
2414+
const stepOf = (field: RenderableField) => sections.findIndex((s) => s.fields.includes(field));
23772415
const horizontalRows = loaded?.form?.layout === 'horizontal';
23782416

23792417
const behavior: EffectiveSubmitBehavior = resolveSubmitBehavior(
@@ -2421,40 +2459,28 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24212459
navigate(recordTo ?? cancelPath ?? '/');
24222460
};
24232461

2424-
const handleSubmit = async (e: FormEvent) => {
2425-
e.preventDefault();
2426-
if (!loaded) return;
2427-
// The client-side refusals — see `findSubmitRefusals`. Both go out on the
2428-
// page's failure channel and the outcome toast id, so a retry that
2429-
// succeeds supersedes them exactly as it supersedes a refused write
2430-
// (objectui#7252).
2431-
const { faultedVisibleWhen, missing } = findSubmitRefusals(
2432-
sections,
2433-
values,
2434-
loaded.record,
2435-
isCreateForm,
2436-
predicateScope,
2437-
);
2438-
// On a tabbed form view the refused field may sit on a tab that is not
2439-
// open (objectstack-ai/objectstack#26843): open the tab of the first field
2440-
// the message below names, the way the app's tabbed form jumps to it.
2441-
const refused = faultedVisibleWhen[0] ?? missing[0];
2442-
if (refused && tabbedView) {
2443-
const at = sections.findIndex((s) => s.fields.includes(refused));
2444-
if (at >= 0) setPickedTab(sectionTabKey(sections[at], at));
2445-
}
2462+
/**
2463+
* Puts the client-side refusals — see `findSubmitRefusals` — on screen, and
2464+
* answers whether there were any. Both go out on the page's failure channel
2465+
* and the outcome toast id, so a retry that succeeds supersedes them exactly
2466+
* as it supersedes a refused write (objectui#7252).
2467+
*/
2468+
const reportRefusals = (
2469+
object: string,
2470+
{ faultedVisibleWhen, missing }: ReturnType<typeof findSubmitRefusals>,
2471+
): boolean => {
24462472
// A faulted `visibleWhen` first (objectui#8069): it names the field and
24472473
// the rule, and nothing typed into the form can clear it, so the required
24482474
// check behind it would only ask for work that is refused anyway.
24492475
if (faultedVisibleWhen.length > 0) {
24502476
const msg = t('form.visibleWhenFaulted', {
24512477
fields: faultedVisibleWhen
2452-
.map((f) => fieldLabel(loaded.object, f.name, f.label))
2478+
.map((f) => fieldLabel(object, f.name, f.label))
24532479
.join(t('validation.formInvalidJoiner')),
24542480
});
24552481
setError(msg);
24562482
toast.error(msg, { id: outcomeToastId });
2457-
return;
2483+
return true;
24582484
}
24592485
// The client-side required refusal the native attribute used to give the
24602486
// hand-rolled controls (objectui#10179).
@@ -2464,13 +2490,83 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24642490
// the words are the locale's, not a concatenation of English.
24652491
const msg = t('publicForm.requiredFields', {
24662492
fields: missing
2467-
.map((f) => fieldLabel(loaded.object, f.name, f.label))
2493+
.map((f) => fieldLabel(object, f.name, f.label))
24682494
.join(t('validation.formInvalidJoiner')),
24692495
});
24702496
setError(msg);
24712497
toast.error(msg, { id: outcomeToastId });
2498+
return true;
2499+
}
2500+
return false;
2501+
};
2502+
2503+
/** Opens a wizard step, from the indicator or Back (objectstack-ai/objectstack#27224). */
2504+
const goToStep = (step: number) => {
2505+
setWizardStep(step);
2506+
setError(null);
2507+
};
2508+
2509+
/**
2510+
* A wizard step's Next (objectstack-ai/objectstack#27224): the step gate of
2511+
* the app's wizard form. The step's own fields are checked by the same rules
2512+
* the final submit applies, and the next step opens only when none is
2513+
* refused; nothing is written.
2514+
*/
2515+
const handleWizardNext = (object: string) => {
2516+
const refusals = findSubmitRefusals(
2517+
[sections[currentStep]],
2518+
values,
2519+
loaded?.record,
2520+
isCreateForm,
2521+
predicateScope,
2522+
);
2523+
const refused = reportRefusals(object, refusals);
2524+
setInvalidSteps((prev) => {
2525+
if (refused === prev.has(currentStep)) return prev;
2526+
const next = new Set(prev);
2527+
if (refused) next.add(currentStep);
2528+
else next.delete(currentStep);
2529+
return next;
2530+
});
2531+
if (refused) return;
2532+
setCompletedSteps((prev) => new Set(prev).add(currentStep));
2533+
goToStep(currentStep + 1);
2534+
};
2535+
2536+
const handleSubmit = async (e: FormEvent) => {
2537+
e.preventDefault();
2538+
if (!loaded) return;
2539+
// Before the last step of a wizard, a submit — the Next button, or Enter
2540+
// in a field — is that step's Next.
2541+
if (wizardView && !onLastStep) {
2542+
handleWizardNext(loaded.object);
24722543
return;
24732544
}
2545+
const refusals = findSubmitRefusals(
2546+
sections,
2547+
values,
2548+
loaded.record,
2549+
isCreateForm,
2550+
predicateScope,
2551+
);
2552+
// On a tabbed form view the refused field may sit on a tab that is not
2553+
// open (objectstack-ai/objectstack#26843): open the tab of the first field
2554+
// the message below names, the way the app's tabbed form jumps to it. A
2555+
// wizard opens that field's step (objectstack-ai/objectstack#27224) and
2556+
// marks every step a refused field sits on, as the app's wizard does.
2557+
const refused = refusals.faultedVisibleWhen[0] ?? refusals.missing[0];
2558+
if (refused && tabbedView) {
2559+
const at = stepOf(refused);
2560+
if (at >= 0) setPickedTab(sectionTabKey(sections[at], at));
2561+
}
2562+
if (wizardView) {
2563+
const blocked = refusals.faultedVisibleWhen.length > 0
2564+
? refusals.faultedVisibleWhen
2565+
: refusals.missing;
2566+
setInvalidSteps(new Set(blocked.map(stepOf).filter((at) => at >= 0)));
2567+
if (refused && stepOf(refused) >= 0) setWizardStep(stepOf(refused));
2568+
}
2569+
if (reportRefusals(loaded.object, refusals)) return;
24742570
setSubmitting(true);
24752571
setError(null);
24762572
try {
@@ -2561,6 +2657,9 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
25612657
// stage a wipe of every field on the next save.
25622658
const allFields = sections.flatMap((s) => s.fields);
25632659
setValues(readPrefill(allFields, search, loaded.record, loaded.form?.defaults));
2660+
// A wizard starts over from its first step, as the app's wizard does.
2661+
setWizardStep(0);
2662+
setCompletedSteps(new Set());
25642663
break;
25652664
}
25662665
case 'next-record':
@@ -2806,6 +2905,89 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
28062905
);
28072906
};
28082907

2908+
/**
2909+
* A `type: 'wizard'` form view (objectstack-ai/objectstack#27224): one step
2910+
* per section, the step semantics of the app's wizard form — an indicator
2911+
* (unless `showStepIndicator: false`) that opens any step up to the current
2912+
* one and a later one once completed or under `allowSkip`, and the current
2913+
* step's card below it. Every step is mounted and only the current one
2914+
* drawn, so a control on another step keeps its widget state (an upload in
2915+
* flight included) as well as its value.
2916+
*/
2917+
const renderWizardSteps = () => (
2918+
<>
2919+
{loaded.form?.showStepIndicator !== false && (
2920+
<nav aria-label={t('wizard.progressLabel')}>
2921+
<ol className="flex flex-wrap items-center gap-x-4 gap-y-2">
2922+
{sections.map((sec, at) => {
2923+
const key = sectionTabKey(sec, at);
2924+
const current = at === currentStep;
2925+
const invalid = invalidSteps.has(at);
2926+
const completed = completedSteps.has(at);
2927+
return (
2928+
<li key={key}>
2929+
<button
2930+
type="button"
2931+
onClick={() => goToStep(at)}
2932+
disabled={!canOpenWizardStep(at, currentStep, completedSteps, loaded.form?.allowSkip)}
2933+
aria-current={current ? 'step' : undefined}
2934+
data-testid={`form-step:${key}`}
2935+
data-state={invalid ? 'invalid' : current ? 'current' : completed ? 'complete' : 'upcoming'}
2936+
className="flex items-center gap-2 text-sm font-medium disabled:cursor-not-allowed"
2937+
>
2938+
<span
2939+
className={cn(
2940+
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs',
2941+
invalid
2942+
? 'border-2 border-destructive text-destructive'
2943+
: completed
2944+
? 'bg-primary text-primary-foreground'
2945+
: current
2946+
? 'border-2 border-primary text-primary'
2947+
: 'border-2 border-muted text-muted-foreground',
2948+
)}
2949+
>
2950+
{at + 1}
2951+
</span>
2952+
<span
2953+
className={cn(
2954+
'hidden sm:inline',
2955+
invalid ? 'text-destructive' : current ? 'text-foreground' : 'text-muted-foreground',
2956+
)}
2957+
>
2958+
{sectionHeading(sec) || t('wizard.stepFallback', { n: at + 1 })}
2959+
</span>
2960+
</button>
2961+
</li>
2962+
);
2963+
})}
2964+
</ol>
2965+
</nav>
2966+
)}
2967+
{sections.map((sec, at) => (
2968+
<section
2969+
key={sectionTabKey(sec, at)}
2970+
hidden={at !== currentStep}
2971+
data-testid={`form-step-panel:${sectionTabKey(sec, at)}`}
2972+
className="rounded-md border bg-card p-4 sm:p-5"
2973+
>
2974+
{sec.label && (
2975+
<h2 className={sec.description ? 'mb-1 text-sm font-medium' : 'mb-3 text-sm font-medium'}>
2976+
{sectionHeading(sec)}
2977+
</h2>
2978+
)}
2979+
{sec.fields.length > 0 ? (
2980+
renderSectionBody(sec)
2981+
) : (
2982+
// A step with no fields (a final review step, say) — the app's
2983+
// wizard says so rather than drawing an empty card.
2984+
<p className="py-6 text-center text-sm text-muted-foreground">{t('wizard.emptyStep')}</p>
2985+
)}
2986+
</section>
2987+
))}
2988+
</>
2989+
);
2990+
28092991
return (
28102992
<div className="mx-auto max-w-2xl p-4 sm:p-6">
28112993
<header className="mb-6">
@@ -2856,7 +3038,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
28563038
)}
28573039
</header>
28583040
<form onSubmit={handleSubmit} className="space-y-6">
2859-
{tabbedView ? renderTabbedSections() : sections.map((sec, i) => {
3041+
{wizardView ? renderWizardSteps() : tabbedView ? renderTabbedSections() : sections.map((sec, i) => {
28603042
// A section conditioned away is skipped IN PLACE rather than filtered
28613043
// out of the list first (objectui#5627). The key here is the section's
28623044
// POSITION, and these predicates re-decide on every keystroke: filtering
@@ -2887,7 +3069,12 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
28873069
{error}
28883070
</div>
28893071
)}
2890-
<div className="flex justify-end gap-2">
3072+
<div className="flex flex-wrap items-center justify-end gap-2">
3073+
{wizardView && (
3074+
<span className="mr-auto text-sm text-muted-foreground">
3075+
{t('form.stepOf', { current: currentStep + 1, total: sections.length })}
3076+
</span>
3077+
)}
28913078
{mode === 'internal' && cancelButton.show && (
28923079
<button
28933080
type="button"
@@ -2903,7 +3090,27 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
29033090
submit that beats it writes an empty field and still reports
29043091
success. The widget contract exists for this — see
29053092
`FieldInputProps.onUploadingChange`. */}
2906-
{submitButton.show && (
3093+
{wizardView && currentStep > 0 && (
3094+
<button
3095+
type="button"
3096+
onClick={() => goToStep(currentStep - 1)}
3097+
disabled={submitting}
3098+
className="rounded-md border bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
3099+
>
3100+
{t('wizard.back')}
3101+
</button>
3102+
)}
3103+
{/* A wizard's Next submits the form, so Enter in a field moves on
3104+
too; `handleSubmit` runs it as the step's Next. */}
3105+
{wizardView && !onLastStep && (
3106+
<button
3107+
type="submit"
3108+
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90"
3109+
>
3110+
{t('common.next')}
3111+
</button>
3112+
)}
3113+
{submitButton.show && (!wizardView || onLastStep) && (
29073114
<button
29083115
type="submit"
29093116
disabled={submitting || isUploading}

0 commit comments

Comments
 (0)