diff --git a/Documentation/StepperCommandDialog/index.md b/Documentation/StepperCommandDialog/index.md
index b369880..a4dfb0d 100644
--- a/Documentation/StepperCommandDialog/index.md
+++ b/Documentation/StepperCommandDialog/index.md
@@ -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
@@ -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
@@ -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
+
+ command={DeleteEnvironment}
+ title="Delete environment"
+ okLabel="Delete"
+ showCancel
+ cancelLabel="Keep environment"
+ onCancel={() => closeDialog(DialogResult.Cancelled)}
+>
+
+ value={c => c.environmentId} title="Environment" options={environments} />
+
+
+ value={c => c.confirmationText} title="Type the environment name to confirm" />
+
+
+```
## 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
diff --git a/Source/CommandDialog/StepperCommandDialog.stories.tsx b/Source/CommandDialog/StepperCommandDialog.stories.tsx
index 1ede92e..d7462d8 100644
--- a/Source/CommandDialog/StepperCommandDialog.stories.tsx
+++ b/Source/CommandDialog/StepperCommandDialog.stories.tsx
@@ -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 (
+
+
+ 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.
+
+
{
+ setOutcome('');
+ setVisible(true);
+ }}
+ >
+ Open Dialog
+
+
+ {outcome && (
+
+ Outcome: {outcome}
+
+ )}
+
+
+ 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);
+ }}
+ >
+
+
+ value={c => c.name}
+ title="Project Name"
+ placeholder="Enter project name (min 2 chars)"
+ />
+
+ value={c => c.email}
+ title="Contact Email"
+ placeholder="Enter contact email"
+ type="email"
+ />
+
+
+
+ value={c => c.description}
+ title="Description"
+ placeholder="Describe the project (min 10 chars)"
+ rows={4}
+ />
+
+ value={c => c.budget}
+ title="Budget"
+ placeholder="Enter budget (must be > 0)"
+ />
+
+
+
+ );
+ },
+};
+
/**
* A step rendered as `{condition && }` disappears entirely when the condition
* is false. Toggle the optional step off and the dialog must behave as a genuine two-step
diff --git a/Source/CommandDialog/StepperCommandDialog.tsx b/Source/CommandDialog/StepperCommandDialog.tsx
index c739bee..23e50ed 100644
--- a/Source/CommandDialog/StepperCommandDialog.tsx
+++ b/Source/CommandDialog/StepperCommandDialog.tsx
@@ -63,7 +63,12 @@ export interface StepperCommandDialogProps {
- if (onBeforeExecute) {
- const applied = applyBeforeExecute(onBeforeExecute, commandInstance);
- setCommandValues(applied instanceof Promise ? await applied : applied);
- }
-
setIsBusy(true);
let result: ICommandResult;
try {
+ if (onBeforeExecute) {
+ const applied = applyBeforeExecute(onBeforeExecute, commandInstance);
+ setCommandValues(applied instanceof Promise ? await applied : applied);
+ }
+
result = await (commandInstance as unknown as { execute: () => Promise> }).execute();
} finally {
setIsBusy(false);
@@ -238,6 +262,15 @@ const StepperCommandDialogWrapper =
+ {showCancel && (
+ handleClose(DialogResult.Cancelled)}
+ disabled={isBusy}
+ outlined
+ />
+ )}
{!isFirstStep && (
);
+ // 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 (
{
});
};
+/**
+ * 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
@@ -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.
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_dismissing_while_the_command_runs/and_escape_is_pressed.ts b/Source/CommandDialog/for_StepperCommandDialog/when_dismissing_while_the_command_runs/and_escape_is_pressed.ts
new file mode 100644
index 0000000..f807416
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_dismissing_while_the_command_runs/and_escape_is_pressed.ts
@@ -0,0 +1,167 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ click,
+ pressEscape,
+ render,
+ settle,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+// The dialog is only busy for as long as the command it submitted is still running, so the spec
+// owns that promise and decides when it finishes.
+const { execution } = vi.hoisted(() => {
+ const execution: { settle: (result: unknown) => void; promise: Promise } = {
+ settle: () => { },
+ promise: Promise.resolve({ isSuccess: false, isValid: true }),
+ };
+
+ return { execution };
+});
+
+const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn() }));
+
+// PrimeReact hides on Escape only while `closable && closeOnEscape` - both default to true, and its
+// Dialog computes exactly that conjunction before subscribing to the key. The mock reproduces the
+// conjunction so Escape is exercised as a dismissal rather than read back as a prop value.
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: {
+ closable?: boolean;
+ closeOnEscape?: boolean;
+ onHide?: () => void;
+ footer?: React.ReactNode;
+ children?: React.ReactNode;
+ }) => {
+ const closesOnEscape = props.closable !== false && props.closeOnEscape !== false;
+ const onHide = props.onHide;
+
+ React.useEffect(() => {
+ if (!closesOnEscape) {
+ return () => { };
+ }
+
+ const dismiss = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ onHide?.();
+ }
+ };
+
+ document.addEventListener('keydown', dismiss);
+ return () => document.removeEventListener('keydown', dismiss);
+ }, [closesOnEscape, onHide]);
+
+ return React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children);
+ },
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => ({ closeDialog }),
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+ const commandInstance = { execute: () => execution.promise };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => commandInstance,
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+/** Arms a fresh command run that will not finish until the spec says so. */
+const anUnfinishedCommandRun = () => {
+ execution.promise = new Promise(resolve => {
+ execution.settle = resolve as (result: unknown) => void;
+ });
+};
+
+/** Finishes the armed run the way a rejected command does - the dialog stays open. */
+const aRejectedResult = { isSuccess: false, isValid: true };
+
+class TestCommand {
+ name: string = '';
+}
+
+const onCancel = vi.fn(() => true);
+
+const aWizard = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ onCancel,
+ },
+ React.createElement(StepperPanel, { header: 'Only Step' }, 'Only Step content'));
+
+// Escape is the dismissal with no control to disable - the dialog can only refuse it by telling
+// PrimeReact not to listen while the command runs. Pressing it in both states is what separates
+// "refused for now" from "never listened at all": the second press, after the command has returned,
+// has to be honored, or the silence during the run says nothing about the guard.
+describe('when escape is pressed on a wizard whose command has not returned', () => {
+ let dialog: StepperDialogInTheDom;
+ let cancelCallsWhileRunning: number;
+ let cancelCallsAfterReturning: number;
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+ anUnfinishedCommandRun();
+
+ dialog = await render(aWizard());
+ await click(dialog, 'Submit');
+
+ await pressEscape();
+ cancelCallsWhileRunning = onCancel.mock.calls.length;
+
+ await settle(() => execution.settle(aRejectedResult));
+
+ await pressEscape();
+ cancelCallsAfterReturning = onCancel.mock.calls.length;
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_not_dismiss_on_escape_while_the_command_runs', () => {
+ cancelCallsWhileRunning.should.equal(0);
+ });
+
+ it('should_dismiss_on_escape_once_the_command_returns', () => {
+ cancelCallsAfterReturning.should.equal(1);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_dismissing_while_the_command_runs/and_the_header_close_is_used.ts b/Source/CommandDialog/for_StepperCommandDialog/when_dismissing_while_the_command_runs/and_the_header_close_is_used.ts
new file mode 100644
index 0000000..0a8e600
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_dismissing_while_the_command_runs/and_the_header_close_is_used.ts
@@ -0,0 +1,159 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ buttonLabels,
+ click,
+ render,
+ settle,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+// The dialog is only busy for as long as the command it submitted is still running, so the spec
+// owns that promise and decides when it finishes.
+const { execution } = vi.hoisted(() => {
+ const execution: { settle: (result: unknown) => void; promise: Promise } = {
+ settle: () => { },
+ promise: Promise.resolve({ isSuccess: false, isValid: true }),
+ };
+
+ return { execution };
+});
+
+const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn() }));
+
+// PrimeReact renders the header close control only while `closable` (default true). The mock
+// renders that control on the same condition instead of handing the prop back, so the spec presses
+// the X the operator would press rather than reading what the dialog asked for.
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: {
+ closable?: boolean;
+ onHide?: () => void;
+ footer?: React.ReactNode;
+ children?: React.ReactNode;
+ }) => React.createElement(
+ 'div',
+ { 'data-testid': 'dialog' },
+ props.closable === false ? null : React.createElement('button', { onClick: () => props.onHide?.() }, 'X'),
+ props.footer,
+ props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => ({ closeDialog }),
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+ const commandInstance = { execute: () => execution.promise };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => commandInstance,
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+/** Arms a fresh command run that will not finish until the spec says so. */
+const anUnfinishedCommandRun = () => {
+ execution.promise = new Promise(resolve => {
+ execution.settle = resolve as (result: unknown) => void;
+ });
+};
+
+/** Finishes the armed run the way a rejected command does - the dialog stays open. */
+const aRejectedResult = { isSuccess: false, isValid: true };
+
+class TestCommand {
+ name: string = '';
+}
+
+const onCancel = vi.fn(() => true);
+
+const aWizard = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ onCancel,
+ },
+ React.createElement(StepperPanel, { header: 'Only Step' }, 'Only Step content'));
+
+// The footer is not the only way out of this dialog: the header carries an X that closes it through
+// the same cancel arm. Withdrawing the footer Cancel while the command runs and leaving the X live
+// would close a working dialog anyway - and then report cancellation for a command that goes on to
+// succeed. The withdrawal is read as a window rather than as a state: the X is gone while the
+// command runs and back once it returns, and the click it takes then is honored, so a dialog that
+// simply never offered an X could not pass.
+describe('when the header close is used on a wizard whose command has not returned', () => {
+ let dialog: StepperDialogInTheDom;
+ let offeredWhileRunning: string[];
+ let offeredAfterReturning: string[];
+ let cancelCallsAfterReturning: number;
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+ anUnfinishedCommandRun();
+
+ dialog = await render(aWizard());
+ await click(dialog, 'Submit');
+ offeredWhileRunning = buttonLabels(dialog);
+
+ await click(dialog, 'X');
+
+ await settle(() => execution.settle(aRejectedResult));
+ offeredAfterReturning = buttonLabels(dialog);
+
+ await click(dialog, 'X');
+ cancelCallsAfterReturning = onCancel.mock.calls.length;
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_withdraw_the_header_close_while_the_command_runs', () => {
+ offeredWhileRunning.should.deep.equal(['Submit']);
+ });
+
+ it('should_offer_the_header_close_again_once_the_command_returns', () => {
+ offeredAfterReturning.should.deep.equal(['X', 'Submit']);
+ });
+
+ it('should_honor_the_header_close_once_the_command_returns', () => {
+ cancelCallsAfterReturning.should.equal(1);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_an_async_transform_has_not_settled.ts b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_an_async_transform_has_not_settled.ts
new file mode 100644
index 0000000..d60e227
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_an_async_transform_has_not_settled.ts
@@ -0,0 +1,169 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ buttonLabels,
+ click,
+ render,
+ settle,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+// `onBeforeExecute` may be async, so pressing Submit opens a window in which the dialog has
+// committed to running the command but the request has not gone out yet. The spec owns the
+// transform's promise so it can hold the dialog inside that window and act on it; `execution`
+// counts the runs, because the whole question is whether a command the operator cancelled ran
+// anyway.
+const { execution } = vi.hoisted(() => ({ execution: { calls: 0 } }));
+
+const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn() }));
+
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => ({ closeDialog }),
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever. The
+// command settles immediately and rejected - the delay under test is the transform's, and a
+// rejected result keeps the dialog open so the state after the window is still observable.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+ const commandInstance = {
+ name: '',
+ execute: () => {
+ execution.calls += 1;
+ return Promise.resolve({ isSuccess: false, isValid: true });
+ },
+ };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => commandInstance,
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+class TestCommand {
+ name: string = '';
+}
+
+const transform: { settle: (values: TestCommand) => void; promise: Promise } = {
+ settle: () => { },
+ promise: Promise.resolve(new TestCommand()),
+};
+
+/** Arms a fresh transform that will not hand back values until the spec says so. */
+const aTransformThatHasNotSettled = () => {
+ transform.promise = new Promise(resolve => {
+ transform.settle = resolve;
+ });
+};
+
+const onCancel = vi.fn(() => true);
+const onBeforeExecute = vi.fn(() => transform.promise);
+
+const aWizardOfferingCancel = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ showCancel: true,
+ onCancel,
+ onBeforeExecute,
+ },
+ React.createElement(StepperPanel, { header: 'Only Step' }, 'Only Step content'));
+
+// The dangerous cell of the state machine: submit committed, command not yet sent. A Cancel honored
+// here is a lie - the dialog reports cancellation, the transform then resolves, and the write lands
+// regardless. Which is why the spec does not stop at "Cancel did nothing": it settles the transform
+// and reads that the command really did run, so the silence from the dialog context is measured
+// against a run that happened rather than against a submit that quietly went nowhere.
+describe('when cancel is clicked while an async before-execute transform is still running', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerWhileTransforming: string[];
+ let executeCallsWhileTransforming: number;
+ let closeDialogCallsWhileTransforming: number;
+ let executeCallsAfterSettling: number;
+ let cancelCallsAfterSettling: number;
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+ onBeforeExecute.mockClear();
+ execution.calls = 0;
+ aTransformThatHasNotSettled();
+
+ dialog = await render(aWizardOfferingCancel());
+ await click(dialog, 'Submit');
+ footerWhileTransforming = buttonLabels(dialog);
+ executeCallsWhileTransforming = execution.calls;
+
+ await click(dialog, 'Cancel');
+ closeDialogCallsWhileTransforming = closeDialog.mock.calls.length;
+
+ await settle(() => transform.settle(new TestCommand()));
+ executeCallsAfterSettling = execution.calls;
+
+ await click(dialog, 'Cancel');
+ cancelCallsAfterSettling = onCancel.mock.calls.length;
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_still_render_a_cancel_to_click', () => {
+ footerWhileTransforming.should.deep.equal(['Cancel', 'Submit']);
+ });
+
+ it('should_not_have_run_the_command_yet', () => {
+ executeCallsWhileTransforming.should.equal(0);
+ });
+
+ it('should_report_no_cancellation_to_the_dialog_context', () => {
+ closeDialogCallsWhileTransforming.should.equal(0);
+ });
+
+ it('should_run_the_command_the_submit_committed_to', () => {
+ executeCallsAfterSettling.should.equal(1);
+ });
+
+ it('should_honor_a_cancel_click_once_the_command_returns', () => {
+ cancelCallsAfterSettling.should.equal(1);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_asked_for.ts b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_asked_for.ts
new file mode 100644
index 0000000..ea69152
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_asked_for.ts
@@ -0,0 +1,115 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ click,
+ footerLayout,
+ render,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => undefined,
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => ({}),
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+class TestCommand {
+ name: string = '';
+}
+
+const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`);
+
+const aWizardOfferingCancel = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ showCancel: true,
+ },
+ step('Step 1'), step('Step 2'), step('Step 3'));
+
+// Dismissal has to sit in the same place all the way through the wizard, or the user learns a
+// position on step one that a later step takes away from them. Previous is the button that comes
+// and goes, so the whole footer is described at each step: that pins the order Cancel keeps, and
+// pins that Previous really did appear - an order proven only where nothing else moves proves
+// nothing about moving. The spacer is described too, because on the first step there is no
+// Previous, and without it "leads the footer" and "trails it" produce the same list of buttons.
+describe('when a stepper dialog is asked for a footer cancel', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerOnTheFirstStep: string[];
+ let footerOnAMiddleStep: string[];
+ let footerOnTheLastStep: string[];
+
+ beforeEach(async () => {
+ dialog = await render(aWizardOfferingCancel());
+ footerOnTheFirstStep = footerLayout(dialog);
+
+ await click(dialog, 'Next');
+ footerOnAMiddleStep = footerLayout(dialog);
+
+ await click(dialog, 'Next');
+ footerOnTheLastStep = footerLayout(dialog);
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_lead_the_footer_with_cancel_on_the_first_step', () => {
+ footerOnTheFirstStep.should.deep.equal(['Cancel', 'spacer', 'Next']);
+ });
+
+ it('should_keep_cancel_leading_the_footer_once_previous_appears', () => {
+ footerOnAMiddleStep.should.deep.equal(['Cancel', 'Previous', 'spacer', 'Next']);
+ });
+
+ it('should_keep_cancel_leading_the_footer_on_the_last_step', () => {
+ footerOnTheLastStep.should.deep.equal(['Cancel', 'Previous', 'spacer', 'Submit']);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_clicked.ts b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_clicked.ts
new file mode 100644
index 0000000..c877765
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_clicked.ts
@@ -0,0 +1,126 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ buttonLabels,
+ click,
+ render,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn() }));
+
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => ({ closeDialog }),
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => ({}),
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+class TestCommand {
+ name: string = '';
+}
+
+const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`);
+
+const onCancel = vi.fn(() => true);
+const onConfirm = vi.fn(() => true);
+
+const aWizardOfferingCancel = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ showCancel: true,
+ onCancel,
+ onConfirm,
+ },
+ step('Step 1'), step('Step 2'));
+
+// Closing is not the point - the dialog closes on submit too. The point is *which* arm of
+// handleClose the button takes, so the dialog is given a cancel handler and a confirm handler
+// that are told apart by which one ran, and the result handed to the dialog context is read as a
+// value rather than as "something happened". Cancelled is 4 in the mocked DialogResult; Ok is 3.
+describe('when the footer cancel is clicked', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerBeforeClicking: string[];
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+ onConfirm.mockClear();
+
+ dialog = await render(aWizardOfferingCancel());
+ footerBeforeClicking = buttonLabels(dialog);
+
+ await click(dialog, 'Cancel');
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_have_offered_a_cancel_to_click', () => {
+ footerBeforeClicking.should.deep.equal(['Cancel', 'Next']);
+ });
+
+ it('should_invoke_the_cancel_callback_once', () => {
+ onCancel.mock.calls.length.should.equal(1);
+ });
+
+ it('should_leave_the_confirm_callback_alone', () => {
+ onConfirm.mock.calls.length.should.equal(0);
+ });
+
+ it('should_close_through_the_dialog_context_once', () => {
+ closeDialog.mock.calls.length.should.equal(1);
+ });
+
+ it('should_close_with_cancelled', () => {
+ closeDialog.mock.calls[0][0].should.equal(4);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_given_a_label.ts b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_given_a_label.ts
new file mode 100644
index 0000000..8e7e696
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_given_a_label.ts
@@ -0,0 +1,116 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ buttonLabels,
+ click,
+ render,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn() }));
+
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => ({ closeDialog }),
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => ({}),
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+class TestCommand {
+ name: string = '';
+}
+
+const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`);
+
+const onCancel = vi.fn(() => true);
+
+const aWizardOfferingADiscard = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ showCancel: true,
+ cancelLabel: 'Discard',
+ onCancel,
+ },
+ step('Step 1'), step('Step 2'));
+
+// A wizard that throws work away wants to say so, so the label is the caller's. Renaming a button
+// is only worth anything if the renamed button is still the one that cancels, so the label and the
+// arm it takes are pinned together - a label the dialog forwarded to some other control would
+// satisfy the first claim on its own.
+describe('when a footer cancel is given a label of its own', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerBeforeClicking: string[];
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+
+ dialog = await render(aWizardOfferingADiscard());
+ footerBeforeClicking = buttonLabels(dialog);
+
+ await click(dialog, 'Discard');
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_lead_the_footer_with_that_label', () => {
+ footerBeforeClicking.should.deep.equal(['Discard', 'Next']);
+ });
+
+ it('should_still_invoke_the_cancel_callback_when_clicked', () => {
+ onCancel.mock.calls.length.should.equal(1);
+ });
+
+ it('should_still_close_with_cancelled', () => {
+ closeDialog.mock.calls[0][0].should.equal(4);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_not_asked_for.ts b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_not_asked_for.ts
new file mode 100644
index 0000000..2c00172
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_it_is_not_asked_for.ts
@@ -0,0 +1,112 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ click,
+ footerLayout,
+ render,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => undefined,
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => ({}),
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+class TestCommand {
+ name: string = '';
+}
+
+const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`);
+
+const aWizardWithoutACancelProp = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ },
+ step('Step 1'), step('Step 2'), step('Step 3'));
+
+// The footer cancel is opt-in, so the wizard that never mentions it must look exactly as it did
+// before the prop existed. Each step is described by the whole footer - spacer included - rather
+// than by the absence of one label: a dialog whose footer rendered nothing at all also has no
+// Cancel in it, and that is a worse bug than the one being guarded against.
+describe('when a stepper dialog is never asked for a footer cancel', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerOnTheFirstStep: string[];
+ let footerOnAMiddleStep: string[];
+ let footerOnTheLastStep: string[];
+
+ beforeEach(async () => {
+ dialog = await render(aWizardWithoutACancelProp());
+ footerOnTheFirstStep = footerLayout(dialog);
+
+ await click(dialog, 'Next');
+ footerOnAMiddleStep = footerLayout(dialog);
+
+ await click(dialog, 'Next');
+ footerOnTheLastStep = footerLayout(dialog);
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_offer_next_alone_on_the_first_step', () => {
+ footerOnTheFirstStep.should.deep.equal(['spacer', 'Next']);
+ });
+
+ it('should_offer_previous_and_next_alone_on_a_middle_step', () => {
+ footerOnAMiddleStep.should.deep.equal(['Previous', 'spacer', 'Next']);
+ });
+
+ it('should_offer_previous_and_submit_alone_on_the_last_step', () => {
+ footerOnTheLastStep.should.deep.equal(['Previous', 'spacer', 'Submit']);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_submit_is_clicked_instead.ts b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_submit_is_clicked_instead.ts
new file mode 100644
index 0000000..68b6ae3
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_submit_is_clicked_instead.ts
@@ -0,0 +1,127 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ buttonLabels,
+ click,
+ render,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn() }));
+
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => ({ closeDialog }),
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+ const commandInstance = {
+ execute: async () => ({ isSuccess: true, isValid: true, validationResults: [], response: {} }),
+ };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => commandInstance,
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+class TestCommand {
+ name: string = '';
+}
+
+const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`);
+
+const onCancel = vi.fn(() => true);
+const onConfirm = vi.fn(() => true);
+
+const aWizardOfferingCancel = () => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ showCancel: true,
+ onCancel,
+ onConfirm,
+ },
+ step('Step 1'), step('Step 2'));
+
+// The control for the cancel arm next door. It runs the exact same harness - same handlers, same
+// dialog context, a footer that still carries Cancel - down the confirm path, so that "the confirm
+// callback never ran" and "the context was closed with Cancelled" are claims this setup is able to
+// falsify. Without it, a footer whose buttons all took the confirm arm and a footer whose confirm
+// arm was unreachable would look identical.
+describe('when submit is clicked on a wizard that also offers a footer cancel', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerBeforeClicking: string[];
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+ onConfirm.mockClear();
+
+ dialog = await render(aWizardOfferingCancel());
+ await click(dialog, 'Next');
+ footerBeforeClicking = buttonLabels(dialog);
+
+ await click(dialog, 'Submit');
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_have_offered_cancel_alongside_submit', () => {
+ footerBeforeClicking.should.deep.equal(['Cancel', 'Previous', 'Submit']);
+ });
+
+ it('should_invoke_the_confirm_callback_once', () => {
+ onConfirm.mock.calls.length.should.equal(1);
+ });
+
+ it('should_leave_the_cancel_callback_alone', () => {
+ onCancel.mock.calls.length.should.equal(0);
+ });
+
+ it('should_close_with_ok', () => {
+ closeDialog.mock.calls[0][0].should.equal(3);
+ });
+});
diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_the_command_is_executing.ts b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_the_command_is_executing.ts
new file mode 100644
index 0000000..2b2f515
--- /dev/null
+++ b/Source/CommandDialog/for_StepperCommandDialog/when_offering_a_footer_cancel/and_the_command_is_executing.ts
@@ -0,0 +1,237 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+// @vitest-environment jsdom
+
+import React from 'react';
+import { vi } from 'vitest';
+import { StepperPanel } from 'primereact/stepperpanel';
+import { StepperCommandDialog } from '../../StepperCommandDialog';
+import {
+ buttonLabels,
+ click,
+ disabledButtonLabels,
+ render,
+ settle,
+ unmount,
+ type StepperDialogInTheDom
+} from '../given/a_stepper_dialog_in_the_dom';
+
+// The dialog is only busy for as long as the command it submitted is still running, so the spec
+// owns that promise and decides when it finishes. `execute` reads the holder on every call rather
+// than closing over one promise, which is what lets each spec start from a fresh, unsettled run.
+const { execution } = vi.hoisted(() => {
+ const execution: { settle: (result: unknown) => void; promise: Promise } = {
+ settle: () => { },
+ promise: Promise.resolve({ isSuccess: false, isValid: true }),
+ };
+
+ return { execution };
+});
+
+const { closeDialog } = vi.hoisted(() => ({ closeDialog: vi.fn() }));
+
+vi.mock('primereact/dialog', () => ({
+ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children),
+}));
+
+vi.mock('primereact/stepper', () => ({
+ Stepper: (props: { children?: React.ReactNode; activeStep?: number }) =>
+ React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children),
+}));
+
+vi.mock('primereact/stepperpanel', () => ({
+ StepperPanel: (props: { header?: string; children?: React.ReactNode }) =>
+ React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children),
+}));
+
+vi.mock('primereact/button', () => ({
+ Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) =>
+ React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label),
+}));
+
+vi.mock('@cratis/arc.react/dialogs', () => ({
+ DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
+ useDialogContext: () => ({ closeDialog }),
+}));
+
+// The context object is created once rather than per call: this spec renders for real, so an
+// identity that changed on every render would re-run the stepper's error effect forever.
+vi.mock('@cratis/arc.react/commands', () => {
+ const commandFormContext = {
+ isValid: true,
+ setCommandValues: () => { },
+ setCommandResult: () => { },
+ getFieldError: () => undefined,
+ };
+ const commandInstance = { execute: () => execution.promise };
+
+ return {
+ CommandForm: (props: { children?: React.ReactNode }) =>
+ React.createElement('div', null, props.children),
+ useCommandFormContext: () => commandFormContext,
+ useCommandInstance: () => commandInstance,
+ CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
+ React.createElement('div', null, props.field),
+ };
+});
+
+/** Arms a fresh command run that will not finish until the spec says so. */
+const anUnfinishedCommandRun = () => {
+ execution.promise = new Promise(resolve => {
+ execution.settle = resolve as (result: unknown) => void;
+ });
+};
+
+/** Finishes the armed run the way a rejected command does - the dialog stays open. */
+const aRejectedResult = { isSuccess: false, isValid: true };
+
+class TestCommand {
+ name: string = '';
+}
+
+const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`);
+
+const onCancel = vi.fn(() => true);
+
+const aWizardOfferingCancel = (...steps: React.ReactElement[]) => React.createElement(
+ StepperCommandDialog,
+ {
+ command: TestCommand as unknown as new () => object,
+ visible: true,
+ title: 'Test Dialog',
+ showCancel: true,
+ onCancel,
+ },
+ ...steps);
+
+// Busy is only reachable through Submit, and Submit only exists on the last step - so the state the
+// dialog guards against is "the user pressed Submit and the command has not come back yet". A
+// disabled attribute is asserted next to what it is supposed to buy: the click that a live Cancel
+// would have honored does nothing while the command runs, and is honored again once it returns.
+// Reading the attribute alone would pass just as happily on a dialog that disabled Cancel forever.
+describe('when the command submitted from the last step has not returned', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerBeforeSubmitting: string[];
+ let disabledBeforeSubmitting: string[];
+ let footerWhileRunning: string[];
+ let disabledWhileRunning: string[];
+ let cancelCallsWhileRunning: number;
+ let disabledAfterReturning: string[];
+ let cancelCallsAfterReturning: number;
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+ anUnfinishedCommandRun();
+
+ dialog = await render(aWizardOfferingCancel(step('Step 1'), step('Step 2'), step('Step 3')));
+ await click(dialog, 'Next');
+ await click(dialog, 'Next');
+ footerBeforeSubmitting = buttonLabels(dialog);
+ disabledBeforeSubmitting = disabledButtonLabels(dialog);
+
+ await click(dialog, 'Submit');
+ footerWhileRunning = buttonLabels(dialog);
+ disabledWhileRunning = disabledButtonLabels(dialog);
+
+ await click(dialog, 'Cancel');
+ cancelCallsWhileRunning = onCancel.mock.calls.length;
+
+ await settle(() => execution.settle(aRejectedResult));
+ disabledAfterReturning = disabledButtonLabels(dialog);
+
+ await click(dialog, 'Cancel');
+ cancelCallsAfterReturning = onCancel.mock.calls.length;
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_have_reached_the_last_step_with_cancel_live', () => {
+ footerBeforeSubmitting.should.deep.equal(['Cancel', 'Previous', 'Submit']);
+ });
+
+ it('should_not_have_had_cancel_disabled_before_submitting', () => {
+ disabledBeforeSubmitting.should.not.contain('Cancel');
+ });
+
+ it('should_keep_cancel_in_the_footer_while_the_command_runs', () => {
+ footerWhileRunning.should.deep.equal(['Cancel', 'Previous', 'Submit']);
+ });
+
+ it('should_disable_cancel_while_the_command_runs', () => {
+ disabledWhileRunning.should.contain('Cancel');
+ });
+
+ // Named for what it measures. A disabled control never dispatches a click, so this does not
+ // observe a handler turning the click away - it observes that Cancel is out of the click path
+ // altogether. It is kept rather than deleted as a twin of the disabled attribute because it is
+ // the "before" half of the window: without it, the honored click below would be satisfied just
+ // as well by a click that landed while the command was still running.
+ it('should_take_cancel_out_of_the_click_path_while_the_command_runs', () => {
+ cancelCallsWhileRunning.should.equal(0);
+ });
+
+ it('should_release_cancel_once_the_command_returns', () => {
+ disabledAfterReturning.should.not.contain('Cancel');
+ });
+
+ it('should_honor_a_cancel_click_once_the_command_returns', () => {
+ cancelCallsAfterReturning.should.equal(1);
+ });
+});
+
+// A one-step wizard is its own first and last step, which is the only shape where the busy footer
+// carries no Previous - the cell of the state machine where Cancel is the leading button *and* the
+// dialog is working. On a middle step there is no Submit to press and Previous is itself disabled
+// while busy, so a busy middle step cannot be reached at all.
+//
+// The run is settled and Cancel clicked a second time for the same reason as next door: the click
+// while busy is turned away by a disabled attribute, not by a handler, so on its own it would pass
+// on a dialog that never wired the button up at all. Clicking again once the command has returned
+// is what puts the onClick inside this suite's reach.
+describe('when the command submitted from the only step has not returned', () => {
+ let dialog: StepperDialogInTheDom;
+ let footerWhileRunning: string[];
+ let disabledWhileRunning: string[];
+ let cancelCallsWhileRunning: number;
+ let cancelCallsAfterReturning: number;
+
+ beforeEach(async () => {
+ closeDialog.mockClear();
+ onCancel.mockClear();
+ anUnfinishedCommandRun();
+
+ dialog = await render(aWizardOfferingCancel(step('Only Step')));
+ await click(dialog, 'Submit');
+ footerWhileRunning = buttonLabels(dialog);
+ disabledWhileRunning = disabledButtonLabels(dialog);
+
+ await click(dialog, 'Cancel');
+ cancelCallsWhileRunning = onCancel.mock.calls.length;
+
+ await settle(() => execution.settle(aRejectedResult));
+
+ await click(dialog, 'Cancel');
+ cancelCallsAfterReturning = onCancel.mock.calls.length;
+ });
+
+ afterEach(async () => await unmount(dialog));
+
+ it('should_still_lead_the_footer_with_cancel', () => {
+ footerWhileRunning.should.deep.equal(['Cancel', 'Submit']);
+ });
+
+ it('should_disable_cancel_while_the_command_runs', () => {
+ disabledWhileRunning.should.contain('Cancel');
+ });
+
+ it('should_take_cancel_out_of_the_click_path_while_the_command_runs', () => {
+ cancelCallsWhileRunning.should.equal(0);
+ });
+
+ it('should_honor_a_cancel_click_once_the_command_returns', () => {
+ cancelCallsAfterReturning.should.equal(1);
+ });
+});