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
The same file the React page shows. A group is a sub-flow entered once per item, and neither rendering knows how many items there are.
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
Also the same file: where an item’s answers live, how a rendering reads the key off the frame, and 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;
}
Trip.vue
The rendering, with `v-for` where the React file has `map`. The engine decides which of the three views is on screen.
<script setup lang="ts">
import { useNavigation, useStep, useWizard, useWizardSelector } from '@wizzard-packages/vue';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { answerPath, initialData } from './flow';
import {
answersOf,
goToPassenger,
keyOf,
listOf,
nextId,
withoutAnswers,
type Passenger,
} from './party';
/**
* R-C on the Vue binding. The same application as `App.tsx`: the rendering
* draws the list, one passenger's question, or 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.
*/
const SEATS = ['Window', 'Aisle'];
const MEALS = ['Standard', 'Vegetarian', 'None'];
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 = ref('');
const heading = useTemplateRef<HTMLHeadingElement>('heading');
const list = computed(() => listOf(data.value));
const itemKey = computed(() => keyOf(stack.value));
const at = computed(() =>
itemKey.value === null ? -1 : list.value.findIndex((p) => p.id === itemKey.value)
);
const standing = computed(() => current.value ?? active.value[0] ?? 'party');
// 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');
watch(current, (to) => {
if (to === null) return;
heading.value?.focus();
});
const answersFor = (id: string): { seat?: string; meal?: string } =>
answersOf(data.value)[id] ?? {};
const nameOf = (id: string): string => list.value.find((p) => p.id === id)?.name ?? '';
const choose = (field: 'seat' | 'meal', value: string): void => {
if (itemKey.value === null) return;
wizard.set(answerPath(itemKey.value, field), value);
};
const chosen = (field: 'seat' | 'meal'): string | undefined =>
itemKey.value === null ? undefined : answersFor(itemKey.value)[field];
const addPassenger = (): void => {
wizard.set('passengers', [
...list.value,
{ id: nextId(list.value, answersOf(data.value)), name: '' },
]);
announcement.value = `Passenger ${list.value.length} 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.value.filter((p: Passenger) => p.id !== id)
);
wizard.set('answers', withoutAnswers(data.value, id));
});
announcement.value = `Passenger removed. ${list.value.length} left.`;
};
const rename = (id: string, name: string): void => {
wizard.set(
'passengers',
list.value.map((p: Passenger) => (p.id === id ? { ...p, name } : p))
);
};
async function onNext(): Promise<void> {
const result = await wizard.next();
if (result.ok) {
if (result.to === '@end') announcement.value = 'Booked.';
return;
}
announcement.value = `That move was refused: ${result.reason}.`;
}
async function startAgain(): Promise<void> {
announcement.value = '';
wizard.reset(initialData());
await wizard.start();
}
async function edit(id: string): Promise<void> {
const reached = await goToPassenger(wizard, id);
announcement.value = reached ? `Editing ${nameOf(id)}.` : `Could not reach ${nameOf(id)}.`;
}
const printFrame = (frame: { flow: string; step: string; key?: string }): string =>
frame.key === undefined
? `${frame.flow}.${frame.step}`
: `${frame.flow}.${frame.step}[${frame.key}]`;
</script>
<template>
<div class="app">
<template v-if="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 class="party">
<li v-for="(person, position) in list" :key="person.id">
<label class="field">
<span class="field-label">Passenger {{ position + 1 }}</span>
<input
:value="person.name"
placeholder="Name"
@input="rename(person.id, ($event.target as HTMLInputElement).value)"
/>
</label>
<button class="button button-secondary" type="button" @click="removePassenger(person.id)">
Remove
</button>
</li>
</ul>
<div class="actions">
<button class="button button-secondary" type="button" @click="addPassenger">
Add a passenger
</button>
</div>
</template>
<template v-else-if="(standing === 'seat' || standing === 'meal') && itemKey !== null">
<p class="app-where">
Passenger {{ at + 1 }} of {{ list.length
}}{{ nameOf(itemKey) === '' ? '' : `, ${nameOf(itemKey)}` }} — step {{ index + 1 }} of
{{ active.length }}
</p>
<h2 ref="heading" tabindex="-1">{{ standing === 'seat' ? 'Seat' : 'Meal' }}</h2>
<fieldset class="field">
<legend class="field-label">
{{ standing === 'seat' ? 'Where would they sit' : 'What would they eat' }}
</legend>
<div class="segmented">
<button
v-for="option in standing === 'seat' ? SEATS : MEALS"
:key="option"
type="button"
:aria-pressed="chosen(standing) === option"
@click="choose(standing, option)"
>
{{ option }}
</button>
</div>
</fieldset>
</template>
<template v-else-if="standing === 'review'">
<h2 ref="heading" tabindex="-1">Review</h2>
<table class="party-review">
<thead>
<tr>
<th scope="col">Passenger</th>
<th scope="col">Seat</th>
<th scope="col">Meal</th>
<th scope="col"><span class="visually-hidden">Actions</span></th>
</tr>
</thead>
<tbody>
<tr v-for="(person, position) in list" :key="person.id">
<th scope="row">
{{ person.name === '' ? `Passenger ${position + 1}` : person.name }}
</th>
<td>{{ answersFor(person.id).seat ?? 'not chosen' }}</td>
<td>{{ answersFor(person.id).meal ?? 'not chosen' }}</td>
<td>
<button class="button button-secondary" type="button" @click="edit(person.id)">
Edit {{ person.name === '' ? `passenger ${position + 1}` : person.name }}
</button>
</td>
</tr>
</tbody>
</table>
<p v-if="finished">Booked. Nothing below moves until this one starts again.</p>
<div v-else class="actions">
<button class="button button-secondary" type="button" @click="wizard.go('party')">
Change who is travelling
</button>
</div>
</template>
<div class="actions">
<button v-if="finished" class="button button-accent" type="button" @click="startAgain()">
Start again
</button>
<template v-else>
<button
class="button button-accent"
type="button"
:disabled="current === null || isBusy"
@click="onNext()"
>
{{ standing === 'review' ? 'Book the trip' : 'Next' }}
</button>
<button
class="button button-secondary"
type="button"
:disabled="current === null || !canBack || isBusy"
@click="back()"
>
Back
</button>
</template>
</div>
<dl class="app-state">
<dt>stack</dt>
<dd>{{ stack.map(printFrame).join(' / ') || 'not started' }}</dd>
<dt>passengers</dt>
<dd>{{ list.length === 0 ? 'none' : list.map((p: Passenger) => p.id).join(', ') }}</dd>
</dl>
<p class="app-live" role="status" aria-live="polite">
{{ current === null ? 'Starting.' : announcement }}
</p>
</div>
</template>