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

@ -1,74 +1,400 @@
// <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
// canvas does the editing.
import { layoutFlow, NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
import { describeNode, ellipsize, type Flow } from "./FlowTypes.js";
// branches to the right, with a palette of node kinds above.
//
// Rewiring is by pointer or by keyboard, and both end in the same edits the
// 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;
// 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({
flow,
selected,
onSelect,
limits,
palette,
...edits
}: {
flow: Flow;
selected: string | null;
onSelect: (id: string) => void;
}) {
limits: Limits;
palette: PaletteKind[];
} & CanvasEdits) {
const layout = layoutFlow(flow);
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 (
<div class="notify-canvas">
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="group"
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
>
{layout.edges.map((e) => {
const mid = (e.x1 + e.x2) / 2;
return (
<path
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);
return (
<g
key={p.id}
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}`}
transform={`translate(${p.x} ${p.y})`}
tabIndex={0}
role="button"
aria-pressed={p.id === selected}
aria-label={`${node.kind} ${p.id}: ${text}`}
onClick={() => onSelect(p.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
<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">
<svg
ref={svgRef}
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="group"
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
aria-describedby="notify-canvas-help"
>
{layout.edges.map((e) => {
const mid = (e.x1 + e.x2) / 2;
return (
<path
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();
onSelect(p.id);
}
}}
>
<title>{text}</title>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x="10" y="17">
{node.kind}
</text>
<text class="notify-box-text" x="10" y="35">
{ellipsize(text, TEXT_CHARS)}
</text>
</g>
);
})}
</svg>
(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>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x="10" y="17">
{node.kind}
</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">
{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>
);
}

View file

@ -7,6 +7,11 @@
// would reject. The tree rules mirror `Flow::validate` in swarm-wire.
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 {
maxNodes: 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.
export function blankNode(
flow: Flow,
kind: "match" | "todo",
): Extract<FlowNode, { kind: "match" | "todo" }> {
const id = newNodeId(flow, kind);
return kind === "match"
? { id, kind, when: [] }
: {
kind: AddableKind,
): Extract<FlowNode, { kind: AddableKind }> {
const id = newNodeId(flow, ID_PREFIX[kind]);
switch (kind) {
case "match":
return { id, kind, when: [] };
case "request_reviewer":
return { id, kind, reviewer: "" };
case "todo":
return {
id,
kind,
to: { field: "assignees" },
@ -122,6 +159,7 @@ export function blankNode(
prefix: false,
text: "",
};
}
}
// `added` under `parentId`.
@ -133,7 +171,9 @@ export function addChild(
): Edit {
const parent = node(flow, parentId);
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 (!isId(added.id) || node(flow, added.id)) {
return refuse(`${added.id} is not a free node id`);
@ -181,10 +221,15 @@ export function reparent(
const parent = node(flow, parentId);
if (!target || !parent) return refuse("that node is not in the flow");
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)) {
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) {
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
// be a prefix, so each is reset where the new op has no place for it.
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",
);
}
if (next.kind === "todo" && childrenOf(flow, next.id).length > 0) {
return refuse("a node with children cannot become a todo");
if (isLeafKind(next.kind) && childrenOf(flow, next.id).length > 0) {
return refuse(`a node with children cannot become a ${next.kind}`);
}
return {
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 { FlowCanvas } from "./FlowCanvas.js";
import {
addChild,
blankNode,
isDirty,
limitsOf,
moveAmongSiblings,
paletteKinds,
removeNode,
reparent,
saveOutcome,
saveRequest,
parentOf,
withFlow,
withoutFlow,
type Edit,
} from "./FlowDraft.js";
import { useLeaveGuard } from "./useLeaveGuard.js";
import { NodeInspector } from "./NodeInspector.js";
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
import "./NotificationsPage.css";
@ -87,13 +95,9 @@ export function FlowEditorPage({
draft !== null &&
isDirty(view, withFlow(view.flows, draft), view.disabled_builtins);
// Leaving the page with edits not saved asks first.
useEffect(() => {
if (!dirty) return;
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
window.addEventListener("beforeunload", warn);
return () => window.removeEventListener("beforeunload", warn);
}, [dirty]);
// Leaving the page with edits not saved asks first, whether by closing the
// tab, following a link or pressing back.
const leaving = useLeaveGuard(dirty);
// Send `flows` as the document; true when the controller took it.
async function put(flows: Flow[]): Promise<boolean> {
@ -137,7 +141,11 @@ export function FlowEditorPage({
async function onDelete() {
if (!view) return;
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>;
@ -237,7 +245,25 @@ export function FlowEditorPage({
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
<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 ? (
<NodeInspector
flow={draft}
@ -258,6 +284,15 @@ export function FlowEditorPage({
>
<p>Delete the flow {draft.name}? The router stops running it.</p>
</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>
);
}

View file

@ -35,6 +35,38 @@ export interface Layout {
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 {
const children = new Map<string, string[]>();
for (const e of flow.edges) {

View file

@ -20,7 +20,8 @@ export type FlowNode =
key: string;
prefix: boolean;
text: string;
};
}
| { id: string; kind: "request_reviewer"; reviewer: string };
export interface Flow {
id: string;
@ -82,6 +83,11 @@ export function describeNode(node: FlowNode): string {
return node.op === "clear"
? `clear todos ${node.key}${node.prefix ? "*" : ""} of ${describeRecipients(node.to)}`
: `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 },
};
// 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.
export function ellipsize(text: string, max: number): string {
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
// it: add a child, move it under another node, remove it. Every change goes
// out as an `Edit` of the flow; the editor shows the reason when one is
// refused.
// it: move it under another node, change when it runs among its siblings,
// remove it. Every change goes out as an `Edit` of the flow; the editor shows
// the reason when one is refused. New nodes come from the canvas palette.
import { Button } from "../../ui/button/Button.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
import { TextField } from "../../ui/text-field/TextField.js";
import {
addChild,
blankNode,
moveAmongSiblings,
parentOf,
removeNode,
reparent,
replaceNode,
siblingPosition,
type Edit,
type Limits,
withOp,
} from "./FlowDraft.js";
import {
kindValues,
ordinal,
type Condition,
type Described,
type Flow,
@ -31,6 +32,9 @@ type TodoNode = Extract<FlowNode, { kind: "todo" }>;
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[]) {
return list.map((d) => ({
value: d.name,
@ -57,15 +61,17 @@ export function NodeInspector({
const firstField = schema.fields[0]?.name ?? "";
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);
// Where it can go: under its current parent, or under any node it could be
// moved to.
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}` }));
const order = siblingPosition(flow, node.id);
return (
<div class="notify-inspector">
@ -121,13 +127,46 @@ export function NodeInspector({
/>
) : 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">
{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" ? (
<Button
onClick={() =>

View file

@ -73,10 +73,65 @@
gap: 1em;
}
.notify-canvas {
.notify-canvas-wrap {
flex: 1 1 24em;
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;
user-select: none;
overflow: auto;
border: 1px solid var(--border);
border-radius: 6px;
@ -90,9 +145,29 @@
}
.notify-box {
cursor: grab;
touch-action: none;
}
.notify-box-source {
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 {
fill: var(--bg-elev);
stroke: var(--border);
@ -107,7 +182,8 @@
stroke: var(--purple);
}
.notify-box-todo rect {
.notify-box-todo rect,
.notify-box-request_reviewer rect {
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 };
}