diff --git a/.github/skills/skill-creator/SKILL.md b/.github/skills/skill-creator/SKILL.md index 942bfe8..5bb069c 100644 --- a/.github/skills/skill-creator/SKILL.md +++ b/.github/skills/skill-creator/SKILL.md @@ -138,6 +138,13 @@ Output: feat(auth): implement JWT-based authentication Try to explain to the model why things are important in lieu of heavy-handed musty MUSTs. Use theory of mind and try to make the skill general and not super-narrow to specific examples. Start by writing a draft and then look at it with fresh eyes and improve it. +### Quality Gate Before Finalizing + +Before saving any SKILL.md, do a quick self-check: + +- **American English only** — this project enforces US spelling throughout all code, comments, and documentation. Scan for common UK variants and replace: `behaviour→behavior`, `colour→color`, `customisation→customization`, `customising→customizing`, `organisation→organization`, `recognise→recognize`, `favour→favor`, `neighbour→neighbor`, `analyse→analyze`, `initialise→initialize`, `finalise→finalize`. +- All examples follow the project's coding conventions (naming, formatting, structure). + ### Test Cases After writing the skill draft, come up with 2-3 realistic test prompts — the kind of thing a real user would actually say. Share them with the user: [you don't have to use this exact language] "Here are a few test cases I'd like to try. Do these look right, or do you want to add more?" Then run them. diff --git a/.github/skills/stepper-command-dialog/SKILL.md b/.github/skills/stepper-command-dialog/SKILL.md new file mode 100644 index 0000000..736b068 --- /dev/null +++ b/.github/skills/stepper-command-dialog/SKILL.md @@ -0,0 +1,234 @@ +--- +name: stepper-command-dialog +description: Step-by-step guidance for building a multi-step wizard dialog (StepperCommandDialog) in a Cratis Arc application. Use whenever a command requires gathering information across multiple steps, implementing a wizard flow, breaking a complex form into named stages, or using StepperCommandDialog, StepperPanel, validateOnInit, or wizard-style navigation. +--- + +# StepperCommandDialog — Wizard Dialogs + +`StepperCommandDialog` organizes a single command form across multiple named steps. Users navigate with **Previous** and **Next** buttons; **Submit** only appears on the last step when every field across all steps is valid. + +Use this instead of `CommandDialog` when: +- The form has too many fields to show at once +- Fields can be grouped into logical stages (e.g. "Contact Info → Project Details → Summary") +- You want guided, linear input with per-step validation feedback +- The operation feels like a wizard or an onboarding flow + +--- + +## Step 1 — Define the command + +A single command collects all fields across all steps. Each step contributes properties to the same command instance. + +```tsx +// API/Projects/CreateProject.ts (proxy-generated — run dotnet build first) +// C# side: +[Command] +public record CreateProject(string Name, string Email, string Description, decimal Budget) +{ + public ProjectCreated Handle() => new(Name, Email, Description, Budget); +} +``` + +--- + +## Step 2 — Build the dialog component + +```tsx +import { StepperCommandDialog } from '@cratis/components/CommandDialog'; +import { StepperPanel } from 'primereact/stepperpanel'; +import { InputTextField, TextAreaField, NumberField } from '@cratis/components/CommandForm/fields'; +import { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs'; +import { CreateProject } from '../api/Projects/CreateProject'; + +const CreateProjectDialog = () => { + const { closeDialog } = useDialogContext(); + + return ( + + command={CreateProject} + title="Create New Project" + okLabel="Create" + onConfirm={() => closeDialog(DialogResult.Ok)} + onCancel={() => closeDialog(DialogResult.Cancelled)} + > + + + value={c => c.email} + title="Contact Email" + placeholder="Enter contact email" + type="email" + /> + + + + value={c => c.name} + title="Project Name" + placeholder="Enter project name" + /> + + value={c => c.description} + title="Description" + placeholder="Describe the project" + rows={4} + /> + + + + value={c => c.budget} + title="Budget" + placeholder="Enter budget" + /> + + + ); +}; +``` + +**Rules:** +- Each `StepperPanel` takes a `header` string — this is the step label shown in the wizard navigation bar +- All `CommandForm` fields inside any `StepperPanel` are bound to the **same** command instance +- Fields map to command properties via the `value={c => c.propertyName}` accessor +- The `Next` button is disabled while the current step has validation errors +- `Submit` only appears on the **last** step when all fields (across all steps) are valid + +--- + +## Step 3 — Wire the dialog to a parent component + +```tsx +import { useDialog } from '@cratis/arc.react/dialogs'; + +export const ProjectsPage = () => { + const [CreateProjectDialogWrapper, showCreateProject] = useDialog(CreateProjectDialog); + + return ( + <> + + + + ); +}; +``` + +--- + +## Navigation behavior + +| Step | Footer buttons shown | +|------|---------------------| +| First step | **Next** | +| Middle step | **Previous**, **Next** | +| Last step (any step invalid) | **Previous** | +| Last step (all valid) | **Previous**, **Submit** | + +Cancel is always available via the **×** button in the dialog header. + +--- + +## Validation indicators + +Step number circles in the wizard navigation bar change color to reflect validity: + +- **Red circle** — the step contains at least one field with a validation error +- **Green circle** — the step has been visited (navigated through) and all its fields are valid +- **Default color** — the step has not been visited yet +- **Dimmed** — a step that is not the currently active step + +To trigger validation immediately on open (before the user types anything), pass `validateOnInit`: + +```tsx + +``` + +This is useful when the dialog opens with pre-populated values that may already be invalid. + +--- + +## Customizing step labels + +The `okLabel`, `nextLabel`, and `previousLabel` props override the default button text: + +```tsx + +``` + +--- + +## Pre-populating values (edit wizard) + +Use `currentValues` for fields the user can change and `initialValues` for hidden/fixed fields (e.g. an ID): + +```tsx + +``` + +--- + +## Stepper orientation + +For longer wizards, vertical orientation can be more readable: + +```tsx + + ... + ... + +``` + +--- + +## Props reference + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `command` | `Constructor` | — | **Required.** Command class constructor | +| `title` | `string` | — | **Required.** Dialog header title | +| `children` | `StepperPanel[]` | — | **Required.** Wizard steps | +| `okLabel` | `string` | `'Submit'` | Submit button label (last step) | +| `nextLabel` | `string` | `'Next'` | Next button label | +| `previousLabel` | `string` | `'Previous'` | Previous button label | +| `visible` | `boolean` | `true` | Controls dialog visibility | +| `width` | `string` | `'600px'` | Dialog width | +| `isValid` | `boolean` | — | Extra validity gate combined with form validity | +| `validateOnInit` | `boolean` | — | Run validation on mount to show errors immediately | +| `orientation` | `'horizontal' \| 'vertical'` | `'horizontal'` | Stepper layout direction | +| `linear` | `boolean` | `true` | Require steps to be completed in order | +| `initialValues` | `Partial` | — | Fixed initial values (not shown to user as editable) | +| `currentValues` | `Partial` | — | Pre-populated editable values | +| `onConfirm` | `() => void \| Promise` | — | Called after successful execute | +| `onCancel` | `() => void \| Promise` | — | Called when user dismisses dialog | +| `onBeforeExecute` | `(values: TCommand) => TCommand` | — | Transform values before execution | +| `pt` | `StepperProps['pt']` | — | PrimeReact PassThrough for deep DOM customization | + +--- + +## Common mistakes + +| Mistake | Fix | +|---------|-----| +| Putting a Cancel button in the footer | Don't — the × in the dialog header is the cancel action | +| One step per field | Group related fields; aim for 2–5 fields per step | +| Fields from different steps sharing the same `value` accessor | Each property should appear on exactly one step | +| Forgetting `header` on `StepperPanel` | Always set `header` — it is the navigation label | +| Using `CommandDialog` for a 4+ field form | Consider `StepperCommandDialog` to reduce cognitive load | diff --git a/.github/skills/write-documentation/SKILL.md b/.github/skills/write-documentation/SKILL.md index b1cc792..10c1693 100644 --- a/.github/skills/write-documentation/SKILL.md +++ b/.github/skills/write-documentation/SKILL.md @@ -91,6 +91,7 @@ The project's voice is **direct, practical, and opinionated**. Write like an exp - Use headings, lists, and code blocks to organize content — dense paragraphs lose readers. - Focus on public APIs and features — never internal implementation. - Do not document third-party libraries. +- **American English only.** Always use US spellings: `color` not `colour`, `behavior` not `behaviour`, `customize` not `customise`, `organize` not `organise`, `recognize` not `recognise`, `analyze` not `analyse`, `initialize` not `initialise`. ## Code Examples diff --git a/Documentation/CommandDialog/stepper-command-dialog.md b/Documentation/CommandDialog/stepper-command-dialog.md new file mode 100644 index 0000000..8bac44c --- /dev/null +++ b/Documentation/CommandDialog/stepper-command-dialog.md @@ -0,0 +1,170 @@ +# StepperCommandDialog + +The `StepperCommandDialog` component provides a multi-step wizard dialog interface for executing commands, powered by the PrimeReact Stepper. + +## Purpose + +`StepperCommandDialog` organizes a command form across multiple steps, guiding users through a wizard-like workflow. All steps gather into the same underlying command — the Submit button only **appears** when all fields across every step are valid and the user has reached the last step. + +## Key Features + +- Multi-step wizard navigation with Previous and Next buttons +- All steps share a single command form — one command is submitted at the end +- Submit button only appears on the last step when all fields are valid +- Previous button hidden on the first step; Next button hidden on the last step +- Cancel via the X button in the upper-right corner — no footer Cancel button +- Step number circles change color to indicate validation state (red = errors, green = visited and valid) +- Non-active steps are visually dimmed to keep focus on the current step +- Busy state management during command execution +- All PrimeReact `Stepper` customization props available directly (orientation, headerPosition, pt, etc.) +- Supports any `CommandForm` field types inside each `StepperPanel` +- Full integration with Cratis Arc command system + +## Basic Usage + +```typescript +import { StepperCommandDialog } from '@cratis/components/CommandDialog'; +import { StepperPanel } from 'primereact/stepperpanel'; +import { InputTextField, TextAreaField, NumberField } from '@cratis/components/CommandForm/fields'; +import { CommandResult } from '@cratis/arc/commands'; +import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; + +const CreateProjectDialog = () => { + const { closeDialog } = useDialogContext>(); + + return ( + + command={CreateProject} + title="Create New Project" + okLabel="Create" + onConfirm={() => closeDialog(DialogResult.Ok)} + onCancel={() => closeDialog(DialogResult.Cancelled)} + > + + value={c => c.name} title="Project Name" /> + value={c => c.email} title="Contact Email" type="email" /> + + + value={c => c.description} title="Description" rows={4} /> + value={c => c.budget} title="Budget" /> + + + ); +}; + +function MyComponent() { + const [CreateProjectDialogWrapper, showCreateProjectDialog] = useDialog(CreateProjectDialog); + + return ( + <> + + + + ); +} +``` + +## Props + +### Required Props + +- `command`: Constructor for the command type +- `title`: Dialog title text +- `children`: `StepperPanel` elements defining each step + +### Dialog Props + +- `visible`: Boolean controlling dialog visibility (defaults to `true`) +- `initialValues`: Initial values for the command form +- `currentValues`: Current values to populate the form +- `onConfirm`: Confirm callback — called only after successful command execution +- `onCancel`: Cancel callback — invoked when the X button is clicked +- `onClose`: Fallback close callback +- `okLabel`: Label for the submit button shown on the last step when valid (default: `'Submit'`) +- `nextLabel`: Label for the next step button (default: `'Next'`) +- `previousLabel`: Label for the previous step button (default: `'Previous'`) +- `isValid`: Additional validity gate combined with command form validity +- `width`: Dialog width (default: `'600px'`) +- `resizable`: Whether the dialog can be resized +- `style`: Custom CSS styles +- `onFieldValidate`: Custom validation function for fields +- `onFieldChange`: Callback when field values change +- `onBeforeExecute`: Transform command values before execution + +### Stepper Props + +All [PrimeReact Stepper](https://primereact.org/stepper/) customization props are available directly: + +- `orientation`: `'horizontal'` (default) or `'vertical'` +- `headerPosition`: `'top'`, `'right'`, `'bottom'`, or `'left'` +- `linear`: Whether steps must be completed in order (default: `true`) +- `onChangeStep`: Callback when the active step changes +- `start`: Custom content rendered before the stepper navigation +- `end`: Custom content rendered after the stepper navigation +- `pt`: PrimeReact PassThrough options for deep DOM customization +- `ptOptions`: PassThrough configuration options +- `unstyled`: Removes built-in component styles + +## Validation Indicators + +The step number circles in the wizard navigation bar reflect the validation state of each step: + +| Circle color | Meaning | +|---|---| +| **Red** | The step contains at least one field with a validation error | +| **Green** | The step has been visited (navigated through) and all its fields are valid | +| **Default** (theme primary) | The step has not been visited yet | + +Steps that are not currently active are dimmed to keep visual focus on the current step. + +To show validation indicators immediately on open — before the user has touched any fields — pass the `validateOnInit` prop: + +```tsx + +``` + +This is useful when the dialog opens with pre-populated values that may already be partially invalid. + +## Navigation and Submit + +| Step position | Footer content | +|---|---| +| First step | Next | +| Middle step | Previous, Next | +| Last step (invalid) | Previous | +| Last step (valid) | Previous, Submit | + +Cancel is always available via the X button in the dialog header. The Submit button is hidden until the user reaches the last step **and** all command form fields across every step pass validation. + +## Busy State + +`StepperCommandDialog` automatically manages a busy state during command execution: + +- When Submit is clicked, the Submit button shows a loading spinner and all navigation buttons are disabled. +- Once execution completes (success or failure), the buttons return to their normal state. + +## Step Structure + +Each step is defined by a `StepperPanel` from `primereact/stepperpanel`. The `header` prop sets the step title shown in the stepper navigation: + +```tsx + + value={c => c.email} title="Email" /> + +``` + +CommandForm fields placed inside a `StepperPanel` are automatically bound to the same command instance, regardless of which step they are on. + +## Integration + +`StepperCommandDialog` integrates with: + +- `@cratis/arc/commands` for command execution +- `@cratis/arc.react/commands` for form handling +- PrimeReact `Stepper` and `StepperPanel` components for the wizard UI +- PrimeReact `Dialog` component for the modal wrapper + diff --git a/Documentation/CommandDialog/toc.yml b/Documentation/CommandDialog/toc.yml index b3f9d27..91d3ea2 100644 --- a/Documentation/CommandDialog/toc.yml +++ b/Documentation/CommandDialog/toc.yml @@ -2,3 +2,5 @@ href: index.md - name: Advanced Features href: advanced-features.md +- name: StepperCommandDialog + href: stepper-command-dialog.md diff --git a/Source/CommandDialog/StepperCommandDialog.css b/Source/CommandDialog/StepperCommandDialog.css new file mode 100644 index 0000000..e363720 --- /dev/null +++ b/Source/CommandDialog/StepperCommandDialog.css @@ -0,0 +1,14 @@ +/* Copyright (c) Cratis. All rights reserved. */ +/* Licensed under the MIT license. See LICENSE file in the project root for full license information. */ + +/* ── Step validation colors ─────────────────────────────────────────────── */ +/* The circle background colour for error/valid states is applied via inline + style on the number span through PrimeReact's passthrough API, so no CSS + class rules are needed here for those states. + + Non-active steps are dimmed using PrimeReact's own data-p-active attribute + so no extra passthrough class injection is required. */ + +.p-stepper [data-pc-section="header"][data-p-active="false"] { + opacity: 0.5; +} diff --git a/Source/CommandDialog/StepperCommandDialog.stories.tsx b/Source/CommandDialog/StepperCommandDialog.stories.tsx new file mode 100644 index 0000000..df5e6d8 --- /dev/null +++ b/Source/CommandDialog/StepperCommandDialog.stories.tsx @@ -0,0 +1,335 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import React, { useState } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { StepperCommandDialog } from './StepperCommandDialog'; +import { Command, CommandResult, CommandValidator } from '@cratis/arc/commands'; +import { PropertyDescriptor } from '@cratis/arc/reflection'; +import { InputTextField, NumberField, TextAreaField } from '../CommandForm/fields'; +import { DialogResult, useDialog, useDialogContext } from '@cratis/arc.react/dialogs'; +import { StepperPanel } from 'primereact/stepperpanel'; +import '@cratis/arc/validation'; + +const meta: Meta = { + title: 'CommandDialog/StepperCommandDialog', + component: StepperCommandDialog, +}; + +export default meta; +type Story = StoryObj; + +class CreateProjectValidator extends CommandValidator { + constructor() { + super(); + this.ruleFor((c: CreateProjectCommand) => c.name).notEmpty().minLength(2).maxLength(100); + this.ruleFor((c: CreateProjectCommand) => c.email).notEmpty().emailAddress(); + this.ruleFor((c: CreateProjectCommand) => c.description).notEmpty().minLength(10); + this.ruleFor((c: CreateProjectCommand) => c.budget).greaterThan(0); + } +} + +class CreateProjectCommand 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); + } + return CommandResult.empty; + } +} + +/** Command that simulates a 2-second server delay to demonstrate the busy state. */ +class SlowCreateProjectCommand extends CreateProjectCommand { + override async execute(): Promise> { + await new Promise(resolve => setTimeout(resolve, 2000)); + return CommandResult.empty; + } +} + +export const Default: Story = { + render: () => { + const [result, setResult] = useState(''); + + const CreateProjectDialogComponent = () => { + const { closeDialog } = useDialogContext>(); + + return ( + + command={CreateProjectCommand} + title="Create New Project" + okLabel="Create" + autoServerValidate={false} + onConfirm={async () => closeDialog(DialogResult.Ok)} + onCancel={() => closeDialog(DialogResult.Cancelled)} + > + + + value={c => c.name} + title="Project Name" + placeholder="Enter project name (min 2 chars)" + /> + + value={c => c.email} + title="Contact Email" + placeholder="Enter contact email" + type="email" + /> + + + + value={c => c.description} + title="Description" + placeholder="Describe the project (min 10 chars)" + rows={4} + /> + + value={c => c.budget} + title="Budget" + placeholder="Enter budget (must be > 0)" + /> + + + ); + }; + + const [CreateProjectDialogWrapper, showCreateProjectDialog] = useDialog>(CreateProjectDialogComponent); + + return ( +
+ + + {result && ( +
+ Result: {result} +
+ )} + + +
+ ); + }, +}; + +export const ThreeSteps: Story = { + render: () => { + const [visible, setVisible] = useState(false); + const [result, setResult] = useState(''); + + return ( +
+ + + {result && ( +
+ Submitted: {result} +
+ )} + + + command={CreateProjectCommand} + visible={visible} + title="Create New Project (3 Steps)" + okLabel="Create" + autoServerValidate={false} + onConfirm={async () => { + setResult('Project created successfully'); + setVisible(false); + }} + onCancel={() => setVisible(false)} + > + + + value={c => c.email} + title="Contact Email" + placeholder="Enter contact email" + type="email" + /> + + + + value={c => c.name} + title="Project Name" + placeholder="Enter project name (min 2 chars)" + /> + + + + value={c => c.description} + title="Description" + placeholder="Describe the project (min 10 chars)" + rows={4} + /> + + value={c => c.budget} + title="Budget" + placeholder="Enter budget (must be > 0)" + /> + + +
+ ); + }, +}; + +export const WithValidationIndicators: Story = { + render: () => { + const [visible, setVisible] = useState(true); + + return ( +
+

+ validateOnInit triggers validation immediately — step indicators appear on + any step whose fields are invalid right from the start. +

+ + + + command={CreateProjectCommand} + visible={visible} + title="New Project" + okLabel="Create" + autoServerValidate={false} + validateOnInit + onConfirm={async () => setVisible(false)} + onCancel={() => setVisible(false)} + > + + + value={c => c.name} + title="Project Name" + placeholder="Enter project name (min 2 chars)" + /> + + value={c => c.email} + title="Contact Email" + placeholder="Enter contact email" + type="email" + /> + + + + value={c => c.description} + title="Description" + placeholder="Describe the project (min 10 chars)" + rows={4} + /> + + value={c => c.budget} + title="Budget" + placeholder="Enter budget (must be > 0)" + /> + + +
+ ); + }, +}; + +export const WithBusyState: Story = { + render: () => { + const [visible, setVisible] = useState(false); + + return ( +
+

+ Simulates a 2-second server delay. Fill all fields and click Submit to see the busy state. +

+ + + + command={SlowCreateProjectCommand} + visible={visible} + title="Create New Project (Slow)" + okLabel="Create" + autoServerValidate={false} + onConfirm={async () => setVisible(false)} + 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 new file mode 100644 index 0000000..158bd71 --- /dev/null +++ b/Source/CommandDialog/StepperCommandDialog.tsx @@ -0,0 +1,409 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ICommandResult } from '@cratis/arc/commands'; +import { DialogResult, useDialogContext } from '@cratis/arc.react/dialogs'; +import { Dialog as PrimeDialog } from 'primereact/dialog'; +import { Stepper as PrimeStepper, type StepperProps } from 'primereact/stepper'; +import { Button } from 'primereact/button'; +import React, { useMemo, useState } from 'react'; +import { + CommandForm, + CommandFormFieldWrapper, + useCommandFormContext, + useCommandInstance, + type CommandFormProps +} from '@cratis/arc.react/commands'; +import type { CloseDialog, ConfirmCallback, CancelCallback } from '../Dialogs/Dialog'; +import { CSSProperties } from 'react'; +import './StepperCommandDialog.css'; + +/** Extracts the property name from an accessor function like `c => c.name`. */ +const getPropertyName = (accessor: ((obj: unknown) => unknown) | unknown): string => { + if (typeof accessor !== 'function') return ''; + const fnStr = accessor.toString(); + const match = fnStr.match(/\.([a-zA-Z_$][a-zA-Z0-9_$]*)/); + return match ? match[1] : ''; +}; + +/** Recursively collects all CommandFormField property names from a React node tree. */ +const extractFieldNamesFromNode = (nodes: React.ReactNode): string[] => { + const names: string[] = []; + React.Children.forEach(nodes, (child) => { + if (!React.isValidElement(child)) return; + const component = child.type as React.ComponentType; + if ((component as { displayName?: string }).displayName === 'CommandFormField') { + const fieldProps = child.props as { value?: (obj: unknown) => unknown }; + const name = getPropertyName(fieldProps.value); + if (name) names.push(name); + } + const childProps = child.props as Record; + if (childProps.children != null) { + names.push(...extractFieldNamesFromNode(childProps.children as React.ReactNode)); + } + }); + return names; +}; + +/** + * Stepper-specific customization props forwarded directly to PrimeReact Stepper. + * `activeStep` and `children` are managed internally and are excluded. + */ +type StepperCustomizationProps = Pick; + +export interface StepperCommandDialogProps + extends Omit, 'children'>, + StepperCustomizationProps { + /** Dialog title text. */ + title: string; + /** Controls dialog visibility. Defaults to `true`. */ + visible?: boolean; + /** Dialog width. */ + width?: string; + /** Custom CSS styles applied to the dialog. */ + style?: CSSProperties; + /** Whether the dialog can be resized. Defaults to `false`. */ + resizable?: boolean; + /** Additional validity gate combined with command form validity. */ + isValid?: boolean; + /** Fallback close callback. */ + onClose?: CloseDialog; + /** Confirm callback — called only after successful command execution. */ + onConfirm?: ConfirmCallback; + /** Cancel callback — invoked when the dialog X button is clicked. */ + onCancel?: CancelCallback; + /** Label for the submit button shown on the last step when valid. Defaults to `'Submit'`. */ + okLabel?: string; + /** Label for the next step button. Defaults to `'Next'`. */ + nextLabel?: string; + /** Label for the previous step button. Defaults to `'Previous'`. */ + previousLabel?: string; + /** StepperPanel children defining each wizard step. */ + children?: React.ReactNode; +} + +const StepperCommandDialogWrapper = ({ + title, + visible = true, + width = '600px', + style, + resizable = false, + isValid, + onClose, + onConfirm, + onCancel, + onBeforeExecute, + okLabel = 'Submit', + nextLabel = 'Next', + previousLabel = 'Previous', + orientation = 'horizontal', + headerPosition, + linear = true, + onChangeStep, + start, + end, + pt, + ptOptions, + unstyled, + children +}: { + title: string; + visible?: boolean; + width?: string; + style?: CSSProperties; + resizable?: boolean; + isValid?: boolean; + onClose?: CloseDialog; + onConfirm?: ConfirmCallback; + onCancel?: CancelCallback; + onBeforeExecute?: (values: TCommand) => TCommand; + okLabel?: string; + nextLabel?: string; + previousLabel?: string; + children?: React.ReactNode; +} & StepperCustomizationProps) => { + const { setCommandValues, setCommandResult, isValid: isCommandFormValid, getFieldError } = useCommandFormContext(); + const commandInstance = useCommandInstance(); + const [isBusy, setIsBusy] = useState(false); + const [activeStep, setActiveStep] = useState(0); + const [visitedSteps, setVisitedSteps] = useState>(new Set([0])); + + let contextCloseDialog: ((result: DialogResult) => void) | undefined; + try { + const context = useDialogContext(); + contextCloseDialog = context?.closeDialog; + } catch { + contextCloseDialog = undefined; + } + + const stepCount = React.Children.count(children); + const isLastStep = activeStep === stepCount - 1; + const isFirstStep = activeStep === 0; + const isDialogValid = isValid !== false && isCommandFormValid; + + // Pre-compute the command property names for each StepperPanel step. + // Used to determine whether a step has validation errors for the indicator badge. + const stepFieldNames = useMemo( + () => React.Children.toArray(children).map((step) => { + if (!React.isValidElement(step)) return [] as string[]; + const stepProps = step.props as Record; + return extractFieldNamesFromNode(stepProps.children as React.ReactNode); + }), + [children] + ); + + const stepHasError = (stepIndex: number): boolean => + stepFieldNames[stepIndex]?.some(fieldName => !!(getFieldError?.(fieldName))) ?? false; + + const handleClose = async (result: DialogResult) => { + let shouldCloseThroughContext = true; + + if (result === DialogResult.Ok || result === DialogResult.Yes) { + if (onConfirm) { + const closeResult = await onConfirm(); + shouldCloseThroughContext = closeResult === true; + } else if (onClose) { + const closeResult = await onClose(result); + shouldCloseThroughContext = closeResult !== false; + } + } else { + if (onCancel) { + const closeResult = await onCancel(); + shouldCloseThroughContext = closeResult === true; + } else if (onClose) { + const closeResult = await onClose(result); + shouldCloseThroughContext = closeResult !== false; + } + } + + if (shouldCloseThroughContext) { + contextCloseDialog?.(result); + } + }; + + const handleSubmit = async () => { + if (onBeforeExecute) { + const transformedValues = onBeforeExecute(commandInstance); + setCommandValues(transformedValues); + } + + setIsBusy(true); + let result: ICommandResult; + + try { + result = await (commandInstance as unknown as { execute: () => Promise> }).execute(); + } finally { + setIsBusy(false); + } + + if (!result.isSuccess) { + setCommandResult(result); + return; + } + + await handleClose(DialogResult.Ok); + }; + + const processChildren = (nodes: React.ReactNode): React.ReactNode => { + return React.Children.map(nodes, (child) => { + if (!React.isValidElement(child)) return child; + + const component = child.type as React.ComponentType; + if (component.displayName === 'CommandFormField') { + type FieldElement = Parameters[0]['field']; + return ; + } + + const childProps = child.props as Record; + if (childProps.children != null) { + return React.cloneElement(child as React.ReactElement>, { + children: processChildren(childProps.children as React.ReactNode) + }); + } + + return child; + }); + }; + + /** + * Builds the passthrough `pt` object for PrimeStepper, injecting an inline + * style onto the step *number* span to colour it red (errors) or green (visited + * and valid). Targeting the number span — rather than the header `
  • ` — means + * PrimeReact's default `p-stepper-header` class and all its layout/separator + * CSS are never disturbed. + * Merges with any user-supplied `pt` prop. + */ + const stepperPt = useMemo(() => { + type StepContext = { context: { index: number } }; + type NumberPtFn = (opts: StepContext) => Record; + + const userPt = pt as Record | undefined; + const userStepperPanelPt = userPt?.stepperpanel as Record | undefined; + const userNumberPt = userStepperPanelPt?.number; + + return { + ...userPt, + stepperpanel: { + ...userStepperPanelPt, + number: (opts: StepContext) => { + const existing: Record = + typeof userNumberPt === 'function' + ? (userNumberPt as NumberPtFn)(opts) + : (userNumberPt as Record | undefined) ?? {}; + const idx = opts.context.index; + const hasError = stepFieldNames[idx]?.some(fieldName => !!(getFieldError?.(fieldName))) ?? false; + const isVisited = visitedSteps.has(idx); + + const bgColor = hasError + ? 'var(--red-500, #ef4444)' + : isVisited + ? 'var(--green-500, #22c55e)' + : null; + + if (!bgColor) return existing; + const existingStyle = existing.style as Record | undefined; + return { + ...existing, + style: { ...existingStyle, backgroundColor: bgColor, color: '#fff' } + }; + } + } + }; + }, [pt, stepFieldNames, getFieldError, visitedSteps]); + + const headerElement = ( +
    + {title} +
    + ); + + const footer = ( +
    + {!isFirstStep && ( +
    + ); + + return ( + handleClose(DialogResult.Cancelled)} + visible={visible} + style={{ width, ...style }} + resizable={resizable} + closable + > + + {processChildren(children)} + + + ); +}; + +const StepperCommandDialogComponent = ( + props: StepperCommandDialogProps +) => { + const { + title, + visible, + width, + style, + resizable, + isValid, + onClose, + onConfirm, + onCancel, + okLabel, + nextLabel, + previousLabel, + orientation, + headerPosition, + linear, + onChangeStep, + start, + end, + pt, + ptOptions, + unstyled, + children, + ...commandFormProps + } = props; + + return ( + {...commandFormProps}> + + title={title} + visible={visible} + width={width} + style={style} + resizable={resizable} + isValid={isValid} + onClose={onClose} + onConfirm={onConfirm} + onCancel={onCancel} + onBeforeExecute={commandFormProps.onBeforeExecute} + okLabel={okLabel} + nextLabel={nextLabel} + previousLabel={previousLabel} + orientation={orientation} + headerPosition={headerPosition} + linear={linear} + onChangeStep={onChangeStep} + start={start} + end={end} + pt={pt} + ptOptions={ptOptions} + unstyled={unstyled} + > + {children} + + + ); +}; + +export const StepperCommandDialog = StepperCommandDialogComponent; diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts b/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts new file mode 100644 index 0000000..ba3bba6 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_form_is_invalid.ts @@ -0,0 +1,74 @@ +// 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'; +import { StepperCommandDialog } from '../StepperCommandDialog'; +import { StepperPanel } from 'primereact/stepperpanel'; + +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), +})); + +vi.mock('primereact/stepperpanel', () => ({ + StepperPanel: (props: { header?: string; children?: React.ReactNode }) => + React.createElement('div', { 'data-header': props.header }, props.children), +})); + +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) => + React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, 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: true, + setCommandValues: () => {}, + setCommandResult: () => {}, + getFieldError: () => undefined, + }), + useCommandInstance: () => ({}), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), +})); + +class TestCommand { + name: string = ''; +} + +describe('when StepperCommandDialog has an external isValid=false gate on the last step', () => { + let html: string; + + beforeEach(() => { + const element = React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + isValid: false, + }, + React.createElement(StepperPanel, { header: 'Only Step' }, 'Content') + ); + html = renderToStaticMarkup(element); + }); + + it('should_not_show_submit_button_when_externally_invalid', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_not_executing.ts b/Source/CommandDialog/for_StepperCommandDialog/when_not_executing.ts new file mode 100644 index 0000000..09453e3 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_not_executing.ts @@ -0,0 +1,91 @@ +// 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'; +import { StepperCommandDialog } from '../StepperCommandDialog'; +import { StepperPanel } from 'primereact/stepperpanel'; + +vi.mock('primereact/dialog', () => ({ + Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children), +})); + +vi.mock('primereact/stepper', () => ({ + Stepper: (props: { children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'stepper' }, props.children), +})); + +vi.mock('primereact/stepperpanel', () => ({ + StepperPanel: (props: { header?: string; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children), +})); + +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; loading?: boolean; icon?: string }) => + React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, 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: true, + setCommandValues: () => {}, + setCommandResult: () => {}, + getFieldError: () => undefined, + }), + useCommandInstance: () => ({}), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), +})); + +class TestCommand { + name: string = ''; + description: string = ''; +} + +describe('when StepperCommandDialog is in its initial state', () => { + let html: string; + + beforeEach(() => { + const element = React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Stepper Dialog', + }, + React.createElement(StepperPanel, { header: 'Step 1' }, 'Step 1 content'), + React.createElement(StepperPanel, { header: 'Step 2' }, 'Step 2 content') + ); + html = renderToStaticMarkup(element); + }); + + it('should_not_have_buttons_disabled_due_to_busy', () => { + html.should.not.include('data-loading="true"'); + }); + + it('should_not_show_previous_button_on_first_step', () => { + html.should.not.include('>Previous<'); + }); + + it('should_show_next_button_on_first_step', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_submit_button_on_first_step', () => { + html.should.not.include('>Submit<'); + }); + + it('should_not_show_cancel_button', () => { + html.should.not.include('>Cancel<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_single_step.ts b/Source/CommandDialog/for_StepperCommandDialog/when_single_step.ts new file mode 100644 index 0000000..4e33e70 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_single_step.ts @@ -0,0 +1,86 @@ +// 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'; +import { StepperCommandDialog } from '../StepperCommandDialog'; +import { StepperPanel } from 'primereact/stepperpanel'; + +vi.mock('primereact/dialog', () => ({ + Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children), +})); + +vi.mock('primereact/stepper', () => ({ + Stepper: (props: { children?: React.ReactNode; activeStep?: number }) => + React.createElement('div', { 'data-testid': 'stepper', 'data-active-step': props.activeStep }, props.children), +})); + +vi.mock('primereact/stepperpanel', () => ({ + StepperPanel: (props: { header?: string; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'stepper-panel', 'data-header': props.header }, props.children), +})); + +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; loading?: boolean }) => + React.createElement('button', { disabled: props.disabled, 'data-loading': props.loading }, 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: true, + setCommandValues: () => {}, + setCommandResult: () => {}, + getFieldError: () => undefined, + }), + useCommandInstance: () => ({}), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), +})); + +class TestCommand { + name: string = ''; + description: string = ''; +} + +describe('when StepperCommandDialog has a single step', () => { + let html: string; + + beforeEach(() => { + const element = React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Single Step Dialog', + }, + React.createElement(StepperPanel, { header: 'Only Step' }, 'Content') + ); + html = renderToStaticMarkup(element); + }); + + it('should_not_show_previous_button', () => { + html.should.not.include('>Previous<'); + }); + + it('should_not_show_next_button', () => { + html.should.not.include('>Next<'); + }); + + it('should_show_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_cancel_button', () => { + html.should.not.include('>Cancel<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_step_has_field_errors.ts b/Source/CommandDialog/for_StepperCommandDialog/when_step_has_field_errors.ts new file mode 100644 index 0000000..1bf0e1a --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_step_has_field_errors.ts @@ -0,0 +1,124 @@ +// 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'; +import { StepperCommandDialog } from '../StepperCommandDialog'; +import { StepperPanel } from 'primereact/stepperpanel'; + +vi.mock('primereact/dialog', () => ({ + Dialog: (props: { footer?: React.ReactNode; children?: React.ReactNode }) => + React.createElement('div', { 'data-testid': 'dialog' }, props.footer, props.children), +})); + +// Simulate PrimeReact's Stepper: invoke pt.stepperpanel.number for each child and +// attach the resulting backgroundColor as data-number-bg so specs can assert on it. +vi.mock('primereact/stepper', () => ({ + 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' }, children); + }, +})); + +// Set displayName so the indicator code path in processChildren is triggered. +// Forward data-number-bg (injected by the Stepper mock above) so specs can assert on it. +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), +})); + +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, +})); + +// isValid: true — only getFieldError drives the per-step indicator. +vi.mock('@cratis/arc.react/commands', () => ({ + CommandForm: (props: { children?: React.ReactNode }) => + React.createElement('div', null, props.children), + useCommandFormContext: () => ({ + isValid: true, + setCommandValues: () => {}, + setCommandResult: () => {}, + getFieldError: (fieldName: string) => + fieldName === 'name' ? 'Name is required' : undefined, + }), + useCommandInstance: () => ({}), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), +})); + +// A minimal CommandFormField stand-in with the correct displayName so that +// extractFieldNamesFromNode can identify it and extract the property name. +const FakeNameField = (props: { value?: (c: TestCommand) => unknown }) => { + void props; + return React.createElement('div', null); +}; +FakeNameField.displayName = 'CommandFormField'; + +class TestCommand { + name: string = ''; + description: string = ''; +} + +describe('when a step contains a field with a validation error', () => { + let html: string; + + beforeEach(() => { + const element = React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + React.createElement( + StepperPanel, + { header: 'Step 1' }, + React.createElement(FakeNameField, { value: (c: TestCommand) => c.name }) + ), + React.createElement(StepperPanel, { header: 'Step 2' }, 'No errors here') + ); + html = renderToStaticMarkup(element); + }); + + it('should_mark_the_invalid_step_with_error_class', () => { + // Step 1 has a field error — its number circle should have the red error background + const step1Match = html.match(/data-header="Step 1"[^>]*data-number-bg="([^"]*)"/); + const step1Bg = step1Match?.[1] ?? ''; + step1Bg.should.include('red'); + }); + + it('should_not_mark_the_valid_step_with_error_class', () => { + // Step 2 has no field errors — its number circle should not have the red error background + const step2Match = html.match(/data-header="Step 2"[^>]*data-number-bg="([^"]*)"/); + const step2Bg = step2Match?.[1] ?? ''; + step2Bg.should.not.include('red'); + }); +}); diff --git a/Source/CommandDialog/index.ts b/Source/CommandDialog/index.ts index 63b3591..e2ba798 100644 --- a/Source/CommandDialog/index.ts +++ b/Source/CommandDialog/index.ts @@ -2,3 +2,4 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. export * from './CommandDialog'; +export * from './StepperCommandDialog';