Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: rewire a notification flow by drag or keyboard

The flow editor can now change a flow's shape on the canvas, not only
through the inspector's fields:

- drag a box onto another to move it and its branch there; a drop the
  save would refuse shows the target dashed red and gives the reason;
- a palette of node kinds adds a node below the picked box on click, or
  below any box it is dragged onto; it offers what the controller lists;
- arrow keys walk the tree, M picks a box up and Enter drops it under
  the focused one, Alt+Up/Down reorder a node among its siblings, Delete
  removes a branch; focus follows the edit;
- each box with siblings says when it runs, and the inspector can move
  it earlier or later, since the router runs children in edge order;
- request_reviewer is a second leaf kind beside todo, with a reviewer
  field;
- leaving with unsaved edits asks first, also for in-app links and the
  back button (a router hook runs navigation past a page's guard).
This commit is contained in:
iris 2026-10-24 14:49:48 +02:00 • committed by mara
commit 68e289fff5
16 changed files with 1072 additions and 133 deletions

View file

@ -238,21 +238,38 @@ the page shows why. Reload to switch against the current document.
#### Editing a flow #### Editing a flow
`/notifications/<flow id>` edits one custom flow. The canvas draws the flow left `/notifications/<flow id>` edits one custom flow. The canvas draws the flow left
to right: the source on the left, each match to its right, and the todos at the to right: the source on the left, each match to its right, and the leaf nodes at
ends. Pick a node to edit it in the panel beside the canvas: the ends. Pick a node to edit it in the panel beside the canvas:
- a **source** picks the forge events the flow starts from; - a **source** picks the forge events the flow starts from;
- a **match** lists conditions, each an event field and the value it must equal; - a **match** lists conditions, each an event field and the value it must equal;
- a **todo** says who gets it (the agents an event field names, or one agent), - a **todo** says who gets it (the agents an event field names, or one agent),
whether it sets or clears a todo, the todo's key, and its text. The key and the whether it sets or clears a todo, the todo's key, and its text. The key and the
text can use `{field}` placeholders. text can use `{field}` placeholders;
- a **request_reviewer** names the forge login the router asks to review the
pull request. It ends a branch, like a todo, and the row of kinds above the
canvas only offers it when the controller does.
Beside the fields, **add a match below** and **add a todo below** grow the Rewire the flow on the canvas:
branch, **move under** moves a node and what's below it to another match, and
**remove** takes a node and its branch out. The editor turns away any edit the - **Add** a node from the row of kinds above the canvas. Choose a kind to add it
controller would refuse to save, and says why: a todo with children, a second below the picked box, or drag it onto any box.
parent, a node under its own branch, or more nodes or levels than the - **Move** a node and its branch by dragging its box onto another box. With the
controller allows. keyboard, the arrow keys walk the tree, **M** picks up the focused box, the
arrows go to the box you want it under, and **Enter** drops it there.
**Escape** puts it back. The panel's **move under** list does the same.
- **Reorder** the branches of a node. The router runs a node's branches in the
order they sit, so when a clear and an upsert share a key prefix, the order
decides what's left. A box with siblings says when it runs, such as "runs
2nd". **run earlier** and **run later** in the panel, or **Alt+Up** and
**Alt+Down** on the canvas, swap it with its neighbour.
- **Remove** a node and its branch with **remove** in the panel or the
**Delete** key.
The editor turns away any edit the controller would refuse to save, and says
why: a leaf node with children, a second parent, a node under its own branch, or
more nodes or levels than the controller allows. A drop it will refuse shows the
target box dashed and red while you hold the box over it.
Edits stay in a draft until **save**, which sends the whole flows document with Edits stay in a draft until **save**, which sends the whole flows document with
the revision the editor read. **discard edits** goes back to the saved flow and the revision the editor read. **discard edits** goes back to the saved flow and
@ -262,8 +279,9 @@ editor offers to reload and drop your edits, or to keep them and replace this
flow's latest version with them. For any other refusal the editor shows the flow's latest version with them. For any other refusal the editor shows the
controller's own message. The router runs a saved flow from its next event. controller's own message. The router runs a saved flow from its next event.
Closing or reloading the tab with unsaved edits asks first. Leaving through a Leaving the page with unsaved edits asks first, whether you close or reload the
link in the app, or the browser's back button, drops the draft without asking. tab, follow a link in the app, or press the browser's back button. Choose
**leave and drop edits** to go on, or cancel to stay and save.
### Sending a message to an agent ### Sending a message to an agent

View file

@ -1,7 +1,8 @@
// Root shell component — routing only. Each route's content is its own // Root shell component — routing only. Each route's content is its own
// page component under `./pages/`; this file just maps paths to them. // page component under `./pages/`; this file just maps paths to them.
import { Route, Switch } from "wouter-preact"; import { Route, Router, Switch } from "wouter-preact";
import { Shell } from "./shell/Shell.js"; import { Shell } from "./shell/Shell.js";
import { useGuardedLocation } from "./shell/useGuardedLocation.js";
import { AgentsPage } from "./pages/agents/AgentsPage.js"; import { AgentsPage } from "./pages/agents/AgentsPage.js";
import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js"; import { AgentTerminalPage } from "./pages/agents/AgentTerminalPage.js";
import { AlertsPage } from "./pages/AlertsPage.js"; import { AlertsPage } from "./pages/AlertsPage.js";
@ -29,6 +30,7 @@ function NotFound() {
export function App() { export function App() {
return ( return (
<Router hook={useGuardedLocation}>
<Shell> <Shell>
<Switch> <Switch>
<Route path="/" component={StartPage} /> <Route path="/" component={StartPage} />
@ -53,5 +55,6 @@ export function App() {
<Route component={NotFound} /> <Route component={NotFound} />
</Switch> </Switch>
</Shell> </Shell>
</Router>
); );
} }

View file

@ -1,30 +1,316 @@
// <FlowCanvas> — a flow drawn as boxes and lines: the source on the left, its // <FlowCanvas> — a flow drawn as boxes and lines: the source on the left, its
// branches to the right. Pick a node to edit it; the inspector beside the // branches to the right, with a palette of node kinds above.
// canvas does the editing. //
import { layoutFlow, NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js"; // Rewiring is by pointer or by keyboard, and both end in the same edits the
import { describeNode, ellipsize, type Flow } from "./FlowTypes.js"; // inspector makes:
// - drag a box onto another to move it (and its branch) under that one, or
// drag a palette chip onto a box to add a new node under it;
// - arrow keys walk the tree, M picks the focused box up and Enter drops it
// under the box now focused, Alt+Up/Down changes when a branch runs among
// its siblings, Delete removes the box and its branch.
// A drop the save would refuse is turned away with the reason, so a bad flow
// is never drawn.
import { useEffect, useRef, useState } from "preact/hooks";
import {
addChild,
blankNode,
isLeafKind,
reparent,
siblingPosition,
type AddableKind,
type Edit,
type Limits,
} from "./FlowDraft.js";
import {
layoutFlow,
neighbour,
nodeAt,
NODE_HEIGHT,
NODE_WIDTH,
} from "./FlowLayout.js";
import { describeNode, ellipsize, ordinal, type Flow } from "./FlowTypes.js";
const TEXT_CHARS = 30; const TEXT_CHARS = 30;
// How far the pointer must travel before a press counts as a drag.
const DRAG_PX = 4;
export interface PaletteKind {
kind: AddableKind;
description: string;
}
type Source = { kind: "move"; id: string } | { kind: "add"; node: AddableKind };
interface Drag {
source: Source;
startX: number;
startY: number;
x: number;
y: number;
moved: boolean;
// The box under the pointer, if any.
over: string | undefined;
}
export interface CanvasEdits {
onSelect: (id: string) => void;
onAdd: (kind: AddableKind, parentId: string) => void;
onMove: (id: string, parentId: string) => void;
onReorder: (id: string, delta: -1 | 1) => void;
onRemove: (id: string) => void;
// A drop or key press the draft turned away, and why.
onRefused: (reason: string) => void;
}
const HELP =
"Drag a box onto another to move it there, or drag a kind from above onto a box to add it. " +
"Keys: arrows walk the tree, M picks a box up and Enter drops it under the focused box, " +
"Alt+Up and Alt+Down change when a branch runs, Delete removes a box and its branch.";
export function FlowCanvas({ export function FlowCanvas({
flow, flow,
selected, selected,
onSelect, limits,
palette,
...edits
}: { }: {
flow: Flow; flow: Flow;
selected: string | null; selected: string | null;
onSelect: (id: string) => void; limits: Limits;
}) { palette: PaletteKind[];
} & CanvasEdits) {
const layout = layoutFlow(flow); const layout = layoutFlow(flow);
const byId = new Map(flow.nodes.map((n) => [n.id, n])); const byId = new Map(flow.nodes.map((n) => [n.id, n]));
const svgRef = useRef<SVGSVGElement>(null);
const dragRef = useRef<Drag | null>(null);
const [, redraw] = useState(0);
// The box picked up with the keyboard, and the one the cursor is on.
const [carrying, setCarrying] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [said, setSaid] = useState("");
// When a key press last asked for focus to follow the selection, so a stale
// request cannot pull focus out of the inspector later.
const refocusAt = useRef(0);
const wantRefocus = () => {
refocusAt.current = Date.now();
};
// What an edit at `over` would do, without doing it.
const dryRun = (source: Source, over: string): Edit =>
source.kind === "move"
? reparent(flow, source.id, over, limits)
: addChild(flow, over, blankNode(flow, source.node), limits);
// The pointer handlers outlive a render, so they read the latest of these.
const latest = useRef({ layout, flow, dryRun, edits });
latest.current = { layout, flow, dryRun, edits };
const focusNode = (id: string) =>
svgRef.current?.querySelector<SVGGElement>(`[data-node="${id}"]`)?.focus();
// After an edit that moved or removed the focused box, put focus on the
// selected one so the keyboard carries on from where it was.
useEffect(() => {
if (selected && Date.now() - refocusAt.current < 300) {
refocusAt.current = 0;
focusNode(selected);
}
});
const dragging = dragRef.current !== null;
useEffect(() => {
if (!dragging) return;
const overAt = (e: PointerEvent): string | undefined => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return undefined;
return nodeAt(
latest.current.layout,
e.clientX - rect.left,
e.clientY - rect.top,
);
};
const onMove = (e: PointerEvent) => {
const d = dragRef.current;
if (!d) return;
const over = overAt(e);
dragRef.current = {
...d,
x: e.clientX,
y: e.clientY,
moved:
d.moved ||
Math.hypot(e.clientX - d.startX, e.clientY - d.startY) > DRAG_PX,
over:
d.source.kind === "move" && over === d.source.id ? undefined : over,
};
redraw((n) => n + 1);
};
const end = (drop: boolean) => {
const d = dragRef.current;
dragRef.current = null;
redraw((n) => n + 1);
if (!d) return;
const { edits: e, dryRun: dry } = latest.current;
if (!d.moved) {
// A press without travel: a click on a box picks it. A palette chip
// is a button, and its own click adds.
if (drop && d.source.kind === "move") e.onSelect(d.source.id);
return;
}
if (!drop || !d.over) return;
const result = dry(d.source, d.over);
if (!result.ok) {
e.onRefused(result.reason);
return;
}
if (d.source.kind === "move") e.onMove(d.source.id, d.over);
else e.onAdd(d.source.node, d.over);
};
const up = () => end(true);
const cancel = () => end(false);
const key = (e: KeyboardEvent) => {
if (e.key === "Escape") cancel();
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", cancel);
window.addEventListener("keydown", key);
return () => {
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", up);
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("keydown", key);
};
}, [dragging]);
const startDrag = (e: PointerEvent, source: Source) => {
if (e.button !== 0) return;
dragRef.current = {
source,
startX: e.clientX,
startY: e.clientY,
x: e.clientX,
y: e.clientY,
moved: false,
over: undefined,
};
redraw((n) => n + 1);
};
const drag = dragRef.current;
const dragSource = drag?.source;
// How a box reads while something is held over it.
const holdClass = (id: string): string => {
if (drag?.moved && dragSource && drag.over === id) {
return dryRun(dragSource, id).ok
? " notify-box-target"
: " notify-box-refused";
}
if (carrying && cursor === id && carrying !== id) {
return dryRun({ kind: "move", id: carrying }, id).ok
? " notify-box-target"
: " notify-box-refused";
}
return "";
};
const drop = (id: string) => {
if (!carrying) return;
const moving = carrying;
const result = reparent(flow, moving, id, limits);
setCarrying(null);
if (!result.ok) {
edits.onRefused(result.reason);
setSaid(`Not moved: ${result.reason}.`);
return;
}
wantRefocus();
edits.onMove(moving, id);
setSaid(`Moved ${moving} under ${id}.`);
};
const onKey = (e: KeyboardEvent, id: string) => {
const dirs: Record<string, "up" | "down" | "left" | "right"> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
};
const dir = dirs[e.key];
if (dir) {
if (e.altKey && (dir === "up" || dir === "down")) {
e.preventDefault();
wantRefocus();
edits.onReorder(id, dir === "up" ? -1 : 1);
return;
}
const next = neighbour(layout, flow, id, dir);
if (!next) return;
e.preventDefault();
setCursor(next);
if (!carrying) edits.onSelect(next);
focusNode(next);
return;
}
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
if (carrying) drop(id);
else edits.onSelect(id);
} else if (e.key === "m" || e.key === "M") {
const node = byId.get(id);
if (!node || node.kind === "source") return;
e.preventDefault();
setCarrying(id);
setCursor(id);
setSaid(
`Picked up ${id}. Arrow to the box to move it under, Enter to drop, Escape to cancel.`,
);
} else if (e.key === "Escape" && carrying) {
e.preventDefault();
setCarrying(null);
setSaid(`Put ${carrying} back.`);
} else if (e.key === "Delete" || e.key === "Backspace") {
const node = byId.get(id);
if (!node || node.kind === "source") return;
e.preventDefault();
wantRefocus();
edits.onRemove(id);
}
};
const tabStop = selected ?? flow.nodes.find((n) => n.kind === "source")?.id;
const selectedNode = selected ? byId.get(selected) : undefined;
const canAddBelow = !!selectedNode && !isLeafKind(selectedNode.kind);
return ( return (
<div class="notify-canvas-wrap">
<div class="notify-palette" role="group" aria-label="add a node">
<span class="notify-hint">add</span>
{palette.map((p) => (
<button
key={p.kind}
type="button"
class={`notify-chip notify-chip-${p.kind}`}
title={`${p.description ? `${p.description}. ` : ""}Click to add below the picked box, or drag onto a box.`}
onPointerDown={(e) => startDrag(e, { kind: "add", node: p.kind })}
onClick={() => {
if (selected && canAddBelow) edits.onAdd(p.kind, selected);
else edits.onRefused("pick a match or the source to add below");
}}
>
{p.kind}
</button>
))}
</div>
<div class="notify-canvas"> <div class="notify-canvas">
<svg <svg
ref={svgRef}
width={layout.width} width={layout.width}
height={layout.height} height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`} viewBox={`0 0 ${layout.width} ${layout.height}`}
role="group" role="group"
aria-label={`${flow.name}, ${flow.nodes.length} nodes`} aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
aria-describedby="notify-canvas-help"
> >
{layout.edges.map((e) => { {layout.edges.map((e) => {
const mid = (e.x1 + e.x2) / 2; const mid = (e.x1 + e.x2) / 2;
@ -40,28 +326,53 @@ export function FlowCanvas({
const node = byId.get(p.id); const node = byId.get(p.id);
if (!node) return null; if (!node) return null;
const text = describeNode(node); const text = describeNode(node);
const pos = siblingPosition(flow, p.id);
const order =
pos.count > 1
? `, runs ${ordinal(pos.index + 1)} of ${pos.count}`
: "";
const held =
drag?.moved &&
dragSource?.kind === "move" &&
dragSource.id === p.id;
return ( return (
<g <g
key={p.id} key={p.id}
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}`} data-node={p.id}
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}${held || carrying === p.id ? " notify-box-carried" : ""}${holdClass(p.id)}`}
transform={`translate(${p.x} ${p.y})`} transform={`translate(${p.x} ${p.y})`}
tabIndex={0} tabIndex={p.id === tabStop ? 0 : -1}
role="button" role="button"
aria-pressed={p.id === selected} aria-pressed={p.id === selected}
aria-label={`${node.kind} ${p.id}: ${text}`} aria-label={`${node.kind} ${p.id}: ${text}${order}`}
onClick={() => onSelect(p.id)} onPointerDown={(e) => {
onKeyDown={(e) => { if (node.kind === "source") {
if (e.key === "Enter" || e.key === " ") { // The source cannot move; a press just picks it.
e.preventDefault(); if (e.button === 0) edits.onSelect(p.id);
onSelect(p.id); return;
} }
e.preventDefault();
(e.currentTarget as SVGGElement).focus();
startDrag(e, { kind: "move", id: p.id });
}} }}
onFocus={() => setCursor(p.id)}
onKeyDown={(e) => onKey(e, p.id)}
> >
<title>{text}</title> <title>{text}</title>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" /> <rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x="10" y="17"> <text class="notify-box-kind" x="10" y="17">
{node.kind} {node.kind}
</text> </text>
{pos.count > 1 ? (
<text
class="notify-box-kind notify-box-order"
x={NODE_WIDTH - 10}
y="17"
text-anchor="end"
>
{`runs ${ordinal(pos.index + 1)}`}
</text>
) : null}
<text class="notify-box-text" x="10" y="35"> <text class="notify-box-text" x="10" y="35">
{ellipsize(text, TEXT_CHARS)} {ellipsize(text, TEXT_CHARS)}
</text> </text>
@ -70,5 +381,20 @@ export function FlowCanvas({
})} })}
</svg> </svg>
</div> </div>
<p class="notify-hint" id="notify-canvas-help">
{HELP}
</p>
<p class="notify-sr" role="status">
{said}
</p>
{drag?.moved ? (
<div
class="notify-ghost"
style={{ left: `${drag.x + 12}px`, top: `${drag.y + 12}px` }}
>
{dragSource?.kind === "move" ? dragSource.id : dragSource?.node}
</div>
) : null}
</div>
); );
} }

View file

@ -7,6 +7,11 @@
// would reject. The tree rules mirror `Flow::validate` in swarm-wire. // would reject. The tree rules mirror `Flow::validate` in swarm-wire.
import type { Flow, FlowNode, FlowSchema, FlowsView } from "./FlowTypes.js"; import type { Flow, FlowNode, FlowSchema, FlowsView } from "./FlowTypes.js";
// Kinds that end a branch: nothing hangs below them.
export function isLeafKind(kind: string): boolean {
return kind === "todo" || kind === "request_reviewer";
}
export interface Limits { export interface Limits {
maxNodes: number; maxNodes: number;
maxDepth: number; maxDepth: number;
@ -105,15 +110,47 @@ export function newNodeId(flow: Flow, prefix: string): string {
} }
} }
// The kinds a user can add; the source is only ever the root.
export type AddableKind = "match" | "todo" | "request_reviewer";
export const ADDABLE_KINDS: AddableKind[] = [
"match",
"todo",
"request_reviewer",
];
// The kinds the palette offers, each with the controller's description of it.
// A controller that sends no kinds predates `request_reviewer`, so only the
// two it always had are offered.
export function paletteKinds(
schema: FlowSchema,
): { kind: AddableKind; description: string }[] {
const listed = new Map(schema.node_kinds.map((k) => [k.kind, k.description]));
return ADDABLE_KINDS.filter((kind) =>
listed.size === 0 ? kind !== "request_reviewer" : listed.has(kind),
).map((kind) => ({ kind, description: listed.get(kind) ?? "" }));
}
// The word a new node's id starts with, such as `reviewer` for `reviewer1`.
const ID_PREFIX: Record<AddableKind, string> = {
match: "match",
todo: "todo",
request_reviewer: "reviewer",
};
// A node of `kind` with the fields a user is asked to fill in left empty. // A node of `kind` with the fields a user is asked to fill in left empty.
export function blankNode( export function blankNode(
flow: Flow, flow: Flow,
kind: "match" | "todo", kind: AddableKind,
): Extract<FlowNode, { kind: "match" | "todo" }> { ): Extract<FlowNode, { kind: AddableKind }> {
const id = newNodeId(flow, kind); const id = newNodeId(flow, ID_PREFIX[kind]);
return kind === "match" switch (kind) {
? { id, kind, when: [] } case "match":
: { return { id, kind, when: [] };
case "request_reviewer":
return { id, kind, reviewer: "" };
case "todo":
return {
id, id,
kind, kind,
to: { field: "assignees" }, to: { field: "assignees" },
@ -122,6 +159,7 @@ export function blankNode(
prefix: false, prefix: false,
text: "", text: "",
}; };
}
} }
// `added` under `parentId`. // `added` under `parentId`.
@ -133,7 +171,9 @@ export function addChild(
): Edit { ): Edit {
const parent = node(flow, parentId); const parent = node(flow, parentId);
if (!parent) return refuse("that parent is not in the flow"); if (!parent) return refuse("that parent is not in the flow");
if (parent.kind === "todo") return refuse("a todo ends a branch"); if (isLeafKind(parent.kind)) {
return refuse(`a ${parent.kind} ends a branch`);
}
if (added.kind === "source") return refuse("a flow has one source"); if (added.kind === "source") return refuse("a flow has one source");
if (!isId(added.id) || node(flow, added.id)) { if (!isId(added.id) || node(flow, added.id)) {
return refuse(`${added.id} is not a free node id`); return refuse(`${added.id} is not a free node id`);
@ -181,10 +221,15 @@ export function reparent(
const parent = node(flow, parentId); const parent = node(flow, parentId);
if (!target || !parent) return refuse("that node is not in the flow"); if (!target || !parent) return refuse("that node is not in the flow");
if (target.kind === "source") return refuse("the source stays the root"); if (target.kind === "source") return refuse("the source stays the root");
if (parent.kind === "todo") return refuse("a todo ends a branch"); if (isLeafKind(parent.kind)) {
return refuse(`a ${parent.kind} ends a branch`);
}
if (subtreeIds(flow, id).includes(parentId)) { if (subtreeIds(flow, id).includes(parentId)) {
return refuse("a node cannot go under itself or its own branch"); return refuse("a node cannot go under itself or its own branch");
} }
if (parentOf(flow, id) === parentId) {
return refuse(`${id} is already under ${parentId}`);
}
if (depthOf(flow, parentId) + 1 + heightBelow(flow, id) > limits.maxDepth) { if (depthOf(flow, parentId) + 1 + heightBelow(flow, id) > limits.maxDepth) {
return refuse(`a flow is at most ${limits.maxDepth} levels deep`); return refuse(`a flow is at most ${limits.maxDepth} levels deep`);
} }
@ -200,6 +245,40 @@ export function reparent(
}; };
} }
// Where `id` runs among the nodes sharing its parent: `index` from 0 of
// `count`. A node with no parent (the source) is the only one of its kind.
export function siblingPosition(
flow: Flow,
id: string,
): { index: number; count: number } {
const parent = parentOf(flow, id);
if (parent === undefined) return { index: 0, count: 1 };
const siblings = childrenOf(flow, parent);
return { index: Math.max(0, siblings.indexOf(id)), count: siblings.length };
}
// `id` swapped with its neighbour that runs `delta` places earlier (-1) or
// later (+1). The router runs a node's children in edge order, so this changes
// which branch acts first; other edges keep their places.
export function moveAmongSiblings(flow: Flow, id: string, delta: -1 | 1): Edit {
const parent = parentOf(flow, id);
if (parent === undefined) return refuse("only a branch can be reordered");
const at: number[] = [];
flow.edges.forEach((e, i) => {
if (e.from === parent) at.push(i);
});
const here = at.findIndex((i) => flow.edges[i].to === id);
const there = here + delta;
if (there < 0 || there >= at.length) {
return refuse(
delta < 0 ? `${id} already runs first` : `${id} already runs last`,
);
}
const edges = [...flow.edges];
[edges[at[here]], edges[at[there]]] = [edges[at[there]], edges[at[here]]];
return { ok: true, flow: { ...flow, edges } };
}
// A todo with its op switched. A clear carries no text, and only a clear may // A todo with its op switched. A clear carries no text, and only a clear may
// be a prefix, so each is reset where the new op has no place for it. // be a prefix, so each is reset where the new op has no place for it.
export function withOp( export function withOp(
@ -225,8 +304,8 @@ export function replaceNode(flow: Flow, next: FlowNode): Edit {
"the source cannot change kind, and no other node can become one", "the source cannot change kind, and no other node can become one",
); );
} }
if (next.kind === "todo" && childrenOf(flow, next.id).length > 0) { if (isLeafKind(next.kind) && childrenOf(flow, next.id).length > 0) {
return refuse("a node with children cannot become a todo"); return refuse(`a node with children cannot become a ${next.kind}`);
} }
return { return {
ok: true, ok: true,

View file

@ -19,14 +19,22 @@ import { TextField } from "../../ui/text-field/TextField.js";
import { fetchJson, ProblemError } from "../../ui/polled/usePolled.js"; import { fetchJson, ProblemError } from "../../ui/polled/usePolled.js";
import { FlowCanvas } from "./FlowCanvas.js"; import { FlowCanvas } from "./FlowCanvas.js";
import { import {
addChild,
blankNode,
isDirty, isDirty,
limitsOf, limitsOf,
moveAmongSiblings,
paletteKinds,
removeNode,
reparent,
saveOutcome, saveOutcome,
saveRequest, saveRequest,
parentOf,
withFlow, withFlow,
withoutFlow, withoutFlow,
type Edit, type Edit,
} from "./FlowDraft.js"; } from "./FlowDraft.js";
import { useLeaveGuard } from "./useLeaveGuard.js";
import { NodeInspector } from "./NodeInspector.js"; import { NodeInspector } from "./NodeInspector.js";
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js"; import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
import "./NotificationsPage.css"; import "./NotificationsPage.css";
@ -87,13 +95,9 @@ export function FlowEditorPage({
draft !== null && draft !== null &&
isDirty(view, withFlow(view.flows, draft), view.disabled_builtins); isDirty(view, withFlow(view.flows, draft), view.disabled_builtins);
// Leaving the page with edits not saved asks first. // Leaving the page with edits not saved asks first, whether by closing the
useEffect(() => { // tab, following a link or pressing back.
if (!dirty) return; const leaving = useLeaveGuard(dirty);
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
// Send `flows` as the document; true when the controller took it. // Send `flows` as the document; true when the controller took it.
async function put(flows: Flow[]): Promise<boolean> { async function put(flows: Flow[]): Promise<boolean> {
@ -137,7 +141,11 @@ export function FlowEditorPage({
async function onDelete() { async function onDelete() {
if (!view) return; if (!view) return;
setConfirmDelete(false); setConfirmDelete(false);
if (await put(withoutFlow(view.flows, flowId))) navigate("/notifications"); if (await put(withoutFlow(view.flows, flowId))) {
// The flow is gone; there is nothing left to lose by leaving.
leaving.release();
navigate("/notifications");
}
} }
const back = <Link href="/notifications">all flows</Link>; const back = <Link href="/notifications">all flows</Link>;
@ -237,7 +245,25 @@ export function FlowEditorPage({
{dirty ? <p class="notify-hint">Not saved yet.</p> : null} {dirty ? <p class="notify-hint">Not saved yet.</p> : null}
<div class="notify-editor-body"> <div class="notify-editor-body">
<FlowCanvas flow={draft} selected={selected} onSelect={setSelected} /> <FlowCanvas
flow={draft}
selected={selected}
limits={limits}
palette={paletteKinds(schema)}
onSelect={setSelected}
onAdd={(kind, parentId) => {
const added = blankNode(draft, kind);
onEdit(addChild(draft, parentId, added, limits), added.id);
}}
onMove={(id, parentId) =>
onEdit(reparent(draft, id, parentId, limits), id)
}
onReorder={(id, delta) =>
onEdit(moveAmongSiblings(draft, id, delta), id)
}
onRemove={(id) => onEdit(removeNode(draft, id), parentOf(draft, id))}
onRefused={setNotice}
/>
{node ? ( {node ? (
<NodeInspector <NodeInspector
flow={draft} flow={draft}
@ -258,6 +284,15 @@ export function FlowEditorPage({
> >
<p>Delete the flow {draft.name}? The router stops running it.</p> <p>Delete the flow {draft.name}? The router stops running it.</p>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
open={leaving.pending}
label="leave without saving"
confirmLabel="leave and drop edits"
onCancel={leaving.stay}
onConfirm={leaving.leave}
>
<p>This flow has edits that are not saved. Leaving drops them.</p>
</ConfirmDialog>
</Panel> </Panel>
); );
} }

View file

@ -35,6 +35,38 @@ export interface Layout {
height: number; height: number;
} }
// The node whose box holds the point (`x`, `y`) in canvas coordinates, if any.
export function nodeAt(
layout: Layout,
x: number,
y: number,
): string | undefined {
return layout.nodes.find(
(n) =>
x >= n.x && x <= n.x + NODE_WIDTH && y >= n.y && y <= n.y + NODE_HEIGHT,
)?.id;
}
// The node next to `id` in direction `dir` on the canvas: left is its parent,
// right its first child, up and down the nearest box above or below it in the
// same column. Undefined at an edge, so a key press there does nothing.
export function neighbour(
layout: Layout,
flow: Flow,
id: string,
dir: "up" | "down" | "left" | "right",
): string | undefined {
const me = layout.nodes.find((n) => n.id === id);
if (!me) return undefined;
if (dir === "left") return flow.edges.find((e) => e.to === id)?.from;
if (dir === "right") return flow.edges.find((e) => e.from === id)?.to;
const column = layout.nodes
.filter((n) => n.depth === me.depth && n.id !== id)
.filter((n) => (dir === "up" ? n.y < me.y : n.y > me.y))
.sort((a, b) => (dir === "up" ? b.y - a.y : a.y - b.y));
return column[0]?.id;
}
export function layoutFlow(flow: Flow): Layout { export function layoutFlow(flow: Flow): Layout {
const children = new Map<string, string[]>(); const children = new Map<string, string[]>();
for (const e of flow.edges) { for (const e of flow.edges) {

View file

@ -20,7 +20,8 @@ export type FlowNode =
key: string; key: string;
prefix: boolean; prefix: boolean;
text: string; text: string;
}; }
| { id: string; kind: "request_reviewer"; reviewer: string };
export interface Flow { export interface Flow {
id: string; id: string;
@ -82,6 +83,11 @@ export function describeNode(node: FlowNode): string {
return node.op === "clear" return node.op === "clear"
? `clear todos ${node.key}${node.prefix ? "*" : ""} of ${describeRecipients(node.to)}` ? `clear todos ${node.key}${node.prefix ? "*" : ""} of ${describeRecipients(node.to)}`
: `todo to ${describeRecipients(node.to)}, key ${node.key}: ${node.text}`; : `todo to ${describeRecipients(node.to)}, key ${node.key}: ${node.text}`;
case "request_reviewer":
return `request a review from ${node.reviewer || "(nobody yet)"}`;
default:
// A kind this editor predates: show its name rather than nothing.
return `${(node as { kind: string }).kind} (not editable here)`;
} }
} }
@ -148,6 +154,13 @@ export const FALLBACK_SCHEMA: FlowSchema = {
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 }, limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
}; };
// 1 as "1st", 2 as "2nd", 11 as "11th".
export function ordinal(n: number): string {
const tens = n % 100;
if (tens >= 11 && tens <= 13) return `${n}th`;
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
}
// `text` cut to `max` characters, with an ellipsis if it was longer. // `text` cut to `max` characters, with an ellipsis if it was longer.
export function ellipsize(text: string, max: number): string { export function ellipsize(text: string, max: number): string {
return text.length <= max ? text : `${text.slice(0, max - 1)}…`; return text.length <= max ? text : `${text.slice(0, max - 1)}…`;

View file

@ -1,25 +1,26 @@
// <NodeInspector> — the fields of the picked node, and what can be done to // <NodeInspector> — the fields of the picked node, and what can be done to
// it: add a child, move it under another node, remove it. Every change goes // it: move it under another node, change when it runs among its siblings,
// out as an `Edit` of the flow; the editor shows the reason when one is // remove it. Every change goes out as an `Edit` of the flow; the editor shows
// refused. // the reason when one is refused. New nodes come from the canvas palette.
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { SelectField } from "../../ui/select-field/SelectField.js"; import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { import {
addChild, moveAmongSiblings,
blankNode,
parentOf, parentOf,
removeNode, removeNode,
reparent, reparent,
replaceNode, replaceNode,
siblingPosition,
type Edit, type Edit,
type Limits, type Limits,
withOp, withOp,
} from "./FlowDraft.js"; } from "./FlowDraft.js";
import { import {
kindValues, kindValues,
ordinal,
type Condition, type Condition,
type Described, type Described,
type Flow, type Flow,
@ -31,6 +32,9 @@ type TodoNode = Extract<FlowNode, { kind: "todo" }>;
const OPS_FALLBACK = ["upsert", "clear"]; const OPS_FALLBACK = ["upsert", "clear"];
// The kinds this editor has fields for.
const KNOWN_KINDS: string[] = ["source", "match", "todo", "request_reviewer"];
function options(list: Described[]) { function options(list: Described[]) {
return list.map((d) => ({ return list.map((d) => ({
value: d.name, value: d.name,
@ -57,15 +61,17 @@ export function NodeInspector({
const firstField = schema.fields[0]?.name ?? ""; const firstField = schema.fields[0]?.name ?? "";
const key = `ne-${node.id}`; const key = `ne-${node.id}`;
const add = (kind: "match" | "todo") => {
const added = blankNode(flow, kind);
onEdit(addChild(flow, node.id, added, limits), added.id);
};
const parent = parentOf(flow, node.id); const parent = parentOf(flow, node.id);
// Where it can go: under its current parent, or under any node it could be
// moved to.
const parents = flow.nodes const parents = flow.nodes
.filter((n) => n.id !== node.id && reparent(flow, node.id, n.id, limits).ok) .filter(
(n) =>
n.id === parent ||
(n.id !== node.id && reparent(flow, node.id, n.id, limits).ok),
)
.map((n) => ({ value: n.id, label: `${n.kind} ${n.id}` })); .map((n) => ({ value: n.id, label: `${n.kind} ${n.id}` }));
const order = siblingPosition(flow, node.id);
return ( return (
<div class="notify-inspector"> <div class="notify-inspector">
@ -121,13 +127,46 @@ export function NodeInspector({
/> />
) : null} ) : null}
<div class="notify-inspector-actions"> {node.kind === "request_reviewer" ? (
{node.kind !== "todo" ? ( <fieldset>
<> <legend>asks for a review</legend>
<Button onClick={() => add("match")}>add a match below</Button> <TextField
<Button onClick={() => add("todo")}>add a todo below</Button> id={`${key}-reviewer`}
</> label="reviewer (a forge login)"
value={node.reviewer}
onInput={(reviewer) => set({ ...node, reviewer })}
/>
</fieldset>
) : null} ) : null}
{!KNOWN_KINDS.includes(node.kind) ? (
<p class="notify-hint">
This editor does not know how to edit a {node.kind}. It is kept as it
is when you save.
</p>
) : null}
{order.count > 1 ? (
<div class="notify-inspector-actions">
<span class="notify-hint">
runs {ordinal(order.index + 1)} of {order.count} below its parent
</span>
<Button
disabled={order.index === 0}
onClick={() => onEdit(moveAmongSiblings(flow, node.id, -1))}
>
run earlier
</Button>
<Button
disabled={order.index === order.count - 1}
onClick={() => onEdit(moveAmongSiblings(flow, node.id, 1))}
>
run later
</Button>
</div>
) : null}
<div class="notify-inspector-actions">
{node.kind !== "source" ? ( {node.kind !== "source" ? (
<Button <Button
onClick={() => onClick={() =>

View file

@ -73,10 +73,65 @@
gap: 1em; gap: 1em;
} }
.notify-canvas { .notify-canvas-wrap {
flex: 1 1 24em; flex: 1 1 24em;
min-width: 0; min-width: 0;
}
.notify-palette {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em;
margin-bottom: 0.5em;
}
.notify-chip {
padding: 0.2em 0.7em;
border: 1.5px solid var(--border);
border-radius: 999px;
background: var(--bg-elev);
color: var(--fg);
font: inherit;
cursor: grab;
touch-action: none;
}
.notify-chip-match {
border-color: var(--purple);
}
.notify-chip-todo {
border-color: var(--green);
}
.notify-chip-request_reviewer {
border-color: var(--green);
}
.notify-ghost {
position: fixed;
z-index: 10;
padding: 0.2em 0.6em;
border: 1.5px solid var(--fg);
border-radius: 6px;
background: var(--bg-elev);
color: var(--fg);
pointer-events: none;
}
.notify-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.notify-canvas {
max-height: 70vh; max-height: 70vh;
user-select: none;
overflow: auto; overflow: auto;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 6px; border-radius: 6px;
@ -90,9 +145,29 @@
} }
.notify-box { .notify-box {
cursor: grab;
touch-action: none;
}
.notify-box-source {
cursor: pointer; cursor: pointer;
} }
.notify-box-carried {
opacity: 0.45;
}
.notify-box.notify-box-target rect {
stroke: var(--green);
stroke-width: 3;
}
.notify-box.notify-box-refused rect {
stroke: var(--red);
stroke-width: 3;
stroke-dasharray: 5 3;
}
.notify-box rect { .notify-box rect {
fill: var(--bg-elev); fill: var(--bg-elev);
stroke: var(--border); stroke: var(--border);
@ -107,7 +182,8 @@
stroke: var(--purple); stroke: var(--purple);
} }
.notify-box-todo rect { .notify-box-todo rect,
.notify-box-request_reviewer rect {
stroke: var(--green); stroke: var(--green);
} }

View file

@ -0,0 +1,88 @@
// Stops a page with unsaved edits from losing them without asking.
//
// Closing or reloading the tab gets the browser's own prompt. A move inside the
// app goes past the nav guard, and the browser back button is caught with a
// spare history entry: while there are edits the page pushes a copy of its own
// URL, so back lands on the same page and the prompt can be shown.
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { navigate } from "wouter-preact/use-browser-location";
import { setNavGuard } from "../../shell/navGuard.js";
// `to` is where the person was headed, or null for the back button.
interface Pending {
to: string | null;
}
export function useLeaveGuard(dirty: boolean): {
pending: boolean;
leave: () => void;
stay: () => void;
// Lets the page's own navigation through, such as after deleting the flow.
release: () => void;
} {
const [pending, setPending] = useState<Pending | null>(null);
const spare = useRef(false);
const released = useRef(false);
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
useEffect(() => {
if (!dirty) return;
setNavGuard((to) => {
if (released.current) return true;
setPending({ to });
return false;
});
return () => setNavGuard(null);
}, [dirty]);
useEffect(() => {
const onPop = () => {
if (!spare.current) return;
if (dirty && !released.current) {
// Back landed on the page again; the spare entry is used up.
spare.current = false;
setPending({ to: null });
} else {
// Nothing left to protect: go on to where back was meant to go.
spare.current = false;
history.back();
}
};
window.addEventListener("popstate", onPop);
if (dirty && !spare.current) {
history.pushState(history.state, "", location.href);
spare.current = true;
}
return () => window.removeEventListener("popstate", onPop);
}, [dirty]);
const stay = useCallback(() => {
if (pending?.to === null && !spare.current) {
history.pushState(history.state, "", location.href);
spare.current = true;
}
setPending(null);
}, [pending]);
const leave = useCallback(() => {
released.current = true;
setNavGuard(null);
const to = pending?.to;
setPending(null);
if (to === null) history.back();
else if (to !== undefined) navigate(to);
}, [pending]);
const release = useCallback(() => {
released.current = true;
setNavGuard(null);
}, []);
return { pending: pending !== null, leave, stay, release };
}

View file

@ -0,0 +1,20 @@
// A page holding work that leaving would lose registers a guard here, and
// every navigation inside the app asks it first. The guard returns false to
// stop the move, typically to show its own "leave without saving?" prompt and
// carry on from there once the person answers.
//
// Closing the tab and the browser back button never reach this: a page that
// cares handles `beforeunload` and `popstate` itself.
type Guard = (to: string) => boolean;
let current: Guard | null = null;
// Install `guard`, or pass null to remove it.
export function setNavGuard(guard: Guard | null): void {
current = guard;
}
// Whether a move to `to` may go ahead now.
export function mayNavigate(to: string): boolean {
return current === null || current(to);
}

View file

@ -0,0 +1,16 @@
// The router's location hook, with each in-app navigation put past the page's
// nav guard first. Pass it to `<Router hook={useGuardedLocation}>`.
import { useBrowserLocation } from "wouter-preact/use-browser-location";
import { mayNavigate } from "./navGuard.js";
type Navigate = ReturnType<typeof useBrowserLocation>[1];
export function useGuardedLocation(
...args: Parameters<typeof useBrowserLocation>
): [string, Navigate] {
const [path, navigate] = useBrowserLocation(...args);
const guarded: Navigate = (to, opts) => {
if (mayNavigate(String(to))) navigate(to, opts);
};
return [path, guarded];
}

View file

@ -9,14 +9,18 @@ import {
duplicateFlow, duplicateFlow,
heightBelow, heightBelow,
isDirty, isDirty,
isLeafKind,
limitsOf, limitsOf,
moveAmongSiblings,
newFlow, newFlow,
newNodeId, newNodeId,
paletteKinds,
removeNode, removeNode,
reparent, reparent,
replaceNode, replaceNode,
saveOutcome, saveOutcome,
saveRequest, saveRequest,
siblingPosition,
slugId, slugId,
subtreeIds, subtreeIds,
withFlow, withFlow,
@ -254,3 +258,96 @@ test("limitsOf takes the controller's caps and falls back to today's", () => {
); );
assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS); assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS);
}); });
const reviewer = (id) => ({ id, kind: "request_reviewer", reviewer: "argus" });
test("a request_reviewer ends a branch like a todo", () => {
assert.ok(isLeafKind("todo") && isLeafKind("request_reviewer"));
assert.ok(!isLeafKind("match") && !isLeafKind("source"));
const f = flow();
const r = addChild(f, "c", reviewer("r1"));
assert.ok(r.ok);
assert.equal(
addChild(r.flow, "r1", todo("t2")).ok,
false,
"nothing under it",
);
assert.equal(reparent(r.flow, "b", "r1").ok, false, "no move under it");
assert.equal(
replaceNode(f, reviewer("a")).ok,
false,
"a node with children cannot become one",
);
assert.ok(replaceNode(f, reviewer("c")).ok, "a leaf may");
});
test("a new reviewer request is blank and its id reads reviewer1", () => {
const f = flow();
const n = blankNode(f, "request_reviewer");
assert.deepEqual(n, {
id: "reviewer1",
kind: "request_reviewer",
reviewer: "",
});
const r = addChild(f, "c", n);
assert.ok(r.ok);
assert.equal(blankNode(r.flow, "request_reviewer").id, "reviewer2");
});
test("the palette offers what the controller lists, and the old two without a schema", () => {
const schema = (kinds) => ({
node_kinds: kinds.map((kind) => ({
kind,
description: kind + "!",
fields: [],
})),
});
assert.deepEqual(
paletteKinds(schema(["source", "match", "todo", "request_reviewer"])).map(
(p) => p.kind,
),
["match", "todo", "request_reviewer"],
);
assert.deepEqual(
paletteKinds(schema(["source", "match", "todo"])).map((p) => p.kind),
["match", "todo"],
);
assert.deepEqual(
paletteKinds(schema([])).map((p) => p.kind),
["match", "todo"],
);
assert.equal(paletteKinds(schema(["match"]))[0].description, "match!");
});
test("reparent refuses a move under the node's own parent", () => {
const r = reparent(flow(), "b", "a");
assert.equal(r.ok, false);
assert.match(r.reason, /already under/);
});
test("siblingPosition says where a branch runs among its siblings", () => {
const f = flow();
assert.deepEqual(siblingPosition(f, "a"), { index: 0, count: 2 });
assert.deepEqual(siblingPosition(f, "c"), { index: 1, count: 2 });
assert.deepEqual(siblingPosition(f, "b"), { index: 0, count: 1 });
assert.deepEqual(siblingPosition(f, "source"), { index: 0, count: 1 });
});
test("moveAmongSiblings swaps two branches and leaves other edges alone", () => {
const f = flow();
const later = moveAmongSiblings(f, "a", 1);
assert.ok(later.ok);
assert.deepEqual(
later.flow.edges.filter((e) => e.from === "source").map((e) => e.to),
["c", "a"],
);
assert.deepEqual(
later.flow.edges.filter((e) => e.from !== "source"),
f.edges.filter((e) => e.from !== "source"),
);
const back = moveAmongSiblings(later.flow, "a", -1);
assert.deepEqual(back.flow.edges, f.edges);
assert.equal(moveAmongSiblings(f, "a", -1).ok, false, "already first");
assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last");
assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source");
});

View file

@ -3,6 +3,8 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
layoutFlow, layoutFlow,
neighbour,
nodeAt,
NODE_HEIGHT, NODE_HEIGHT,
NODE_WIDTH, NODE_WIDTH,
} from "../src/pages/notifications/FlowLayout.ts"; } from "../src/pages/notifications/FlowLayout.ts";
@ -120,3 +122,41 @@ test("an empty flow has an empty layout", () => {
assert.deepEqual(l.nodes, []); assert.deepEqual(l.nodes, []);
assert.ok(l.width >= 0 && l.height >= 0); assert.ok(l.width >= 0 && l.height >= 0);
}); });
test("nodeAt finds the box holding a point and nothing between boxes", () => {
const l = layoutFlow(flow([source, match("a")], [["s", "a"]]));
const a = at(l, "a");
assert.equal(nodeAt(l, a.x + 5, a.y + 5), "a");
assert.equal(
nodeAt(l, a.x + NODE_WIDTH, a.y + NODE_HEIGHT),
"a",
"the edge counts",
);
assert.equal(nodeAt(l, a.x - 1, a.y + 5), undefined);
assert.equal(nodeAt(l, 5000, 5000), undefined);
});
test("neighbour walks to the parent, the first child and the box above or below", () => {
const f = flow(
[source, match("a"), match("b"), match("c")],
[
["s", "a"],
["s", "b"],
["a", "c"],
],
);
const l = layoutFlow(f);
assert.equal(neighbour(l, f, "a", "left"), "s");
assert.equal(neighbour(l, f, "s", "right"), "a", "the first in edge order");
assert.equal(
neighbour(l, f, "s", "left"),
undefined,
"nothing left of the source",
);
assert.equal(neighbour(l, f, "c", "right"), undefined, "a leaf has no child");
// a sits above b in the same column.
assert.equal(neighbour(l, f, "a", "down"), "b");
assert.equal(neighbour(l, f, "b", "up"), "a");
assert.equal(neighbour(l, f, "a", "up"), undefined);
assert.equal(neighbour(l, f, "nope", "up"), undefined);
});

View file

@ -5,6 +5,7 @@ import {
describeNode, describeNode,
ellipsize, ellipsize,
kindValues, kindValues,
ordinal,
treeRows, treeRows,
withBuiltin, withBuiltin,
} from "../src/pages/notifications/FlowTypes.ts"; } from "../src/pages/notifications/FlowTypes.ts";
@ -105,3 +106,31 @@ test("kindValues lists the values a schema allows for a field", () => {
assert.deepEqual(kindValues(schema, "match", "when"), []); assert.deepEqual(kindValues(schema, "match", "when"), []);
assert.deepEqual(kindValues({ node_kinds: [] }, "todo", "op"), []); assert.deepEqual(kindValues({ node_kinds: [] }, "todo", "op"), []);
}); });
test("ordinal reads 1st, 2nd, 3rd, 4th and the teens as th", () => {
assert.deepEqual([1, 2, 3, 4, 11, 12, 13, 21, 22, 101, 111].map(ordinal), [
"1st",
"2nd",
"3rd",
"4th",
"11th",
"12th",
"13th",
"21st",
"22nd",
"101st",
"111th",
]);
});
test("describeNode says what a reviewer request and an unknown kind are", () => {
assert.equal(
describeNode({ id: "r", kind: "request_reviewer", reviewer: "argus" }),
"request a review from argus",
);
assert.match(
describeNode({ id: "r", kind: "request_reviewer", reviewer: "" }),
/nobody yet/,
);
assert.match(describeNode({ id: "x", kind: "future" }), /future/);
});

View file

@ -0,0 +1,28 @@
// The guard a page holding unsaved work puts in front of in-app navigation.
// Run by `npm test`.
import assert from "node:assert/strict";
import { test } from "node:test";
import { mayNavigate, setNavGuard } from "../src/shell/navGuard.ts";
test("with no guard every move goes ahead", () => {
setNavGuard(null);
assert.equal(mayNavigate("/alerts"), true);
});
test("a guard that says no stops the move and is told where it was headed", () => {
const asked = [];
setNavGuard((to) => {
asked.push(to);
return false;
});
assert.equal(mayNavigate("/alerts"), false);
assert.deepEqual(asked, ["/alerts"]);
setNavGuard(null);
assert.equal(mayNavigate("/alerts"), true, "removing it lets moves through");
});
test("a guard that says yes lets the move through", () => {
setNavGuard(() => true);
assert.equal(mayNavigate("/jobs"), true);
setNavGuard(null);
});