Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/27224-wizard-form-layout-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-form': patch
'@object-ui/console': patch
---

fix(ui): a wizard form keeps `layout: 'horizontal'`, and the form page draws `type: 'wizard'` as steps (#27224)

Clause-②: no
Metadata-protocol: none

A wizard form view (`type: 'wizard'`) dropped two things it declares:

- **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.
- **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.
263 changes: 235 additions & 28 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>,
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
Expand Down Expand Up @@ -2276,6 +2293,16 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
* view's `defaultTab` decides until then; see {@link resolveActiveTab}.
*/
const [pickedTab, setPickedTab] = useState<string | null>(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<ReadonlySet<number>>(() => new Set());
const [invalidSteps, setInvalidSteps] = useState<ReadonlySet<number>>(() => 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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<typeof findSubmitRefusals>,
): 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).
Expand All @@ -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 {
Expand Down Expand Up @@ -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':
Expand Down Expand Up @@ -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 && (
<nav aria-label={t('wizard.progressLabel')}>
<ol className="flex flex-wrap items-center gap-x-4 gap-y-2">
{sections.map((sec, at) => {
const key = sectionTabKey(sec, at);
const current = at === currentStep;
const invalid = invalidSteps.has(at);
const completed = completedSteps.has(at);
return (
<li key={key}>
<button
type="button"
onClick={() => goToStep(at)}
disabled={!canOpenWizardStep(at, currentStep, completedSteps, loaded.form?.allowSkip)}
aria-current={current ? 'step' : undefined}
data-testid={`form-step:${key}`}
data-state={invalid ? 'invalid' : current ? 'current' : completed ? 'complete' : 'upcoming'}
className="flex items-center gap-2 text-sm font-medium disabled:cursor-not-allowed"
>
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs',
invalid
? 'border-2 border-destructive text-destructive'
: completed
? 'bg-primary text-primary-foreground'
: current
? 'border-2 border-primary text-primary'
: 'border-2 border-muted text-muted-foreground',
)}
>
{at + 1}
</span>
<span
className={cn(
'hidden sm:inline',
invalid ? 'text-destructive' : current ? 'text-foreground' : 'text-muted-foreground',
)}
>
{sectionHeading(sec) || t('wizard.stepFallback', { n: at + 1 })}
</span>
</button>
</li>
);
})}
</ol>
</nav>
)}
{sections.map((sec, at) => (
<section
key={sectionTabKey(sec, at)}
hidden={at !== currentStep}
data-testid={`form-step-panel:${sectionTabKey(sec, at)}`}
className="rounded-md border bg-card p-4 sm:p-5"
>
{sec.label && (
<h2 className={sec.description ? 'mb-1 text-sm font-medium' : 'mb-3 text-sm font-medium'}>
{sectionHeading(sec)}
</h2>
)}
{sec.fields.length > 0 ? (
renderSectionBody(sec)
) : (
// A step with no fields (a final review step, say) — the app's
// wizard says so rather than drawing an empty card.
<p className="py-6 text-center text-sm text-muted-foreground">{t('wizard.emptyStep')}</p>
)}
</section>
))}
</>
);

return (
<div className="mx-auto max-w-2xl p-4 sm:p-6">
<header className="mb-6">
Expand Down Expand Up @@ -2856,7 +3038,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
)}
</header>
<form onSubmit={handleSubmit} className="space-y-6">
{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
Expand Down Expand Up @@ -2887,7 +3069,12 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
{error}
</div>
)}
<div className="flex justify-end gap-2">
<div className="flex flex-wrap items-center justify-end gap-2">
{wizardView && (
<span className="mr-auto text-sm text-muted-foreground">
{t('form.stepOf', { current: currentStep + 1, total: sections.length })}
</span>
)}
{mode === 'internal' && cancelButton.show && (
<button
type="button"
Expand All @@ -2903,7 +3090,27 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
submit that beats it writes an empty field and still reports
success. The widget contract exists for this — see
`FieldInputProps.onUploadingChange`. */}
{submitButton.show && (
{wizardView && currentStep > 0 && (
<button
type="button"
onClick={() => goToStep(currentStep - 1)}
disabled={submitting}
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"
>
{t('wizard.back')}
</button>
)}
{/* 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 && (
<button
type="submit"
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow-sm hover:bg-primary/90"
>
{t('common.next')}
</button>
)}
{submitButton.show && (!wizardView || onLastStep) && (
<button
type="submit"
disabled={submitting || isUploading}
Expand Down
Loading
Loading