Skip to content

CommandDialog advanced features

The snippets on this page are excerpts. They assume generated command proxies such as CreateUser, and app helpers such as showNotification and navigate. For the dialog lifecycle and how to open and close the dialog, start with CommandDialog.

CommandDialog supports typed command responses and provides callbacks for different execution outcomes:

import { CommandDialog } from '@cratis/components/CommandDialog';
type CreateUserResponse = {
userId: string;
username: string;
message: string;
};
<CommandDialog<CreateUser, CreateUserResponse>
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}`);
}}
>
<InputTextField<CreateUser> value={(c) => c.username} title="Username" />
</CommandDialog>

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. On failure the dialog stays open and server validation messages appear on the matching fields.

The response type parameter is optional and defaults to object:

// Explicit response type
<CommandDialog<CreateUser, CreateUserResponse>
command={CreateUser}
title="Create User"
onSuccess={(response) => {
// response is CreateUserResponse
}}
/>
// Default object response type
<CommandDialog<CreateUser>
command={CreateUser}
title="Create User"
onSuccess={(response) => {
// response is object
}}
/>

Provide custom validation logic for individual fields. onFieldValidate runs synchronously each time a field value changes and returns the message to show, or undefined:

<CommandDialog<CreateUser>
command={CreateUser}
title="Create User"
onFieldValidate={(command, fieldName, oldValue, newValue) => {
if (fieldName === 'email' && !String(newValue ?? '').includes('@')) {
return 'Invalid email address';
}
return undefined;
}}
>
<InputTextField<CreateUser> value={(c) => c.email} title="Email" type="email" />
</CommandDialog>

Transform command values before execution. onBeforeExecute receives the current command values and must return the values to run with. It may be async, and it runs inside the busy state:

<CommandDialog<CreateUser>
command={CreateUser}
title="Create User"
onBeforeExecute={(values) => {
values.email = values.email.trim().toLowerCase();
return values;
}}
/>

It runs only after the user clicks confirm, so it cannot make an invalid form valid. Seed required values through initialValues, as described in Initialize command values. If the callback returns nothing, CommandDialog keeps the current values and logs a warning.

When dialog content is not built from CommandForm fields, keep the command instance synchronized with the custom input values. CommandDialog still uses command-form validity as the source of truth, and isValid is only an additional external gate.

Prefer currentValues for externally managed values so client validation sees the same command values that will be submitted. onBeforeExecute can still perform final transformations, but values populated only in onBeforeExecute are not visible to client validation before the confirm button is clicked:

<CommandDialog<UpdateProject>
command={UpdateProject}
title="Update project"
currentValues={projectDraft}
isValid={projectDraft.name.trim().length > 0}
/>

React to field value changes:

<CommandDialog<CreateUser>
command={CreateUser}
title="Create User"
onFieldChange={(command, fieldName, oldValue, newValue) => {
console.log(`${fieldName} changed from ${oldValue} to ${newValue}`);
}}
/>

onFieldChange also receives a fifth argument with the field’s current validation state ({ isValid, errors }).

Combining multiple validation patterns in one onFieldValidate callback:

const validateField = (command: CreateUser, fieldName: string, oldValue: unknown, newValue: unknown) => {
switch (fieldName) {
case 'email':
if (!newValue || !String(newValue).includes('@')) {
return 'Valid email address is required';
}
break;
case 'age':
if (Number(newValue) < 18) {
return 'Must be at least 18 years old';
}
if (Number(newValue) > 120) {
return 'Please enter a valid age';
}
break;
case 'password': {
const password = String(newValue ?? '');
if (password.length < 8) {
return 'Password must be at least 8 characters';
}
if (!/[A-Z]/.test(password)) {
return 'Password must contain an uppercase letter';
}
if (!/[0-9]/.test(password)) {
return 'Password must contain a number';
}
break;
}
}
return undefined;
};

Validate fields based on other field values. The command argument already holds the new value:

const validateField = (command: CreateUser, fieldName: string, oldValue: unknown, newValue: unknown) => {
if (fieldName === 'confirmPassword' && newValue !== command.password) {
return 'Passwords do not match';
}
if (fieldName === 'endDate' && (newValue as Date) < command.startDate) {
return 'End date must be after start date';
}
return undefined;
};

A cross-field message is attached to the field that changed. Editing password afterwards does not re-run the check for confirmPassword.

React to one field changing, for example to update component state:

const handleFieldChange = (command: CreateOrder, fieldName: string, oldValue: unknown, newValue: unknown) => {
if (fieldName === 'country' && newValue === 'USA') {
// Could trigger state updates or side effects
console.log('Country changed to USA, update state list');
}
if (fieldName === 'quantity') {
// Calculate derived values
const total = Number(newValue) * command.pricePerUnit;
console.log('New total:', total);
}
};

To change another command property as a result, keep the value in component state and pass it through currentValues, so the form re-validates with it.

onFieldValidate is synchronous: a returned promise is not awaited. For a rule that needs the server, such as checking that a username is available, write it as a validator on the backend command and turn on server validation while the user edits:

<CommandDialog<CreateUser>
command={CreateUser}
title="Create User"
autoServerValidate
autoServerValidateThrottle={500}
>
<InputTextField<CreateUser> value={(c) => c.username} title="Username" />
</CommandDialog>

Once client validation passes, the form calls the command’s validate endpoint, waiting autoServerValidateThrottle milliseconds (500 by default) after the last change. The server result is shown on the fields and also enables or disables confirm.

Common transformation scenarios:

const transformBeforeExecute = (values: CreateUser): CreateUser => {
// Normalize data
values.email = values.email.toLowerCase().trim();
// Convert formats
values.dateOfBirth = new Date(values.dateOfBirth);
return values;
};

The returned values are copied onto the command by property name, and only properties the command declares are sent. Adding a key the command does not have, such as a timestamp or a derived total, has no effect; add it to the backend command if the server needs it. Do not set a required property to undefined to “remove” a UI-only field: the command’s own validation runs again before the request and rejects it.