Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions Documentation/CommandDialog/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<CommandDialog
command={UpdateProject}
currentValues={projectDraft}
isValid={projectDraft.name.trim().length > 0}
/>
```

## Field Change Tracking

React to field value changes:
Expand Down
16 changes: 16 additions & 0 deletions Documentation/StepperCommandDialog/advanced-features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<StepperCommandDialog
command={UpdateProject}
currentValues={projectDraft}
isValid={projectDraft.stepsComplete}
>
{/* steps */}
</StepperCommandDialog>
```

## Field Change Tracking

React to field value changes across any step:
Expand Down
133 changes: 133 additions & 0 deletions Source/CommandDialog/for_CommandDialog/when_validity_is_gated.ts
Original file line number Diff line number Diff line change
@@ -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> | 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(/<button[^>]*>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;
});
});
137 changes: 124 additions & 13 deletions Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,51 @@
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 }) =>
React.createElement('div', null, props.footer, props.children),
}));

vi.mock('primereact/stepper', () => ({
Stepper: (props: { children?: React.ReactNode }) =>
React.createElement('div', null, props.children),
Stepper: (props: { children?: React.ReactNode; pt?: Record<string, unknown>; activeStep?: number }) => {
type StepCtx = { context: { index: number } };
type NumberPtFn = (opts: StepCtx) => { style?: { backgroundColor?: string } };
const ptStepperpanel = (props.pt as Record<string, unknown> | undefined)?.stepperpanel as Record<string, unknown> | 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<Record<string, unknown>>, { '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> | 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', () => ({
Expand All @@ -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),
}));
Expand All @@ -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<TestCommand>,
{
Expand All @@ -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<TestCommand>,
{
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<TestCommand>,
{
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<TestCommand>,
{
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;
});
});
Loading