diff --git a/Documentation/CommandDialog/index.md b/Documentation/CommandDialog/index.md index 914b9cb..2ea1ca5 100644 --- a/Documentation/CommandDialog/index.md +++ b/Documentation/CommandDialog/index.md @@ -15,48 +15,75 @@ CommandDialog simplifies the process of presenting a command form to users withi - Success and cancellation handling - Integration with Cratis Arc command system +## Recommended Usage Pattern + +For new implementations, use the same dialog pattern as other typed dialogs: + +- Open dialogs through `useDialog()` +- Close from inside the dialog through `useDialogContext()` +- `await` the dialog at the call site and handle `[dialogResult, value]` + +When the value represents command execution output, use `CommandResult` as the dialog result type. + ## Basic Usage ```typescript -import { CommandDialog } from '@cratis/components'; -import { MyCommand } from './commands'; +import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; +import { CommandResult } from '@cratis/arc/commands'; +import { CommandDialog } from '@cratis/components/CommandDialog'; +import { CreateProject } from './commands'; + +type CreateProjectResponse = { + projectId: string; +}; + +const CreateProjectDialog = () => { + const { closeDialog } = useDialogContext>(); + + return ( + + command={CreateProject} + header='Create project' + onConfirm={async result => closeDialog(DialogResult.Ok, result as CommandResult)} + onCancel={() => closeDialog(DialogResult.Cancelled)} + /> + ); +}; function MyComponent() { - const [visible, setVisible] = useState(false); + const [CreateProjectDialogWrapper, showCreateProjectDialog] = useDialog>(CreateProjectDialog); - const handleConfirm = async (result) => { - if (result.isSuccess) { - // Handle success - setVisible(false); + const handleCreateProject = async () => { + const [dialogResult, commandResult] = await showCreateProjectDialog(); + + if (dialogResult === DialogResult.Ok && commandResult?.isSuccess) { + // Handle successful command response } }; return ( - setVisible(false)} - > - {/* Custom form fields go here */} - + <> + + + ); } ``` +> `CommandDialog` invokes `onConfirm` only when command execution succeeds. + ## Props ### Required Props - `command`: Constructor for the command type -- `visible`: Boolean controlling dialog visibility - `header`: Dialog title text - `onConfirm`: Callback function when command succeeds - `onCancel`: Callback function when dialog is cancelled ### Optional Props +- `visible`: Boolean controlling dialog visibility (defaults to `true`) - `initialValues`: Initial values for the command form - `currentValues`: Current values to populate the form - `confirmLabel`: Custom text for confirm button (default: "OK") @@ -71,7 +98,9 @@ function MyComponent() { ## Context -The component provides a `CommandDialogContext` accessible via `useCommandDialogContext` hook for child components to access dialog state and callbacks. +`CommandDialog` is built on top of `CommandForm` and uses the command form context internally for values, validation, and execution state. + +When used as an awaitable dialog, pair it with `useDialogContext>()` in a wrapping dialog component. ## Integration diff --git a/Documentation/Dialogs/dialog.md b/Documentation/Dialogs/dialog.md index f09b7e2..785db04 100644 --- a/Documentation/Dialogs/dialog.md +++ b/Documentation/Dialogs/dialog.md @@ -1,75 +1,78 @@ # Dialog -Base dialog component for creating custom dialogs. +Base dialog component for creating typed dialogs that can be awaited. -## Purpose +## Recommended Pattern -The Dialog component provides a customizable modal dialog foundation for building various types of dialogs. +Use `useDialog()` at the call site and `useDialogContext()` inside the dialog component. -## Key Features +- The caller opens the dialog with `await` and receives `[dialogResult, value]` +- The dialog closes itself through `closeDialog(...)` +- The generic `T` is the value returned from the dialog -- Customizable header and footer -- Flexible button configuration -- Modal and non-modal modes -- Responsive sizing -- Integration with PrimeReact Dialog +This pattern gives strongly typed dialog results and a simple async flow. -## Basic Usage +## Example ```typescript -import { Dialog } from '@cratis/components'; +import { useState } from 'react'; +import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; +import { Dialog } from '@cratis/components/Dialogs'; -function MyComponent() { - const [visible, setVisible] = useState(false); +type Project = { + id: string; + name: string; +}; + +const AddProjectDialog = () => { + const { closeDialog } = useDialogContext(); + const [name, setName] = useState(''); return ( setVisible(false)} - buttons={null} + title='Add project' + isValid={name.trim().length > 0} + onConfirm={() => closeDialog(DialogResult.Ok, { id: crypto.randomUUID(), name })} + onCancel={() => closeDialog(DialogResult.Cancelled)} > -

Dialog content goes here

