Skip to the content

Capsid menu

app/components/admin/kb-form-steps.tsx

415 lines and 66 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of kb-form-steps.tsx
FunctionLinesComplexityCoveredCRAPMutation
goTo19 to 286No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:25>25 to 251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
StepCard41 to 2694No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
add72 to 807No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
pick81 to 871No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:81>81 to 875No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:88>88 to 882No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:89>89 to 891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:89>89 to 891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:97>97 to 1015No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:111>111 to 1111No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:114>114 to 1141No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:125>125 to 1251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:132>132 to 1321No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:136>136 to 1361No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:137>137 to 1391No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:146>146 to 1501No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:157>157 to 1611No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:168>168 to 1721No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:179>179 to 1831No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:189>189 to 1891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:191>191 to 1911No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:192>192 to 1941No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:196>196 to 1961No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:203>203 to 2031No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:205>205 to 2051No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:212>212 to 2546No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:220>220 to 2253No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
named222 to 2222No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:224>224 to 2242No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:227>227 to 2311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:239>239 to 2391No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:239>239 to 2392No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:246>246 to 2461No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:246>246 to 2462No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:249>249 to 2491No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:249>249 to 2491No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:255>255 to 2551No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
StepList271 to 3381No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
move290 to 3005No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:299>299 to 2991No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:303>303 to 3352No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:313>313 to 3131No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:313>313 to 3132No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:314>314 to 3141No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:315>315 to 3151No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:315>315 to 3151No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
onDragStart319 to 3221No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
onDragOver323 to 3252No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
onDrop326 to 3302No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
onDragEnd331 to 3311No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
BodyEditor340 to 4251No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
setSection354 to 3541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:354>354 to 3542No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:365>365 to 3651No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:368>368 to 4193No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:374>374 to 3741No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:376>376 to 4043No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:389>389 to 3891No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:389>389 to 3892No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:402>402 to 4021No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:402>402 to 4022No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:409>409 to 4142No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:410>410 to 4101No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:412>412 to 4122No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:420>420 to 4201No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/components/admin/kb-file-editor.tsx, app/components/admin/kb-form-fields.tsx, app/components/admin/kb-step-text.tsx, app/kb/form.mjs, app/kb/form.server.ts, app/kb/gap-labels.mjs. Imported by app/components/admin/kb-form.tsx.

Source

import { useRef, useState } from "react";

import { Button } from "capsomer/react/button";
import { Panel } from "capsomer/react/panel";

import { StepText } from "~/components/admin/kb-step-text";
import type { FormBlock, FormBody, FormStep } from "~/kb/form.mjs";
import { stepAnchor } from "~/kb/gap-labels.mjs";
import type { Choice, KbForm } from "~/kb/form.server";
import { plural } from "~/components/admin/kb-file-editor";
import { DURATIONS, ReferencePicker, UNITS } from "~/components/admin/kb-form-fields";

/* The form editor's method (kb-form.tsx): the step cards in their sections, and the body editor that orders them. */

/**
 * Scrolls to where a gap is filled in and puts the cursor there: the input itself, or in a step card the note that still
 * says MISSING. Without script the link is a plain #anchor.
 */
export function goTo(e: React.MouseEvent | null, anchor: string) {
  const target = document.getElementById(anchor);
  if (!target) return;
  e?.preventDefault();
  target.scrollIntoView({ block: "center" });
  const inputs = [...target.querySelectorAll<HTMLInputElement>("input")];
  const field = target.matches("input, select, textarea") ? target : (inputs.find((i) => i.value.startsWith("MISSING:")) ?? target.querySelector<HTMLElement>("input, select, textarea"));
  (field ?? target).focus({ preventScroll: true });
  if (e) history.replaceState(null, "", `#${anchor}`);
}

