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
48 changes: 38 additions & 10 deletions Documentation/StepperCommandDialog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ The `StepperCommandDialog` component provides a multi-step wizard dialog interfa
- All steps share a single command form — one command is submitted at the end
- Submit button only appears on the last step when all fields are valid
- Previous button hidden on the first step; Next button hidden on the last step
- Cancel via the X button in the upper-right corner — no footer Cancel button
- Cancel via the X button in the dialog header or the Escape key, and — with `showCancel` — a Cancel button in the footer
- Step number circles change color to indicate validation state (red = errors, green = visited and valid)
- Non-active steps are visually dimmed to keep focus on the current step
- Busy state management during command execution
Expand Down Expand Up @@ -93,11 +93,13 @@ function MyComponent() {
- `onUnauthorized`: Callback invoked when authorization fails
- `onValidationFailure`: Callback invoked on validation errors with the validation results
- `onConfirm`: Confirm callback — called only after successful command execution
- `onCancel`: Cancel callback — invoked when the X button is clicked
- `onCancel`: Cancel callback — invoked for every dismissal that is not a successful submit: the X in the dialog header, the Escape key, and the footer Cancel button when `showCancel` is on
- `onClose`: Fallback close callback
- `okLabel`: Label for the submit button shown on the last step when valid (default: `'Submit'`)
- `nextLabel`: Label for the next step button (default: `'Next'`)
- `previousLabel`: Label for the previous step button (default: `'Previous'`)
- `showCancel`: Adds a Cancel button as the first item in the footer (default: `false`)
- `cancelLabel`: Label for the footer cancel button (default: `'Cancel'`)
- `isValid`: Additional validity gate combined with command form validity
- `width`: Dialog width (default: `'600px'`)
- `resizable`: Whether the dialog can be resized
Expand Down Expand Up @@ -169,21 +171,47 @@ This is useful when the dialog opens with pre-populated values that may already

## Navigation and Submit

| Step position | Footer content |
|---|---|
| First step | Next |
| Middle step | Previous, Next |
| Last step (invalid) | Previous |
| Last step (valid) | Previous, Submit |
| Step position | Footer content | Footer content with `showCancel` |
|---|---|---|
| First step | Next | Cancel, Next |
| Middle step | Previous, Next | Cancel, Previous, Next |
| Last step (invalid) | Previous | Cancel, Previous |
| Last step (valid) | Previous, Submit | Cancel, Previous, Submit |

The Submit button is hidden until the user reaches the last step **and** all command form fields across every step pass validation.

## Cancelling

Dismissal is always reachable from the X button in the dialog header and from the Escape key. Both run `onCancel` and close with `DialogResult.Cancelled`.

Cancel is always available via the X button in the dialog header. The Submit button is hidden until the user reaches the last step **and** all command form fields across every step pass validation.
Set `showCancel` to add a Cancel button to the footer as well. It leads the footer on every step — on the dismissal side of the divider, opposite Next and Submit — and takes exactly the same path as the header X. Use it for a wizard whose dismissal should be as reachable as its submit: a destructive or long flow, or one presented without a visible header. `cancelLabel` renames it.

```tsx
<StepperCommandDialog<DeleteEnvironment>
command={DeleteEnvironment}
title="Delete environment"
okLabel="Delete"
showCancel
cancelLabel="Keep environment"
onCancel={() => closeDialog(DialogResult.Cancelled)}
>
<StepperPanel header="Environment">
<DropdownField<DeleteEnvironment> value={c => c.environmentId} title="Environment" options={environments} />
</StepperPanel>
<StepperPanel header="Confirm">
<InputTextField<DeleteEnvironment> value={c => c.confirmationText} title="Type the environment name to confirm" />
</StepperPanel>
</StepperCommandDialog>
```

## Busy State

`StepperCommandDialog` automatically manages a busy state during command execution:

- When Submit is clicked, the Submit button shows a loading spinner and all navigation buttons are disabled.
- Once execution completes (success or failure), the buttons return to their normal state.
- Every route out of the dialog is withdrawn for the same window: the footer Cancel is disabled, the header X is not rendered, and Escape does not dismiss. A dialog can therefore never report cancellation for a command that goes on to execute anyway.
- The window opens the moment Submit is pressed — including while an `async` `onBeforeExecute` transform is still resolving, before the command has been sent.
- Once execution completes (success or failure), the buttons and every dismissal route return to their normal state.

## Step Structure

Expand Down
82 changes: 82 additions & 0 deletions Source/CommandDialog/StepperCommandDialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,88 @@ export const WithResponseTypeAndCallbacks: Story = {
},
};

