diff --git a/Documentation/CommandDialog/advanced-features.md b/Documentation/CommandDialog/advanced-features.md index 8343fcc..3b4ba96 100644 --- a/Documentation/CommandDialog/advanced-features.md +++ b/Documentation/CommandDialog/advanced-features.md @@ -115,6 +115,20 @@ const transformBeforeExecute = (values) => { /> ``` +## Custom Inputs + +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: + +```typescript + 0} +/> +``` + ## Field Change Tracking React to field value changes: diff --git a/Documentation/StepperCommandDialog/advanced-features.md b/Documentation/StepperCommandDialog/advanced-features.md index 106fdb1..8d44ea3 100644 --- a/Documentation/StepperCommandDialog/advanced-features.md +++ b/Documentation/StepperCommandDialog/advanced-features.md @@ -130,6 +130,22 @@ const transformBeforeExecute = (values) => { > ``` +## Custom Inputs + +When steps contain custom controls instead of `CommandForm` fields, keep the command instance synchronized with the custom input values. `StepperCommandDialog` 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 final Submit button is clicked: + +```typescript + + {/* steps */} + +``` + ## Field Change Tracking React to field value changes across any step: diff --git a/Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts b/Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts new file mode 100644 index 0000000..62e44a1 --- /dev/null +++ b/Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts @@ -0,0 +1,133 @@ +// 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'; + +const { commandFormValidity, executeCommand, setCommandValues } = vi.hoisted(() => ({ + commandFormValidity: { isValid: false }, + executeCommand: vi.fn(async () => ({ isSuccess: true, isValid: true, validationResults: [] })), + setCommandValues: vi.fn() +})); + +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.disabled !== true) { + void 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: () => undefined, +})); + +vi.mock('@cratis/arc.react/commands', () => ({ + CommandForm: (props: { children?: React.ReactNode }) => + React.createElement('div', null, props.children), + useCommandFormContext: () => ({ + isValid: commandFormValidity.isValid, + setCommandValues, + setCommandResult: () => {}, + getFieldError: (fieldName: string) => + fieldName === 'name' ? 'Name is required' : undefined, + }), + useCommandInstance: () => ({ + execute: executeCommand, + }), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), +})); + +class TestCommand { + name: string = ''; +} + +describe('when CommandDialog validity is gated', () => { + let CommandDialog: typeof import('../CommandDialog').CommandDialog; + + beforeEach(async () => { + commandFormValidity.isValid = false; + executeCommand.mockClear(); + setCommandValues.mockClear(); + vi.resetModules(); + CommandDialog = (await import('../CommandDialog')).CommandDialog; + }); + + const renderDialog = (props?: { isValid?: boolean; onBeforeExecute?: (values: TestCommand) => TestCommand }) => renderToStaticMarkup( + React.createElement(CommandDialog, { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + ...props + }) + ); + + const getOkButton = (html: string) => html.match(/]*>Ok<\/button>/)?.[0] ?? ''; + + afterEach(() => { + commandFormValidity.isValid = true; + }); + + it('should_use_command_form_validity_when_isValid_is_not_provided', () => { + commandFormValidity.isValid = false; + + const html = renderDialog(); + + getOkButton(html).should.include('disabled'); + executeCommand.should.not.have.been.called; + }); + + it('should_not_allow_isValid_true_to_override_invalid_command_form_state', () => { + commandFormValidity.isValid = false; + + const html = renderDialog({ + isValid: true, + onBeforeExecute: () => ({ name: 'External value' }) + }); + + getOkButton(html).should.include('disabled'); + setCommandValues.should.not.have.been.called; + executeCommand.should.not.have.been.called; + }); + + it('should_allow_isValid_false_to_disable_an_internally_valid_form', () => { + commandFormValidity.isValid = true; + + const html = renderDialog({ isValid: false }); + + getOkButton(html).should.include('disabled'); + executeCommand.should.not.have.been.called; + }); + + it('should_execute_when_command_form_is_valid_and_isValid_is_not_provided', () => { + commandFormValidity.isValid = true; + + const html = renderDialog(); + + getOkButton(html).should.not.include('disabled'); + executeCommand.should.have.been.calledOnce; + }); + + it('should_execute_when_command_form_is_valid_and_isValid_is_true', () => { + commandFormValidity.isValid = true; + + const html = renderDialog({ + isValid: true, + onBeforeExecute: () => ({ name: 'External value' }) + }); + + getOkButton(html).should.not.include('disabled'); + setCommandValues.should.have.been.calledOnceWith({ name: 'External value' }); + executeCommand.should.have.been.calledOnce; + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts b/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts index ba3bba6..c069738 100644 --- a/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts +++ b/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts @@ -4,8 +4,11 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { vi } from 'vitest'; -import { StepperCommandDialog } from '../StepperCommandDialog'; -import { StepperPanel } from 'primereact/stepperpanel'; + +const { commandFormValidity, executeCommand } = vi.hoisted(() => ({ + commandFormValidity: { isValid: true }, + executeCommand: vi.fn(async () => ({ isSuccess: true, isValid: true, validationResults: [] })) +})); vi.mock('primereact/dialog', () => ({ Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => @@ -13,18 +16,39 @@ vi.mock('primereact/dialog', () => ({ })); vi.mock('primereact/stepper', () => ({ - Stepper: (props: { children?: React.ReactNode }) => - React.createElement('div', null, props.children), + Stepper: (props: { children?: React.ReactNode; pt?: Record; activeStep?: number }) => { + type StepCtx = { context: { index: number } }; + type NumberPtFn = (opts: StepCtx) => { style?: { backgroundColor?: string } }; + const ptStepperpanel = (props.pt as Record | undefined)?.stepperpanel as Record | undefined; + const numberPtFn = ptStepperpanel?.number as NumberPtFn | undefined; + const children = React.Children.map(props.children, (child, index) => { + if (!React.isValidElement(child)) return child; + const result = typeof numberPtFn === 'function' ? numberPtFn({ context: { index } }) : {}; + const bg = result?.style?.backgroundColor ?? ''; + return React.cloneElement(child as React.ReactElement>, { 'data-number-bg': bg }); + }); + return React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, children); + }, })); -vi.mock('primereact/stepperpanel', () => ({ - StepperPanel: (props: { header?: string; children?: React.ReactNode }) => - React.createElement('div', { 'data-header': props.header }, props.children), -})); +vi.mock('primereact/stepperpanel', () => { + const MockStepperPanel = (props: { header?: string; children?: React.ReactNode; 'data-number-bg'?: string }) => + React.createElement('div', { + 'data-testid': 'stepper-panel', + 'data-header': props.header, + 'data-number-bg': props['data-number-bg'] ?? '', + }, props.children); + MockStepperPanel.displayName = 'StepperPanel'; + return { StepperPanel: MockStepperPanel }; +}); vi.mock('primereact/button', () => ({ - Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) => - React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label), + Button: (props: { icon?: string; label?: string; onClick?: () => Promise | void; disabled?: boolean; loading?: boolean }) => { + if (props.icon === 'pi pi-check' && props.onClick && props.disabled !== true) { + void props.onClick(); + } + return React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, props.label); + }, })); vi.mock('@cratis/arc.react/dialogs', () => ({ @@ -37,12 +61,13 @@ vi.mock('@cratis/arc.react/commands', () => ({ CommandForm: (props: { children?: React.ReactNode }) => React.createElement('div', null, props.children), useCommandFormContext: () => ({ - isValid: true, + isValid: commandFormValidity.isValid, setCommandValues: () => {}, setCommandResult: () => {}, - getFieldError: () => undefined, + getFieldError: (fieldName: string) => + fieldName === 'name' ? 'Name is required' : undefined, }), - useCommandInstance: () => ({}), + useCommandInstance: () => ({ execute: executeCommand }), CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => React.createElement('div', null, props.field), })); @@ -51,10 +76,25 @@ class TestCommand { name: string = ''; } +let StepperCommandDialog: typeof import('../StepperCommandDialog').StepperCommandDialog; +let StepperPanel: typeof import('primereact/stepperpanel').StepperPanel; + +beforeEach(async () => { + executeCommand.mockClear(); + vi.resetModules(); + StepperCommandDialog = (await import('../StepperCommandDialog')).StepperCommandDialog; + StepperPanel = (await import('primereact/stepperpanel')).StepperPanel; +}); + +afterEach(() => { + commandFormValidity.isValid = true; +}); + describe('when StepperCommandDialog has an external isValid=false gate on the last step', () => { let html: string; beforeEach(() => { + commandFormValidity.isValid = true; const element = React.createElement( StepperCommandDialog, { @@ -70,5 +110,76 @@ describe('when StepperCommandDialog has an external isValid=false gate on the la it('should_not_show_submit_button_when_externally_invalid', () => { html.should.not.include('>Submit<'); + executeCommand.should.not.have.been.called; + }); +}); + +describe('when StepperCommandDialog has an invalid command form on the last step', () => { + let html: string; + + beforeEach(() => { + commandFormValidity.isValid = false; + const element = React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + React.createElement(StepperPanel, { header: 'Only Step' }, 'Content') + ); + html = renderToStaticMarkup(element); + }); + + it('should_not_show_submit_button_when_isValid_is_not_provided', () => { + html.should.not.include('>Submit<'); + executeCommand.should.not.have.been.called; + }); +}); + +describe('when StepperCommandDialog has isValid=true and an invalid command form on the last step', () => { + let html: string; + + beforeEach(() => { + commandFormValidity.isValid = false; + const element = React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + isValid: true, + }, + React.createElement(StepperPanel, { header: 'Only Step' }, 'Content') + ); + html = renderToStaticMarkup(element); + }); + + it('should_not_show_submit_button_when_command_form_is_invalid', () => { + html.should.not.include('>Submit<'); + executeCommand.should.not.have.been.called; + }); +}); + +describe('when StepperCommandDialog has a valid command form on the last step', () => { + let html: string; + + beforeEach(() => { + commandFormValidity.isValid = true; + const element = React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + React.createElement(StepperPanel, { header: 'Only Step' }, 'Content') + ); + html = renderToStaticMarkup(element); + }); + + it('should_show_submit_button_and_execute', () => { + html.should.include('>Submit<'); + executeCommand.should.have.been.calledOnce; }); });