/** The things a step card can insert, from the protocol's own lists and the lab registry. */
export type Inserts = {
  reagents: Choice[];
  equipment: Choice[];
  primers: Choice[];
  strains: Choice[];
  /** The names a chip can take: the protocol's reagents and equipment as it lists them. */
  names: { reagent: string[]; equipment: string[] };
  references: KbForm["references"];
};

function StepCard({
  step,
  index,
  count,
  number,
  anchor,
  flags,
  inserts,
  onChange,
  onMove,
  onRemove,
  onInsert,
  dragProps,
}: {
  step: FormStep;
  index: number;
  count: number;
  number: number;
  anchor: string;
  flags: readonly string[];
  inserts: Inserts;
  onChange: (step: FormStep) => void;
  onMove: (to: number) => void;
  onRemove: () => void;
  onInsert: (kind: "reagent" | "equipment" | "primer" | "strain", value: string, amount?: string) => string | null;
  dragProps: React.HTMLAttributes<HTMLLIElement>;
}) {
  const caret = useRef<number | null>(null);
  const [amount, setAmount] = useState({ value: "", unit: "µl" });
  const [duration, setDuration] = useState({ value: "", unit: "minutes" });
  const [temperature, setTemperature] = useState("");
  const add = (words: string) => {
    if (!words) return;
    const at = Math.min(caret.current ?? step.text.length, step.text.length);
    const before = step.text.slice(0, at);
    const after = step.text.slice(at);
    const pad = before && !before.endsWith(" ") ? " " : "";
    onChange({ ...step, text: `${before}${pad}${words}${after && !after.startsWith(" ") ? " " : ""}${after}` });
    caret.current = before.length + pad.length + words.length;
  };
  const pick = (kind: "reagent" | "equipment" | "primer" | "strain") => (e: React.ChangeEvent<HTMLSelectElement>) => {
    const value = e.target.value;
    e.target.value = "";
    if (!value) return;
    const words = onInsert(kind, value, kind === "reagent" && amount.value ? `${amount.value}%${amount.unit}` : undefined);
    if (words) add(words);
  };
  const flagLines = step.lines.flatMap((line, i) => ("kind" in line ? [{ line, i }] : []));
  const rawLines = step.lines.filter((line) => "raw" in line).map((line) => (line as { raw: string }).raw);
  const label = `Step ${number}`;
  return (
    <li
      id={anchor}
      className="kb-step"
      aria-label={label}
      tabIndex={-1}
      onKeyDown={(e) => {
        if (!e.altKey || (e.key !== "ArrowUp" && e.key !== "ArrowDown")) return;
        e.preventDefault();
        onMove(e.key === "ArrowUp" ? index - 1 : index + 1);
      }}
      {...dragProps}
    >
      <div className="kb-step-head">
        <span className="kb-drag" aria-hidden="true" title="Drag to reorder">
          ⠿
        </span>
        <span className="kb-step-number">{label}</span>
        <div className="app-actions" role="toolbar" aria-label={`${label}: move or remove`}>
          {/* Native buttons, so the first step's Move up and the last step's Move down are simply off, with no reason to read. */}
          <button type="button" className="cap-btn" data-variant="quiet" data-size="sm" aria-keyshortcuts="Alt+ArrowUp" disabled={index === 0} onClick={() => onMove(index - 1)} aria-label={`Move ${label.toLowerCase()} up`}>
            Move up
          </button>
          <button type="button" className="cap-btn" data-variant="quiet" data-size="sm" aria-keyshortcuts="Alt+ArrowDown" disabled={index === count - 1} onClick={() => onMove(index + 1)} aria-label={`Move ${label.toLowerCase()} down`}>
            Move down
          </button>
          <Button type="button" size="sm" variant="quiet" onClick={onRemove} aria-label={`Remove ${label.toLowerCase()}`}>
            Remove
          </Button>
        </div>
      </div>
      <p className="cap-field-label" id={`${anchor}-label`}>
        What to do
      </p>
      <StepText id={`${anchor}-text`} label={`${label}: what to do`} text={step.text} names={inserts.names} caret={caret} onChange={(next) => onChange({ ...step, text: next })} />

      <details className="kb-insert">
        <summary>Insert into this step: a reagent, equipment, a primer, a strain, a timer or a temperature</summary>
      <div className="kb-inserts" role="group" aria-label={`${label}: insert`}>
        <label className="kb-cell kb-cell-sm">
          <span className="cap-field-label">Amount</span>
          <input className="cap-input" inputMode="decimal" value={amount.value} onChange={(e) => setAmount({ ...amount, value: e.target.value })} />
        </label>
        <label className="kb-cell kb-cell-sm">
          <span className="cap-field-label">Unit</span>
          <select className="cap-input" value={amount.unit} onChange={(e) => setAmount({ ...amount, unit: e.target.value })}>
            {UNITS.map((u) => (
              <option key={u}>{u}</option>
            ))}
          </select>
        </label>
        <label className="kb-cell">
          <span className="cap-field-label">Insert a reagent</span>
          <select className="cap-input" defaultValue="" onChange={pick("reagent")}>
            <option value="">Choose a reagent</option>
            {inserts.reagents.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
        </label>
        <label className="kb-cell">
          <span className="cap-field-label">Insert equipment</span>
          <select className="cap-input" defaultValue="" onChange={pick("equipment")}>
            <option value="">Choose equipment</option>
            {inserts.equipment.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
        </label>
        <label className="kb-cell">
          <span className="cap-field-label">Insert a primer</span>
          <select className="cap-input" defaultValue="" onChange={pick("primer")}>
            <option value="">Choose a primer</option>
            {inserts.primers.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
        </label>
        <label className="kb-cell">
          <span className="cap-field-label">Insert a strain</span>
          <select className="cap-input" defaultValue="" onChange={pick("strain")}>
            <option value="">Choose a strain</option>
            {inserts.strains.map((o) => (
              <option key={o.value} value={o.value}>
                {o.label}
              </option>
            ))}
          </select>
        </label>
        <div className="kb-cell kb-cell-pair">
          <label className="kb-cell kb-cell-sm">
            <span className="cap-field-label">Duration</span>
            <input className="cap-input" inputMode="decimal" value={duration.value} onChange={(e) => setDuration({ ...duration, value: e.target.value })} />
          </label>
          <select className="cap-input" aria-label="Duration unit" value={duration.unit} onChange={(e) => setDuration({ ...duration, unit: e.target.value })}>
            {DURATIONS.map((u) => (
              <option key={u}>{u}</option>
            ))}
          </select>
          <Button type="button" size="sm" disabledReason={duration.value ? undefined : "Type a duration first."} onClick={() => add(`~{${duration.value}%${duration.unit}}`)}>
            Insert timer
          </Button>
        </div>
        <div className="kb-cell kb-cell-pair">
          <label className="kb-cell kb-cell-sm">
            <span className="cap-field-label">Temperature, °C</span>
            <input className="cap-input" inputMode="decimal" value={temperature} onChange={(e) => setTemperature(e.target.value)} />
          </label>
          <Button type="button" size="sm" disabledReason={temperature ? undefined : "Type a temperature first."} onClick={() => add(`${temperature} °C`)}>
            Insert temperature
          </Button>
        </div>
      </div>
      </details>

      {flagLines.map(({ line, i }) => {
        const flag = line as { kind: string; text: string; indent: string };
        return (
          <div className="kb-flag" key={i} data-flag={flag.kind}>
            <select
              className="cap-input"
              aria-label={`${label}: note kind`}
              value={flag.kind}
              onChange={(e) => {
                // A note that names something starts empty, to be picked; words carry over between the other kinds.
                const named = (k: string) => k === "TROUBLESHOOTING" || k === "CALC";
                const text = named(e.target.value) || named(flag.kind) ? "" : flag.text;
                onChange({ ...step, lines: step.lines.map((l, j) => (j === i ? { ...flag, kind: e.target.value, text } : l)) });
              }}
            >
              {flags.map((f) => (
                <option key={f} value={f}>
                  {f.charAt(0) + f.slice(1).toLowerCase()}
                </option>
              ))}
            </select>
            {flag.kind === "TROUBLESHOOTING" || flag.kind === "CALC" ? (
              <ReferencePicker
                label={`${label}: ${flag.kind === "CALC" ? "calculator" : "troubleshooting row"}`}
                choices={flag.kind === "CALC" ? inserts.references.calculators : inserts.references.troubleshooting}
                empty={flag.kind === "CALC" ? "Choose a calculator" : "Choose a row of the troubleshooting table"}
                text={flag.text}
                onChange={(next) => onChange({ ...step, lines: step.lines.map((l, j) => (j === i ? { ...flag, text: next } : l)) })}
              />
            ) : (
              <input
                className="cap-input"
                aria-label={`${label}: ${flag.kind.toLowerCase()} note`}
                value={flag.text}
                onChange={(e) => onChange({ ...step, lines: step.lines.map((l, j) => (j === i ? { ...flag, text: e.target.value } : l)) })}
              />
            )}
            <Button type="button" size="sm" variant="quiet" onClick={() => onChange({ ...step, lines: step.lines.filter((_, j) => j !== i) })} aria-label={`Remove this ${flag.kind.toLowerCase()} note`}>
              Remove
            </Button>
          </div>
        );
      })}
      <Button type="button" size="sm" onClick={() => onChange({ ...step, lines: [...step.lines, { kind: "CRITICAL", text: "", indent: "   " }] })}>
        Add a note
      </Button>
      {rawLines.length > 0 ? (
        <details className="kb-raw-lines">
          <summary>{plural(rawLines.length, "more line", "more lines")} under this step, as written</summary>
          <pre>
            <code>{rawLines.join("\n")}</code>
          </pre>
          <p className="cap-field-help">Commands, output and wrapped notes are kept as written; edit them under Advanced.</p>
        </details>
      ) : null}
    </li>
  );
}

function StepList({
  block,
  sectionTitle,
  flags,
  inserts,
  onChange,
  onInsert,
  announce,
}: {
  block: Extract<FormBlock, { type: "steps" }>;
  sectionTitle: string;
  flags: readonly string[];
  inserts: Inserts;
  onChange: (block: Extract<FormBlock, { type: "steps" }>) => void;
  onInsert: (kind: "reagent" | "equipment" | "primer" | "strain", value: string, amount?: string) => string | null;
  announce: (text: string) => void;
}) {
  const list = useRef<HTMLOListElement>(null);
  const [dragging, setDragging] = useState<number | null>(null);
  const move = (from: number, to: number) => {
    if (to < 0 || to >= block.steps.length || from === to) return;
    const steps = [...block.steps];
    const [moved] = steps.splice(from, 1);
    if (!moved) return;
    steps.splice(to, 0, moved);
    onChange({ ...block, steps });
    announce(`Moved to step ${block.start + to} of ${block.start + steps.length - 1}, in ${sectionTitle}.`);
    // Focus stays on the step that moved, so it can be moved again and its new place is read out.
    requestAnimationFrame(() => list.current?.querySelectorAll<HTMLLIElement>(":scope > li")[to]?.focus());
  };
  return (
    <ol className="kb-steps" ref={list} start={block.start}>
      {block.steps.map((step, i) => (
        <StepCard
          key={i}
          step={step}
          index={i}
          count={block.steps.length}
          number={block.start + i}
          anchor={stepAnchor(sectionTitle, block.start + i)}
          flags={flags}
          inserts={inserts}
          onChange={(next) => onChange({ ...block, steps: block.steps.map((s, j) => (j === i ? next : s)) })}
          onMove={(to) => move(i, to)}
          onRemove={() => onChange({ ...block, steps: block.steps.filter((_, j) => j !== i) })}
          onInsert={onInsert}
          dragProps={{
            draggable: true,
            onDragStart: (e) => {
              setDragging(i);
              e.dataTransfer.effectAllowed = "move";
            },
            onDragOver: (e) => {
              if (dragging !== null) e.preventDefault();
            },
            onDrop: (e) => {
              e.preventDefault();
              if (dragging !== null) move(dragging, i);
              setDragging(null);
            },
            onDragEnd: () => setDragging(null),
            className: `kb-step${dragging === i ? " kb-step-dragging" : ""}`,
          }}
        />
      ))}
    </ol>
  );
}

export function BodyEditor({
  body,
  flags,
  inserts,
  onChange,
  onInsert,
}: {
  body: FormBody;
  flags: readonly string[];
  inserts: Inserts;
  onChange: (body: FormBody) => void;
  onInsert: (kind: "reagent" | "equipment" | "primer" | "strain", value: string, amount?: string) => string | null;
}) {
  const [said, setSaid] = useState("");
  const setSection = (i: number, next: FormBody["sections"][number]) => onChange({ ...body, sections: body.sections.map((s, j) => (j === i ? next : s)) });
  return (
    <>
      <div aria-live="polite" className="cap-sr-only">
        {said}
      </div>
      <Panel title="Introduction">
        <div className="cap-field">
          <label className="cap-field-label" htmlFor="kb-intro">
            What this is, where it came from, who it is for
          </label>
          <textarea id="kb-intro" className="cap-input" rows={6} value={body.intro} onChange={(e) => onChange({ ...body, intro: e.target.value })} />
        </div>
      </Panel>
      {body.sections.map((section, i) => (
        <Panel key={i} title={section.heading || "New section"}>
          <div className="cap-field">
            <label className="cap-field-label" htmlFor={`kb-section-${i}`}>
              Section heading
            </label>
            <input id={`kb-section-${i}`} className="cap-input" value={section.heading} onChange={(e) => setSection(i, { ...section, heading: e.target.value })} />
          </div>
          {section.blocks.map((block, b) =>
            block.type === "prose" ? (
              // Blank lines between blocks are kept in the file and not shown as an empty box.
              block.text.trim() === "" ? null : (
              <div className="cap-field" key={b}>
                <label className="cap-field-label" htmlFor={`kb-prose-${i}-${b}`}>
                  Text
                </label>
                <textarea
                  id={`kb-prose-${i}-${b}`}
                  className="cap-input"
                  rows={Math.min(10, Math.max(2, block.text.split("\n").length))}
                  value={block.text}
                  onChange={(e) => setSection(i, { ...section, blocks: section.blocks.map((x, k) => (k === b ? { type: "prose", text: e.target.value } : x)) })}
                />
              </div>
              )
            ) : (
              <StepList
                key={b}
                block={block}
                sectionTitle={section.heading}
                flags={flags}
                inserts={inserts}
                onInsert={onInsert}
                announce={setSaid}
                onChange={(next) => setSection(i, { ...section, blocks: section.blocks.map((x, k) => (k === b ? next : x)) })}
              />
            ),
          )}
          <Button
            type="button"
            size="sm"
            onClick={() => {
              const last = [...section.blocks].reverse().find((x) => x.type === "steps") as Extract<FormBlock, { type: "steps" }> | undefined;
              const step: FormStep = { number: 0, text: "", lines: [], blanksBefore: 0 };
              if (last) setSection(i, { ...section, blocks: section.blocks.map((x) => (x === last ? { ...last, steps: [...last.steps, step] } : x)) });
              else setSection(i, { ...section, blocks: [...section.blocks, { type: "prose", text: "" }, { type: "steps", start: 1, steps: [step] }] });
            }}
          >
            Add a step to {section.heading || "this section"}
          </Button>
        </Panel>
      ))}
      <Button type="button" onClick={() => onChange({ ...body, sections: [...body.sections, { heading: "New section", blocks: [{ type: "prose", text: "" }] }] })}>
        Add a section
      </Button>
    </>
  );
}