From 6ec5c849e7dd7432653cd8342f4a4cabe3fb2613 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:57:55 +0000 Subject: [PATCH 1/3] fix(ui): a wizard form view keeps layout horizontal, and the form page draws it as steps Claude-Session: https://claude.ai/code/session_01JQwxbZ31aDwuL2615F85BS Co-authored-by: Claude --- ui/apps/console/src/components/FormPage.tsx | 263 ++++++++++++++++-- ui/packages/plugin-form/src/WizardForm.tsx | 14 +- .../__tests__/wizardFormLayout-27224.test.tsx | 107 +++++++ 3 files changed, 355 insertions(+), 29 deletions(-) create mode 100644 ui/packages/plugin-form/src/__tests__/wizardFormLayout-27224.test.tsx diff --git a/ui/apps/console/src/components/FormPage.tsx b/ui/apps/console/src/components/FormPage.tsx index 9468e86f073..1bf663260e0 100644 --- a/ui/apps/console/src/components/FormPage.tsx +++ b/ui/apps/console/src/components/FormPage.tsx @@ -1026,6 +1026,23 @@ export function resolveActiveTab( return drawn[0]; } +/** + * Whether the step indicator of a `type: 'wizard'` form view may open `step` + * (objectstack-ai/objectstack#27224) — the rule `@object-ui/plugin-form`'s + * `WizardForm` applies: any step up to the current one, and a later one only + * once it has been completed or the form view declares `allowSkip`. The step + * gate is navigation, never authorization: the final submit re-checks every + * step whichever way the user got there. + */ +export function canOpenWizardStep( + step: number, + current: number, + completed: ReadonlySet, + allowSkip: boolean | undefined, +): boolean { + return step <= current || !!allowSkip || completed.has(step); +} + /** * One field row under the form view's `layout: 'horizontal'` * (objectstack-ai/objectstack#26843): the label in a fixed column beside its @@ -2276,6 +2293,16 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { * view's `defaultTab` decides until then; see {@link resolveActiveTab}. */ const [pickedTab, setPickedTab] = useState(null); + /** + * Where a `type: 'wizard'` form view stands (objectstack-ai/objectstack#27224): + * the step drawn, the steps whose Next passed their check (a later step opens + * from the indicator only once completed, see {@link canOpenWizardStep}), and + * the steps a refused final submit found a field on, so their indicator says + * so. The form's ONE `values` map still holds every step's fields. + */ + const [wizardStep, setWizardStep] = useState(0); + const [completedSteps, setCompletedSteps] = useState>(() => new Set()); + const [invalidSteps, setInvalidSteps] = useState>(() => new Set()); /** * The sonner id this form's submit OUTCOME is published under — one id for * the confirmation and for the refusal, so the later of the two SUPERSEDES @@ -2374,6 +2401,17 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { * carries them all. */ const tabbedView = loaded?.form?.type === 'tabbed' && sections.length > 1; + /** + * The form view's `type: 'wizard'` (objectstack-ai/objectstack#27224), which + * this page drew as stacked sections: one step per section, in array order, + * with the semantics of the app's wizard form. A single section has no step + * to move to, so it draws stacked, as a single-section tabbed view does. + */ + const wizardView = loaded?.form?.type === 'wizard' && sections.length > 1; + const currentStep = Math.min(wizardStep, Math.max(sections.length - 1, 0)); + const onLastStep = currentStep === sections.length - 1; + /** The index of the section holding `field`, or -1. */ + const stepOf = (field: RenderableField) => sections.findIndex((s) => s.fields.includes(field)); const horizontalRows = loaded?.form?.layout === 'horizontal'; const behavior: EffectiveSubmitBehavior = resolveSubmitBehavior( @@ -2421,40 +2459,28 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { navigate(recordTo ?? cancelPath ?? '/'); }; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (!loaded) return; - // The client-side refusals — see `findSubmitRefusals`. Both go out on the - // page's failure channel and the outcome toast id, so a retry that - // succeeds supersedes them exactly as it supersedes a refused write - // (objectui#7252). - const { faultedVisibleWhen, missing } = findSubmitRefusals( - sections, - values, - loaded.record, - isCreateForm, - predicateScope, - ); - // On a tabbed form view the refused field may sit on a tab that is not - // open (objectstack-ai/objectstack#26843): open the tab of the first field - // the message below names, the way the app's tabbed form jumps to it. - const refused = faultedVisibleWhen[0] ?? missing[0]; - if (refused && tabbedView) { - const at = sections.findIndex((s) => s.fields.includes(refused)); - if (at >= 0) setPickedTab(sectionTabKey(sections[at], at)); - } + /** + * Puts the client-side refusals — see `findSubmitRefusals` — on screen, and + * answers whether there were any. Both go out on the page's failure channel + * and the outcome toast id, so a retry that succeeds supersedes them exactly + * as it supersedes a refused write (objectui#7252). + */ + const reportRefusals = ( + object: string, + { faultedVisibleWhen, missing }: ReturnType, + ): boolean => { // A faulted `visibleWhen` first (objectui#8069): it names the field and // the rule, and nothing typed into the form can clear it, so the required // check behind it would only ask for work that is refused anyway. if (faultedVisibleWhen.length > 0) { const msg = t('form.visibleWhenFaulted', { fields: faultedVisibleWhen - .map((f) => fieldLabel(loaded.object, f.name, f.label)) + .map((f) => fieldLabel(object, f.name, f.label)) .join(t('validation.formInvalidJoiner')), }); setError(msg); toast.error(msg, { id: outcomeToastId }); - return; + return true; } // The client-side required refusal the native attribute used to give the // hand-rolled controls (objectui#10179). @@ -2464,13 +2490,83 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { // the words are the locale's, not a concatenation of English. const msg = t('publicForm.requiredFields', { fields: missing - .map((f) => fieldLabel(loaded.object, f.name, f.label)) + .map((f) => fieldLabel(object, f.name, f.label)) .join(t('validation.formInvalidJoiner')), }); setError(msg); toast.error(msg, { id: outcomeToastId }); + return true; + } + return false; + }; + + /** Opens a wizard step, from the indicator or Back (objectstack-ai/objectstack#27224). */ + const goToStep = (step: number) => { + setWizardStep(step); + setError(null); + }; + + /** + * A wizard step's Next (objectstack-ai/objectstack#27224): the step gate of + * the app's wizard form. The step's own fields are checked by the same rules + * the final submit applies, and the next step opens only when none is + * refused; nothing is written. + */ + const handleWizardNext = (object: string) => { + const refusals = findSubmitRefusals( + [sections[currentStep]], + values, + loaded?.record, + isCreateForm, + predicateScope, + ); + const refused = reportRefusals(object, refusals); + setInvalidSteps((prev) => { + if (refused === prev.has(currentStep)) return prev; + const next = new Set(prev); + if (refused) next.add(currentStep); + else next.delete(currentStep); + return next; + }); + if (refused) return; + setCompletedSteps((prev) => new Set(prev).add(currentStep)); + goToStep(currentStep + 1); + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!loaded) return; + // Before the last step of a wizard, a submit — the Next button, or Enter + // in a field — is that step's Next. + if (wizardView && !onLastStep) { + handleWizardNext(loaded.object); return; } + const refusals = findSubmitRefusals( + sections, + values, + loaded.record, + isCreateForm, + predicateScope, + ); + // On a tabbed form view the refused field may sit on a tab that is not + // open (objectstack-ai/objectstack#26843): open the tab of the first field + // the message below names, the way the app's tabbed form jumps to it. A + // wizard opens that field's step (objectstack-ai/objectstack#27224) and + // marks every step a refused field sits on, as the app's wizard does. + const refused = refusals.faultedVisibleWhen[0] ?? refusals.missing[0]; + if (refused && tabbedView) { + const at = stepOf(refused); + if (at >= 0) setPickedTab(sectionTabKey(sections[at], at)); + } + if (wizardView) { + const blocked = refusals.faultedVisibleWhen.length > 0 + ? refusals.faultedVisibleWhen + : refusals.missing; + setInvalidSteps(new Set(blocked.map(stepOf).filter((at) => at >= 0))); + if (refused && stepOf(refused) >= 0) setWizardStep(stepOf(refused)); + } + if (reportRefusals(loaded.object, refusals)) return; setSubmitting(true); setError(null); try { @@ -2561,6 +2657,9 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { // stage a wipe of every field on the next save. const allFields = sections.flatMap((s) => s.fields); setValues(readPrefill(allFields, search, loaded.record, loaded.form?.defaults)); + // A wizard starts over from its first step, as the app's wizard does. + setWizardStep(0); + setCompletedSteps(new Set()); break; } case 'next-record': @@ -2806,6 +2905,89 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { ); }; + /** + * A `type: 'wizard'` form view (objectstack-ai/objectstack#27224): one step + * per section, the step semantics of the app's wizard form — an indicator + * (unless `showStepIndicator: false`) that opens any step up to the current + * one and a later one once completed or under `allowSkip`, and the current + * step's card below it. Every step is mounted and only the current one + * drawn, so a control on another step keeps its widget state (an upload in + * flight included) as well as its value. + */ + const renderWizardSteps = () => ( + <> + {loaded.form?.showStepIndicator !== false && ( + + )} + {sections.map((sec, at) => ( + + ))} + + ); + return (
@@ -2856,7 +3038,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { )}
- {tabbedView ? renderTabbedSections() : sections.map((sec, i) => { + {wizardView ? renderWizardSteps() : tabbedView ? renderTabbedSections() : sections.map((sec, i) => { // A section conditioned away is skipped IN PLACE rather than filtered // out of the list first (objectui#5627). The key here is the section's // POSITION, and these predicates re-decide on every keystroke: filtering @@ -2887,7 +3069,12 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) { {error}
)} -
+
+ {wizardView && ( + + {t('form.stepOf', { current: currentStep + 1, total: sections.length })} + + )} {mode === 'internal' && cancelButton.show && ( + )} + {/* A wizard's Next submits the form, so Enter in a field moves on + too; `handleSubmit` runs it as the step's Next. */} + {wizardView && !onLastStep && ( + + )} + {submitButton.show && (!wizardView || onLastStep) && (