/**
* `showCancel` adds a Cancel button to the footer, where it leads every step on the dismissal side
* of the divider, opposite Next and Submit; `cancelLabel` renames it. The command behind this
* wizard takes two seconds, so submitting also shows what the busy window does to every route out
* of the dialog: the footer Cancel greys out and the header X disappears until the command returns.
*/
export const WithFooterCancel: Story = {
render: () => {
const [visible, setVisible] = useState(false);
const [outcome, setOutcome] = useState('');

return (
<div className="storybook-wrapper">
<p className="mb-3 text-sm text-color-secondary">
The footer leads with a renamed Cancel. Fill both steps and click Create to run a 2-second
command — while it runs, neither the footer Cancel nor the header X can dismiss the dialog.
</p>
<button
className="p-button p-component mb-3"
onClick={() => {
setOutcome('');
setVisible(true);
}}
>
Open Dialog
</button>

{outcome && (
<div className="p-3 mt-3 bg-green-100 border-round">
<strong>Outcome:</strong> {outcome}
</div>
)}

<StepperCommandDialog<SlowCreateProjectCommand>
command={SlowCreateProjectCommand}
visible={visible}
title="Create New Project"
okLabel="Create"
showCancel
cancelLabel="Discard draft"
autoServerValidate={false}
onConfirm={async () => {
setOutcome('Created');
setVisible(false);
}}
onCancel={() => {
setOutcome('Discarded');
setVisible(false);
}}
>
<StepperPanel header="Basic Info">
<InputTextField<SlowCreateProjectCommand>
value={c => c.name}
title="Project Name"
placeholder="Enter project name (min 2 chars)"
/>
<InputTextField<SlowCreateProjectCommand>
value={c => c.email}
title="Contact Email"
placeholder="Enter contact email"
type="email"
/>
</StepperPanel>
<StepperPanel header="Details">
<TextAreaField<SlowCreateProjectCommand>
value={c => c.description}
title="Description"
placeholder="Describe the project (min 10 chars)"
rows={4}
/>
<NumberField<SlowCreateProjectCommand>
value={c => c.budget}
title="Budget"
placeholder="Enter budget (must be > 0)"
/>
</StepperPanel>
</StepperCommandDialog>
</div>
);
},
};

