diff --git a/Documentation/CommandDialog/advanced-features.md b/Documentation/CommandDialog/advanced-features.md index 6fd6e98..8343fcc 100644 --- a/Documentation/CommandDialog/advanced-features.md +++ b/Documentation/CommandDialog/advanced-features.md @@ -1,5 +1,84 @@ # CommandDialog - Advanced Features +## Response Type Handling + +`CommandDialog` supports typed command responses and provides callbacks for different execution outcomes: + +```typescript +import { CommandDialog } from '@cratis/components/CommandDialog'; +import { ValidationResult } from '@cratis/arc/validation'; + +type CreateUserResponse = { + userId: string; + username: string; + message: string; +}; + + + command={CreateUser} + title="Create User" + onSuccess={(response) => { + // Handle successful creation - response is fully typed + console.log(`User created with ID: ${response.userId}`); + showNotification(response.message); + navigate(`/users/${response.userId}`); + }} + onFailed={(commandResult) => { + // Handle any failure - includes all failure details + console.error('Command failed:', commandResult); + }} + onException={(messages, stackTrace) => { + // Handle exceptions specifically + console.error('Exception occurred:', messages.join(', ')); + console.error('Stack trace:', stackTrace); + }} + onUnauthorized={() => { + // Handle authorization failures + showNotification('You are not authorized to perform this action'); + navigate('/login'); + }} + onValidationFailure={(validationResults) => { + // Handle validation failures + const errors = validationResults.map(r => r.message).join(', '); + showNotification(`Validation failed: ${errors}`); + }} +/> +``` + +### Callback Execution Order + +Multiple callbacks may fire for the same command execution: + +1. **onSuccess**: Only fires when `commandResult.isSuccess` is `true` +2. **onFailed**: Fires for any failure (validation, exception, unauthorized, etc.) +3. **onException**: Fires specifically when an exception occurs +4. **onUnauthorized**: Fires specifically when authorization fails +5. **onValidationFailure**: Fires specifically when validation fails + +For example, a validation failure will trigger both `onFailed` and `onValidationFailure`. + +### Response Type Inference + +The response type parameter is optional and defaults to `object`: + +```typescript +// Explicit response type + + command={CreateUser} + onSuccess={(response) => { + // response is CreateUserResponse + }} +/> + +// Default object response type + + command={CreateUser} + onSuccess={(response) => { + // response is object + }} +/> +``` + ## Field Validation Provide custom validation logic for individual fields: diff --git a/Documentation/CommandDialog/index.md b/Documentation/CommandDialog/index.md index 1095e89..5b7b70d 100644 --- a/Documentation/CommandDialog/index.md +++ b/Documentation/CommandDialog/index.md @@ -42,11 +42,17 @@ const CreateProjectDialog = () => { const { closeDialog } = useDialogContext>(); return ( - + command={CreateProject} title='Create project' okLabel='Create' - onConfirm={async () => closeDialog(DialogResult.Ok)} + onSuccess={(response) => { + console.log('Project created:', response.projectId); + closeDialog(DialogResult.Ok); + }} + onValidationFailure={(errors) => { + console.error('Validation failed:', errors); + }} onCancel={() => closeDialog(DialogResult.Cancelled)} /> ); @@ -72,7 +78,7 @@ function MyComponent() { } ``` -> `CommandDialog` invokes `onConfirm` only when command execution succeeds. +> `CommandDialog` invokes `onSuccess` when command execution succeeds, and other callbacks based on the command result. ## Props @@ -86,6 +92,11 @@ function MyComponent() { - `visible`: Boolean controlling dialog visibility (defaults to `true`) - `initialValues`: Initial values for the command form - `currentValues`: Current values to populate the form +- `onSuccess`: Callback invoked on successful command execution with the typed response +- `onFailed`: Callback invoked when command execution fails with the full `CommandResult` +- `onException`: Callback invoked when the command throws an exception with error messages and stack trace +- `onUnauthorized`: Callback invoked when authorization fails +- `onValidationFailure`: Callback invoked on validation errors with the validation results - `onConfirm`: Confirm callback from `Dialog` (called only after successful command execution) - `onCancel`: Cancel callback from `Dialog` - `onClose`: Fallback close callback from `Dialog` @@ -104,6 +115,20 @@ function MyComponent() { ## Callback Behavior +### Result Callbacks + +`CommandDialog` supports the following result callbacks that are invoked based on the command execution outcome: + +- `onSuccess(response: TResponse)`: Invoked when the command executes successfully. Receives the typed response. +- `onFailed(commandResult: CommandResult)`: Invoked when command execution fails for any reason. +- `onException(messages: string[], stackTrace: string)`: Invoked when the command throws an exception. +- `onUnauthorized()`: Invoked when authorization fails. +- `onValidationFailure(validationResults: ValidationResult[])`: Invoked on validation errors. + +Multiple callbacks may fire for the same execution. For example, both `onFailed` and `onValidationFailure` will be invoked for validation errors. + +### Dialog Callbacks + - `onConfirm` is executed only after command execution succeeds. - If `onConfirm` returns `true`, the dialog closes; otherwise it stays open. - If `onConfirm` is not provided, `onClose(DialogResult.Ok)` is used. diff --git a/Documentation/StepperCommandDialog/advanced-features.md b/Documentation/StepperCommandDialog/advanced-features.md index c9de28a..106fdb1 100644 --- a/Documentation/StepperCommandDialog/advanced-features.md +++ b/Documentation/StepperCommandDialog/advanced-features.md @@ -2,6 +2,94 @@ All advanced features described here apply to every step in the wizard, because all steps share a single underlying command instance. +## Response Type Handling + +`StepperCommandDialog` supports typed command responses and provides callbacks for different execution outcomes: + +```typescript +import { StepperCommandDialog } from '@cratis/components/CommandDialog'; +import { StepperPanel } from 'primereact/stepperpanel'; +import { ValidationResult } from '@cratis/arc/validation'; + +type CreateProjectResponse = { + projectId: string; + message: string; +}; + + + command={CreateProject} + title="Create Project" + onSuccess={(response) => { + // Handle successful creation - response is fully typed + console.log(`Project created with ID: ${response.projectId}`); + showNotification(response.message); + navigate(`/projects/${response.projectId}`); + }} + onFailed={(commandResult) => { + // Handle any failure - includes all failure details + console.error('Command failed:', commandResult); + }} + onException={(messages, stackTrace) => { + // Handle exceptions specifically + console.error('Exception occurred:', messages.join(', ')); + }} + onUnauthorized={() => { + // Handle authorization failures + showNotification('You are not authorized to perform this action'); + }} + onValidationFailure={(validationResults) => { + // Handle validation failures + const errors = validationResults.map(r => r.message).join(', '); + showNotification(`Validation failed: ${errors}`); + }} +> + + value={c => c.name} title="Name" /> + + + value={c => c.description} title="Description" /> + + +``` + +### Callback Execution Order + +Multiple callbacks may fire for the same command execution: + +1. **onSuccess**: Only fires when `commandResult.isSuccess` is `true` +2. **onFailed**: Fires for any failure (validation, exception, unauthorized, etc.) +3. **onException**: Fires specifically when an exception occurs +4. **onUnauthorized**: Fires specifically when authorization fails +5. **onValidationFailure**: Fires specifically when validation fails + +For example, a validation failure will trigger both `onFailed` and `onValidationFailure`. + +### Response Type Inference + +The response type parameter is optional and defaults to `object`: + +```typescript +// Explicit response type + + command={CreateProject} + onSuccess={(response) => { + // response is CreateProjectResponse + }} +> + {/* steps */} + + +// Default object response type + + command={CreateProject} + onSuccess={(response) => { + // response is object + }} +> + {/* steps */} + +``` + ## Field Validation Provide custom validation logic for individual fields: diff --git a/Documentation/StepperCommandDialog/index.md b/Documentation/StepperCommandDialog/index.md index a53597a..3c47dca 100644 --- a/Documentation/StepperCommandDialog/index.md +++ b/Documentation/StepperCommandDialog/index.md @@ -29,15 +29,25 @@ import { InputTextField, TextAreaField, NumberField } from '@cratis/components/C import { CommandResult } from '@cratis/arc/commands'; import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; +type CreateProjectResponse = { + projectId: string; +}; + const CreateProjectDialog = () => { - const { closeDialog } = useDialogContext>(); + const { closeDialog } = useDialogContext>(); return ( - + command={CreateProject} title="Create New Project" okLabel="Create" - onConfirm={() => closeDialog(DialogResult.Ok)} + onSuccess={(response) => { + console.log('Project created:', response.projectId); + closeDialog(DialogResult.Ok); + }} + onValidationFailure={(errors) => { + console.error('Validation failed:', errors); + }} onCancel={() => closeDialog(DialogResult.Cancelled)} > @@ -77,6 +87,11 @@ function MyComponent() { - `visible`: Boolean controlling dialog visibility (defaults to `true`) - `initialValues`: Initial values for the command form - `currentValues`: Current values to populate the form +- `onSuccess`: Callback invoked on successful command execution with the typed response +- `onFailed`: Callback invoked when command execution fails with the full `CommandResult` +- `onException`: Callback invoked when the command throws an exception with error messages and stack trace +- `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 - `onClose`: Fallback close callback @@ -106,6 +121,28 @@ All [PrimeReact Stepper](https://primereact.org/stepper/) customization props ar - `ptOptions`: PassThrough configuration options - `unstyled`: Removes built-in component styles +## Callback Behavior + +### Result Callbacks + +`StepperCommandDialog` supports the following result callbacks that are invoked based on the command execution outcome: + +- `onSuccess(response: TResponse)`: Invoked when the command executes successfully. Receives the typed response. +- `onFailed(commandResult: CommandResult)`: Invoked when command execution fails for any reason. +- `onException(messages: string[], stackTrace: string)`: Invoked when the command throws an exception. +- `onUnauthorized()`: Invoked when authorization fails. +- `onValidationFailure(validationResults: ValidationResult[])`: Invoked on validation errors. + +Multiple callbacks may fire for the same execution. For example, both `onFailed` and `onValidationFailure` will be invoked for validation errors. + +### Dialog Callbacks + +- `onConfirm` is executed only after command execution succeeds. +- If `onConfirm` returns `true`, the dialog closes; otherwise it stays open. +- If `onConfirm` is not provided, `onClose(DialogResult.Ok)` is used. +- `onCancel` follows the same behavior as `Dialog` (`true` closes). +- `onClose` closes unless it returns `false`. + ## Validation Indicators The step number circles in the wizard navigation bar reflect the validation state of each step: diff --git a/Source/CommandDialog/CommandDialog.stories.tsx b/Source/CommandDialog/CommandDialog.stories.tsx index 4da3a39..6300e8d 100644 --- a/Source/CommandDialog/CommandDialog.stories.tsx +++ b/Source/CommandDialog/CommandDialog.stories.tsx @@ -740,3 +740,112 @@ export const WithBusyState: Story = { ); }, }; + +/** Demonstrates typed response handling with success and failure callbacks. */ +export const WithResponseTypeAndCallbacks: Story = { + render: () => { + const [visible, setVisible] = useState(true); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + + type CreateUserResponse = { + userId: string; + username: string; + message: string; + }; + + class CreateUserCommand extends Command { + readonly route: string = '/api/users/create'; + 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) as CommandResult; + } + return CommandResult.empty as CommandResult; + } + + override async execute(): Promise> { + // In real usage, the server would return a CommandResult with a typed response + // For this story, we just demonstrate the type safety + await new Promise(resolve => setTimeout(resolve, 500)); + return CommandResult.empty as CommandResult; + } + } + + return ( +
+ + + {result && ( +
+ Success: {result} +
+ )} + + {error && ( +
+ Error: {error} +
+ )} + + + command={CreateUserCommand} + visible={visible} + title="Create User (with Response Type)" + okLabel="Create" + cancelLabel="Cancel" + autoServerValidate={false} + onSuccess={() => { + // Response type is fully typed - in real usage the response would contain data from the server + setResult(`User created successfully! Response type is fully typed.`); + setVisible(false); + }} + onValidationFailure={(validationResults) => { + const errors = validationResults.map(r => r.message).join(', '); + setError(`Validation failed: ${errors}`); + }} + onFailed={(commandResult) => { + setError(`Command failed: ${commandResult.exceptionMessages?.join(', ') || 'Unknown error'}`); + }} + 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)" /> + +
+ ); + }, +}; diff --git a/Source/CommandDialog/CommandDialog.tsx b/Source/CommandDialog/CommandDialog.tsx index 486f223..fa5bbef 100644 --- a/Source/CommandDialog/CommandDialog.tsx +++ b/Source/CommandDialog/CommandDialog.tsx @@ -13,8 +13,8 @@ import { type CommandFormProps } from '@cratis/arc.react/commands'; -export interface CommandDialogProps - extends Omit, 'children'>, +export interface CommandDialogProps + extends Omit, 'children'>, Omit { children?: React.ReactNode; } @@ -142,7 +142,7 @@ const CommandDialogWrapper = ({ ); }; -const CommandDialogComponent = (props: CommandDialogProps) => { +const CommandDialogComponent = (props: CommandDialogProps) => { const { title, visible, @@ -164,7 +164,7 @@ const CommandDialogComponent = (props: Command } = props; return ( - {...commandFormProps}> + {...commandFormProps}> title={title} visible={visible} diff --git a/Source/CommandDialog/StepperCommandDialog.stories.tsx b/Source/CommandDialog/StepperCommandDialog.stories.tsx index df5e6d8..84b28d9 100644 --- a/Source/CommandDialog/StepperCommandDialog.stories.tsx +++ b/Source/CommandDialog/StepperCommandDialog.stories.tsx @@ -333,3 +333,136 @@ export const WithBusyState: Story = { ); }, }; + +/** Demonstrates typed response handling with success and failure callbacks. */ +export const WithResponseTypeAndCallbacks: Story = { + render: () => { + const [visible, setVisible] = useState(true); + const [result, setResult] = useState(''); + const [error, setError] = useState(''); + + type CreateProjectResponse = { + projectId: string; + projectName: string; + message: string; + }; + + class CreateProjectWithResponseCommand extends Command { + readonly route: string = '/api/projects/create'; + readonly validation: CommandValidator = new CreateProjectValidator(); + readonly propertyDescriptors: PropertyDescriptor[] = [ + new PropertyDescriptor('name', String), + new PropertyDescriptor('email', String), + new PropertyDescriptor('description', String), + new PropertyDescriptor('budget', Number), + ]; + + name = ''; + email = ''; + description = ''; + budget = 0; + + constructor() { + super(Object, false); + } + + get requestParameters(): string[] { + return []; + } + + get properties(): string[] { + return ['name', 'email', 'description', 'budget']; + } + + override async validate(): Promise> { + const errors = this.validation?.validate(this) ?? []; + if (errors.length > 0) { + return CommandResult.validationFailed(errors) as CommandResult; + } + return CommandResult.empty as CommandResult; + } + + override async execute(): Promise> { + // In real usage, the server would return a CommandResult with a typed response + // For this story, we just demonstrate the type safety + await new Promise(resolve => setTimeout(resolve, 500)); + return CommandResult.empty as CommandResult; + } + } + + return ( +
+ + + {result && ( +
+ Success: {result} +
+ )} + + {error && ( +
+ Error: {error} +
+ )} + + + command={CreateProjectWithResponseCommand} + visible={visible} + title="Create Project (with Response Type)" + okLabel="Create" + autoServerValidate={false} + onSuccess={() => { + // Response type is fully typed - in real usage the response would contain data from the server + setResult(`Project created successfully! Response type is fully typed.`); + setVisible(false); + }} + onValidationFailure={(validationResults) => { + const errors = validationResults.map(r => r.message).join(', '); + setError(`Validation failed: ${errors}`); + }} + onFailed={(commandResult) => { + setError(`Command failed: ${commandResult.exceptionMessages?.join(', ') || 'Unknown error'}`); + }} + onCancel={() => setVisible(false)} + > + + + value={c => c.name} + title="Project Name" + placeholder="Enter project name" + /> + + value={c => c.email} + title="Contact Email" + placeholder="Enter contact email" + type="email" + /> + + + + value={c => c.description} + title="Description" + placeholder="Describe the project" + rows={4} + /> + + value={c => c.budget} + title="Budget" + placeholder="Enter budget" + /> + + +
+ ); + }, +}; diff --git a/Source/CommandDialog/StepperCommandDialog.tsx b/Source/CommandDialog/StepperCommandDialog.tsx index 13f6d5b..91c805b 100644 --- a/Source/CommandDialog/StepperCommandDialog.tsx +++ b/Source/CommandDialog/StepperCommandDialog.tsx @@ -52,8 +52,8 @@ type StepperCustomizationProps = Pick; -export interface StepperCommandDialogProps - extends Omit, 'children'>, +export interface StepperCommandDialogProps + extends Omit, 'children'>, StepperCustomizationProps { /** Dialog title text. */ title: string; @@ -349,8 +349,8 @@ const StepperCommandDialogWrapper = ({ ); }; -const StepperCommandDialogComponent = ( - props: StepperCommandDialogProps +const StepperCommandDialogComponent = ( + props: StepperCommandDialogProps ) => { const { title, @@ -380,7 +380,7 @@ const StepperCommandDialogComponent = ( } = props; return ( - {...commandFormProps}> + {...commandFormProps}> title={title} visible={visible} diff --git a/Source/package.json b/Source/package.json index b0544d3..e53b70a 100644 --- a/Source/package.json +++ b/Source/package.json @@ -106,9 +106,9 @@ "build-storybook": "storybook build" }, "dependencies": { - "@cratis/arc": "^19.6.9", - "@cratis/arc.react": "^19.6.9", - "@cratis/arc.vite": "^19.6.9", + "@cratis/arc": "^19.11.0", + "@cratis/arc.react": "^19.11.0", + "@cratis/arc.vite": "^19.11.0", "allotment": "1.20.5", "framer-motion": "12.38.0", "pixi.js": "^8.16.0",