Skip to content

Commit 4e65de1

Browse files
authored
Merge pull request #21 from Cratis:copilot/add-isbusy-support-for-dialog
Support isBusy for Dialog and CommandDialog
2 parents 0c68b9e + a0e0139 commit 4e65de1

8 files changed

Lines changed: 262 additions & 10 deletions

File tree

Documentation/CommandDialog/index.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CommandDialog simplifies the process of presenting a command form to users withi
1313
- Field-level change tracking
1414
- Pre-execution transformation of values
1515
- Success and cancellation handling
16+
- Busy state management during command execution (buttons disabled, spinner shown)
1617
- Integration with Cratis Arc command system
1718

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

112+
## Busy State
113+
114+
`CommandDialog` automatically manages a busy state during command execution:
115+
116+
- When the Ok/Yes button is clicked and command execution begins, all buttons are disabled and the primary button shows a loading spinner.
117+
- Once execution completes (success or failure), the buttons return to their normal state.
118+
- This prevents duplicate submissions and gives users clear visual feedback.
119+
111120
## Context
112121

113122
`CommandDialog` is built on top of `CommandForm` and `Dialog`, and uses command form context internally for values, validation, and execution state.

Documentation/Dialogs/dialog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ const MyComponent = () => {
7171
- `style`: Custom dialog style forwarded to PrimeReact `Dialog`
7272
- `resizable`: Enables resize
7373
- `isValid`: Enables or disables confirm actions
74+
- `isBusy`: When `true`, disables all buttons and shows a loading spinner on the primary action button
7475
- `okLabel`, `cancelLabel`, `yesLabel`, `noLabel`: Button labels
7576

7677
## Notes

Source/CommandDialog/CommandDialog.stories.tsx

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,46 @@ class UpdateUserCommand extends Command<object> {
6161
}
6262
}
6363

