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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion Documentation/DataPage/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<div style={{ height: '100vh' }}>
<DataPage title="Authors" query={AllAuthors} emptyMessage="No authors found">
<DataPage.Columns>
<Column field="name" header="Name" sortable />
</DataPage.Columns>
</DataPage>
</div>
```

```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
<div>
<DataPage title="Authors" query={AllAuthors} emptyMessage="No authors found">
<DataPage.Columns>
<Column field="name" header="Name" sortable />
</DataPage.Columns>
</DataPage>
</div>
```

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:
Expand Down
65 changes: 65 additions & 0 deletions Source/CommandDialog/CommandStepper.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,68 @@ export const WithValidationIndicators: Story = {
);
},
};

/**
* A step rendered as `{condition && <StepperPanel/>}` 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 (
<div style={{ width: '600px', padding: '1.5rem' }}>
<button
className="p-button p-component mb-3"
onClick={() => setIncludeBudgetStep(current => !current)}
>
{includeBudgetStep ? 'Hide the optional Budget step' : 'Show the optional Budget step'}
</button>
<p className="mb-3 text-sm text-color-secondary">
The Budget step is currently <strong>{includeBudgetStep ? 'shown' : 'hidden'}</strong>, so the
wizard has {includeBudgetStep ? 'three' : 'two'} steps.
</p>

<CommandStepper<CreateProjectCommand>
command={CreateProjectCommand}
autoServerValidate={false}
validateOn="change"
onSuccess={async () => setResult('Command submitted successfully')}
>
<StepperPanel header="Basic Info">
<InputTextField<CreateProjectCommand>
value={c => c.name}
title="Project Name"
placeholder="Enter project name (min 2 chars)"
/>
</StepperPanel>
<StepperPanel header="Details">
<TextAreaField<CreateProjectCommand>
value={c => c.description}
title="Description"
placeholder="Describe the project (min 10 chars)"
rows={4}
/>
</StepperPanel>
{includeBudgetStep && (
<StepperPanel header="Budget">
<NumberField<CreateProjectCommand>
value={c => c.budget}
title="Budget"
placeholder="Enter budget (must be > 0)"
/>
</StepperPanel>
)}
</CommandStepper>

{result && (
<div className="p-2 mt-3 border-round surface-100" style={{ border: '1px solid var(--cratis-surface-border)' }}>
{result}
</div>
)}
</div>
);
},
};
40 changes: 25 additions & 15 deletions Source/CommandDialog/CommandStepper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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 && <StepperPanel/>}`)
// 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<string, unknown>;
return extractFieldNamesFromNode(stepProps.children as React.ReactNode);
}),
[children]
[steps]
);

const stepErrors = useMemo(
Expand All @@ -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(() => {
Expand Down Expand Up @@ -223,34 +233,34 @@ 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 = () => {
if (isCurrentStepInvalid) {
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 (
<div className="cratis-command-stepper">
<PrimeStepper
activeStep={activeStep}
activeStep={currentStep}
linear={linear}
orientation={orientation}
headerPosition={headerPosition}
Expand All @@ -261,7 +271,7 @@ export const CommandStepperContent = ({
ptOptions={ptOptions}
unstyled={unstyled}
>
{processChildren(children)}
{processChildren(steps)}
</PrimeStepper>

{showNavigation && (
Expand Down
81 changes: 81 additions & 0 deletions Source/CommandDialog/StepperCommandDialog.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,84 @@ export const WithResponseTypeAndCallbacks: Story = {
);
},
};

/**
* A step rendered as `{condition && <StepperPanel/>}` 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 (
<div className="storybook-wrapper">
<button
className="p-button p-component mb-3"
onClick={() => setIncludeBudgetStep(current => !current)}
>
{includeBudgetStep ? 'Hide the optional Budget step' : 'Show the optional Budget step'}
</button>
<button
className="p-button p-component mb-3 ml-2"
onClick={() => {
setResult('');
setVisible(true);
}}
>
Open Dialog
</button>
<p className="mb-3 text-sm text-color-secondary">
The Budget step is currently <strong>{includeBudgetStep ? 'shown' : 'hidden'}</strong>, so the
wizard has {includeBudgetStep ? 'three' : 'two'} steps.
</p>

{result && (
<div className="p-3 mt-3 bg-green-100 border-round">
<strong>Submitted:</strong> {result}
</div>
)}

<StepperCommandDialog<CreateProjectCommand>
command={CreateProjectCommand}
visible={visible}
title="Create New Project"
okLabel="Create"
autoServerValidate={false}
onConfirm={async () => {
setResult('Project created successfully');
setVisible(false);
}}
onCancel={() => setVisible(false)}
>
<StepperPanel header="Basic Info">
<InputTextField<CreateProjectCommand>
value={c => c.name}
title="Project Name"
placeholder="Enter project name (min 2 chars)"
/>
</StepperPanel>
<StepperPanel header="Details">
<TextAreaField<CreateProjectCommand>
value={c => c.description}
title="Description"
placeholder="Describe the project (min 10 chars)"
rows={4}
/>
</StepperPanel>
{includeBudgetStep && (
<StepperPanel header="Budget">
<NumberField<CreateProjectCommand>
value={c => c.budget}
title="Budget"
placeholder="Enter budget (must be > 0)"
/>
</StepperPanel>
)}
</StepperCommandDialog>
</div>
);
},
};
25 changes: 17 additions & 8 deletions Source/CommandDialog/StepperCommandDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -159,11 +160,19 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
contextCloseDialog = undefined;
}

const stepCount = React.Children.count(children);
const isLastStep = activeStep === stepCount - 1;
const isFirstStep = activeStep === 0;
// Only the steps that actually render count. That count is not fixed: a conditional step
// (`{condition && <StepperPanel/>}`) 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;
Expand Down Expand Up @@ -233,7 +242,7 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
<Button
label={previousLabel}
icon="pi pi-arrow-left"
onClick={() => setActiveStep(s => s - 1)}
onClick={() => setActiveStep(Math.max(0, currentStep - 1))}
disabled={isBusy}
outlined
/>
Expand All @@ -245,8 +254,8 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
icon="pi pi-arrow-right"
iconPos="right"
onClick={() => {
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}
/>
Expand Down Expand Up @@ -281,7 +290,7 @@ const StepperCommandDialogWrapper = <TCommand extends object, TResponse = object
unstyled={dialogUnstyled}
>
<CommandStepperContent
activeStep={activeStep}
activeStep={currentStep}
visitedSteps={visitedSteps}
getFieldError={getFieldError}
onActiveStepChange={setActiveStep}
Expand Down
Loading
Loading