diff --git a/Documentation/DataPage/index.md b/Documentation/DataPage/index.md index 44f48bd..d913ae2 100644 --- a/Documentation/DataPage/index.md +++ b/Documentation/DataPage/index.md @@ -110,13 +110,48 @@ The component automatically detects the query type and renders the appropriate d ## Layout -DataPage uses Allotment for resizable split panels when a DetailsComponent is provided. The layout consists of: +DataPage uses Allotment for the resizable split when a `detailsComponent` is provided. The layout consists of: 1. Page header with title 2. Menu bar with actions 3. Data table 4. Optional details panel (when item is selected) +Allotment positions its panes from a stylesheet rather than from inline styles, so the split view only works once that stylesheet is on the page. DataPage imports it itself, the same way every other stylesheet in this package travels with the component that needs it — there is nothing for you to import, and nothing to configure. When no `detailsComponent` is supplied there is nothing to split, so no split view is mounted at all. + +Inside the page, the menu bar and the data table share one vertical column. The menu bar keeps the height it needs; the table region takes everything that is left and scrolls its rows internally. Given an ancestor with a real height — the condition described next — the paginator therefore sits at the bottom of the page rather than below its edge, however many rows the query returns, and whether or not the page is split. + +### DataPage needs an ancestor with a height + +That division only works if there is a height to divide. Every element from the page root down is sized as a percentage of its parent, so **some ancestor of `DataPage` has to have a definite height** — a viewport unit, a pixel height, a grid row, or a flex child that is allowed to shrink. Give it one and the paginator stays on screen no matter how many rows the query returns. + +```tsx +// ✅ the layout gives the page a height to divide +
+ + + + + +
+``` + +```tsx +// ❌ nothing above resolves to a height, so the table grows to its content and +// the paginator ends up past the bottom of the page +
+ + + + + +
+``` + +A flex or grid child counts as bounded only when it is allowed to shrink — `min-height: 0` on the item, or `overflow: hidden` on the container. Without that, the item's automatic minimum keeps it at content height, which is the same as having no bound at all. + +When no ancestor supplies a height, DataPage falls back to a small fixed height so the page stays usable instead of collapsing to nothing. Treat that fallback as a symptom, not a solution — fix the ancestor. + ## Integration DataPage integrates with: diff --git a/Source/CommandDialog/CommandStepper.stories.tsx b/Source/CommandDialog/CommandStepper.stories.tsx index f42729b..a126a18 100644 --- a/Source/CommandDialog/CommandStepper.stories.tsx +++ b/Source/CommandDialog/CommandStepper.stories.tsx @@ -297,3 +297,68 @@ export const WithValidationIndicators: Story = { ); }, }; + +/** + * A step rendered as `{condition && }` disappears entirely when the condition + * is false. Toggle the optional step off and the wizard must behave as a genuine two-step + * wizard: Submit shows on "Details" instead of a Next button that leads nowhere. + */ +export const ConditionalSteps: Story = { + render: () => { + const [includeBudgetStep, setIncludeBudgetStep] = useState(false); + const [result, setResult] = useState(''); + + return ( +
+ +

+ The Budget step is currently {includeBudgetStep ? 'shown' : 'hidden'}, so the + wizard has {includeBudgetStep ? 'three' : 'two'} steps. +

+ + + command={CreateProjectCommand} + autoServerValidate={false} + validateOn="change" + onSuccess={async () => setResult('Command submitted successfully')} + > + + + 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} + /> + + {includeBudgetStep && ( + + + value={c => c.budget} + title="Budget" + placeholder="Enter budget (must be > 0)" + /> + + )} + + + {result && ( +
+ {result} +
+ )} +
+ ); + }, +}; diff --git a/Source/CommandDialog/CommandStepper.tsx b/Source/CommandDialog/CommandStepper.tsx index 92d19b0..e081872 100644 --- a/Source/CommandDialog/CommandStepper.tsx +++ b/Source/CommandDialog/CommandStepper.tsx @@ -13,6 +13,7 @@ import { type CommandFormProps } from '@cratis/arc.react/commands'; import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute'; +import { getStepPanels } from './stepChildren'; import './CommandStepper.css'; /** @@ -154,17 +155,26 @@ export const CommandStepperContent = ({ ptOptions, unstyled, }: CommandStepperContentProps) => { - const stepCount = React.Children.count(children); - const isLastStep = activeStep >= stepCount - 1; - const isFirstStep = activeStep <= 0; + // The steps that actually render. Conditional steps (`{condition && }`) + // leave falsy children behind, so the count, the per-step validation state and what the + // Stepper renders are all derived from this one list — they cannot drift apart. + const steps = useMemo(() => getStepPanels(children), [children]); + const stepCount = steps.length; + + // A conditional step can vanish after the user has advanced past it, which leaves the + // incoming index pointing at a step that is no longer rendered. Clamp it into the set that + // is, so the panel shown, the validation state read and the buttons offered all belong to a + // step that exists. + const currentStep = Math.min(Math.max(activeStep, 0), Math.max(stepCount - 1, 0)); + const isLastStep = currentStep >= stepCount - 1; + const isFirstStep = currentStep <= 0; const stepFieldNames = useMemo( - () => React.Children.toArray(children).map((step) => { - if (!React.isValidElement(step)) return [] as string[]; + () => steps.map((step) => { const stepProps = step.props as Record; return extractFieldNamesFromNode(stepProps.children as React.ReactNode); }), - [children] + [steps] ); const stepErrors = useMemo( @@ -176,7 +186,7 @@ export const CommandStepperContent = ({ onStepErrorsChange?.(stepErrors); }, [onStepErrorsChange, stepErrors]); - const isCurrentStepInvalid = stepErrors[activeStep] ?? false; + const isCurrentStepInvalid = stepErrors[currentStep] ?? false; const hasAnyStepErrors = stepErrors.some(hasError => hasError); const stepperPt = useMemo(() => { @@ -223,19 +233,19 @@ export const CommandStepperContent = ({ onChangeStep?.(event); const index = (event as { index?: number }).index; if (typeof index === 'number') { - if (index > activeStep && isCurrentStepInvalid) { + if (index > currentStep && isCurrentStepInvalid) { return; } - if (index > activeStep) { - onVisitedStepsChange?.(new Set(visitedSteps).add(activeStep)); + if (index > currentStep) { + onVisitedStepsChange?.(new Set(visitedSteps).add(currentStep)); } onActiveStepChange?.(index); } }; const handlePrevious = () => { - onActiveStepChange?.(Math.max(0, activeStep - 1)); + onActiveStepChange?.(Math.max(0, currentStep - 1)); }; const handleNext = () => { @@ -243,14 +253,14 @@ export const CommandStepperContent = ({ return; } - onVisitedStepsChange?.(new Set(visitedSteps).add(activeStep)); - onActiveStepChange?.(Math.min(stepCount - 1, activeStep + 1)); + onVisitedStepsChange?.(new Set(visitedSteps).add(currentStep)); + onActiveStepChange?.(Math.min(stepCount - 1, currentStep + 1)); }; return (
- {processChildren(children)} + {processChildren(steps)} {showNavigation && ( diff --git a/Source/CommandDialog/StepperCommandDialog.stories.tsx b/Source/CommandDialog/StepperCommandDialog.stories.tsx index 84b28d9..1ede92e 100644 --- a/Source/CommandDialog/StepperCommandDialog.stories.tsx +++ b/Source/CommandDialog/StepperCommandDialog.stories.tsx @@ -466,3 +466,84 @@ export const WithResponseTypeAndCallbacks: Story = { ); }, }; + +/** + * A step rendered as `{condition && }` disappears entirely when the condition + * is false. Toggle the optional step off and the dialog must behave as a genuine two-step + * wizard: Submit shows on "Details" instead of a Next button that leads to an empty step. + */ +export const ConditionalSteps: Story = { + render: () => { + const [visible, setVisible] = useState(true); + const [includeBudgetStep, setIncludeBudgetStep] = useState(false); + const [result, setResult] = useState(''); + + return ( +
+ + +

+ The Budget step is currently {includeBudgetStep ? 'shown' : 'hidden'}, so the + wizard has {includeBudgetStep ? 'three' : 'two'} steps. +

+ + {result && ( +
+ Submitted: {result} +
+ )} + + + command={CreateProjectCommand} + visible={visible} + title="Create New Project" + okLabel="Create" + autoServerValidate={false} + onConfirm={async () => { + setResult('Project created successfully'); + setVisible(false); + }} + onCancel={() => setVisible(false)} + > + + + 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} + /> + + {includeBudgetStep && ( + + + value={c => c.budget} + title="Budget" + placeholder="Enter budget (must be > 0)" + /> + + )} + +
+ ); + }, +}; diff --git a/Source/CommandDialog/StepperCommandDialog.tsx b/Source/CommandDialog/StepperCommandDialog.tsx index 3ac532e..c739bee 100644 --- a/Source/CommandDialog/StepperCommandDialog.tsx +++ b/Source/CommandDialog/StepperCommandDialog.tsx @@ -15,6 +15,7 @@ import { import type { CloseDialog, ConfirmCallback, CancelCallback } from '../Dialogs/Dialog'; import { CommandStepperContent, type StepperCustomizationProps } from './CommandStepper'; import { applyBeforeExecute, type BeforeExecuteCallback } from './applyBeforeExecute'; +import { getStepPanels } from './stepChildren'; /** * Props for {@link StepperCommandDialog}. Combines the command-form props, @@ -159,11 +160,19 @@ const StepperCommandDialogWrapper = }`) can disappear after the user has already advanced past + // it — a late-resolving query or a `currentValues` overlay flipping the condition is enough. + // The step the wizard is on is therefore clamped into the set that still renders, and the + // last/first tests are inequalities, so an index left stranded above the end still resolves + // to the last surviving step instead of a step that is neither last nor navigable. Same + // shape as CommandStepperContent, which this dialog's body is. + const stepCount = getStepPanels(children).length; + const currentStep = Math.min(Math.max(activeStep, 0), Math.max(stepCount - 1, 0)); + const isLastStep = currentStep >= stepCount - 1; + const isFirstStep = currentStep <= 0; const isDialogValid = isValid !== false && isCommandFormValid; - const isCurrentStepInvalid = stepErrors[activeStep] ?? false; + const isCurrentStepInvalid = stepErrors[currentStep] ?? false; const handleClose = async (result: DialogResult) => { let shouldCloseThroughContext = true; @@ -233,7 +242,7 @@ const StepperCommandDialogWrapper = setActiveStep(s => s - 1)} + onClick={() => setActiveStep(Math.max(0, currentStep - 1))} disabled={isBusy} outlined /> @@ -245,8 +254,8 @@ const StepperCommandDialogWrapper = { - setVisitedSteps(prev => new Set(prev).add(activeStep)); - setActiveStep(s => s + 1); + setVisitedSteps(prev => new Set(prev).add(currentStep)); + setActiveStep(Math.min(stepCount - 1, currentStep + 1)); }} disabled={isBusy || isCurrentStepInvalid} /> @@ -281,7 +290,7 @@ const StepperCommandDialogWrapper = ({ buttonClicks: new Map void>() })); + +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), +})); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderStepper = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...children +)); + +/** + * Drives CommandStepperContent as a controlled wizard: render, click Next, render again, + * until Next is gone. The step it comes to rest on is the furthest the user can navigate. + */ +const walkForward = (children: React.ReactNode) => { + let activeStep = 0; + let visitedSteps = new Set([0]); + let html = ''; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (steps: Set) => { visitedSteps = steps; }, + children + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + + return { activeStep, html }; +}; + +const whitespaceBetweenSteps = ' '; + +describe('when a bare string follows the only step and the stepper is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderStepper(panel('Step 1'), whitespaceBetweenSteps); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when a bare string sits between two steps and the user walks forward', () => { + let result: { activeStep: number; html: string }; + + beforeEach(() => { + result = walkForward([panel('Step 1'), whitespaceBetweenSteps, panel('Step 2')]); + }); + + it('should_stop_on_the_last_rendered_step', () => { + result.activeStep.should.equal(1); + }); + + it('should_show_the_submit_button_there', () => { + result.html.should.include('>Submit<'); + }); + + it('should_not_offer_another_next_step', () => { + result.html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_in_the_middle.ts b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_in_the_middle.ts new file mode 100644 index 0000000..9ea5124 --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_in_the_middle.ts @@ -0,0 +1,153 @@ +// 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 { CommandStepper, CommandStepperContent } from '../../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +const { buttonClicks } = vi.hoisted(() => ({ buttonClicks: new Map void>() })); + +// Stands in for PrimeReact's Stepper: it invokes pt.stepperpanel.number for each panel it +// renders — by the index it renders it at — and surfaces the resulting background color. +vi.mock('primereact/stepper', () => ({ + Stepper: (props: { children?: React.ReactNode; pt?: Record }) => { + type StepContext = { context: { index: number } }; + type NumberPtFn = (opts: StepContext) => { style?: { backgroundColor?: string } }; + const stepperPanelPt = (props.pt as Record | undefined)?.stepperpanel as Record | undefined; + const numberPt = stepperPanelPt?.number as NumberPtFn | undefined; + const children = React.Children.map(props.children, (child, index) => { + if (!React.isValidElement(child)) return child; + const result = typeof numberPt === 'function' ? numberPt({ context: { index } }) : {}; + return React.cloneElement(child as React.ReactElement>, { + 'data-number-bg': result?.style?.backgroundColor ?? '' + }); + }); + return React.createElement('div', { 'data-testid': 'stepper' }, 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 }; +}); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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), +})); + +class TestCommand { + name: string = ''; + description: string = ''; +} + +// A stand-in for a CommandForm field — the displayName is what the field-name extraction keys on. +const NameField = (props: { value?: (command: TestCommand) => unknown }) => { + void props; + return React.createElement('div', null); +}; +NameField.displayName = 'CommandFormField'; + +const numberBackgroundOf = (html: string, header: string) => + html.match(new RegExp(`data-header="${header}"[^>]*data-number-bg="([^"]*)"`))?.[1] ?? ''; + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +const steps = () => [ + React.createElement(StepperPanel, { header: 'Contact', key: 'contact' }, 'No fields here'), + showOptionalStep && React.createElement(StepperPanel, { header: 'Optional', key: 'optional' }, 'Hidden'), + React.createElement( + StepperPanel, + { header: 'Details', key: 'details' }, + React.createElement(NameField, { value: (command: TestCommand) => command.name }) + ) +]; + +describe('when the hidden step sits between two rendered steps', () => { + let html: string; + + beforeEach(() => { + html = renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...steps() + )); + }); + + it('should_render_only_the_two_surviving_steps', () => { + html.split('data-testid="stepper-panel"').length.should.equal(3); + }); + + it('should_not_mark_the_step_without_field_errors', () => { + numberBackgroundOf(html, 'Contact').should.not.include('red'); + }); + + it('should_mark_the_step_whose_own_field_has_an_error', () => { + numberBackgroundOf(html, 'Details').should.include('red'); + }); + + it('should_show_the_next_button_on_the_first_of_two_steps', () => { + html.should.include('>Next<'); + }); +}); + +describe('when the hidden step sits in the middle and the user walks forward', () => { + let activeStep: number; + let html: string; + + beforeEach(() => { + let visitedSteps = new Set([0]); + activeStep = 0; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (updated: Set) => { visitedSteps = updated; }, + children: steps() + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + }); + + it('should_stop_on_the_last_rendered_step', () => { + activeStep.should.equal(1); + }); + + it('should_not_offer_another_next_step', () => { + html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_null.ts b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_null.ts new file mode 100644 index 0000000..fede2ce --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_null.ts @@ -0,0 +1,127 @@ +// 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 { CommandStepper, CommandStepperContent } from '../../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +const { buttonClicks } = vi.hoisted(() => ({ buttonClicks: new Map void>() })); + +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), +})); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderStepper = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...children +)); + +/** + * Drives CommandStepperContent as a controlled wizard: render, click Next, render again, + * until Next is gone. The step it comes to rest on is the furthest the user can navigate. + */ +const walkForward = (children: React.ReactNode) => { + let activeStep = 0; + let visitedSteps = new Set([0]); + let html = ''; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (steps: Set) => { visitedSteps = steps; }, + children + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + + return { activeStep, html }; +}; + +const hiddenStep = null; + +describe('when the last of two steps is null and the stepper is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderStepper(panel('Step 1'), hiddenStep); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when the last of three steps is null and the user walks forward', () => { + let result: { activeStep: number; html: string }; + + beforeEach(() => { + result = walkForward([panel('Step 1'), panel('Step 2'), hiddenStep]); + }); + + it('should_stop_on_the_last_rendered_step', () => { + result.activeStep.should.equal(1); + }); + + it('should_show_the_submit_button_there', () => { + result.html.should.include('>Submit<'); + }); + + it('should_not_offer_another_next_step', () => { + result.html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_undefined.ts b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_undefined.ts new file mode 100644 index 0000000..4838352 --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_it_is_undefined.ts @@ -0,0 +1,127 @@ +// 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 { CommandStepper, CommandStepperContent } from '../../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +const { buttonClicks } = vi.hoisted(() => ({ buttonClicks: new Map void>() })); + +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), +})); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderStepper = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...children +)); + +/** + * Drives CommandStepperContent as a controlled wizard: render, click Next, render again, + * until Next is gone. The step it comes to rest on is the furthest the user can navigate. + */ +const walkForward = (children: React.ReactNode) => { + let activeStep = 0; + let visitedSteps = new Set([0]); + let html = ''; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (steps: Set) => { visitedSteps = steps; }, + children + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + + return { activeStep, html }; +}; + +const hiddenStep = undefined; + +describe('when the last of two steps is undefined and the stepper is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderStepper(panel('Step 1'), hiddenStep); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when the last of three steps is undefined and the user walks forward', () => { + let result: { activeStep: number; html: string }; + + beforeEach(() => { + result = walkForward([panel('Step 1'), panel('Step 2'), hiddenStep]); + }); + + it('should_stop_on_the_last_rendered_step', () => { + result.activeStep.should.equal(1); + }); + + it('should_show_the_submit_button_there', () => { + result.html.should.include('>Submit<'); + }); + + it('should_not_offer_another_next_step', () => { + result.html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_first_step_is_hidden.ts b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_first_step_is_hidden.ts new file mode 100644 index 0000000..808c88d --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_first_step_is_hidden.ts @@ -0,0 +1,128 @@ +// 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 { CommandStepper, CommandStepperContent } from '../../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +const { buttonClicks } = vi.hoisted(() => ({ buttonClicks: new Map void>() })); + +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), +})); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderStepper = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...children +)); + +/** + * Drives CommandStepperContent as a controlled wizard: render, click Next, render again, + * until Next is gone. The step it comes to rest on is the furthest the user can navigate. + */ +const walkForward = (children: React.ReactNode) => { + let activeStep = 0; + let visitedSteps = new Set([0]); + let html = ''; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (steps: Set) => { visitedSteps = steps; }, + children + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + + return { activeStep, html }; +}; + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +describe('when the first of two steps is hidden and the stepper is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderStepper(showOptionalStep && panel('Step 1'), panel('Step 2')); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when the first of three steps is hidden and the user walks forward', () => { + let result: { activeStep: number; html: string }; + + beforeEach(() => { + result = walkForward([showOptionalStep && panel('Step 1'), panel('Step 2'), panel('Step 3')]); + }); + + it('should_stop_on_the_last_rendered_step', () => { + result.activeStep.should.equal(1); + }); + + it('should_show_the_submit_button_there', () => { + result.html.should.include('>Submit<'); + }); + + it('should_not_offer_another_next_step', () => { + result.html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_last_step_is_false.ts b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_last_step_is_false.ts new file mode 100644 index 0000000..71bf6a6 --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_last_step_is_false.ts @@ -0,0 +1,128 @@ +// 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 { CommandStepper, CommandStepperContent } from '../../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +const { buttonClicks } = vi.hoisted(() => ({ buttonClicks: new Map void>() })); + +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), +})); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderStepper = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...children +)); + +/** + * Drives CommandStepperContent as a controlled wizard: render, click Next, render again, + * until Next is gone. The step it comes to rest on is the furthest the user can navigate. + */ +const walkForward = (children: React.ReactNode) => { + let activeStep = 0; + let visitedSteps = new Set([0]); + let html = ''; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (steps: Set) => { visitedSteps = steps; }, + children + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + + return { activeStep, html }; +}; + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +describe('when the only hidden step is the last of two and the stepper is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderStepper(panel('Step 1'), showOptionalStep && panel('Step 2')); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when the hidden step is the last of three and the user walks forward', () => { + let result: { activeStep: number; html: string }; + + beforeEach(() => { + result = walkForward([panel('Step 1'), panel('Step 2'), showOptionalStep && panel('Step 3')]); + }); + + it('should_stop_on_the_last_rendered_step', () => { + result.activeStep.should.equal(1); + }); + + it('should_show_the_submit_button_there', () => { + result.html.should.include('>Submit<'); + }); + + it('should_not_offer_another_next_step', () => { + result.html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_steps_come_from_a_map.ts b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_steps_come_from_a_map.ts new file mode 100644 index 0000000..d740702 --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_the_steps_come_from_a_map.ts @@ -0,0 +1,128 @@ +// 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 { CommandStepper, CommandStepperContent } from '../../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +const { buttonClicks } = vi.hoisted(() => ({ buttonClicks: new Map void>() })); + +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), +})); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderStepper = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...children +)); + +/** + * Drives CommandStepperContent as a controlled wizard: render, click Next, render again, + * until Next is gone. The step it comes to rest on is the furthest the user can navigate. + */ +const walkForward = (children: React.ReactNode) => { + let activeStep = 0; + let visitedSteps = new Set([0]); + let html = ''; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (steps: Set) => { visitedSteps = steps; }, + children + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + + return { activeStep, html }; +}; + +const headersOf = (count: number) => Array.from({ length: count }, (_, index) => `Step ${index + 1}`); + +// Nothing is hidden here — this pins the behavior a naive "subtract the falsy children" fix would break. +describe('when a map produces a single step and the stepper is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderStepper(headersOf(1).map(panel)); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when a map produces three steps and the user walks forward', () => { + let result: { activeStep: number; html: string }; + + beforeEach(() => { + result = walkForward(headersOf(3).map(panel)); + }); + + it('should_stop_on_the_last_rendered_step', () => { + result.activeStep.should.equal(2); + }); + + it('should_show_the_submit_button_there', () => { + result.html.should.include('>Submit<'); + }); + + it('should_not_offer_another_next_step', () => { + result.html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_two_are_hidden.ts b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_two_are_hidden.ts new file mode 100644 index 0000000..06d9f31 --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_a_step_is_conditionally_hidden/and_two_are_hidden.ts @@ -0,0 +1,129 @@ +// 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 { CommandStepper, CommandStepperContent } from '../../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +const { buttonClicks } = vi.hoisted(() => ({ buttonClicks: new Map void>() })); + +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), +})); + +// Only an enabled button is clickable, so only an enabled button is recorded. +vi.mock('primereact/button', () => ({ + Button: (props: { label?: string; disabled?: boolean; onClick?: () => void }) => { + if (props.label && props.disabled !== true) buttonClicks.set(props.label, () => props.onClick?.()); + return React.createElement('button', { disabled: props.disabled }, props.label); + }, +})); + +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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderStepper = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + CommandStepper, + { command: TestCommand as unknown as new () => object }, + ...children +)); + +/** + * Drives CommandStepperContent as a controlled wizard: render, click Next, render again, + * until Next is gone. The step it comes to rest on is the furthest the user can navigate. + */ +const walkForward = (children: React.ReactNode) => { + let activeStep = 0; + let visitedSteps = new Set([0]); + let html = ''; + + const render = () => { + buttonClicks.clear(); + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep, + visitedSteps, + onActiveStepChange: (stepIndex: number) => { activeStep = stepIndex; }, + onVisitedStepsChange: (steps: Set) => { visitedSteps = steps; }, + children + })); + }; + + render(); + for (let guard = 0; guard < 10 && buttonClicks.has('Next'); guard++) { + buttonClicks.get('Next')!(); + render(); + } + + return { activeStep, html }; +}; + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +// Two hidden steps: a fix that merely decremented the count by one would still be wrong here. +describe('when two of three steps are hidden and the stepper is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderStepper(panel('Step 1'), showOptionalStep && panel('Step 2'), showOptionalStep && panel('Step 3')); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when two of four steps are hidden and the user walks forward', () => { + let result: { activeStep: number; html: string }; + + beforeEach(() => { + result = walkForward([panel('Step 1'), showOptionalStep && panel('Step 2'), panel('Step 3'), showOptionalStep && panel('Step 4')]); + }); + + it('should_stop_on_the_last_rendered_step', () => { + result.activeStep.should.equal(1); + }); + + it('should_show_the_submit_button_there', () => { + result.html.should.include('>Submit<'); + }); + + it('should_not_offer_another_next_step', () => { + result.html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_every_step_is_hidden.ts b/Source/CommandDialog/for_CommandStepper/when_every_step_is_hidden.ts new file mode 100644 index 0000000..6d98505 --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_every_step_is_hidden.ts @@ -0,0 +1,62 @@ +// 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 { CommandStepperContent } from '../CommandStepper'; + +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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +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), +})); + +// The body is a published component in its own right, and standalone it renders the footer the +// dialog suppresses - `showNavigation` defaults to true there. So a consumer whose every step is +// `{condition && }` with every condition false has no steps at all, and the footer +// is the only thing the wizard offers: Next would lead nowhere, and without Submit the wizard +// cannot be finished. The last-step test has to hold for zero steps, which is the one count where +// an equality against `stepCount - 1` stops matching. +describe('when every step is hidden', () => { + let html: string; + + beforeEach(() => { + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep: 0, + visitedSteps: new Set([0]), + children: [false, false] + })); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_CommandStepper/when_the_active_step_is_past_the_last_rendered_step.ts b/Source/CommandDialog/for_CommandStepper/when_the_active_step_is_past_the_last_rendered_step.ts new file mode 100644 index 0000000..a0aeccf --- /dev/null +++ b/Source/CommandDialog/for_CommandStepper/when_the_active_step_is_past_the_last_rendered_step.ts @@ -0,0 +1,73 @@ +// 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 { CommandStepperContent } from '../CommandStepper'; +import { StepperPanel } from 'primereact/stepperpanel'; + +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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +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), +})); + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const activeStepOf = (html: string) => html.match(/data-active-step="(\d+)"/)?.[1] ?? 'none'; + +// The active step is a prop here, and the owner of that state - CommandStepper's own wrapper, or +// StepperCommandDialog - keeps it across a re-render. So a step vanishing after the user advanced +// past it hands this component an index no rendered panel answers to, which is exactly the shape +// reproduced below: three steps were walked, the middle one then went away. +describe('when the active step is past the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderToStaticMarkup(React.createElement(CommandStepperContent, { + activeStep: 2, + visitedSteps: new Set([0, 1, 2]), + children: [panel('Step 1'), false, panel('Step 3')] + })); + }); + + it('should_render_only_the_surviving_steps', () => { + (html.split('data-testid="stepper-panel"').length - 1).should.equal(2); + }); + + it('should_hand_the_stepper_the_last_step_that_still_exists', () => { + activeStepOf(html).should.equal('1'); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/given/a_stepper_dialog_in_the_dom.ts b/Source/CommandDialog/for_StepperCommandDialog/given/a_stepper_dialog_in_the_dom.ts new file mode 100644 index 0000000..a923f70 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/given/a_stepper_dialog_in_the_dom.ts @@ -0,0 +1,104 @@ +// 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 { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +/** + * A `StepperCommandDialog` mounted into a real document, together with what is + * needed to take it down again. + * + * Which step the wizard sits on is state the dialog owns, so a step vanishing + * *after* the user has advanced past it can only be reached by driving the real + * component: click through to the step, then render it again with the step gone. + * Static markup always starts on step 0 and can never get there. + */ +export interface StepperDialogInTheDom { + container: HTMLDivElement; + root: Root; +} + +/** + * Renders an element into a real document and lets React settle. + * @param element - The element to render. + * @returns The mounted dialog, to be passed to {@link unmount}. + */ +export const render = async (element: React.ReactElement): Promise => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(element); + }); + + return { container, root }; +}; + +/** + * Renders a new element into the same root, which is what a parent re-rendering + * with a changed condition does — the dialog keeps the step state it had. + * @param dialog - The mounted dialog. + * @param element - The element to render in its place. + */ +export const rerender = async (dialog: StepperDialogInTheDom, element: React.ReactElement) => { + await act(async () => { + dialog.root.render(element); + }); +}; + +/** + * Unmounts a dialog rendered with {@link render} and removes its container. + * @param dialog - The mounted dialog. + */ +export const unmount = async (dialog: StepperDialogInTheDom) => { + await act(async () => { + dialog.root.unmount(); + }); + dialog.container.remove(); +}; + +/** + * Clicks a footer button by its visible label. + * @param dialog - The mounted dialog. + * @param label - The label of the button to click. + */ +export const click = async (dialog: StepperDialogInTheDom, label: string) => { + const button = Array.from(dialog.container.querySelectorAll('button')).find(candidate => candidate.textContent === label); + + await act(async () => { + button?.click(); + }); +}; + +/** + * The labels of every button the dialog currently offers. Rendered elements come + * from the jsdom realm and carry no `should`, so the footer is described as plain + * strings — and a failure then says which buttons were offered instead of only + * that a lookup came back empty. + * @param dialog - The mounted dialog. + * @returns The button labels, in document order. + */ +export const buttonLabels = (dialog: StepperDialogInTheDom): string[] => + Array.from(dialog.container.querySelectorAll('button')).map(button => button.textContent ?? ''); + +/** + * The headers of the step panels the wizard actually rendered, in render order. + * @param dialog - The mounted dialog. + * @returns The step headers. + */ +export const renderedSteps = (dialog: StepperDialogInTheDom): string[] => + Array.from(dialog.container.querySelectorAll('[data-testid="stepper-panel"]')) + .map(panel => panel.getAttribute('data-header') ?? ''); + +/** + * The step index the wizard handed the Stepper, or `'none'` when no stepper was + * rendered at all. + * @param dialog - The mounted dialog. + * @returns The active step index as a string. + */ +export const activeStep = (dialog: StepperDialogInTheDom): string => + dialog.container.querySelector('[data-testid="stepper"]')?.getAttribute('data-active-step') ?? 'none'; diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_disappears_after_it_was_passed.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_disappears_after_it_was_passed.ts new file mode 100644 index 0000000..3d22c90 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_disappears_after_it_was_passed.ts @@ -0,0 +1,127 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import React from 'react'; +import { vi } from 'vitest'; +import { StepperPanel } from 'primereact/stepperpanel'; +import { StepperCommandDialog } from '../StepperCommandDialog'; +import { + activeStep, + buttonLabels, + click, + render, + rerender, + renderedSteps, + unmount, + type StepperDialogInTheDom +} from './given/a_stepper_dialog_in_the_dom'; + +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; onClick?: () => void }) => + React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 }, + useDialogContext: () => undefined, +})); + +// The context object is created once rather than per call: this spec renders for real, so an +// identity that changed on every render would re-run the stepper's error effect forever. +vi.mock('@cratis/arc.react/commands', () => { + const commandFormContext = { + isValid: true, + setCommandValues: () => { }, + setCommandResult: () => { }, + getFieldError: () => undefined, + }; + + return { + CommandForm: (props: { children?: React.ReactNode }) => + React.createElement('div', null, props.children), + useCommandFormContext: () => commandFormContext, + useCommandInstance: () => ({}), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), + }; +}); + +class TestCommand { + name: string = ''; +} + +const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`); + +const aWizard = (...steps: (React.ReactElement | false)[]) => React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...steps); + +// The step set is not fixed once the wizard has opened: `{condition && }` re-evaluates +// on every parent render, so a late-resolving query or a `currentValues` overlay can take a step away +// while the user is standing beyond it. The wizard then holds an index no step answers to. +describe('when a step disappears after it was passed', () => { + let dialog: StepperDialogInTheDom; + let stepBeforeItVanished: string; + + beforeEach(async () => { + dialog = await render(aWizard(step('Step 1'), step('Step 2'), step('Step 3'))); + await click(dialog, 'Next'); + await click(dialog, 'Next'); + stepBeforeItVanished = activeStep(dialog); + + await rerender(dialog, aWizard(step('Step 1'), false, step('Step 3'))); + }); + + afterEach(async () => await unmount(dialog)); + + it('should_have_walked_the_user_onto_the_third_step_first', () => { + stepBeforeItVanished.should.equal('2'); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedSteps(dialog).should.deep.equal(['Step 1', 'Step 3']); + }); + + it('should_put_the_wizard_on_the_last_step_that_still_exists', () => { + activeStep(dialog).should.equal('1'); + }); + + it('should_offer_submit', () => { + buttonLabels(dialog).should.contain('Submit'); + }); + + it('should_not_offer_next', () => { + buttonLabels(dialog).should.not.contain('Next'); + }); + + // The index the dialog holds is one past the end, and Previous steps back from whatever it + // holds. Stepping back from the stale index lands on the step already on screen, so the click + // does nothing visible - once per step that vanished, before the wizard finally moves. + it('should_move_the_wizard_when_previous_is_clicked', async () => { + await click(dialog, 'Previous'); + + activeStep(dialog).should.equal('0'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_a_bare_string_sits_between_steps.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_a_bare_string_sits_between_steps.ts new file mode 100644 index 0000000..ec090cc --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_a_bare_string_sits_between_steps.ts @@ -0,0 +1,107 @@ +// 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderDialog = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...children +)); + +const whitespaceBetweenSteps = ' '; + +describe('when a bare string follows the only step and the dialog is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), whitespaceBetweenSteps); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when a bare string sits between two steps and the dialog is on the first rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), whitespaceBetweenSteps, panel('Step 2')); + }); + + it('should_render_every_surviving_step', () => { + renderedPanels(html).should.equal(2); + }); + + it('should_show_the_next_button', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_in_the_middle.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_in_the_middle.ts new file mode 100644 index 0000000..0cdf321 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_in_the_middle.ts @@ -0,0 +1,127 @@ +// 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), +})); + +// Stands in for PrimeReact's Stepper: it invokes pt.stepperpanel.number for each panel it +// renders — by the index it renders it at — and surfaces the resulting background color. +vi.mock('primereact/stepper', () => ({ + Stepper: (props: { children?: React.ReactNode; pt?: Record }) => { + type StepContext = { context: { index: number } }; + type NumberPtFn = (opts: StepContext) => { style?: { backgroundColor?: string } }; + const stepperPanelPt = (props.pt as Record | undefined)?.stepperpanel as Record | undefined; + const numberPt = stepperPanelPt?.number as NumberPtFn | undefined; + const children = React.Children.map(props.children, (child, index) => { + if (!React.isValidElement(child)) return child; + const result = typeof numberPt === 'function' ? numberPt({ context: { index } }) : {}; + return React.cloneElement(child as React.ReactElement>, { + 'data-number-bg': result?.style?.backgroundColor ?? '' + }); + }); + return React.createElement('div', { 'data-testid': 'stepper' }, 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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: (fieldName: string) => fieldName === 'name' ? 'Name is required' : undefined, + }), + useCommandInstance: () => ({}), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), +})); + +class TestCommand { + name: string = ''; + description: string = ''; +} + +// A stand-in for a CommandForm field — the displayName is what the field-name extraction keys on. +const NameField = (props: { value?: (command: TestCommand) => unknown }) => { + void props; + return React.createElement('div', null); +}; +NameField.displayName = 'CommandFormField'; + +const numberBackgroundOf = (html: string, header: string) => + html.match(new RegExp(`data-header="${header}"[^>]*data-number-bg="([^"]*)"`))?.[1] ?? ''; + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +describe('when the hidden step sits between two rendered steps', () => { + let html: string; + + beforeEach(() => { + html = renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + React.createElement(StepperPanel, { header: 'Contact', key: 'contact' }, 'No fields here'), + showOptionalStep && React.createElement(StepperPanel, { header: 'Optional', key: 'optional' }, 'Hidden'), + React.createElement( + StepperPanel, + { header: 'Details', key: 'details' }, + React.createElement(NameField, { value: (command: TestCommand) => command.name }) + ) + )); + }); + + it('should_render_only_the_two_surviving_steps', () => { + html.split('data-testid="stepper-panel"').length.should.equal(3); + }); + + it('should_not_mark_the_step_without_field_errors', () => { + numberBackgroundOf(html, 'Contact').should.not.include('red'); + }); + + it('should_mark_the_step_whose_own_field_has_an_error', () => { + numberBackgroundOf(html, 'Details').should.include('red'); + }); + + it('should_show_the_next_button_on_the_first_of_two_steps', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_null.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_null.ts new file mode 100644 index 0000000..94a5451 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_null.ts @@ -0,0 +1,107 @@ +// 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderDialog = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...children +)); + +const hiddenStep = null; + +describe('when the last of two steps is null and the dialog is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), hiddenStep); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when the last of three steps is null and the dialog is on the first rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), panel('Step 2'), hiddenStep); + }); + + it('should_render_every_surviving_step', () => { + renderedPanels(html).should.equal(2); + }); + + it('should_show_the_next_button', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_undefined.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_undefined.ts new file mode 100644 index 0000000..3bc4f6c --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_it_is_undefined.ts @@ -0,0 +1,107 @@ +// 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderDialog = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...children +)); + +const hiddenStep = undefined; + +describe('when the last of two steps is undefined and the dialog is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), hiddenStep); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when the last of three steps is undefined and the dialog is on the first rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), panel('Step 2'), hiddenStep); + }); + + it('should_render_every_surviving_step', () => { + renderedPanels(html).should.equal(2); + }); + + it('should_show_the_next_button', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_first_step_is_hidden.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_first_step_is_hidden.ts new file mode 100644 index 0000000..95221d0 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_first_step_is_hidden.ts @@ -0,0 +1,112 @@ +// 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderDialog = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...children +)); + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +describe('when the first of two steps is hidden and the dialog is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(showOptionalStep && panel('Step 1'), panel('Step 2')); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when the first of three steps is hidden and the dialog is on the first rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(showOptionalStep && panel('Step 1'), panel('Step 2'), panel('Step 3')); + }); + + it('should_render_every_surviving_step', () => { + renderedPanels(html).should.equal(2); + }); + + it('should_show_the_next_button', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); + + it('should_not_show_the_previous_button', () => { + html.should.not.include('>Previous<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_last_step_is_false.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_last_step_is_false.ts new file mode 100644 index 0000000..9287ef4 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_last_step_is_false.ts @@ -0,0 +1,121 @@ +// 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +// The dialog opens on step 0, so a shape with a single rendered step puts the wizard on its +// last step immediately — which is where a step count inflated by the hidden step shows up. +describe('when the only hidden step is the last of two and the dialog is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + React.createElement(StepperPanel, { header: 'Step 1' }, 'Step 1 content'), + showOptionalStep && React.createElement(StepperPanel, { header: 'Step 2' }, 'Step 2 content') + )); + }); + + it('should_render_only_the_surviving_step', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); + + it('should_not_show_the_previous_button', () => { + html.should.not.include('>Previous<'); + }); +}); + +describe('when the hidden step is the last of three and the dialog is on the first of two rendered steps', () => { + let html: string; + + beforeEach(() => { + html = renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + React.createElement(StepperPanel, { header: 'Step 1' }, 'Step 1 content'), + React.createElement(StepperPanel, { header: 'Step 2' }, 'Step 2 content'), + showOptionalStep && React.createElement(StepperPanel, { header: 'Step 3' }, 'Step 3 content') + )); + }); + + it('should_render_two_steps', () => { + renderedPanels(html).should.equal(2); + }); + + it('should_show_the_next_button', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_steps_come_from_a_map.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_steps_come_from_a_map.ts new file mode 100644 index 0000000..86468c6 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_the_steps_come_from_a_map.ts @@ -0,0 +1,108 @@ +// 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderDialog = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...children +)); + +const headersOf = (count: number) => Array.from({ length: count }, (_, index) => `Step ${index + 1}`); + +// Nothing is hidden here — this pins the behavior a naive "subtract the falsy children" fix would break. +describe('when a map produces a single step and the dialog is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(headersOf(1).map(panel)); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when a map produces three steps and the dialog is on the first rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(headersOf(3).map(panel)); + }); + + it('should_render_every_surviving_step', () => { + renderedPanels(html).should.equal(3); + }); + + it('should_show_the_next_button', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_two_are_hidden.ts b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_two_are_hidden.ts new file mode 100644 index 0000000..635ae8e --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_a_step_is_conditionally_hidden/and_two_are_hidden.ts @@ -0,0 +1,109 @@ +// 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 }) => + React.createElement('button', { disabled: props.disabled }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + 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 = ''; +} + +const renderedPanels = (html: string) => html.split('data-testid="stepper-panel"').length - 1; + +const panel = (header: string) => React.createElement(StepperPanel, { header, key: header }, `${header} content`); + +const renderDialog = (...children: React.ReactNode[]) => renderToStaticMarkup(React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...children +)); + +// Exactly how a conditional step is written in an application: `{condition && }`. +const showOptionalStep: boolean = false; + +// Two hidden steps: a fix that merely decremented the count by one would still be wrong here. +describe('when two of three steps are hidden and the dialog is on the last rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), showOptionalStep && panel('Step 2'), showOptionalStep && panel('Step 3')); + }); + + it('should_render_only_the_surviving_steps', () => { + renderedPanels(html).should.equal(1); + }); + + it('should_show_the_submit_button', () => { + html.should.include('>Submit<'); + }); + + it('should_not_show_the_next_button', () => { + html.should.not.include('>Next<'); + }); +}); + +describe('when two of four steps are hidden and the dialog is on the first rendered step', () => { + let html: string; + + beforeEach(() => { + html = renderDialog(panel('Step 1'), showOptionalStep && panel('Step 2'), panel('Step 3'), showOptionalStep && panel('Step 4')); + }); + + it('should_render_every_surviving_step', () => { + renderedPanels(html).should.equal(2); + }); + + it('should_show_the_next_button', () => { + html.should.include('>Next<'); + }); + + it('should_not_show_the_submit_button_yet', () => { + html.should.not.include('>Submit<'); + }); +}); diff --git a/Source/CommandDialog/for_StepperCommandDialog/when_every_step_is_hidden.ts b/Source/CommandDialog/for_StepperCommandDialog/when_every_step_is_hidden.ts new file mode 100644 index 0000000..6798c23 --- /dev/null +++ b/Source/CommandDialog/for_StepperCommandDialog/when_every_step_is_hidden.ts @@ -0,0 +1,123 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import React from 'react'; +import { vi } from 'vitest'; +import { StepperPanel } from 'primereact/stepperpanel'; +import { StepperCommandDialog } from '../StepperCommandDialog'; +import { + activeStep, + buttonLabels, + render, + rerender, + renderedSteps, + unmount, + type StepperDialogInTheDom +} from './given/a_stepper_dialog_in_the_dom'; + +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; onClick?: () => void }) => + React.createElement('button', { disabled: props.disabled, onClick: props.onClick }, props.label), +})); + +vi.mock('@cratis/arc.react/dialogs', () => ({ + DialogResult: { None: 0, Yes: 1, No: 2, Ok: 3, Cancelled: 4 }, + useDialogContext: () => undefined, +})); + +// The context object is created once rather than per call: this spec renders for real, so an +// identity that changed on every render would re-run the stepper's error effect forever. +vi.mock('@cratis/arc.react/commands', () => { + const commandFormContext = { + isValid: true, + setCommandValues: () => { }, + setCommandResult: () => { }, + getFieldError: () => undefined, + }; + + return { + CommandForm: (props: { children?: React.ReactNode }) => + React.createElement('div', null, props.children), + useCommandFormContext: () => commandFormContext, + useCommandInstance: () => ({}), + CommandFormFieldWrapper: (props: { field?: React.ReactNode }) => + React.createElement('div', null, props.field), + }; +}); + +class TestCommand { + name: string = ''; +} + +const step = (header: string) => React.createElement(StepperPanel, { header }, `${header} content`); + +const aWizard = (...steps: (React.ReactElement | false)[]) => React.createElement( + StepperCommandDialog, + { + command: TestCommand as unknown as new () => object, + visible: true, + title: 'Test Dialog', + }, + ...steps); + +// Every step conditional and every condition false leaves a wizard with nothing to fill in. The +// command behind it is still valid, so the only sensible footer is the one that submits it — a +// Next button here leads nowhere at all, and no button at all is a dialog the user cannot finish. +describe('when every step is hidden', () => { + let dialog: StepperDialogInTheDom; + let stepsBeforeTheyVanished: string[]; + + beforeEach(async () => { + dialog = await render(aWizard(step('Step 1'), step('Step 2'))); + stepsBeforeTheyVanished = renderedSteps(dialog); + + await rerender(dialog, aWizard(false, false)); + }); + + afterEach(async () => await unmount(dialog)); + + it('should_have_rendered_both_steps_first', () => { + stepsBeforeTheyVanished.should.deep.equal(['Step 1', 'Step 2']); + }); + + it('should_render_no_steps', () => { + renderedSteps(dialog).should.have.lengthOf(0); + }); + + it('should_offer_submit', () => { + buttonLabels(dialog).should.contain('Submit'); + }); + + it('should_not_offer_next', () => { + buttonLabels(dialog).should.not.contain('Next'); + }); + + it('should_not_offer_previous', () => { + buttonLabels(dialog).should.not.contain('Previous'); + }); + + // Zero steps is the one count where the last index is negative, and the step handed to the + // Stepper is what the panel shown, the validation state read and the buttons offered are all + // derived from. Floored at the first step it stays a step a user could be on; unfloored it is + // -1, an index no step ever answers to. + it('should_hand_the_stepper_the_first_step', () => { + activeStep(dialog).should.equal('0'); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_a_child_in_the_middle_is_falsy.ts b/Source/CommandDialog/for_getStepPanels/when_a_child_in_the_middle_is_falsy.ts new file mode 100644 index 0000000..f911f65 --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_a_child_in_the_middle_is_falsy.ts @@ -0,0 +1,26 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when a child in the middle is falsy', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + React.createElement('div', { key: 'first', 'data-name': 'first' }), + false, + React.createElement('div', { key: 'third', 'data-name': 'third' }) + ]); + }); + + it('should_drop_the_falsy_child', () => { + result.should.have.lengthOf(2); + }); + + it('should_close_the_gap_left_by_the_hidden_child', () => { + (result[0].props as { 'data-name': string })['data-name'].should.equal('first'); + (result[1].props as { 'data-name': string })['data-name'].should.equal('third'); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_a_child_is_a_bare_string.ts b/Source/CommandDialog/for_getStepPanels/when_a_child_is_a_bare_string.ts new file mode 100644 index 0000000..8d8597d --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_a_child_is_a_bare_string.ts @@ -0,0 +1,26 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when a bare string sits between two elements', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + React.createElement('div', { key: 'first', 'data-name': 'first' }), + ' ', + React.createElement('div', { key: 'third', 'data-name': 'third' }) + ]); + }); + + it('should_not_treat_the_string_as_a_step', () => { + result.should.have.lengthOf(2); + }); + + it('should_keep_the_element_children', () => { + (result[0].props as { 'data-name': string })['data-name'].should.equal('first'); + (result[1].props as { 'data-name': string })['data-name'].should.equal('third'); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_a_child_is_null.ts b/Source/CommandDialog/for_getStepPanels/when_a_child_is_null.ts new file mode 100644 index 0000000..e1f3ae3 --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_a_child_is_null.ts @@ -0,0 +1,21 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when a child is null', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + React.createElement('div', { key: 'first' }), + React.createElement('div', { key: 'second' }), + null + ]); + }); + + it('should_drop_the_null_child', () => { + result.should.have.lengthOf(2); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_a_child_is_undefined.ts b/Source/CommandDialog/for_getStepPanels/when_a_child_is_undefined.ts new file mode 100644 index 0000000..9e0ad60 --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_a_child_is_undefined.ts @@ -0,0 +1,21 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when a child is undefined', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + React.createElement('div', { key: 'first' }), + React.createElement('div', { key: 'second' }), + undefined + ]); + }); + + it('should_drop_the_undefined_child', () => { + result.should.have.lengthOf(2); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_children_are_produced_by_a_map.ts b/Source/CommandDialog/for_getStepPanels/when_children_are_produced_by_a_map.ts new file mode 100644 index 0000000..630877e --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_children_are_produced_by_a_map.ts @@ -0,0 +1,20 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when children are produced by a map', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels( + ['first', 'second', 'third', 'fourth'].map(name => + React.createElement('div', { key: name, 'data-name': name })) + ); + }); + + it('should_flatten_the_array_into_one_step_per_element', () => { + result.should.have.lengthOf(4); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_children_are_wrapped_in_a_fragment.ts b/Source/CommandDialog/for_getStepPanels/when_children_are_wrapped_in_a_fragment.ts new file mode 100644 index 0000000..4f8a919 --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_children_are_wrapped_in_a_fragment.ts @@ -0,0 +1,26 @@ +// 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 { getStepPanels } from '../stepChildren'; + +// Fragments are not flattened — this pins the long-standing behavior so that adding +// fragment support later is a deliberate, visible change rather than an accident. +describe('when children are wrapped in a fragment', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels( + React.createElement( + React.Fragment, + null, + React.createElement('div', { key: 'first' }), + React.createElement('div', { key: 'second' }) + ) + ); + }); + + it('should_treat_the_fragment_as_a_single_step', () => { + result.should.have.lengthOf(1); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_every_child_is_an_element.ts b/Source/CommandDialog/for_getStepPanels/when_every_child_is_an_element.ts new file mode 100644 index 0000000..7a0b3fc --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_every_child_is_an_element.ts @@ -0,0 +1,21 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when every child is an element', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + React.createElement('div', { key: 'first' }), + React.createElement('div', { key: 'second' }), + React.createElement('div', { key: 'third' }) + ]); + }); + + it('should_keep_every_child', () => { + result.should.have.lengthOf(3); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_the_first_child_is_falsy.ts b/Source/CommandDialog/for_getStepPanels/when_the_first_child_is_falsy.ts new file mode 100644 index 0000000..6557115 --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_the_first_child_is_falsy.ts @@ -0,0 +1,25 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when the first child is falsy', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + false, + React.createElement('div', { key: 'second', 'data-name': 'second' }), + React.createElement('div', { key: 'third', 'data-name': 'third' }) + ]); + }); + + it('should_drop_the_falsy_child', () => { + result.should.have.lengthOf(2); + }); + + it('should_make_the_first_rendered_child_the_first_step', () => { + (result[0].props as { 'data-name': string })['data-name'].should.equal('second'); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_the_last_child_is_false.ts b/Source/CommandDialog/for_getStepPanels/when_the_last_child_is_false.ts new file mode 100644 index 0000000..67d5f51 --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_the_last_child_is_false.ts @@ -0,0 +1,26 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when the last child is false', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + React.createElement('div', { key: 'first', 'data-name': 'first' }), + React.createElement('div', { key: 'second', 'data-name': 'second' }), + false + ]); + }); + + it('should_drop_the_false_child', () => { + result.should.have.lengthOf(2); + }); + + it('should_keep_the_remaining_children_in_order', () => { + (result[0].props as { 'data-name': string })['data-name'].should.equal('first'); + (result[1].props as { 'data-name': string })['data-name'].should.equal('second'); + }); +}); diff --git a/Source/CommandDialog/for_getStepPanels/when_two_children_are_falsy.ts b/Source/CommandDialog/for_getStepPanels/when_two_children_are_falsy.ts new file mode 100644 index 0000000..f204218 --- /dev/null +++ b/Source/CommandDialog/for_getStepPanels/when_two_children_are_falsy.ts @@ -0,0 +1,22 @@ +// 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 { getStepPanels } from '../stepChildren'; + +describe('when two of four children are falsy', () => { + let result: React.ReactElement[]; + + beforeEach(() => { + result = getStepPanels([ + React.createElement('div', { key: 'first' }), + false, + React.createElement('div', { key: 'third' }), + undefined + ]); + }); + + it('should_drop_every_falsy_child_not_just_one', () => { + result.should.have.lengthOf(2); + }); +}); diff --git a/Source/CommandDialog/stepChildren.ts b/Source/CommandDialog/stepChildren.ts new file mode 100644 index 0000000..680c3f3 --- /dev/null +++ b/Source/CommandDialog/stepChildren.ts @@ -0,0 +1,27 @@ +// 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'; + +/** + * Gets the step panels a stepper will actually render, in render order. + * + * A conditional step is written as `{condition && }` — which yields + * `false` when the condition does not hold — and explicit `null` / `undefined` children + * are just as common. `React.Children.count` counts those, so anything deriving a step + * count from it believes the wizard has more steps than it renders: navigation runs past + * the last real panel and the per-step validation gate reads off the end of its array. + * + * `React.Children.toArray` already drops `null`, `undefined` and booleans; filtering to + * valid elements additionally drops bare text children, which are not steps either. + * + * Fragments are deliberately **not** flattened — a `<>…` wrapping several panels stays + * one entry, which is the behavior the stepper has always had. Supporting fragments as a + * container for multiple steps is a separate, additive change. + * + * @param children - The stepper children to inspect. + * @returns The child elements that render as steps. + */ +export function getStepPanels(children: React.ReactNode): React.ReactElement[] { + return React.Children.toArray(children).filter((child): child is React.ReactElement => React.isValidElement(child)); +} diff --git a/Source/DataPage/DataPage.stories.tsx b/Source/DataPage/DataPage.stories.tsx index 4c54200..b3e524e 100644 --- a/Source/DataPage/DataPage.stories.tsx +++ b/Source/DataPage/DataPage.stories.tsx @@ -64,6 +64,46 @@ class PersonsQuery extends QueryFor { } } +const manyPersons: Person[] = Array.from({ length: 24 }, (_, index) => ({ + id: index + 1, + name: `Person ${index + 1}`, + email: `person${index + 1}@example.com`, + role: ['Admin', 'Editor', 'Viewer'][index % 3], +})); + +// Mock query with more records than fit on a page, so the paginator is real and +// the row region has to scroll. This is the shape that shows whether the page +// keeps its paginator inside the height it was given. +class ManyPersonsQuery extends QueryFor { + readonly route = '/api/many-persons'; + readonly routeTemplate = '/api/many-persons'; + readonly defaultValue: Person = [] as unknown as Person; + readonly parameterDescriptors = []; + get requiredRequestParameters() { + return []; + } + constructor() { + super(Object, true); + } + override perform(): Promise> { + const page = this.paging?.page ?? 0; + const size = this.paging?.pageSize ?? 20; + const first = page * size; + + return Promise.resolve({ + data: manyPersons.slice(first, first + size), + paging: { totalItems: manyPersons.length, totalPages: Math.ceil(manyPersons.length / size), page, size }, + isSuccess: true, + isAuthorized: true, + isValid: true, + hasExceptions: false, + validationResults: [], + exceptionMessages: [], + exceptionStackTrace: '', + } as unknown as QueryResult); + } +} + const PersonDetails = ({ item }: { item: Person }) => { return (
@@ -220,3 +260,94 @@ export const WithOnRefresh: Story = {
) }; + +/** + * 24 records at a page size of 20 in a 560px container — more rows than fit, + * so the paginator has to stay on screen while the row region scrolls on its + * own. Page to the short second page and back: the paginator must not move. + */ +export const MultiplePages: Story = { + render: () => ( +
+ + title="Persons (24 records, 20 per page)" + query={ManyPersonsQuery} + emptyMessage="No persons found" + dataKey="id" + globalFilterFields={['name', 'email', 'role']} + > + + } + command={() => alert('Add person clicked')} + /> + + + + + + + + +
+ ) +}; + +/** + * The same overflowing data with no action bar, so the table region is the only + * item in the column and still has to leave room for the paginator. + */ +export const MultiplePagesWithoutMenuItems: Story = { + render: () => ( +
+ + title="Persons (24 records, no action bar)" + query={ManyPersonsQuery} + emptyMessage="No persons found" + dataKey="id" + > + + + + + + + +
+ ) +}; + +/** + * The overflowing data with a details pane — select a row to split the page, then drag the + * divider between the panes. The paginator stays inside the primary pane's bounds at every + * split, and the two panes sit side by side rather than stacking. + */ +export const MultiplePagesWithDetails: Story = { + render: () => ( +
+ + title="Persons (24 records, with details)" + query={ManyPersonsQuery} + emptyMessage="No persons found" + dataKey="id" + detailsComponent={PersonDetails} + > + + } + command={() => alert('Edit person clicked')} + disableOnUnselected + /> + + + + + + + + +
+ ) +}; diff --git a/Source/DataPage/DataPage.tsx b/Source/DataPage/DataPage.tsx index 3ce4226..4de3090 100644 --- a/Source/DataPage/DataPage.tsx +++ b/Source/DataPage/DataPage.tsx @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { ReactNode, useMemo } from 'react'; +import { CSSProperties, ReactNode, useMemo } from 'react'; import { Page } from '../Common/Page'; import React from 'react'; import { MenuItem as PrimeMenuItem } from 'primereact/menuitem'; @@ -12,6 +12,16 @@ import { DataTableFilterMeta, DataTableSelectionSingleChangeEvent, type DataTabl import { DataTableForQuery } from '../DataTables/DataTableForQuery'; import { Allotment } from 'allotment'; import { Constructor } from '@cratis/fundamentals'; +import { DataPageLayout } from './DataPageLayout'; + +// Allotment ships its layout as a stylesheet rather than inline styles, and a pane only becomes +// the absolutely positioned, full-height box the split view assumes once that stylesheet is on +// the page. Without it a pane is an ordinary block that grows to its content, `height: 100%` +// inside it resolves to auto, and the table pushes its paginator past the page's clipped edge. +// Importing it here is the same contract as every other stylesheet in this package: Rollup keeps +// CSS imports external, `sideEffects` marks them live, and the consuming bundler injects it - so +// a consumer gets a working split view without having to know the dependency exists. +import 'allotment/dist/style.css'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -33,7 +43,6 @@ export interface MenuItemProps extends PrimeMenuItem { * directly; the surrounding {@link MenuItems} component reads its props and * forwards them to the action `Menubar`. */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars export const MenuItem = (_: MenuItemProps) => { return null; }; @@ -54,6 +63,29 @@ export interface ColumnProps { children: ReactNode; } +/** + * The action bar is an intrinsically sized item in the page's layout column — + * it takes the height its menubar needs and never gives any of it up, so the + * table region below it is the only part that has to adapt to the space left. + */ +const actionsStyle: CSSProperties = { flexShrink: 0 }; + +/** + * The table region takes every pixel the action bar leaves and no more. + * `minHeight: 0` is the load-bearing half: without it the region's automatic + * minimum keeps it at content height, the column grows past the page, and the + * table's paginator ends up below the clipped edge. + */ +const tableRegionStyle: CSSProperties = { flexGrow: 1, flexBasis: 0, minHeight: 0 }; + +/** + * The floor a `DataPage` falls back to when its ancestors give it no height at + * all. A page that renders as an empty sliver is indistinguishable from a + * broken one, so a contract-violating consumer gets a small but usable page + * instead of nothing. + */ +const pageStyle: CSSProperties = { minHeight: '20rem' }; + /** * Renders an action `Menubar` at the top of a {@link DataPage}, populated from * `` children. Each menu item's `disableOnUnselected` flag @@ -84,7 +116,7 @@ export const MenuItems = ({ children }: MenuItemsProps) => { }, [children, context.selectedItem]); return ( -
+
{ export const Columns = ({ children }: ColumnProps) => { const context = useDataPageContext(); + const isSnapshotQuery = context.query.prototype instanceof QueryFor; - if (context.query.prototype instanceof QueryFor) { - return ( - - {children} - ); - - } else { - return ( - - {children} - ); - } + return ( +
+ {isSnapshotQuery + ? + {children} + + : + {children} + } +
); }; /** @@ -343,6 +374,29 @@ export interface DataPageProps | IObservable * * ``` * + * ## Height — `DataPage` needs a bounded ancestor + * + * `DataPage` fills the height it is given and divides it between the action + * bar and the table region, so the table's paginator always sits at the + * bottom of the page rather than below its edge. It cannot invent that height: + * every element from the page root down sizes as a percentage of its parent, + * so **some ancestor has to have a definite height**. + * + * ```tsx + * // ✅ the router outlet, a sized container, or a flex child with min-height + *
+ * + *
+ * + * // ❌ nothing above resolves to a height — the table grows to its content + *
+ * + *
+ * ``` + * + * Without one, the page falls back to a small fixed height so it stays + * usable instead of collapsing to nothing. + * * ## Styling * * The inner DataTable and Menubar each have their own per-slot props: @@ -370,17 +424,19 @@ const DataPage = | IObservableQueryFor - - - - {props.children} - - {props.detailsComponent && selectedItem && - - + + {props.detailsComponent + ? + + {props.children} - } - + {selectedItem && + + + + } + + : {props.children}} ); diff --git a/Source/DataPage/DataPageLayout.tsx b/Source/DataPage/DataPageLayout.tsx new file mode 100644 index 0000000..a8d7f93 --- /dev/null +++ b/Source/DataPage/DataPageLayout.tsx @@ -0,0 +1,45 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { CSSProperties, ReactNode } from 'react'; + +/** + * Props for {@link DataPageLayout}. + */ +export interface DataPageLayoutProps { + /** The `` and `` content to lay out. */ + children: ReactNode; +} + +/** + * The layout root's own style. Declared inline rather than in a stylesheet on + * purpose: a consumer that never imports a stylesheet from this package still + * has to get a working page out of `DataPage`, and the height allocation is + * what the whole component depends on. + * + * `minHeight: 0` is what lets the column shrink inside the flex parent that + * `Page` renders — without it the default `min-height: auto` keeps the column + * at content height and everything below the available space is clipped. + */ +const layoutStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + height: '100%', + minHeight: 0 +}; + +/** + * The vertical layout inside a `DataPage`'s primary pane. + * + * It is a definite-height flex column, so the action bar can be an intrinsic + * item while the table region absorbs whatever height is left. That is what + * keeps the table's own paginator inside the page instead of pushing it past + * the bottom edge, where the surrounding `overflow: hidden` clips it away. + * + * @param props - {@link DataPageLayoutProps}. + */ +export const DataPageLayout = ({ children }: DataPageLayoutProps) => ( +
+ {children} +
+); diff --git a/Source/DataPage/for_DataPage/given/a_data_page.ts b/Source/DataPage/for_DataPage/given/a_data_page.ts new file mode 100644 index 0000000..89eb926 --- /dev/null +++ b/Source/DataPage/for_DataPage/given/a_data_page.ts @@ -0,0 +1,201 @@ +// 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 { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Column } from 'primereact/column'; +import { DataPage, MenuItem } from '../../DataPage'; +import { PersonsQuery, resetQueryResult, type Person } from './a_paged_query_result'; + +/** + * A `DataPage` mounted into a real document, together with what is needed to + * take it down again. + */ +export interface DataPageInTheDom { + container: HTMLDivElement; + root: Root; +} + +/** + * Which of `DataPage`'s optional parts a spec wants. + */ +export interface DataPageOptions { + /** Render a `` action bar. */ + withMenuItems?: boolean; + + /** Supply a `detailsComponent`, so the page runs its split-view branch. */ + withDetails?: boolean; +} + +const PersonDetails = ({ item }: { item: Person }) => + React.createElement('div', { className: 'person-details' }, item.name); + +const columns = () => React.createElement( + DataPage.Columns, + { key: 'columns' }, + React.createElement(Column, { key: 'id', field: 'id', header: 'Id' }), + React.createElement(Column, { key: 'name', field: 'name', header: 'Name' })); + +const menuItems = () => React.createElement( + DataPage.MenuItems, + { key: 'menuItems' }, + React.createElement(MenuItem, { + key: 'add', + label: 'Add', + icon: () => React.createElement('i', { className: 'pi pi-plus' }) + })); + +/** + * Builds the `DataPage` element the specs render. + * @param options - {@link DataPageOptions} describing the parts to include. + * @returns The element. + */ +export const aDataPage = (options: DataPageOptions = {}) => { + const children = options.withMenuItems ? [menuItems(), columns()] : [columns()]; + + return React.createElement( + DataPage, + { + title: 'Persons', + query: PersonsQuery, + emptyMessage: 'No persons found', + dataKey: 'id', + detailsComponent: options.withDetails ? PersonDetails : undefined + }, + ...children); +}; + +/** + * Renders an element into a real document and lets React settle, so the specs + * look at the tree a browser would have built. + * + * `ResizeObserver` is stubbed because Allotment observes its container for + * size changes and jsdom has no layout engine to report any. + * @param element - The element to render. + * @returns The mounted page, to be passed to {@link unmount}. + */ +export const render = async (element: React.ReactElement): Promise => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + (globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver ??= class { + observe() { } + unobserve() { } + disconnect() { } + }; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(element); + }); + + return { container, root }; +}; + +/** + * Unmounts a page rendered with {@link render} and removes its container. + * @param page - The mounted page. + */ +export const unmount = async (page: DataPageInTheDom) => { + await act(async () => { + page.root.unmount(); + }); + page.container.remove(); + resetQueryResult(); +}; + +/** + * Clicks the first data row, which is how a user opens the details pane. + * @param page - The mounted page. + */ +export const selectFirstRow = async (page: DataPageInTheDom) => { + const row = page.container.querySelector('tbody tr') as HTMLElement | null; + + await act(async () => { + row?.click(); + }); +}; + +/** + * Every layout root the page rendered. There should only ever be one. + * @param page - The mounted page. + * @returns The layout roots. + */ +export const layoutRoots = (page: DataPageInTheDom): HTMLElement[] => + Array.from(page.container.querySelectorAll('.cratis-data-page-layout')); + +/** + * The single layout root of the page. + * @param page - The mounted page. + * @returns The layout root. + */ +export const layoutRoot = (page: DataPageInTheDom): HTMLElement => layoutRoots(page)[0]; + +/** + * The direct children of the layout root, named by the class that says what + * each one is and in the order they stack. Rendered elements come from the + * jsdom realm and carry no `should`, so the shape of the column is described + * as plain strings the spec can assert on directly — and a failure then says + * what the column actually held instead of only that two objects differ. + * @param page - The mounted page. + * @returns The child roles, top to bottom. + */ +export const layoutRootChildren = (page: DataPageInTheDom): string[] => + Array.from(layoutRoot(page).children).map(child => child.className.split(' ')[0]); + +/** + * The action bar, or `null` when the page has no ``. + * @param page - The mounted page. + * @returns The action bar. + */ +export const actionBar = (page: DataPageInTheDom): HTMLElement | null => + page.container.querySelector('.cratis-data-page-actions'); + +/** + * The region the data table lives in. + * @param page - The mounted page. + * @returns The table region. + */ +export const tableRegion = (page: DataPageInTheDom): HTMLElement => + page.container.querySelector('.cratis-data-page-table')!; + +/** + * The element the table scrolls in — found by the declaration that makes it + * scroll rather than by a class, because that is the property the paginator + * has to stay out of. + * @param page - The mounted page. + * @returns The scrolling element. + */ +export const scrollRegion = (page: DataPageInTheDom): HTMLElement => + Array.from(page.container.querySelectorAll('div')).find(element => element.style.overflow === 'auto')!; + +/** + * The paginator rendered by the data table, or `null` when there is none. + * @param page - The mounted page. + * @returns The paginator. + */ +export const paginator = (page: DataPageInTheDom): HTMLElement | null => + page.container.querySelector('.p-paginator'); + +/** + * How each split-view pane is actually laid out, top pane first. + * + * Allotment lays its panes out from a stylesheet rather than from inline styles, so this reads the + * **computed** style: an inline-style assertion would pass on a declaration nothing ever honored, + * which is exactly the failure this page was fixed for. A pane that the stylesheet reached is + * `absolute / 100%` — taken out of flow and given the whole height of the split view. One it did + * not is `static / auto`: an ordinary block that grows to its content, which makes every `height: + * 100%` beneath it resolve to auto and pushes the paginator past the page's clipped edge. + * + * Described as plain strings because rendered elements come from the jsdom realm and carry no + * `should` — and a failure then says how the panes were laid out rather than only that two objects + * differ. An empty array means the page rendered no split view at all, so a claim about the panes + * can never be satisfied by their absence. + * @param page - The mounted page. + * @returns One description per pane, in document order. + */ +export const paneLayouts = (page: DataPageInTheDom): string[] => + Array.from(page.container.querySelectorAll('[class*="splitViewView"]')) + .map(pane => `${getComputedStyle(pane).position} / ${getComputedStyle(pane).height}`); diff --git a/Source/DataPage/for_DataPage/given/a_paged_query_result.ts b/Source/DataPage/for_DataPage/given/a_paged_query_result.ts new file mode 100644 index 0000000..5760c6c --- /dev/null +++ b/Source/DataPage/for_DataPage/given/a_paged_query_result.ts @@ -0,0 +1,114 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { QueryFor, QueryResult } from '@cratis/arc/queries'; + +/** + * A row in the fixture the specs page through. + */ +export interface Person { + id: number; + name: string; + email: string; +} + +const pageSize = 20; + +const allPersons: Person[] = Array.from({ length: 24 }, (_, index) => ({ + id: index + 1, + name: `Person ${index + 1}`, + email: `person${index + 1}@example.com` +})); + +/** + * What the stand-in Arc paging hooks report back. Mutable so a spec can narrow + * the result — an empty one, say — without having to re-mock the module. + */ +export const queryResult = { + totalItems: allPersons.length, + totalPages: 2, + page: 0 +}; + +/** + * Puts the result back to the full two-page set, so one spec's narrowing never + * leaks into the next file's expectations. + */ +export const resetQueryResult = () => { + queryResult.totalItems = allPersons.length; + queryResult.totalPages = 2; + queryResult.page = 0; +}; + +const rowsForCurrentPage = (): Person[] => { + if (queryResult.totalItems === 0) { + return []; + } + const first = queryResult.page * pageSize; + return allPersons.slice(0, queryResult.totalItems).slice(first, first + pageSize); +}; + +/** + * The shape `@cratis/arc.react/queries` is replaced with. Only the two paging + * hooks the data tables consume are provided — the rest of that module would + * drag a transport connection into specs that are about layout. + * + * This module deliberately imports nothing from the components under + * specification: the replacement is built while that very module graph is + * still loading, so reaching back into it would deadlock the run. + * @returns The replacement module. + */ +export const arcQueryHooks = () => { + const currentResult = () => ({ + data: rowsForCurrentPage(), + paging: { + page: queryResult.page, + size: pageSize, + totalItems: queryResult.totalItems, + totalPages: queryResult.totalPages + }, + isSuccess: true, + isAuthorized: true, + isValid: true, + validationResults: [], + hasExceptions: false, + exceptionMessages: [], + exceptionStackTrace: '', + isPerforming: false, + hasData: queryResult.totalItems > 0 + }); + + const setPage = (page: number) => { + queryResult.page = page; + }; + const noop = () => { }; + + return { + useQueryWithPaging: () => [currentResult(), () => Promise.resolve(), noop, setPage, noop], + useObservableQueryWithPaging: () => [currentResult(), noop, setPage, noop] + }; +}; + +/** + * A snapshot query proxy shaped like the ones Arc generates. `DataPage` picks + * its inner table by looking at the prototype chain, so the class has to be a + * real `QueryFor` — it is never performed, because the hooks are replaced. + */ +export class PersonsQuery extends QueryFor { + readonly route = '/api/persons'; + readonly routeTemplate = '/api/persons'; + readonly defaultValue: Person = [] as unknown as Person; + readonly parameterDescriptors = []; + + get requiredRequestParameters(): string[] { + return []; + } + + constructor() { + super(Object, true); + } + + override perform(): Promise> { + return Promise.resolve({ data: rowsForCurrentPage() } as unknown as QueryResult); + } +} diff --git a/Source/DataPage/for_DataPage/when_a_details_component_is_supplied/and_a_row_is_selected.ts b/Source/DataPage/for_DataPage/when_a_details_component_is_supplied/and_a_row_is_selected.ts new file mode 100644 index 0000000..e7e144a --- /dev/null +++ b/Source/DataPage/for_DataPage/when_a_details_component_is_supplied/and_a_row_is_selected.ts @@ -0,0 +1,87 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { + aDataPage, + actionBar, + type DataPageInTheDom, + layoutRoot, + layoutRootChildren, + layoutRoots, + paginator, + paneLayouts, + render, + scrollRegion, + selectFirstRow, + tableRegion, + unmount +} from '../given/a_data_page'; + +vi.mock('@cratis/arc.react/queries', async () => { + const { arcQueryHooks } = await import('../given/a_paged_query_result'); + return arcQueryHooks(); +}); + +describe('when a details component is supplied and a row is selected', () => { + let page: DataPageInTheDom; + + beforeEach(async () => { + page = await render(aDataPage({ withMenuItems: true, withDetails: true })); + await selectFirstRow(page); + }); + + afterEach(async () => { + await unmount(page); + }); + + it('should mount the details pane', () => { + (page.container.querySelector('.person-details') === null).should.be.false; + }); + + it('should render a single layout root', () => { + layoutRoots(page).should.have.lengthOf(1); + }); + + it('should lay the primary pane out as a column', () => { + layoutRoot(page).style.display.should.equal('flex'); + layoutRoot(page).style.flexDirection.should.equal('column'); + }); + + it('should give the layout root a definite height', () => { + layoutRoot(page).style.height.should.equal('100%'); + }); + + it('should let the layout root shrink below its content', () => { + layoutRoot(page).style.minHeight.should.equal('0px'); + }); + + it('should stack the action bar above the table region', () => { + layoutRootChildren(page).should.deep.equal(['cratis-data-page-actions', 'cratis-data-page-table']); + }); + + it('should keep the action bar at its intrinsic height', () => { + actionBar(page)!.style.flexShrink.should.equal('0'); + }); + + it('should let the table region absorb the remaining height', () => { + tableRegion(page).style.flexGrow.should.equal('1'); + tableRegion(page).style.flexBasis.should.equal('0px'); + tableRegion(page).style.minHeight.should.equal('0px'); + }); + + it('should side the two panes rather than stack them', () => { + paneLayouts(page).should.deep.equal(['absolute / 100%', 'absolute / 100%']); + }); + + it('should keep the paginator out of the scrolling region', () => { + (paginator(page) === null).should.be.false; + scrollRegion(page).contains(paginator(page)).should.be.false; + }); + + it('should not lay the primary pane out with the inert flex-grow class', () => { + (page.container.querySelector('.flex-grow') === null).should.be.true; + }); +}); diff --git a/Source/DataPage/for_DataPage/when_a_details_component_is_supplied/and_nothing_is_selected.ts b/Source/DataPage/for_DataPage/when_a_details_component_is_supplied/and_nothing_is_selected.ts new file mode 100644 index 0000000..0884188 --- /dev/null +++ b/Source/DataPage/for_DataPage/when_a_details_component_is_supplied/and_nothing_is_selected.ts @@ -0,0 +1,85 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { + aDataPage, + actionBar, + type DataPageInTheDom, + layoutRoot, + layoutRootChildren, + layoutRoots, + paginator, + paneLayouts, + render, + scrollRegion, + tableRegion, + unmount +} from '../given/a_data_page'; + +vi.mock('@cratis/arc.react/queries', async () => { + const { arcQueryHooks } = await import('../given/a_paged_query_result'); + return arcQueryHooks(); +}); + +describe('when a details component is supplied and nothing is selected', () => { + let page: DataPageInTheDom; + + beforeEach(async () => { + page = await render(aDataPage({ withMenuItems: true, withDetails: true })); + }); + + afterEach(async () => { + await unmount(page); + }); + + it('should not show the details pane', () => { + (page.container.querySelector('.person-details') === null).should.be.true; + }); + + it('should render a single layout root', () => { + layoutRoots(page).should.have.lengthOf(1); + }); + + it('should lay the primary pane out as a column', () => { + layoutRoot(page).style.display.should.equal('flex'); + layoutRoot(page).style.flexDirection.should.equal('column'); + }); + + it('should give the layout root a definite height', () => { + layoutRoot(page).style.height.should.equal('100%'); + }); + + it('should let the layout root shrink below its content', () => { + layoutRoot(page).style.minHeight.should.equal('0px'); + }); + + it('should stack the action bar above the table region', () => { + layoutRootChildren(page).should.deep.equal(['cratis-data-page-actions', 'cratis-data-page-table']); + }); + + it('should keep the action bar at its intrinsic height', () => { + actionBar(page)!.style.flexShrink.should.equal('0'); + }); + + it('should let the table region absorb the remaining height', () => { + tableRegion(page).style.flexGrow.should.equal('1'); + tableRegion(page).style.flexBasis.should.equal('0px'); + tableRegion(page).style.minHeight.should.equal('0px'); + }); + + it('should give the only pane the height the split view was given', () => { + paneLayouts(page).should.deep.equal(['absolute / 100%']); + }); + + it('should keep the paginator out of the scrolling region', () => { + (paginator(page) === null).should.be.false; + scrollRegion(page).contains(paginator(page)).should.be.false; + }); + + it('should not lay the primary pane out with the inert flex-grow class', () => { + (page.container.querySelector('.flex-grow') === null).should.be.true; + }); +}); diff --git a/Source/DataPage/for_DataPage/when_rendered_with_menu_items.ts b/Source/DataPage/for_DataPage/when_rendered_with_menu_items.ts new file mode 100644 index 0000000..e462c16 --- /dev/null +++ b/Source/DataPage/for_DataPage/when_rendered_with_menu_items.ts @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { + aDataPage, + actionBar, + type DataPageInTheDom, + layoutRoot, + layoutRootChildren, + layoutRoots, + paginator, + paneLayouts, + render, + scrollRegion, + tableRegion, + unmount +} from './given/a_data_page'; + +vi.mock('@cratis/arc.react/queries', async () => { + const { arcQueryHooks } = await import('./given/a_paged_query_result'); + return arcQueryHooks(); +}); + +describe('when rendered with menu items', () => { + let page: DataPageInTheDom; + + beforeEach(async () => { + page = await render(aDataPage({ withMenuItems: true })); + }); + + afterEach(async () => { + await unmount(page); + }); + + it('should render a single layout root', () => { + layoutRoots(page).should.have.lengthOf(1); + }); + + it('should lay the page out as a column', () => { + layoutRoot(page).style.display.should.equal('flex'); + layoutRoot(page).style.flexDirection.should.equal('column'); + }); + + it('should give the layout root a definite height', () => { + layoutRoot(page).style.height.should.equal('100%'); + }); + + it('should let the layout root shrink below its content', () => { + layoutRoot(page).style.minHeight.should.equal('0px'); + }); + + it('should stack the action bar above the table region', () => { + layoutRootChildren(page).should.deep.equal(['cratis-data-page-actions', 'cratis-data-page-table']); + }); + + it('should keep the action bar at its intrinsic height', () => { + actionBar(page)!.style.flexShrink.should.equal('0'); + }); + + it('should let the table region absorb the remaining height', () => { + tableRegion(page).style.flexGrow.should.equal('1'); + tableRegion(page).style.flexBasis.should.equal('0px'); + }); + + it('should let the table region shrink below its content', () => { + tableRegion(page).style.minHeight.should.equal('0px'); + }); + + it('should not mount a split view for a page with nothing to split', () => { + paneLayouts(page).should.deep.equal([]); + }); + + it('should keep the paginator out of the scrolling region', () => { + (paginator(page) === null).should.be.false; + scrollRegion(page).contains(paginator(page)).should.be.false; + }); + + it('should not lay the primary pane out with the inert flex-grow class', () => { + (page.container.querySelector('.flex-grow') === null).should.be.true; + }); +}); diff --git a/Source/DataPage/for_DataPage/when_rendered_without_menu_items.ts b/Source/DataPage/for_DataPage/when_rendered_without_menu_items.ts new file mode 100644 index 0000000..0e2f6ae --- /dev/null +++ b/Source/DataPage/for_DataPage/when_rendered_without_menu_items.ts @@ -0,0 +1,84 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { + aDataPage, + actionBar, + type DataPageInTheDom, + layoutRoot, + layoutRootChildren, + layoutRoots, + paginator, + paneLayouts, + render, + scrollRegion, + tableRegion, + unmount +} from './given/a_data_page'; + +vi.mock('@cratis/arc.react/queries', async () => { + const { arcQueryHooks } = await import('./given/a_paged_query_result'); + return arcQueryHooks(); +}); + +describe('when rendered without menu items', () => { + let page: DataPageInTheDom; + + beforeEach(async () => { + page = await render(aDataPage()); + }); + + afterEach(async () => { + await unmount(page); + }); + + it('should render a single layout root', () => { + layoutRoots(page).should.have.lengthOf(1); + }); + + it('should lay the page out as a column', () => { + layoutRoot(page).style.display.should.equal('flex'); + layoutRoot(page).style.flexDirection.should.equal('column'); + }); + + it('should give the layout root a definite height', () => { + layoutRoot(page).style.height.should.equal('100%'); + }); + + it('should let the layout root shrink below its content', () => { + layoutRoot(page).style.minHeight.should.equal('0px'); + }); + + it('should leave the action bar out entirely', () => { + (actionBar(page) === null).should.be.true; + }); + + it('should give the whole column to the table region', () => { + layoutRootChildren(page).should.deep.equal(['cratis-data-page-table']); + }); + + it('should let the table region absorb the remaining height', () => { + tableRegion(page).style.flexGrow.should.equal('1'); + tableRegion(page).style.flexBasis.should.equal('0px'); + }); + + it('should let the table region shrink below its content', () => { + tableRegion(page).style.minHeight.should.equal('0px'); + }); + + it('should not mount a split view for a page with nothing to split', () => { + paneLayouts(page).should.deep.equal([]); + }); + + it('should keep the paginator out of the scrolling region', () => { + (paginator(page) === null).should.be.false; + scrollRegion(page).contains(paginator(page)).should.be.false; + }); + + it('should not lay the primary pane out with the inert flex-grow class', () => { + (page.container.querySelector('.flex-grow') === null).should.be.true; + }); +}); diff --git a/Source/DataTables/DataTableForQuery.stories.tsx b/Source/DataTables/DataTableForQuery.stories.tsx index 85020ad..49923de 100644 --- a/Source/DataTables/DataTableForQuery.stories.tsx +++ b/Source/DataTables/DataTableForQuery.stories.tsx @@ -62,6 +62,45 @@ class ProductsQuery extends QueryFor { } } +const manyProducts: Product[] = Array.from({ length: 24 }, (_, index) => ({ + id: index + 1, + name: `Product ${index + 1}`, + category: ['Electronics', 'Office', 'Accessories'][index % 3], + price: 9.99 + index, + inStock: index % 4 !== 0, +})); + +// Mock query with more records than fit on a page, so the table has to divide a +// fixed height between a scrolling row region and the paginator below it. +class ManyProductsQuery extends QueryFor { + readonly route = '/api/many-products'; + readonly defaultValue: Product = [] as unknown as Product; + readonly parameterDescriptors = []; + get requiredRequestParameters() { + return []; + } + constructor() { + super(Object, true); + } + override perform(): Promise> { + const page = this.paging?.page ?? 0; + const size = this.paging?.pageSize ?? 20; + const first = page * size; + + return Promise.resolve({ + data: manyProducts.slice(first, first + size), + paging: { totalItems: manyProducts.length, totalPages: Math.ceil(manyProducts.length / size), page, size }, + isSuccess: true, + isAuthorized: true, + isValid: true, + hasExceptions: false, + validationResults: [], + exceptionMessages: [], + exceptionStackTrace: '', + } as unknown as QueryResult); + } +} + export const Default: Story = { render: () => (
@@ -96,6 +135,36 @@ export const Default: Story = { ) }; +/** + * The table's own `height: 100%` only means something inside a container that + * has a height. Here it gets 560px and 24 records at a page size of 20, so the + * rows scroll while the paginator stays pinned to the bottom edge. + */ +export const InBoundedHeight: Story = { + render: () => ( +
+
+ + query={ManyProductsQuery} + emptyMessage="No products found" + dataKey="id" + > + + + + `$${rowData.price.toFixed(2)}`} + /> + +
+
+ ) +}; + export const WithSelection: Story = { render: () => { const [selectedProduct, setSelectedProduct] = useState(); diff --git a/Source/DataTables/for_DataTableForQuery/given/a_data_table.ts b/Source/DataTables/for_DataTableForQuery/given/a_data_table.ts new file mode 100644 index 0000000..a2714e8 --- /dev/null +++ b/Source/DataTables/for_DataTableForQuery/given/a_data_table.ts @@ -0,0 +1,71 @@ +// 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 { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Column } from 'primereact/column'; +import { DataTableForQuery } from '../../DataTableForQuery'; +import { ProductsQuery, resetQueryResult } from './a_query_result'; + +/** + * A data table mounted into a real document, together with what is needed to + * take it down again. + */ +export interface DataTableInTheDom { + container: HTMLDivElement; + root: Root; +} + +/** + * Builds the `DataTableForQuery` element the specs render. + * @returns The element. + */ +export const aDataTable = () => React.createElement( + DataTableForQuery, + { + query: ProductsQuery, + emptyMessage: 'No products found', + dataKey: 'id' + }, + React.createElement(Column, { key: 'id', field: 'id', header: 'Id' }), + React.createElement(Column, { key: 'name', field: 'name', header: 'Name' })); + +/** + * Renders an element into a real document and lets React settle. + * @param element - The element to render. + * @returns The mounted table, to be passed to {@link unmount}. + */ +export const render = async (element: React.ReactElement): Promise => { + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render(element); + }); + + return { container, root }; +}; + +/** + * Unmounts a table rendered with {@link render} and removes its container. + * @param table - The mounted table. + */ +export const unmount = async (table: DataTableInTheDom) => { + await act(async () => { + table.root.unmount(); + }); + table.container.remove(); + resetQueryResult(); +}; + +/** + * Whether the table rendered its paginator. + * @param table - The mounted table. + * @returns True when a paginator is present. + */ +export const hasPaginator = (table: DataTableInTheDom): boolean => + table.container.querySelector('.p-paginator') !== null; diff --git a/Source/DataTables/for_DataTableForQuery/given/a_query_result.ts b/Source/DataTables/for_DataTableForQuery/given/a_query_result.ts new file mode 100644 index 0000000..41b25b9 --- /dev/null +++ b/Source/DataTables/for_DataTableForQuery/given/a_query_result.ts @@ -0,0 +1,110 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { QueryFor, QueryResult } from '@cratis/arc/queries'; + +/** + * A row in the fixture the specs page through. + */ +export interface Product { + id: number; + name: string; +} + +const pageSize = 20; + +const allProducts: Product[] = Array.from({ length: 24 }, (_, index) => ({ + id: index + 1, + name: `Product ${index + 1}` +})); + +/** + * What the stand-in Arc paging hook reports back. Mutable so a spec can + * describe an empty result without having to re-mock the module. + */ +export const queryResult = { + totalItems: allProducts.length, + totalPages: 2, + page: 0 +}; + +/** + * Puts the result back to the full two-page set, so one spec's narrowing never + * leaks into the next file's expectations. + */ +export const resetQueryResult = () => { + queryResult.totalItems = allProducts.length; + queryResult.totalPages = 2; + queryResult.page = 0; +}; + +const rowsForCurrentPage = (): Product[] => { + if (queryResult.totalItems === 0) { + return []; + } + const first = queryResult.page * pageSize; + return allProducts.slice(0, queryResult.totalItems).slice(first, first + pageSize); +}; + +/** + * The shape `@cratis/arc.react/queries` is replaced with, so the table renders + * a result of the specs' choosing instead of reaching for a transport. + * + * This module deliberately imports nothing from the component under + * specification: the replacement is built while that very module graph is + * still loading, so reaching back into it would deadlock the run. + * @returns The replacement module. + */ +export const arcQueryHooks = () => { + const currentResult = () => ({ + data: rowsForCurrentPage(), + paging: { + page: queryResult.page, + size: pageSize, + totalItems: queryResult.totalItems, + totalPages: queryResult.totalPages + }, + isSuccess: true, + isAuthorized: true, + isValid: true, + validationResults: [], + hasExceptions: false, + exceptionMessages: [], + exceptionStackTrace: '', + isPerforming: false, + hasData: queryResult.totalItems > 0 + }); + + const setPage = (page: number) => { + queryResult.page = page; + }; + const noop = () => { }; + + return { + useQueryWithPaging: () => [currentResult(), () => Promise.resolve(), noop, setPage, noop], + useObservableQueryWithPaging: () => [currentResult(), noop, setPage, noop] + }; +}; + +/** + * A snapshot query proxy shaped like the ones Arc generates. It is never + * performed, because the hook it would go through is replaced. + */ +export class ProductsQuery extends QueryFor { + readonly route = '/api/products'; + readonly routeTemplate = '/api/products'; + readonly defaultValue: Product = [] as unknown as Product; + readonly parameterDescriptors = []; + + get requiredRequestParameters(): string[] { + return []; + } + + constructor() { + super(Object, true); + } + + override perform(): Promise> { + return Promise.resolve({ data: rowsForCurrentPage() } as unknown as QueryResult); + } +} diff --git a/Source/DataTables/for_DataTableForQuery/when_rendering_a_result/and_it_has_items.ts b/Source/DataTables/for_DataTableForQuery/when_rendering_a_result/and_it_has_items.ts new file mode 100644 index 0000000..3703aa1 --- /dev/null +++ b/Source/DataTables/for_DataTableForQuery/when_rendering_a_result/and_it_has_items.ts @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { aDataTable, type DataTableInTheDom, hasPaginator, render, unmount } from '../given/a_data_table'; +import { queryResult } from '../given/a_query_result'; + +vi.mock('@cratis/arc.react/queries', async () => { + const { arcQueryHooks } = await import('../given/a_query_result'); + return arcQueryHooks(); +}); + +describe('when rendering a result and it has items', () => { + let table: DataTableInTheDom; + + beforeEach(async () => { + queryResult.totalItems = 24; + queryResult.totalPages = 2; + table = await render(aDataTable()); + }); + + afterEach(async () => { + await unmount(table); + }); + + it('should render the paginator', () => { + hasPaginator(table).should.be.true; + }); +}); diff --git a/Source/DataTables/for_DataTableForQuery/when_rendering_a_result/and_it_is_empty.ts b/Source/DataTables/for_DataTableForQuery/when_rendering_a_result/and_it_is_empty.ts new file mode 100644 index 0000000..a712f1e --- /dev/null +++ b/Source/DataTables/for_DataTableForQuery/when_rendering_a_result/and_it_is_empty.ts @@ -0,0 +1,31 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +// @vitest-environment jsdom + +import { vi } from 'vitest'; +import { aDataTable, type DataTableInTheDom, hasPaginator, render, unmount } from '../given/a_data_table'; +import { queryResult } from '../given/a_query_result'; + +vi.mock('@cratis/arc.react/queries', async () => { + const { arcQueryHooks } = await import('../given/a_query_result'); + return arcQueryHooks(); +}); + +describe('when rendering a result and it is empty', () => { + let table: DataTableInTheDom; + + beforeEach(async () => { + queryResult.totalItems = 0; + queryResult.totalPages = 0; + table = await render(aDataTable()); + }); + + afterEach(async () => { + await unmount(table); + }); + + it('should not render the paginator', () => { + hasPaginator(table).should.be.false; + }); +}); diff --git a/Source/vite.config.ts b/Source/vite.config.ts index bf33b7a..402eeae 100644 --- a/Source/vite.config.ts +++ b/Source/vite.config.ts @@ -37,6 +37,15 @@ export default defineConfig({ isolate: true, fileParallelism: false, pool: 'threads', + // Stylesheets are stubbed out by default, which is right for the ones this package writes: + // every layout declaration those specs assert on is an inline style, and processing CSS is + // slow. Allotment's is the exception - its split view is laid out entirely by that + // stylesheet, so a spec can only tell an honored rule from an inert one if the rule is + // actually in the document. Scoped to that one file so nothing else pays for it. + // The cost of that narrow scope: a spec reading getComputedStyle for a property governed by + // a stylesheet outside this list asserts against the browser default and passes whatever the + // rule says, so add the stylesheet here before writing such a spec. + css: { include: [/allotment[\\/]dist[\\/]style\.css$/] }, coverage: { provider: 'v8', exclude: [