64+
/** Command that simulates a 2-second server delay to demonstrate the busy state. */
65+
class DemoSlowUpdateUserCommand extends Command<object> {
66+
readonly route: string = '/api/users/update';
67+
readonly validation: CommandValidator = new UpdateUserCommandValidator();
68+
readonly propertyDescriptors: PropertyDescriptor[] = [
69+
new PropertyDescriptor('name', String),
70+
new PropertyDescriptor('email', String),
71+
new PropertyDescriptor('age', Number),
72+
];
73+
74+
name = '';
75+
email = '';
76+
age = 0;
77+
78+
constructor() {
79+
super(Object, false);
80+
}
81+
82+
get requestParameters(): string[] {
83+
return [];
84+
}
85+
86+
get properties(): string[] {
87+
return ['name', 'email', 'age'];
88+
}
89+
90+
override async validate(): Promise<CommandResult<object>> {
91+
const errors = this.validation?.validate(this) ?? [];
92+
if (errors.length > 0) {
93+
return CommandResult.validationFailed(errors);
94+
}
95+
return CommandResult.empty;
96+
}
97+
98+
override async execute(): Promise<CommandResult<object>> {
99+
await new Promise(resolve => setTimeout(resolve, 2000));
100+
return CommandResult.empty;
101+
}
102+
}
103+
64104
/** Variant that keeps the original server-calling validate() for the WithServerValidation story. */
65105
class UpdateUserCommandWithServer extends Command<object> {
66106
readonly route: string = '/api/users/update';
@@ -681,3 +721,50 @@ const MixedChildrenWrapper = () => {
681721
export const MixedChildren: Story = {
682722
render: () => <MixedChildrenWrapper />,
683723
};
724+
725+
const WithBusyStateWrapper = () => {
726+
const [visible, setVisible] = useState(true);
727+
const [result, setResult] = useState<string>('');
728+
729+
return (
730+
<div className="storybook-wrapper">
731+
<button
732+
className="p-button p-component mb-3"
733+
onClick={() => {
734+
setResult('');
735+
setVisible(true);
736+
}}
737+
>
738+
Open Dialog
739+
</button>
740+
741+
{result && (
742+
<div className="p-3 mt-3 bg-green-100 border-round">
743+
<strong>Saved:</strong> {result}
744+
</div>
745+
)}
746+
747+
<CommandDialog<DemoSlowUpdateUserCommand>
748+
command={DemoSlowUpdateUserCommand}
749+
visible={visible}
750+
title="Save User (2s simulated delay)"
751+
okLabel="Save"
752+
cancelLabel="Cancel"
753+
autoServerValidate={false}
754+
onConfirm={async () => {
755+
setResult('User saved successfully');
756+
setVisible(false);
757+
}}
758+
onCancel={() => setVisible(false)}
759+
>
760+
<InputTextField value={(c: DemoSlowUpdateUserCommand) => c.name} title="Name" placeholder="Enter name (min 2 chars)" />
761+
<InputTextField value={(c: DemoSlowUpdateUserCommand) => c.email} title="Email" placeholder="Enter email" type="email" />
762+
<NumberField value={(c: DemoSlowUpdateUserCommand) => c.age} title="Age" placeholder="Enter age (18-120)" />
763+
</CommandDialog>
764+
</div>
765+
);
766+
};
767+
768+
export const WithBusyState: Story = {
769+
render: () => <WithBusyStateWrapper />,
770+
};

Source/CommandDialog/CommandDialog.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import { ICommandResult } from '@cratis/arc/commands';
55
import { DialogButtons, DialogResult } from '@cratis/arc.react/dialogs';
66
import { Dialog, type DialogProps } from '../Dialogs/Dialog';
7-
import React from 'react';
7+
import React, { useState } from 'react';
88
import {
99
CommandForm,
1010
CommandFormFieldWrapper,
@@ -56,14 +56,21 @@ const CommandDialogWrapper = <TCommand extends object>({
5656
}) => {
5757
const { setCommandValues, setCommandResult, isValid: isCommandFormValid } = useCommandFormContext<TCommand>();
5858
const commandInstance = useCommandInstance<TCommand>();
59+
const [isBusy, setIsBusy] = useState(false);
5960

6061
const handleConfirm = async () => {
6162
if (onBeforeExecute) {
6263
const transformedValues = onBeforeExecute(commandInstance);
6364
setCommandValues(transformedValues);
6465
}
6566

66-
const result = await (commandInstance as unknown as { execute: () => Promise<ICommandResult<unknown>> }).execute();
67+
setIsBusy(true);
68+
let result: ICommandResult<unknown>;
69+
try {
70+
result = await (commandInstance as unknown as { execute: () => Promise<ICommandResult<unknown>> }).execute();
71+
} finally {
72+
setIsBusy(false);
73+
}
6774

6875
if (!result.isSuccess) {
6976
setCommandResult(result);
@@ -123,6 +130,7 @@ const CommandDialogWrapper = <TCommand extends object>({
123130
yesLabel={yesLabel}
124131
noLabel={noLabel}
125132
isValid={isDialogValid}
133+
isBusy={isBusy}
126134
>
127135
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
128136
{processedChildren}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright (c) Cratis. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
import React from 'react';
5+
import { renderToStaticMarkup } from 'react-dom/server';
6+
import { vi } from 'vitest';
7+
import { CommandDialog } from '../CommandDialog';
8+
9+
vi.mock('primereact/dialog', () => ({
10+
Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
11+
React.createElement('div', null, props.footer, props.children),
12+
}));
13+
14+
vi.mock('primereact/button', () => ({
15+
Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) =>
16+
React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label),
17+
}));
18+
19+
vi.mock('@cratis/arc.react/dialogs', () => ({
20+
DialogButtons: { Ok: 1, OkCancel: 2, YesNo: 3, YesNoCancel: 4 },
21+
DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
22+
useDialogContext: () => undefined,
23+
}));
24+
25+
vi.mock('@cratis/arc.react/commands', () => ({
26+
CommandForm: (props: { children?: React.ReactNode }) =>
27+
React.createElement('div', null, props.children),
28+
useCommandFormContext: () => ({
29+
isValid: true,
30+
setCommandValues: () => {},
31+
setCommandResult: () => {},
32+
}),
33+
useCommandInstance: () => ({}),
34+
CommandFormFieldWrapper: (props: { field?: React.ReactNode }) =>
35+
React.createElement('div', null, props.field),
36+
}));
37+
38+
class TestCommand {
39+
name: string = '';
40+
}
41+
42+
describe('when CommandDialog is in its initial state', () => {
43+
let html: string;
44+
45+
beforeEach(() => {
46+
const element = React.createElement(CommandDialog, {
47+
command: TestCommand as unknown as new () => object,
48+
visible: true,
49+
title: 'Test Dialog',
50+
});
51+
html = renderToStaticMarkup(element);
52+
});
53+
54+
it('should_not_have_buttons_disabled_due_to_busy', () => {
55+
html.should.not.include('data-loading="true"');
56+
});
57+
});

Source/Dialogs/Dialog.stories.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,45 @@ export const WithForm: Story = {
131131
}
132132
};
133133

134+
const IsBusyWrapper = () => {
135+
const [busy, setBusy] = useState(false);
136+
137+
const BusyDialog = () => {
138+
const { closeDialog } = useDialogContext();
139+
140+
return (
141+
<Dialog
142+
title="Saving changes"
143+
buttons={DialogButtons.OkCancel}
144+
onConfirm={async () => {
145+
setBusy(true);
146+
await new Promise(resolve => setTimeout(resolve, 3000));
147+
setBusy(false);
148+
closeDialog(DialogResult.Ok);
149+
return true;
150+
}}
151+
onCancel={() => closeDialog(DialogResult.Cancelled)}
152+
isBusy={busy}
153+
>
154+
<p>Click Ok to simulate a 3-second save operation. All buttons become disabled and the primary button shows a spinner.</p>
155+
</Dialog>
156+
);
157+
};
158+
159+
const [DialogComponent, showDialog] = useDialog(BusyDialog);
160+
161+
return (
162+
<>
163+
<Button label="Open Dialog" onClick={async () => await showDialog()} />
164+
<DialogComponent />
165+
</>
166+
);
167+
};
168+
169+
export const IsBusy: Story = {
170+
render: () => <IsBusyWrapper />,
171+
};
172+
134173
export const CustomButtons: Story = {
135174
render: () => {
136175
type ActionResult = { action: 'draft' | 'publish' };

Source/Dialogs/Dialog.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export interface DialogProps {
2222
style?: CSSProperties;
2323
resizable?: boolean;
2424
isValid?: boolean;
25+
isBusy?: boolean;
2526
okLabel?: string;
2627
cancelLabel?: string;
2728
yesLabel?: string;
@@ -40,6 +41,7 @@ export const Dialog = ({
4041
style,
4142
resizable = false,
4243
isValid,
44+
isBusy = false,
4345
okLabel = 'Ok',
4446
cancelLabel = 'Cancel',
4547
yesLabel = 'Yes',
@@ -90,29 +92,29 @@ export const Dialog = ({
9092

9193
const okFooter = (
9294
<>
93-
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid} autoFocus />
95+
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
9496
</>
9597
);
9698

9799
const okCancelFooter = (
98100
<>
99-
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid} autoFocus />
100-
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} />
101+
<Button label={okLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Ok)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
102+
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} disabled={isBusy} />
101103
</>
102104
);
103105

104106
const yesNoFooter = (
105107
<>
106-
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid} autoFocus />
107-
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} />
108+
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
109+
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} disabled={isBusy} />
108110
</>
109111
);
110112

111113
const yesNoCancelFooter = (
112114
<>
113-
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid} autoFocus />
114-
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} />
115-
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} />
115+
<Button label={yesLabel} icon="pi pi-check" onClick={() => handleClose(DialogResult.Yes)} disabled={!isDialogValid || isBusy} loading={isBusy} autoFocus />
116+
<Button label={noLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.No)} disabled={isBusy} />
117+
<Button label={cancelLabel} icon="pi pi-times" outlined onClick={() => handleClose(DialogResult.Cancelled)} disabled={isBusy} />
116118
</>
117119
);
118120

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) Cratis. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
import React from 'react';
5+
import { renderToStaticMarkup } from 'react-dom/server';
6+
import { vi } from 'vitest';
7+
import { Dialog } from '../Dialog';
8+
9+
vi.mock('primereact/dialog', () => ({
10+
Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) =>
11+
React.createElement('div', null, props.footer, props.children),
12+
}));
13+
14+
vi.mock('primereact/button', () => ({
15+
Button: (props: { icon?: string; label?: string; onClick?: () => void | Promise<void>; disabled?: boolean; loading?: boolean }) => {
16+
if (props.icon === 'pi pi-check' && props.onClick) {
17+
props.onClick();
18+
}
19+
return React.createElement('button', { disabled: props.disabled }, props.label);
20+
},
21+
}));
22+
23+
vi.mock('@cratis/arc.react/dialogs', () => ({
24+
DialogButtons: { Ok: 1, OkCancel: 2, YesNo: 3, YesNoCancel: 4 },
25+
DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 },
26+
useDialogContext: () => undefined,
27+
}));
28+
29+
describe('when rendered with is busy', () => {
30+
let html: string;
31+
32+
beforeEach(() => {
33+
const element = React.createElement(Dialog, {
34+
title: 'Save changes',
35+
visible: true,
36+
isBusy: true,
37+
buttons: 2,
38+
children: React.createElement('p', null, 'Dialog content'),
39+
});
40+
41+
html = renderToStaticMarkup(element);
42+
});
43+
44+
it('should_disable_all_buttons', () => {
45+
const disabledCount = (html.match(/disabled=""/g) || []).length;
46+
disabledCount.should.equal(2);
47+
});
48+
});
49+

0 commit comments

Comments
 (0)