diff --git a/.vscode/settings.json b/.vscode/settings.json index b7141e0..d1c067c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,9 @@ "primeicons", "primereact", "trackpad", - "trackpads" + "trackpads", + "tsyringe", + "usehooks" ], "typescript.tsdk": ".yarn/sdks/typescript/lib", "search.exclude": { diff --git a/Source/CommandDialog/CommandDialog.stories.tsx b/Source/CommandDialog/CommandDialog.stories.tsx index 6613d31..62f17c2 100644 --- a/Source/CommandDialog/CommandDialog.stories.tsx +++ b/Source/CommandDialog/CommandDialog.stories.tsx @@ -4,7 +4,7 @@ import React, { useState } from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { CommandDialog } from './CommandDialog'; -import { Command, CommandValidator } from '@cratis/arc/commands'; +import { Command, CommandResult, CommandValidator } from '@cratis/arc/commands'; import { PropertyDescriptor } from '@cratis/arc/reflection'; import { InputTextField, NumberField, TextAreaField } from '../CommandForm/fields'; import '@cratis/arc/validation'; @@ -50,6 +50,41 @@ class UpdateUserCommand extends Command { 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; + } +} + +/** Variant that keeps the original server-calling validate() for the WithServerValidation story. */ +class UpdateUserCommandWithServer 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']; + } } const DialogWrapper = () => { @@ -93,6 +128,67 @@ const DialogWrapper = () => { header="Update User Information (with 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)); + }} + > + 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 [result, setResult] = useState(''); + const [validationErrors, setValidationErrors] = useState([]); + + 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); @@ -101,7 +197,7 @@ const DialogWrapper = () => { 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 { @@ -109,9 +205,9 @@ const DialogWrapper = () => { } }} > - 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)" />
); @@ -121,6 +217,10 @@ export const Default: Story = { render: () => , }; +export const WithServerValidation: Story = { + render: () => , +}; + const EditUserWrapper = () => { const [visible, setVisible] = useState(false); const [selectedUser, setSelectedUser] = useState<{ name: string; email: string; age: number } | undefined>(undefined); @@ -165,6 +265,7 @@ const EditUserWrapper = () => { header={`Edit User: ${selectedUser?.name ?? ''}`} confirmLabel="Save" cancelLabel="Cancel" + autoServerValidate={false} onConfirm={async () => { setResult(`User "${selectedUser?.name}" updated successfully`); setVisible(false); @@ -211,6 +312,7 @@ const CustomValidationWrapper = () => { header="Add User (with Custom Validation)" confirmLabel="Save" cancelLabel="Cancel" + autoServerValidate={false} onConfirm={async () => { setResult('User added successfully'); setVisible(false); @@ -262,6 +364,7 @@ const ValidationOnBlurWrapper = () => { confirmLabel="Save" cancelLabel="Cancel" validateOn="blur" + autoServerValidate={false} onConfirm={async () => setVisible(false)} onCancel={() => setVisible(false)} > @@ -292,6 +395,7 @@ const ValidationOnChangeWrapper = () => { confirmLabel="Save" cancelLabel="Cancel" validateOn="change" + autoServerValidate={false} onConfirm={async () => setVisible(false)} onCancel={() => setVisible(false)} > @@ -322,6 +426,7 @@ const ValidateOnInitWrapper = () => { confirmLabel="Save" cancelLabel="Cancel" validateOnInit={true} + autoServerValidate={false} initialValues={{ name: 'A', email: 'invalid', age: 10 }} onConfirm={async () => setVisible(false)} onCancel={() => setVisible(false)} @@ -354,6 +459,7 @@ const ValidateAllFieldsWrapper = () => { cancelLabel="Cancel" validateOn="blur" validateAllFieldsOnChange={true} + autoServerValidate={false} onConfirm={async () => setVisible(false)} onCancel={() => setVisible(false)} > @@ -392,6 +498,7 @@ const BeforeExecuteWrapper = () => { header="Before Execute Callback" confirmLabel="Save" cancelLabel="Cancel" + autoServerValidate={false} initialValues={{ name: '', email: '', age: 18 }} onBeforeExecute={(command) => { command.name = command.name.trim().replace(/\s+/g, ' '); @@ -430,6 +537,7 @@ const WithIconsWrapper = () => { header="Fields with Icons" confirmLabel="Save" cancelLabel="Cancel" + autoServerValidate={false} onConfirm={async () => setVisible(false)} onCancel={() => setVisible(false)} > @@ -500,6 +608,14 @@ class UpdateProfileCommand extends Command { get properties(): string[] { return ['firstName', 'lastName', 'email', 'phone', 'bio']; } + + override async validate(): Promise> { + const errors = this.validation?.validate(this) ?? []; + if (errors.length > 0) { + return CommandResult.validationFailed(errors); + } + return CommandResult.empty; + } } const MultiColumnWrapper = () => { @@ -517,6 +633,7 @@ const MultiColumnWrapper = () => { confirmLabel="Save" cancelLabel="Cancel" width="70vw" + autoServerValidate={false} onConfirm={async () => setVisible(false)} onCancel={() => setVisible(false)} > @@ -554,6 +671,7 @@ const MixedChildrenWrapper = () => { header="Edit Profile (Mixed Children)" confirmLabel="Save" cancelLabel="Cancel" + autoServerValidate={false} onConfirm={async () => setVisible(false)} onCancel={() => setVisible(false)} > diff --git a/Source/CommandDialog/CommandDialog.tsx b/Source/CommandDialog/CommandDialog.tsx index ba44836..cf54714 100644 --- a/Source/CommandDialog/CommandDialog.tsx +++ b/Source/CommandDialog/CommandDialog.tsx @@ -2,80 +2,30 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { ICommandResult } from '@cratis/arc/commands'; -import { Constructor } from '@cratis/fundamentals'; import { DialogButtons } from '@cratis/arc.react/dialogs'; import { Dialog } from '../Dialogs/Dialog'; -import React, { createContext, useContext } from 'react'; -import { - CommandForm, +import React from 'react'; +import { + CommandForm, CommandFormFieldWrapper, - useCommandFormContext, - useCommandInstance + useCommandFormContext, + useCommandInstance, + type CommandFormProps } from '@cratis/arc.react/commands'; -type CommandFormProps = React.ComponentProps; - -// Local type definitions -export type BeforeExecuteCallback = (values: TCommand) => TCommand; - -export type FieldValidator = (command: TCommand, fieldName: string, oldValue: unknown, newValue: unknown) => string | undefined; -export type FieldChangeCallback = (command: TCommand, fieldName: string, oldValue: unknown, newValue: unknown) => void; - -export interface CommandDialogProps { - command: Constructor; - initialValues?: Partial; - currentValues?: Partial | undefined; +export interface CommandDialogProps + extends Omit, 'children'> { visible: boolean; header: string; confirmLabel?: string; cancelLabel?: string; - confirmIcon?: string; - cancelIcon?: string; onConfirm: (result: ICommandResult) => void | Promise; onCancel: () => void; - onFieldValidate?: FieldValidator; - onFieldChange?: FieldChangeCallback; - onBeforeExecute?: BeforeExecuteCallback; children?: React.ReactNode; style?: React.CSSProperties; width?: string; - showTitles?: boolean; - showErrors?: boolean; - validateOn?: CommandFormProps['validateOn']; - validateAllFieldsOnChange?: boolean; - validateOnInit?: boolean; - autoServerValidate?: boolean; - autoServerValidateThrottle?: number; - fieldContainerComponent?: CommandFormProps['fieldContainerComponent']; - fieldDecoratorComponent?: CommandFormProps['fieldDecoratorComponent']; - errorDisplayComponent?: CommandFormProps['errorDisplayComponent']; - tooltipComponent?: CommandFormProps['tooltipComponent']; - errorClassName?: string; - iconAddonClassName?: string; -} - -interface CommandDialogContextValue { - onSuccess: (result: ICommandResult) => void | Promise; - onCancel: () => void; - confirmLabel: string; - cancelLabel: string; - confirmIcon: string; - cancelIcon: string; - onFieldValidate?: FieldValidator; - onFieldChange?: FieldChangeCallback; - onBeforeExecute?: BeforeExecuteCallback; } -const CommandDialogContext = createContext | undefined>(undefined); - -export const useCommandDialogContext = () => { - const context = useContext(CommandDialogContext); - if (!context) { - throw new Error('useCommandDialogContext must be used within a CommandDialog'); - } - return context as CommandDialogContextValue; -}; - const CommandDialogWrapper = ({ header, visible, @@ -94,14 +44,12 @@ const CommandDialogWrapper = ({ cancelLabel: string; onConfirm: (result: ICommandResult) => void | Promise; onCancel: () => void; - onBeforeExecute?: BeforeExecuteCallback; + onBeforeExecute?: (values: TCommand) => TCommand; children: React.ReactNode; }) => { const { setCommandValues, setCommandResult, isValid } = useCommandFormContext(); const commandInstance = useCommandInstance(); - const isDialogValid = isValid; - const handleConfirm = async () => { if (onBeforeExecute) { const transformedValues = onBeforeExecute(commandInstance); @@ -150,7 +98,7 @@ const CommandDialogWrapper = ({ buttons={DialogButtons.OkCancel} okLabel={confirmLabel} cancelLabel={cancelLabel} - isValid={isDialogValid} + isValid={isValid} >
{processedChildren} @@ -161,85 +109,32 @@ const CommandDialogWrapper = ({ const CommandDialogComponent = (props: CommandDialogProps) => { const { - command, - initialValues, - currentValues, visible, header, confirmLabel = 'Confirm', cancelLabel = 'Cancel', - confirmIcon = 'pi pi-check', - cancelIcon = 'pi pi-times', onConfirm, onCancel, - onFieldValidate, - onFieldChange, - onBeforeExecute, children, width = '50vw', - showTitles, - showErrors, - validateOn, - validateAllFieldsOnChange, - validateOnInit, - autoServerValidate, - autoServerValidateThrottle, - fieldContainerComponent, - fieldDecoratorComponent, - errorDisplayComponent, - tooltipComponent, - errorClassName, - iconAddonClassName + ...commandFormProps } = props; - const contextValue: CommandDialogContextValue = { - onSuccess: onConfirm, - onCancel, - confirmLabel, - cancelLabel, - confirmIcon, - cancelIcon, - onFieldValidate, - onFieldChange, - onBeforeExecute - }; - return ( - - - - {children} - - - + {...commandFormProps}> + + header={header} + visible={visible} + width={width} + confirmLabel={confirmLabel} + cancelLabel={cancelLabel} + onConfirm={onConfirm} + onCancel={onCancel} + onBeforeExecute={commandFormProps.onBeforeExecute} + > + {children} + + ); }; diff --git a/Source/CommandForm/fields/CheckboxField.tsx b/Source/CommandForm/fields/CheckboxField.tsx index 389c5aa..a366c67 100644 --- a/Source/CommandForm/fields/CheckboxField.tsx +++ b/Source/CommandForm/fields/CheckboxField.tsx @@ -15,6 +15,7 @@ export const CheckboxField = asCommandFormField( {props.label && } diff --git a/Source/CommandForm/fields/DropdownField.tsx b/Source/CommandForm/fields/DropdownField.tsx index 8122a39..a0b4960 100644 --- a/Source/CommandForm/fields/DropdownField.tsx +++ b/Source/CommandForm/fields/DropdownField.tsx @@ -17,6 +17,7 @@ export const DropdownField = asCommandFormField( props.onChange(e.value)} + onBlur={props.onBlur} options={props.options} optionValue={props.optionValue} optionLabel={props.optionLabel} diff --git a/Source/CommandForm/fields/InputTextField.tsx b/Source/CommandForm/fields/InputTextField.tsx index 5566571..146e3d5 100644 --- a/Source/CommandForm/fields/InputTextField.tsx +++ b/Source/CommandForm/fields/InputTextField.tsx @@ -16,6 +16,7 @@ export const InputTextField = asCommandFormField( type={props.type || 'text'} value={props.value} onChange={props.onChange} + onBlur={props.onBlur} invalid={props.invalid} placeholder={props.placeholder} className="w-full" diff --git a/Source/CommandForm/fields/NumberField.tsx b/Source/CommandForm/fields/NumberField.tsx index 92f3e43..bad5972 100644 --- a/Source/CommandForm/fields/NumberField.tsx +++ b/Source/CommandForm/fields/NumberField.tsx @@ -17,6 +17,7 @@ export const NumberField = asCommandFormField( props.onChange(e.value ?? 0)} + onBlur={props.onBlur} invalid={props.invalid} placeholder={props.placeholder} min={props.min} diff --git a/Source/CommandForm/fields/SliderField.tsx b/Source/CommandForm/fields/SliderField.tsx index a918ed4..880911e 100644 --- a/Source/CommandForm/fields/SliderField.tsx +++ b/Source/CommandForm/fields/SliderField.tsx @@ -13,7 +13,7 @@ interface SliderFieldComponentProps extends WrappedFieldProps { export const SliderField = asCommandFormField( (props) => ( -
+
props.onChange(e.value)} diff --git a/Source/CommandForm/fields/TextAreaField.tsx b/Source/CommandForm/fields/TextAreaField.tsx index d8ee0b7..e1de09b 100644 --- a/Source/CommandForm/fields/TextAreaField.tsx +++ b/Source/CommandForm/fields/TextAreaField.tsx @@ -16,6 +16,7 @@ export const TextAreaField = asCommandFormField(