diff --git a/Documentation/CommandDialog/index.md b/Documentation/CommandDialog/index.md index d80129b..20eb36f 100644 --- a/Documentation/CommandDialog/index.md +++ b/Documentation/CommandDialog/index.md @@ -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 @@ -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. diff --git a/Documentation/Dialogs/dialog.md b/Documentation/Dialogs/dialog.md index 1480d2c..8fac4d3 100644 --- a/Documentation/Dialogs/dialog.md +++ b/Documentation/Dialogs/dialog.md @@ -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 diff --git a/Source/CommandDialog/CommandDialog.stories.tsx b/Source/CommandDialog/CommandDialog.stories.tsx index 8d4395c..c098565 100644 --- a/Source/CommandDialog/CommandDialog.stories.tsx +++ b/Source/CommandDialog/CommandDialog.stories.tsx @@ -61,6 +61,46 @@ class UpdateUserCommand extends Command { } } +/** Command that simulates a 2-second server delay to demonstrate the busy state. */ +class DemoSlowUpdateUserCommand extends Command { + 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> { + const errors = this.validation?.validate(this) ?? []; + if (errors.length > 0) { + return CommandResult.validationFailed(errors); + } + return CommandResult.empty; + } + + override async execute(): Promise> { + 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 { readonly route: string = '/api/users/update'; @@ -681,3 +721,50 @@ const MixedChildrenWrapper = () => { export const MixedChildren: Story = { render: () => , }; + +const WithBusyStateWrapper = () => { + const [visible, setVisible] = useState(true); + const [result, setResult] = useState(''); + + return ( +
+ + + {result && ( +
+ Saved: {result} +
+ )} + + + 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)} + > + c.name} title="Name" placeholder="Enter name (min 2 chars)" /> + c.email} title="Email" placeholder="Enter email" type="email" /> + c.age} title="Age" placeholder="Enter age (18-120)" /> + +
+ ); +}; + +export const WithBusyState: Story = { + render: () => , +}; diff --git a/Source/CommandDialog/CommandDialog.tsx b/Source/CommandDialog/CommandDialog.tsx index feb0c0f..137dc6f 100644 --- a/Source/CommandDialog/CommandDialog.tsx +++ b/Source/CommandDialog/CommandDialog.tsx @@ -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, @@ -56,6 +56,7 @@ const CommandDialogWrapper = ({ }) => { const { setCommandValues, setCommandResult, isValid: isCommandFormValid } = useCommandFormContext(); const commandInstance = useCommandInstance(); + const [isBusy, setIsBusy] = useState(false); const handleConfirm = async () => { if (onBeforeExecute) { @@ -63,7 +64,13 @@ const CommandDialogWrapper = ({ setCommandValues(transformedValues); } - const result = await (commandInstance as unknown as { execute: () => Promise> }).execute(); + setIsBusy(true); + let result: ICommandResult; + try { + result = await (commandInstance as unknown as { execute: () => Promise> }).execute(); + } finally { + setIsBusy(false); + } if (!result.isSuccess) { setCommandResult(result); @@ -123,6 +130,7 @@ const CommandDialogWrapper = ({ yesLabel={yesLabel} noLabel={noLabel} isValid={isDialogValid} + isBusy={isBusy} >
{processedChildren} diff --git a/Source/CommandDialog/for_CommandDialog/when_not_executing.ts b/Source/CommandDialog/for_CommandDialog/when_not_executing.ts new file mode 100644 index 0000000..1a4d349 --- /dev/null +++ b/Source/CommandDialog/for_CommandDialog/when_not_executing.ts @@ -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"'); + }); +}); diff --git a/Source/Dialogs/Dialog.stories.tsx b/Source/Dialogs/Dialog.stories.tsx index 4c5c390..6c0575f 100644 --- a/Source/Dialogs/Dialog.stories.tsx +++ b/Source/Dialogs/Dialog.stories.tsx @@ -131,6 +131,45 @@ export const WithForm: Story = { } }; +const IsBusyWrapper = () => { + const [busy, setBusy] = useState(false); + + const BusyDialog = () => { + const { closeDialog } = useDialogContext(); + + return ( + { + setBusy(true); + await new Promise(resolve => setTimeout(resolve, 3000)); + setBusy(false); + closeDialog(DialogResult.Ok); + return true; + }} + onCancel={() => closeDialog(DialogResult.Cancelled)} + isBusy={busy} + > +

Click Ok to simulate a 3-second save operation. All buttons become disabled and the primary button shows a spinner.

+
+ ); + }; + + const [DialogComponent, showDialog] = useDialog(BusyDialog); + + return ( + <> +