The source it is running
These blocks are the files the application above imports, read at build time. There is no copy of them to fall out of date.
flow.ts
A sub-flow, and a group that repeats it once per item. `keyBy` is what makes a position durable: the index moves when the list changes, the key does not.
import { empty, get, not } from '@wizzard-packages/core/expr';
import { defineFlow, group, step } from '@wizzard-packages/core';
import type { SubFlows } from '@wizzard-packages/core';
/**
* R-C: one block of steps per passenger.
*
* A repeat group is a sub-flow entered once per item, so the two questions each
* passenger answers are written once, here, and the engine runs them as many
* times as there are passengers. Adding a fourth passenger is a write to
* `data.passengers`; nothing in the definition and nothing in the rendering
* knows how many there are.
*/
export const passenger = defineFlow({
id: 'passenger',
order: ['seat', 'meal'],
steps: {
seat: step({ label: 'Seat' }),
meal: step({ label: 'Meal' }),
},
policy: 'free',
});
export const trip = defineFlow({
id: 'trip',
version: 1,
order: ['party', 'people', 'review'],
steps: {
party: step({ label: 'Who is travelling' }),
people: group({
label: 'Passengers',
flow: 'passenger',
// A group is a step, so it answers to its own `when` first: an empty list
// means the group is not on the route and the flow walks past it.
when: not(empty(get('data.passengers'))),
// `keyBy` is what makes a position durable. The index moves when the list
// is reordered or one is removed; the key does not, so a frame that names
// a key still names the same passenger afterwards.
repeat: { over: get('data.passengers'), keyBy: 'id' },
}),
review: step({ label: 'Review' }),
},
policy: 'free',
});
/** A string `flow` is resolved here, the way a host resolves any other name. */
export const subFlows: SubFlows = { passenger };
/**
* Where one passenger's answers live.
*
* The engine does not namespace an item's data: `set` writes the literal path
* it is given, and a sub-flow step called `seat` would otherwise write the same
* `data.seat` for every passenger. The host decides where the answers go and
* builds the path from the item's key, which is what `loop.key` exists for on
* the flow side.
*/
export const answerPath = (key: string, field: 'seat' | 'meal'): string =>
`answers.${key}.${field}`;
/** One passenger to begin with, so the first thing on screen is a list of one. */
export const initialData = (): Record<string, unknown> => ({
passengers: [{ id: 'p1', name: 'Ada' }],
});
party.ts
The host half of a repeat: where an item’s answers live, and how a rendering reads the key off the frame. It also carries the one workaround this application needed.
import type { Frame, Wizard } from '@wizzard-packages/core';
/** One row of `data.passengers`. `id` is what `keyBy` reads. */
export interface Passenger {
id: string;
name: string;
}
export const listOf = (data: Readonly<Record<string, unknown>>): readonly Passenger[] =>
(data['passengers'] as Passenger[] | undefined) ?? [];
/**
* The key of the repeat frame the wizard is standing in, or null outside one.
*
* The stack is the enclosing frames with the current step last, and only a
* repeat frame carries a key - which is how a rendering knows whose answers it
* is showing without counting anything itself.
*/
export const keyOf = (stack: readonly Frame[]): string | null =>
stack.find((frame) => frame.key !== undefined)?.key ?? null;
/**
* A fresh id, unused by anyone in the list and by anyone who has answered.
*
* The second half is what makes it correct. A key is a data path, and removing
* the last passenger frees `p3` in the list while `answers.p3` is still sitting
* there - so an id chosen from the list alone would hand the next passenger
* somebody else's seat. The host clears the answers on removal too, and this
* looks at both, because either alone is one refactor away from the same bug.
*/
export const nextId = (
list: readonly Passenger[],
answers: Readonly<Record<string, unknown>> = {}
): string => {
let n = list.length + 1;
while (list.some((p) => p.id === `p${n}`) || `p${n}` in answers) n += 1;
return `p${n}`;
};
/** Everyone's answers, keyed by passenger. */
export const answersOf = (
data: Readonly<Record<string, unknown>>
): Readonly<Record<string, { seat?: string; meal?: string }>> =>
(data['answers'] as Record<string, { seat?: string; meal?: string }> | undefined) ?? {};
/** The same, without one passenger. Removing a person removes their answers. */
export const withoutAnswers = (
data: Readonly<Record<string, unknown>>,
id: string
): Record<string, { seat?: string; meal?: string }> => {
const rest = { ...answersOf(data) };
delete rest[id];
return rest;
};
/**
* Walks into the group until the frame names `key`.
*
* **A finding, not a pattern.** `go` addresses a step, never an item: entering
* a repeat group always lands on the first one, because `enter` takes
* `items.keys[0]` and there is no way to say which. So revisiting the second
* passenger after finishing the third means entering and advancing until the
* frame's key matches, which is fine here - every step it passes was already
* answered - and would not be fine in a flow with validators between.
*
* What the engine is missing is a target that can name an item, and this
* application is the argument for it. Filed in `TODOS.md`.
*/
export async function goToPassenger(wizard: Wizard, key: string): Promise<boolean> {
const entered = await wizard.go('people');
if (!entered.ok) return false;
// Two steps per passenger, plus the one the entry already used.
const hops = listOf(wizard.getState().data).length * 2 + 1;
for (let hop = 0; hop < hops; hop++) {
if (keyOf(wizard.getState().stack) === key) return true;
const moved = await wizard.next();
if (!moved.ok || moved.to === '@end') return false;
}
return false;
}
App.tsx
The rendering draws the list, one passenger’s question, or the review. It never counts passengers to work out where it is.
import { groups } from '@wizzard-packages/core/groups';
import {
WizardProvider,
useNavigation,
useStep,
useWizard,
useWizardSelector,
} from '@wizzard-packages/react';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { answerPath, initialData, subFlows, trip } from './flow';
import {
answersOf,
goToPassenger,
keyOf,
listOf,
nextId,
withoutAnswers,
type Passenger,
} from './party';
/**
* R-C on the React binding: one block of steps per passenger.
*
* The rendering draws three things - the list, one passenger's question, and
* the review - and the engine decides which. It never counts passengers to work
* out where it is: the frame carries the key, and everything on screen is read
* from that.
*
* Traversal is installed rather than imported by the engine. A flat flow pays
* nothing for the machinery that walks sub-flows, so a flow that contains a
* group has to be handed it.
*/
const SEATS = ['Window', 'Aisle'] as const;
const MEALS = ['Standard', 'Vegetarian', 'None'] as const;
export default function PassengersApp(): ReactNode {
return (
<WizardProvider flow={trip} groups={groups} subFlows={subFlows} data={initialData()}>
<Trip />
</WizardProvider>
);
}
function Trip(): ReactNode {
const wizard = useWizard();
const { current, active, index } = useStep();
const { back, canBack, isBusy } = useNavigation();
const data = useWizardSelector((s) => s.data);
const stack = useWizardSelector((s) => s.stack);
const [announcement, setAnnouncement] = useState('');
const heading = useRef<HTMLHeadingElement>(null);
const list = listOf(data);
const key = keyOf(stack);
const at = key === null ? -1 : list.findIndex((p) => p.id === key);
const standing = current ?? active[0] ?? 'party';
const moved = useRef(false);
useEffect(() => {
if (current === null) return;
if (!moved.current) {
moved.current = true;
return;
}
heading.current?.focus();
}, [current]);
// Reaching the end does not move the wizard: it stays on the last step and
// says `to: '@end'`. What changed is `status`.
// `status` and not `completed`: a forward `go` marks the step it left as
// completed, and Edit jumps away from Review, so `completed` would call the
// trip booked the moment somebody went back to change a seat.
const finished = useWizardSelector((s) => s.status === 'done');
const answers = (id: string): { seat?: string; meal?: string } => answersOf(data)[id] ?? {};
const choose = (field: 'seat' | 'meal', value: string): void => {
if (key === null) return;
wizard.set(answerPath(key, field), value);
};
const addPassenger = (): void => {
wizard.set('passengers', [...list, { id: nextId(list, answersOf(data)), name: '' }]);
setAnnouncement(`Passenger ${list.length + 1} added.`);
};
const removePassenger = (id: string): void => {
// Their answers go with them, in one commit: a key is a data path, and
// leaving `answers.p3` behind is how the next passenger to be given that
// key would inherit somebody else's seat.
wizard.batch(() => {
wizard.set(
'passengers',
list.filter((p) => p.id !== id)
);
wizard.set('answers', withoutAnswers(data, id));
});
setAnnouncement(`Passenger removed. ${list.length - 1} left.`);
};
const rename = (id: string, name: string): void => {
wizard.set(
'passengers',
list.map((p) => (p.id === id ? { ...p, name } : p))
);
};
async function onNext(): Promise<void> {
const result = await wizard.next();
if (result.ok) {
if (result.to === '@end') setAnnouncement('Booked.');
return;
}
setAnnouncement(`That move was refused: ${result.reason}.`);
}
async function startAgain(): Promise<void> {
setAnnouncement('');
wizard.reset(initialData());
await wizard.start();
}
async function edit(id: string): Promise<void> {
const reached = await goToPassenger(wizard, id);
setAnnouncement(
reached ? `Editing ${nameOf(list, id)}.` : `Could not reach ${nameOf(list, id)}.`
);
}
return (
<div className="app">
{standing === 'party' && (
<>
<h2 ref={heading} tabIndex={-1}>
Who is travelling
</h2>
<p>
Each passenger answers the same two questions. The definition says so once; the engine
runs the block as many times as there are people in this list.
</p>
<ul className="party">
{list.map((person, position) => (
<li key={person.id}>
<label className="field">
<span className="field-label">Passenger {position + 1}</span>
<input
value={person.name}
placeholder="Name"
onChange={(e) => {
rename(person.id, e.target.value);
}}
/>
</label>
<button
className="button button-secondary"
type="button"
onClick={() => {
removePassenger(person.id);
}}
>
Remove
</button>
</li>
))}
</ul>
<div className="actions">
<button className="button button-secondary" type="button" onClick={addPassenger}>
Add a passenger
</button>
</div>
</>
)}
{(standing === 'seat' || standing === 'meal') && key !== null && (
<>
<p className="app-where">
Passenger {at + 1} of {list.length}
{nameOf(list, key) === '' ? '' : `, ${nameOf(list, key)}`} — step {index + 1} of{' '}
{active.length}
</p>
<h2 ref={heading} tabIndex={-1}>
{standing === 'seat' ? 'Seat' : 'Meal'}
</h2>
<Choice
label={standing === 'seat' ? 'Where would they sit' : 'What would they eat'}
options={standing === 'seat' ? SEATS : MEALS}
value={standing === 'seat' ? answers(key).seat : answers(key).meal}
onChoose={(value) => {
choose(standing, value);
}}
/>
</>
)}
{standing === 'review' && (
<>
<h2 ref={heading} tabIndex={-1}>
Review
</h2>
<table className="party-review">
<thead>
<tr>
<th scope="col">Passenger</th>
<th scope="col">Seat</th>
<th scope="col">Meal</th>
<th scope="col">
<span className="visually-hidden">Actions</span>
</th>
</tr>
</thead>
<tbody>
{list.map((person, position) => (
<tr key={person.id}>
<th scope="row">
{person.name === '' ? `Passenger ${position + 1}` : person.name}
</th>
<td>{answers(person.id).seat ?? 'not chosen'}</td>
<td>{answers(person.id).meal ?? 'not chosen'}</td>
<td>
<button
className="button button-secondary"
type="button"
onClick={() => {
void edit(person.id);
}}
>
Edit {person.name === '' ? `passenger ${position + 1}` : person.name}
</button>
</td>
</tr>
))}
</tbody>
</table>
{finished && <p>Booked. Nothing below moves until this one starts again.</p>}
{!finished && (
<div className="actions">
<button
className="button button-secondary"
type="button"
onClick={() => {
void wizard.go('party');
}}
>
Change who is travelling
</button>
</div>
)}
</>
)}
<div className="actions">
{finished ? (
<button
className="button button-accent"
type="button"
onClick={() => {
void startAgain();
}}
>
Start again
</button>
) : (
<>
<button
className="button button-accent"
type="button"
disabled={current === null || isBusy}
onClick={() => {
void onNext();
}}
>
{standing === 'review' ? 'Book the trip' : 'Next'}
</button>
<button
className="button button-secondary"
type="button"
disabled={current === null || !canBack || isBusy}
onClick={() => {
void back();
}}
>
Back
</button>
</>
)}
</div>
<dl className="app-state">
<dt>stack</dt>
<dd>{stack.map((frame) => printFrame(frame)).join(' / ') || 'not started'}</dd>
<dt>passengers</dt>
<dd>{list.length === 0 ? 'none' : list.map((p) => p.id).join(', ')}</dd>
</dl>
<p className="app-live" role="status" aria-live="polite">
{current === null ? 'Starting.' : announcement}
</p>
</div>
);
}
const nameOf = (list: readonly Passenger[], id: string): string =>
list.find((p) => p.id === id)?.name ?? '';
/** The stack, printed the way the engine holds it: enclosing frames, then the step. */
const printFrame = (frame: { flow: string; step: string; key?: string }): string =>
frame.key === undefined
? `${frame.flow}.${frame.step}`
: `${frame.flow}.${frame.step}[${frame.key}]`;
function Choice(props: {
label: string;
options: readonly string[];
value: string | undefined;
onChoose: (value: string) => void;
}): ReactNode {
return (
<fieldset className="field">
<legend className="field-label">{props.label}</legend>
<div className="segmented">
{props.options.map((option) => (
<button
key={option}
type="button"
aria-pressed={props.value === option}
onClick={() => {
props.onChoose(option);
}}
>
{option}
</button>
))}
</div>
</fieldset>
);
}