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
9 changes: 9 additions & 0 deletions Documentation/CommandDialog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ CommandDialog simplifies the process of presenting a command form to users withi
- Field-level change tracking
- Pre-execution transformation of values
- Success and cancellation handling
- Busy state management during command execution (buttons disabled, spinner shown)
- Integration with Cratis Arc command system

## Recommended Usage Pattern
Expand Down Expand Up @@ -108,6 +109,14 @@ function MyComponent() {
- `onCancel` follows the same behavior as `Dialog` (`true` closes).
- `onClose` closes unless it returns `false`.

## Busy State

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

- When the Ok/Yes button is clicked and command execution begins, all buttons are disabled and the primary button shows a loading spinner.
- Once execution completes (success or failure), the buttons return to their normal state.
- This prevents duplicate submissions and gives users clear visual feedback.

## Context

`CommandDialog` is built on top of `CommandForm` and `Dialog`, and uses command form context internally for values, validation, and execution state.
Expand Down
1 change: 1 addition & 0 deletions Documentation/Dialogs/dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ const MyComponent = () => {
- `style`: Custom dialog style forwarded to PrimeReact `Dialog`
- `resizable`: Enables resize
- `isValid`: Enables or disables confirm actions
- `isBusy`: When `true`, disables all buttons and shows a loading spinner on the primary action button
- `okLabel`, `cancelLabel`, `yesLabel`, `noLabel`: Button labels

## Notes
Expand Down
87 changes: 87 additions & 0 deletions Source/CommandDialog/CommandDialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,46 @@ class UpdateUserCommand extends Command<object> {
}
}

/** Command that simulates a 2-second server delay to demonstrate the busy state. */
class DemoSlowUpdateUserCommand extends Command<object> {
readonly route: string = '/api/users/update';
readonly validation: CommandValidator = new UpdateUserCommandValidator();
readonly propertyDescriptors: PropertyDescriptor[] = [
new PropertyDescriptor('name', String),
new PropertyDescriptor('email', String),
new PropertyDescriptor('age', Number),
];

name = '';
email = '';
age = 0;

constructor() {
super(Object, false);
}

get requestParameters(): string[] {
return [];
}

get properties(): string[] {
return ['name', 'email', 'age'];
}

override async validate(): Promise<CommandResult<object>> {
const errors = this.validation?.validate(this) ?? [];
if (errors.length > 0) {
return CommandResult.validationFailed(errors);
}
return CommandResult.empty;
}

override async execute(): Promise<CommandResult<object>> {
await new Promise(resolve => setTimeout(resolve, 2000));
return CommandResult.empty;
}
}

/** Variant that keeps the original server-calling validate() for the WithServerValidation story. */
class UpdateUserCommandWithServer extends Command<object> {
readonly route: string = '/api/users/update';
Expand Down Expand Up @@ -681,3 +721,50 @@ const MixedChildrenWrapper = () => {
export const MixedChildren: Story = {
render: () => <MixedChildrenWrapper />,
};

const WithBusyStateWrapper = () => {
const [visible, setVisible] = useState(true);
const [result, setResult] = useState<string>('');

return (
<div className="storybook-wrapper">
<button
className="p-button p-component mb-3"
onClick={() => {
setResult('');
setVisible(true);
}}
>
Open Dialog
</button>

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

<CommandDialog<DemoSlowUpdateUserCommand>
command={DemoSlowUpdateUserCommand}
visible={visible}
title="Save User (2s simulated delay)"
okLabel="Save"
cancelLabel="Cancel"
autoServerValidate={false}
onConfirm={async () => {
setResult('User saved successfully');
setVisible(false);
}}
onCancel={() => setVisible(false)}
>
<InputTextField value={(c: DemoSlowUpdateUserCommand) => c.name} title="Name" placeholder="Enter name (min 2 chars)" />
<InputTextField value={(c: DemoSlowUpdateUserCommand) => c.email} title="Email" placeholder="Enter email" type="email" />
<NumberField value={(c: DemoSlowUpdateUserCommand) => c.age} title="Age" placeholder="Enter age (18-120)" />
</CommandDialog>
</div>
);
};

export const WithBusyState: Story = {
render: () => <WithBusyStateWrapper />,
};
12 changes: 10 additions & 2 deletions Source/CommandDialog/CommandDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { ICommandResult } from '@cratis/arc/commands';
import { DialogButtons, DialogResult } from '@cratis/arc.react/dialogs';
import { Dialog, type DialogProps } from '../Dialogs/Dialog';
import React from 'react';
import React, { useState } from 'react';
import {
CommandForm,
CommandFormFieldWrapper,
Expand Down Expand Up @@ -56,14 +56,21 @@ const CommandDialogWrapper = <TCommand extends object>({
}) => {
const { setCommandValues, setCommandResult, isValid: isCommandFormValid } = useCommandFormContext<TCommand>();
const commandInstance = useCommandInstance<TCommand>();
const [isBusy, setIsBusy] = useState(false);

const handleConfirm = async () => {
if (onBeforeExecute) {
const transformedValues = onBeforeExecute(commandInstance);
setCommandValues(transformedValues);
}

const result = await (commandInstance as unknown as { execute: () => Promise<ICommandResult<unknown>> }).execute();
setIsBusy(true);
let result: ICommandResult<unknown>;
try {
result = await (commandInstance as unknown as { execute: () => Promise<ICommandResult<unknown>> }).execute();
} finally {
setIsBusy(false);
}

if (!result.isSuccess) {
setCommandResult(result);
Expand Down Expand Up @@ -123,6 +130,7 @@ const CommandDialogWrapper = <TCommand extends object>({
yesLabel={yesLabel}
noLabel={noLabel}
isValid={isDialogValid}
isBusy={isBusy}
>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
{processedChildren}
Expand Down
57 changes: 57 additions & 0 deletions Source/CommandDialog/for_CommandDialog/when_not_executing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { vi } from 'vitest';
import { CommandDialog } from '../CommandDialog';

vi.mock('primereact/dialog', () => ({
Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
React.createElement('div', null, props.footer, props.children),
}));

vi.mock('primereact/button', () => ({
Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) =>
React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label),
}));

vi.mock('@cratis/arc.react/dialogs', () => ({
DialogButtons: { Ok: 1, OkCancel: 2, YesNo: 3, YesNoCancel: 4 },
DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
useDialogContext: () => undefined,
}));

vi.mock('@cratis/arc.react/commands', () => ({
CommandForm: (props: { children?: React.ReactNode }) =>
React.createElement('div', null, props.children),
useCommandFormContext: () => ({
isValid: true,
setCommandValues: () => {},
setCommandResult: () => {},
}),
useCommandInstance: () => ({}),
CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
React.createElement('div', null, props.field),
}));

class TestCommand {
name: string = '';
}

describe('when CommandDialog is in its initial state', () => {
let html: string;

beforeEach(() => {
const element = React.createElement(CommandDialog, {
command: TestCommand as unknown as new () => object,
visible: true,
title: 'Test Dialog',
});
html = renderToStaticMarkup(element);
});

it('should_not_have_buttons_disabled_due_to_busy', () => {
html.should.not.include('data-loading="true"');
});
});
39 changes: 39 additions & 0 deletions Source/Dialogs/Dialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,45 @@ export const WithForm: Story = {
}
};

const IsBusyWrapper = () => {
const [busy, setBusy] = useState(false);

const BusyDialog = () => {
const { closeDialog } = useDialogContext();

return (
<Dialog
title="Saving changes"
buttons={DialogButtons.OkCancel}
onConfirm={async () => {
setBusy(true);
await new Promise(resolve => setTimeout(resolve, 3000));
setBusy(false);
closeDialog(DialogResult.Ok);
return true;
}}
onCancel={() => closeDialog(DialogResult.Cancelled)}
isBusy={busy}
>
<p>Click Ok to simulate a 3-second save operation. All buttons become disabled and the primary button shows a spinner.</p>
</Dialog>
);
};

const [DialogComponent, showDialog] = useDialog(BusyDialog);

return (
<>
<Button label="Open Dialog" onClick={async () => await showDialog()} />
<DialogComponent />
</>
);
};

export const IsBusy: Story = {
render: () => <IsBusyWrapper />,
};

export const CustomButtons: Story = {
render: () => {
type ActionResult = { action: 'draft' | 'publish' };
Expand Down
18 changes: 10 additions & 8 deletions Source/Dialogs/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface DialogProps {
style?: CSSProperties;
resizable?: boolean;
isValid?: boolean;
isBusy?: boolean;
okLabel?: string;
cancelLabel?: string;
yesLabel?: string;
Expand All @@ -40,6 +41,7 @@ export const Dialog = ({
style,
resizable = false,
isValid,
isBusy = false,
okLabel = 'Ok',
cancelLabel = 'Cancel',
yesLabel = 'Yes',
Expand Down Expand Up @@ -90,29 +92,29 @@ export const Dialog = ({

const okFooter = (
<>
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid} autoFocus />
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
</>
);

const okCancelFooter = (
<>
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid} autoFocus />
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} />
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} disabled={isBusy} />
</>
);

const yesNoFooter = (
<>
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid} autoFocus />
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} />
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} disabled={isBusy} />
</>
);

const yesNoCancelFooter = (
<>
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid} autoFocus />
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} />
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} />
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} disabled={isBusy} />
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} disabled={isBusy} />
</>
);

Expand Down
49 changes: 49 additions & 0 deletions Source/Dialogs/for_Dialog/when_rendered_with_is_busy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { vi } from 'vitest';
import { Dialog } from '../Dialog';

vi.mock('primereact/dialog', () => ({
Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
React.createElement('div', null, props.footer, props.children),
}));

vi.mock('primereact/button', () => ({
Button: (props: { icon?: string; label?: string; onClick?: () => void | Promise<void>; disabled?: boolean; loading?: boolean }) => {
if (props.icon === 'pi pi-check' && props.onClick) {
props.onClick();
}
return React.createElement('button', { disabled: props.disabled }, props.label);
},
}));

vi.mock('@cratis/arc.react/dialogs', () => ({
DialogButtons: { Ok: 1, OkCancel: 2, YesNo: 3, YesNoCancel: 4 },
DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
useDialogContext: () => undefined,
}));

describe('when rendered with is busy', () => {
let html: string;

beforeEach(() => {
const element = React.createElement(Dialog, {
title: 'Save changes',
visible: true,
isBusy: true,
buttons: 2,
children: React.createElement('p', null, 'Dialog content'),
});

html = renderToStaticMarkup(element);
});

it('should_disable_all_buttons', () => {
const disabledCount = (html.match(/disabled=""/g) || []).length;
disabledCount.should.equal(2);
});
});

Loading