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:
parent
e01770c056
commit
68e289fff5
16 changed files with 1072 additions and 133 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,29 +30,31 @@ function NotFound() {
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<Shell>
|
<Router hook={useGuardedLocation}>
|
||||||
<Switch>
|
<Shell>
|
||||||
<Route path="/" component={StartPage} />
|
<Switch>
|
||||||
<Route path="/hives" component={HivesPage} />
|
<Route path="/" component={StartPage} />
|
||||||
<Route path="/alerts" component={AlertsPage} />
|
<Route path="/hives" component={HivesPage} />
|
||||||
<Route path="/agents" component={AgentsPage} />
|
<Route path="/alerts" component={AlertsPage} />
|
||||||
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
<Route path="/agents" component={AgentsPage} />
|
||||||
<Route
|
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||||
path="/agents/:name/subagents/:subagent/terminal"
|
<Route
|
||||||
component={AgentTerminalPage}
|
path="/agents/:name/subagents/:subagent/terminal"
|
||||||
/>
|
component={AgentTerminalPage}
|
||||||
<Route path="/jobs" component={JobsPage} />
|
/>
|
||||||
<Route path="/schedules" component={SchedulesPage} />
|
<Route path="/jobs" component={JobsPage} />
|
||||||
<Route path="/builds" component={BuildsPage} />
|
<Route path="/schedules" component={SchedulesPage} />
|
||||||
<Route path="/broadcast" component={BroadcastPage} />
|
<Route path="/builds" component={BuildsPage} />
|
||||||
<Route path="/messages" component={MessagesPage} />
|
<Route path="/broadcast" component={BroadcastPage} />
|
||||||
<Route path="/notifications" component={NotificationsPage} />
|
<Route path="/messages" component={MessagesPage} />
|
||||||
<Route path="/notifications/:flowId" component={FlowEditorPage} />
|
<Route path="/notifications" component={NotificationsPage} />
|
||||||
<Route path="/issues" component={IssueReportPage} />
|
<Route path="/notifications/:flowId" component={FlowEditorPage} />
|
||||||
<Route path="/review" component={ReviewPage} />
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<Route path="/review" component={ReviewPage} />
|
||||||
<Route component={NotFound} />
|
<Route path="/components" component={ComponentsPage} />
|
||||||
</Switch>
|
<Route component={NotFound} />
|
||||||
</Shell>
|
</Switch>
|
||||||
|
</Shell>
|
||||||
|
</Router>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,400 @@
|
||||||
// <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">
|
<div class="notify-canvas-wrap">
|
||||||
<svg
|
<div class="notify-palette" role="group" aria-label="add a node">
|
||||||
width={layout.width}
|
<span class="notify-hint">add</span>
|
||||||
height={layout.height}
|
{palette.map((p) => (
|
||||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
<button
|
||||||
role="group"
|
key={p.kind}
|
||||||
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
|
type="button"
|
||||||
>
|
class={`notify-chip notify-chip-${p.kind}`}
|
||||||
{layout.edges.map((e) => {
|
title={`${p.description ? `${p.description}. ` : ""}Click to add below the picked box, or drag onto a box.`}
|
||||||
const mid = (e.x1 + e.x2) / 2;
|
onPointerDown={(e) => startDrag(e, { kind: "add", node: p.kind })}
|
||||||
return (
|
onClick={() => {
|
||||||
<path
|
if (selected && canAddBelow) edits.onAdd(p.kind, selected);
|
||||||
key={`${e.from}>${e.to}`}
|
else edits.onRefused("pick a match or the source to add below");
|
||||||
class="notify-edge"
|
}}
|
||||||
d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`}
|
>
|
||||||
/>
|
{p.kind}
|
||||||
);
|
</button>
|
||||||
})}
|
))}
|
||||||
{layout.nodes.map((p) => {
|
</div>
|
||||||
const node = byId.get(p.id);
|
<div class="notify-canvas">
|
||||||
if (!node) return null;
|
<svg
|
||||||
const text = describeNode(node);
|
ref={svgRef}
|
||||||
return (
|
width={layout.width}
|
||||||
<g
|
height={layout.height}
|
||||||
key={p.id}
|
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||||
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}`}
|
role="group"
|
||||||
transform={`translate(${p.x} ${p.y})`}
|
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
|
||||||
tabIndex={0}
|
aria-describedby="notify-canvas-help"
|
||||||
role="button"
|
>
|
||||||
aria-pressed={p.id === selected}
|
{layout.edges.map((e) => {
|
||||||
aria-label={`${node.kind} ${p.id}: ${text}`}
|
const mid = (e.x1 + e.x2) / 2;
|
||||||
onClick={() => onSelect(p.id)}
|
return (
|
||||||
onKeyDown={(e) => {
|
<path
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
key={`${e.from}>${e.to}`}
|
||||||
|
class="notify-edge"
|
||||||
|
d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{layout.nodes.map((p) => {
|
||||||
|
const node = byId.get(p.id);
|
||||||
|
if (!node) return null;
|
||||||
|
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 (
|
||||||
|
<g
|
||||||
|
key={p.id}
|
||||||
|
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})`}
|
||||||
|
tabIndex={p.id === tabStop ? 0 : -1}
|
||||||
|
role="button"
|
||||||
|
aria-pressed={p.id === selected}
|
||||||
|
aria-label={`${node.kind} ${p.id}: ${text}${order}`}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (node.kind === "source") {
|
||||||
|
// The source cannot move; a press just picks it.
|
||||||
|
if (e.button === 0) edits.onSelect(p.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onSelect(p.id);
|
(e.currentTarget as SVGGElement).focus();
|
||||||
}
|
startDrag(e, { kind: "move", id: p.id });
|
||||||
}}
|
}}
|
||||||
>
|
onFocus={() => setCursor(p.id)}
|
||||||
<title>{text}</title>
|
onKeyDown={(e) => onKey(e, p.id)}
|
||||||
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
|
>
|
||||||
<text class="notify-box-kind" x="10" y="17">
|
<title>{text}</title>
|
||||||
{node.kind}
|
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
|
||||||
</text>
|
<text class="notify-box-kind" x="10" y="17">
|
||||||
<text class="notify-box-text" x="10" y="35">
|
{node.kind}
|
||||||
{ellipsize(text, TEXT_CHARS)}
|
</text>
|
||||||
</text>
|
{pos.count > 1 ? (
|
||||||
</g>
|
<text
|
||||||
);
|
class="notify-box-kind notify-box-order"
|
||||||
})}
|
x={NODE_WIDTH - 10}
|
||||||
</svg>
|
y="17"
|
||||||
|
text-anchor="end"
|
||||||
|
>
|
||||||
|
{`runs ${ordinal(pos.index + 1)}`}
|
||||||
|
</text>
|
||||||
|
) : null}
|
||||||
|
<text class="notify-box-text" x="10" y="35">
|
||||||
|
{ellipsize(text, TEXT_CHARS)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)}…`;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
||||||
|
{node.kind === "request_reviewer" ? (
|
||||||
|
<fieldset>
|
||||||
|
<legend>asks for a review</legend>
|
||||||
|
<TextField
|
||||||
|
id={`${key}-reviewer`}
|
||||||
|
label="reviewer (a forge login)"
|
||||||
|
value={node.reviewer}
|
||||||
|
onInput={(reviewer) => set({ ...node, reviewer })}
|
||||||
|
/>
|
||||||
|
</fieldset>
|
||||||
|
) : 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">
|
<div class="notify-inspector-actions">
|
||||||
{node.kind !== "todo" ? (
|
|
||||||
<>
|
|
||||||
<Button onClick={() => add("match")}>add a match below</Button>
|
|
||||||
<Button onClick={() => add("todo")}>add a todo below</Button>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{node.kind !== "source" ? (
|
{node.kind !== "source" ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
20
frontend/packages/swarm-ui/src/shell/navGuard.ts
Normal file
20
frontend/packages/swarm-ui/src/shell/navGuard.ts
Normal 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);
|
||||||
|
}
|
||||||
16
frontend/packages/swarm-ui/src/shell/useGuardedLocation.ts
Normal file
16
frontend/packages/swarm-ui/src/shell/useGuardedLocation.ts
Normal 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];
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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/);
|
||||||
|
});
|
||||||
|
|
|
||||||
28
frontend/packages/swarm-ui/test/nav-guard.test.mjs
Normal file
28
frontend/packages/swarm-ui/test/nav-guard.test.mjs
Normal 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);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue