R-C

Add passengers. Revisit any passenger.

One block of steps per passenger, written once and run as many times as there are people. Add and remove passengers, then go back and edit the second after finishing the third.

This page ships the React binding and only that one. React is what the server renders when no framework is named, so the choice is a link rather than a switch: nothing a browser remembered can disagree with what arrived.

Who is travelling

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.

stack
not started
passengers
p1

Starting.

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)}`} &mdash; 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>
  );
}