+ {/* Dialog content */}
); -} +}; + +const MyComponent = () => { + const [AddProjectDialogWrapper, showAddProjectDialog] = useDialog(AddProjectDialog); + + const handleAddProject = async () => { + const [result, project] = await showAddProjectDialog(); + if (result === DialogResult.Ok && project) { + // Use the typed result + } + }; + + return ( + <> + + + + ); +}; ``` ## Props - `title`: Dialog header text -- `visible`: Controls visibility -- `onCancel`: Callback when dialog is closed -- `buttons`: Button configuration (or null for no buttons) +- `visible`: Controls visibility (defaults to `true`) +- `onConfirm`: Callback for confirm actions +- `onCancel`: Callback for cancel actions +- `onClose`: Fallback close callback +- `buttons`: Predefined `DialogButtons` or custom footer content - `width`: Dialog width -- `style`: Custom CSS styles - -## With Custom Buttons - -```typescript -const buttons = [ - { - label: 'Save', - icon: 'pi pi-check', - onClick: handleSave - }, - { - label: 'Cancel', - icon: 'pi pi-times', - onClick: () => setVisible(false) - } -]; - - setVisible(false)} - buttons={buttons} -> - {/* Content */} - -``` +- `resizable`: Enables resize +- `isValid`: Enables or disables confirm actions +- `okLabel`, `cancelLabel`, `yesLabel`, `noLabel`: Button labels -## Integration +## Notes -Integrates with PrimeReact Dialog component for consistent styling and behavior. +- Prefer `onConfirm` and `onCancel` over `onClose` for clear intent. +- For typed, awaitable dialogs, let the dialog call `closeDialog(...)` from `useDialogContext()`. diff --git a/Source/CommandDialog/CommandDialog.stories.tsx b/Source/CommandDialog/CommandDialog.stories.tsx index 62f17c2..6fba6b8 100644 --- a/Source/CommandDialog/CommandDialog.stories.tsx +++ b/Source/CommandDialog/CommandDialog.stories.tsx @@ -7,6 +7,7 @@ import { CommandDialog } from './CommandDialog'; import { Command, CommandResult, CommandValidator } from '@cratis/arc/commands'; import { PropertyDescriptor } from '@cratis/arc/reflection'; import { InputTextField, NumberField, TextAreaField } from '../CommandForm/fields'; +import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; import '@cratis/arc/validation'; const meta: Meta = { @@ -87,7 +88,7 @@ class UpdateUserCommandWithServer extends Command { } } -const DialogWrapper = () => { +const ServerValidationWrapper = () => { const [visible, setVisible] = useState(true); const [result, setResult] = useState(''); const [validationErrors, setValidationErrors] = useState([]); @@ -122,99 +123,88 @@ const DialogWrapper = () => { )} - - command={UpdateUserCommand} + + command={UpdateUserCommandWithServer} visible={visible} - header="Update User Information (with Validation)" + header="Update User Information (with Server Validation)" confirmLabel="Save" cancelLabel="Cancel" - autoServerValidate={false} onConfirm={async (commandResult) => { setResult(JSON.stringify(commandResult)); setVisible(false); }} onCancel={() => setVisible(false)} - onFieldChange={(command) => { - // Client-side only validation - validate as fields change - const errors = command.validation?.validate(command) ?? []; - setValidationErrors(errors.map(v => v.message)); + onFieldChange={async (command) => { + // Progressive validation - validate as fields change + const validationResult = await command.validate(); + + if (!validationResult.isValid) { + setValidationErrors(validationResult.validationResults.map(v => v.message)); + } else { + setValidationErrors([]); + } }} > - 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)" /> + 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)" /> ); }; -const ServerValidationWrapper = () => { - const [visible, setVisible] = useState(true); +const AwaitableWithResultWrapper = () => { const [result, setResult] = useState(''); - const [validationErrors, setValidationErrors] = useState([]); + + const UpdateUserDialog = () => { + const { closeDialog } = useDialogContext>(); + + return ( + + command={UpdateUserCommand} + header="Update User Information (awaitable result)" + confirmLabel="Save" + cancelLabel="Cancel" + autoServerValidate={false} + onConfirm={async commandResult => closeDialog(DialogResult.Ok, commandResult)} + onCancel={() => closeDialog(DialogResult.Cancelled)} + > + 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)" /> + + ); + }; + + const [UpdateUserDialogComponent, showUpdateUserDialog] = useDialog>(UpdateUserDialog); return (
- {validationErrors.length > 0 && ( -
- Validation Errors: -
    - {validationErrors.map((error, index) => ( -
  • {error}
  • - ))} -
-
- )} - {result && (
Command executed: {result}
)} - - command={UpdateUserCommandWithServer} - visible={visible} - header="Update User Information (with Server Validation)" - confirmLabel="Save" - cancelLabel="Cancel" - onConfirm={async (commandResult) => { - setResult(JSON.stringify(commandResult)); - setVisible(false); - }} - onCancel={() => setVisible(false)} - onFieldChange={async (command) => { - // Progressive validation - validate as fields change - const validationResult = await command.validate(); - - if (!validationResult.isValid) { - setValidationErrors(validationResult.validationResults.map(v => v.message)); - } else { - setValidationErrors([]); - } - }} - > - 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 Default: Story = { - render: () => , + render: () => , }; export const WithServerValidation: Story = { diff --git a/Source/CommandDialog/CommandDialog.tsx b/Source/CommandDialog/CommandDialog.tsx index cf54714..071841e 100644 --- a/Source/CommandDialog/CommandDialog.tsx +++ b/Source/CommandDialog/CommandDialog.tsx @@ -15,7 +15,7 @@ import { export interface CommandDialogProps extends Omit, 'children'> { - visible: boolean; + visible?: boolean; header: string; confirmLabel?: string; cancelLabel?: string; @@ -38,7 +38,7 @@ const CommandDialogWrapper = ({ children }: { header: string; - visible: boolean; + visible?: boolean; width: string; confirmLabel: string; cancelLabel: string; @@ -58,7 +58,7 @@ const CommandDialogWrapper = ({ const result = await (commandInstance as unknown as { execute: () => Promise> }).execute(); if (result.isSuccess) { await onConfirm(result); - return true; + return false; } else { setCommandResult(result); return false; diff --git a/Source/CommandDialog/for_CommandDialog/when_confirming_with_close_dialog_and_command_result.ts b/Source/CommandDialog/for_CommandDialog/when_confirming_with_close_dialog_and_command_result.ts new file mode 100644 index 0000000..ebcd2a8 --- /dev/null +++ b/Source/CommandDialog/for_CommandDialog/when_confirming_with_close_dialog_and_command_result.ts @@ -0,0 +1,82 @@ +// 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 { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs'; +import { CommandDialog } from '../CommandDialog'; + +const { closeDialog, commandResult } = vi.hoisted(() => ({ + closeDialog: vi.fn(), + commandResult: { + isSuccess: true, + isValid: true, + validationResults: [], + }, +})); + +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?: () => Promise | void; disabled?: 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: () => ({ closeDialog }), +})); + +vi.mock('@cratis/arc.react/commands', () => ({ + CommandForm: (props: { children?: React.ReactNode }) => + React.createElement('div', null, props.children), + useCommandFormContext: () => ({ + isValid: true, + setCommandValues: () => {}, + setCommandResult: () => {}, + }), + useCommandInstance: () => ({ + execute: async () => commandResult, + }), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), +})); + +class TestCommand { + name: string = ''; +} + +const TestDialog = () => { + const { closeDialog: closeWithResult } = useDialogContext(); + + return React.createElement(CommandDialog, { + command: TestCommand, + header: 'Update user', + onConfirm: async result => closeWithResult(DialogResult.Ok, result), + onCancel: () => closeWithResult(DialogResult.Cancelled), + }); +}; + +describe('when confirming with close dialog and command result', () => { + beforeEach(() => { + closeDialog.mockReset(); + renderToStaticMarkup(React.createElement(TestDialog)); + }); + + it('should_close_once_with_ok_and_the_command_result', () => { + if (closeDialog.mock.calls.length !== 1) { + throw new Error(`Expected one closeDialog call, got ${closeDialog.mock.calls.length}`); + } + closeDialog.mock.calls[0][0].should.equal(3); + closeDialog.mock.calls[0][1].should.equal(commandResult); + }); +}); diff --git a/Source/Common/Page.tsx b/Source/Common/Page.tsx index 0e43fc5..1ea5ded 100644 --- a/Source/Common/Page.tsx +++ b/Source/Common/Page.tsx @@ -11,7 +11,7 @@ export interface PageProps extends HTMLAttributes { export const Page = ({ title, children, panel, ...rest }: PageProps) => { return ( -
+

{title}

{children} diff --git a/Source/Dialogs/Dialog.stories.tsx b/Source/Dialogs/Dialog.stories.tsx index 9968b84..4c5c390 100644 --- a/Source/Dialogs/Dialog.stories.tsx +++ b/Source/Dialogs/Dialog.stories.tsx @@ -4,7 +4,7 @@ import React, { useState } from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { Dialog } from './Dialog'; -import { DialogButtons } from '@cratis/arc.react/dialogs'; +import { DialogButtons, DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; import { Button } from 'primereact/button'; import { InputText } from 'primereact/inputtext'; @@ -20,19 +20,28 @@ export default meta; type Story = StoryObj; const DialogWrapper = ({ buttons, title, children, isValid }: { buttons: DialogButtons; title: string; children: React.ReactNode; isValid?: boolean }) => { - const [visible, setVisible] = useState(false); - return ( - <> -