/**
* A step rendered as `{condition && <StepperPanel/>}` disappears entirely when the condition
* is false. Toggle the optional step off and the dialog must behave as a genuine two-step
Expand Down
56 changes: 49 additions & 7 deletions Source/CommandDialog/StepperCommandDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,27 @@ export interface StepperCommandDialogProps<TCommand extends object, TResponse =
onClose?: CloseDialog;
/** Confirm callback — called only after successful command execution. */
onConfirm?: ConfirmCallback;
/** Cancel callback — invoked when the dialog X button is clicked. */
/**
* Cancel callback — invoked for every dismissal that is not a successful submit: the X in the
* dialog header, the Escape key, and the footer Cancel button when `showCancel` is on. Return
* `true` to let the dialog close through the dialog context. None of the three is offered while
* the command is executing.
*/
onCancel?: CancelCallback;
/** Label for the submit button shown on the last step when valid. Defaults to `'Submit'`. */
okLabel?: string;
/** Label for the next step button. Defaults to `'Next'`. */
nextLabel?: string;
/** Label for the previous step button. Defaults to `'Previous'`. */
previousLabel?: string;
/**
* Show a Cancel action in the footer. Defaults to `false`, leaving the X in the header as the
* only way to dismiss. Turn it on for a wizard whose dismissal should be as reachable as its
* submit — a destructive or long flow, or one presented without a visible header.
*/
showCancel?: boolean;
/** Label for the footer cancel button. Defaults to `'Cancel'`. */
cancelLabel?: string;
/**
* Extra CSS class name forwarded to the underlying PrimeReact Dialog root.
* Use the inherited `pt`/`ptOptions`/`unstyled` props to customize the Stepper.
Expand Down Expand Up @@ -104,6 +117,8 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
okLabel = 'Submit',
nextLabel = 'Next',
previousLabel = 'Previous',
showCancel = false,
cancelLabel = 'Cancel',
orientation = 'horizontal',
headerPosition,
linear = true,
Expand Down Expand Up @@ -136,6 +151,8 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
okLabel?: string;
nextLabel?: string;
previousLabel?: string;
showCancel?: boolean;
cancelLabel?: string;
dialogClassName?: string;
dialogPt?: PrimeDialogProps['pt'];
dialogPtOptions?: PrimeDialogProps['ptOptions'];
Expand Down Expand Up @@ -200,16 +217,23 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
}
};

// Busy is set before anything is awaited, not just around execute(). `onBeforeExecute` may be
// async, and from the moment Submit is pressed the dialog is committed to running the command -
// so every dismissal has to be withdrawn for the whole window, not only for the part of it the
// request is in flight. Setting it after the transform would leave Cancel live while the command
// is already on its way: the operator cancels, the dialog closes reporting cancellation, the
// transform resolves, and the command executes anyway. The `finally` is what releases it, so the
// flag is cleared on the failure paths and on a transform that throws just as it is on success.
const handleSubmit = async () => {
if (onBeforeExecute) {
const applied = applyBeforeExecute(onBeforeExecute, commandInstance);
setCommandValues(applied instanceof Promise ? await applied : applied);
}

setIsBusy(true);
let result: ICommandResult<TResponse>;

try {
if (onBeforeExecute) {
const applied = applyBeforeExecute(onBeforeExecute, commandInstance);
setCommandValues(applied instanceof Promise ? await applied : applied);
}

result = await (commandInstance as unknown as { execute: () => Promise<ICommandResult<TResponse>> }).execute();
} finally {
setIsBusy(false);
Expand Down Expand Up @@ -238,6 +262,15 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object

const footer = (
<div className="flex items-center w-full gap-3">
{showCancel && (
<Button
label={cancelLabel}
icon="pi pi-times"
onClick={() => handleClose(DialogResult.Cancelled)}
disabled={isBusy}
outlined
/>
)}
{!isFirstStep && (
<Button
label={previousLabel}
Expand Down Expand Up @@ -273,6 +306,10 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
</div>
);

// The header X and the Escape key are withdrawn on the same flag as the footer Cancel. A
// dismissal that still worked mid-flight would close the dialog and then let onSuccess fire on a
// dialog that is already gone. PrimeReact gates Escape behind `closable` too, so `closeOnEscape`
// is stated rather than load-bearing - it keeps the Escape path guarded on its own terms.
return (
<PrimeDialog
header={headerElement}
Expand All @@ -283,7 +320,8 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
style={{ width, ...style }}
contentStyle={contentStyle}
resizable={resizable}
closable
closable={!isBusy}
closeOnEscape={!isBusy}
className={dialogClassName}
pt={dialogPt}
ptOptions={dialogPtOptions}
Expand Down Expand Up @@ -418,6 +456,8 @@ const StepperCommandDialogComponent = <TCommand extends object = object, TRespon
okLabel,
nextLabel,
previousLabel,
showCancel,
cancelLabel,
orientation,
headerPosition,
linear,
Expand Down Expand Up @@ -455,6 +495,8 @@ const StepperCommandDialogComponent = <TCommand extends object = object, TRespon
okLabel={okLabel}
nextLabel={nextLabel}
previousLabel={previousLabel}
showCancel={showCancel}
cancelLabel={cancelLabel}
orientation={orientation}
headerPosition={headerPosition}
linear={linear}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ export const click = async (dialog: StepperDialogInTheDom, label: string) => {
});
};

/**
* Presses Escape on the document, which is where a dialog listens for it — the key is not sent to
* any one element, so there is no dialog argument to pass.
*/
export const pressEscape = async () => {
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
});
};

/**
* The labels of every button the dialog currently offers. Rendered elements come
* from the jsdom realm and carry no `should`, so the footer is described as plain
Expand All @@ -85,6 +95,44 @@ export const click = async (dialog: StepperDialogInTheDom, label: string) => {
export const buttonLabels = (dialog: StepperDialogInTheDom): string[] =>
Array.from(dialog.container.querySelectorAll('button')).map(button => button.textContent ?? '');

/**
* The footer laid out the way it is composed: every button by its label, and the flexible spacer
* that divides the dismissal side from the progression side as `'spacer'`. Buttons on their own
* cannot say which side of that spacer a button sits on, and on a step that offers no Previous
* that is the only thing separating "leads the footer" from "trails it".
* @param dialog - The mounted dialog.
* @returns The footer's children, in document order.
*/
export const footerLayout = (dialog: StepperDialogInTheDom): string[] => {
const footer = dialog.container.querySelector('[data-testid="dialog"]')?.firstElementChild;

return Array.from(footer?.children ?? [])
.map(child => child.tagName === 'BUTTON' ? child.textContent ?? '' : 'spacer');
};

/**
* The labels of the buttons the dialog currently renders as disabled, in document order.
* Read alongside {@link buttonLabels} so a spec can tell "the button is disabled" apart
* from "the button is not there at all".
* @param dialog - The mounted dialog.
* @returns The disabled button labels, in document order.
*/
export const disabledButtonLabels = (dialog: StepperDialogInTheDom): string[] =>
Array.from(dialog.container.querySelectorAll('button'))
.filter(button => button.disabled)
.map(button => button.textContent ?? '');

/**
* Runs work that resolves a promise the spec itself holds - settling a command execution,
* say - and lets React process everything it triggers before returning.
* @param work - The work to run.
*/
export const settle = async (work: () => void) => {
await act(async () => {
work();
});
};

/**
* The headers of the step panels the wizard actually rendered, in render order.
* @param dialog - The mounted dialog.
Expand Down
Loading
Loading