From d0a55502a8dd564783855dd28e6b6186f7e5d7fb Mon Sep 17 00:00:00 2001 From: iris Date: Sun, 25 Oct 2026 01:27:50 +0200 Subject: [PATCH] swarm-ui: draw notification flows as graphs in the list and make a new or copied flow a draft until saved The flows list draws each flow as the editor's graph, read-only and with the dry run's hit counts in the boxes, in place of the indented tree. A box's line of text is cut by the browser at the box's edge, so it no longer runs out of it. create flow (a primary button beside the refresh interval) and duplicate open the editor on an unsaved draft at /notifications/new, with ?from= naming the flow to copy; the flow is made by save, under an id taken from its name. Edit and duplicate are buttons in the flow's header row. The editor's head and inspector are laid out as sections. --- docs/swarm/ui.md | 26 +-- .../src/pages/notifications/FlowBox.tsx | 58 +++++++ .../src/pages/notifications/FlowCanvas.tsx | 36 ++--- .../src/pages/notifications/FlowDraft.ts | 33 ++++ .../pages/notifications/FlowEditorPage.tsx | 108 +++++++++---- .../src/pages/notifications/FlowGraph.tsx | 66 ++++++++ .../src/pages/notifications/FlowTypes.ts | 31 ---- .../src/pages/notifications/NodeInspector.tsx | 5 +- .../pages/notifications/NotificationsPage.css | 90 +++++++---- .../pages/notifications/NotificationsPage.tsx | 152 ++++++------------ .../swarm-ui/test/flow-draft.test.mjs | 43 +++++ .../swarm-ui/test/flow-types.test.mjs | 28 ---- 12 files changed, 410 insertions(+), 266 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx create mode 100644 frontend/packages/swarm-ui/src/pages/notifications/FlowGraph.tsx diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 8e45b738..70ece9c5 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -19,7 +19,7 @@ domain, covers host-level detail for one hive. | `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | | `/broadcast` | send one message to several agents at once, picked from a grid of their icons | | `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient | -| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a tree, and an editor for custom flows | +| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows | | `/issues` | a cross-repo issue report | | `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings | | `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed | @@ -223,20 +223,25 @@ routes. in two groups. Each **built-in** flow has an on/off switch; switching one off stops the router running it from its next event, and switching it on again restores it. Each **custom** flow shows its name and whether it's on. Every -flow draws as an indented tree: the forge events it starts from, the -conditions under it, and the todo or review request each branch ends in. +flow draws as a graph, the one the editor shows but read-only: the forge events +it starts from on the left, the conditions after them, and the todo or review +request each branch ends in. A box cuts a long line of text with an ellipsis; +hover over the box to read all of it. -A built-in flow can be **duplicated** into a custom one, and **new flow** starts a -custom flow with just a source; either opens the flow in the editor. A custom -flow has an **edit** link and a **duplicate** button. +A built-in flow has a **duplicate to edit** button and a custom flow has **edit** +and **duplicate** buttons. **create flow**, at the top of the page beside the +refresh interval, starts a custom flow with just a source. Creating and +duplicating open the editor on a draft and make nothing: the flow exists once +you **save** it there. Leaving the draft without saving, or discarding it, leaves +the flows as they were. A switch sends the whole flows document with the revision the page read, `0` if it read no document. The controller refuses the switch when the stored revision is another, including when someone wrote a document after the page read none, and the page shows why. Reload to switch against the current document. -Beside each node in the trees, a number says how many of the router's newest 50 -forge events reached it, from a dry run of the saved flows (`GET /api/notify/dry-run`). +Each box in a graph says how many of the router's newest 50 forge events reached +it (**12 hits**, say), from a dry run of the saved flows (`GET /api/notify/dry-run`). Below the flows, **recent deliveries** lists what the router delivered, newest first: when, which flow and node, the event and issue, who got it, and whether it went through (`GET /api/notify/deliveries`). Both come from the router; while the @@ -246,7 +251,10 @@ works. See [deliveries and dry runs](notify-router.md#deliveries-and-dry-runs). #### Editing a flow -`/notifications/` edits one custom flow. The canvas draws the flow left +`/notifications/` edits one custom flow, and `/notifications/new` makes +one: it opens on an empty flow, or on a copy of a flow with `?from=`, as +**create flow** and **duplicate** do. A new flow takes its id from its name when +you save it, and **save** needs a name. The canvas draws the flow left to right: the source on the left, each match to its right, and the leaf nodes at the ends. Pick a node to edit it in the panel beside the canvas: diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx new file mode 100644 index 00000000..9630fc51 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx @@ -0,0 +1,58 @@ +// โ€” what one node's box shows: its kind, when it runs among its +// siblings, how many events reached it, and what it does. The editor's canvas +// and the list's read-only graph both draw their boxes with it, inside a +// `` of their own that carries the pointer and key handling. +// +// The line of text is cut by the browser to the box's own width, so a long +// description ends in an ellipsis inside the box instead of running out of it; +// the full text is the box's tooltip. +import { NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js"; + +const PAD = 10; + +export function BoxFace({ + kind, + text, + order, + hits, +}: { + kind: string; + text: string; + // "runs 2nd" when the node has siblings, else nothing. + order?: string; + // The events of the last dry run that reached the node, when one covers it. + hits?: number; +}) { + const aside = [ + order, + hits === undefined ? undefined : `${hits} ${hits === 1 ? "hit" : "hits"}`, + ] + .filter((part) => part !== undefined) + .join(" ยท "); + return ( + <> + + + {kind} + + {aside ? ( + + {aside} + + ) : null} + +
{text}
+
+ + ); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx index d1988e02..4022a68d 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx @@ -21,16 +21,10 @@ import { 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"; +import { BoxFace } from "./FlowBox.js"; +import { layoutFlow, neighbour, nodeAt } from "./FlowLayout.js"; +import { describeNode, 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; @@ -359,23 +353,13 @@ export function FlowCanvas({ onKeyDown={(e) => onKey(e, p.id)} > {text} - - - {node.kind} - - {pos.count > 1 ? ( - - {`runs ${ordinal(pos.index + 1)}`} - - ) : null} - - {ellipsize(text, TEXT_CHARS)} - + 1 ? `runs ${ordinal(pos.index + 1)}` : undefined + } + />
); })} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts index 47c95f51..517da5ca 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts @@ -356,6 +356,39 @@ export function duplicateFlow( }); } +// The id of a flow nobody has saved yet. It is the editor's route for one, so +// no saved flow may take it. +export const NEW_FLOW_ID = "new"; + +function savedIds(view: FlowsView): string[] { + return [ + NEW_FLOW_ID, + ...view.builtins.map((f) => f.id), + ...view.flows.map((f) => f.id), + ]; +} + +// The draft the editor opens for a flow not saved yet: an empty one, or a copy +// of the built-in or custom flow `from`. Nothing is sent until it is saved, so +// the id is a placeholder (see `savedAs`). Null when `from` names no flow. +export function seedFlow(view: FlowsView, from: string | null): Flow | null { + const taken = savedIds(view); + if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID }; + const source = [...view.builtins, ...view.flows].find((f) => f.id === from); + if (!source) return null; + return { + ...duplicateFlow(source, `${source.name} (copy)`, taken), + id: NEW_FLOW_ID, + }; +} + +// The draft as it is saved: with an id made of its name that no flow has, +// the built-ins' included (the controller refuses a custom flow that shares a +// built-in's). +export function savedAs(view: FlowsView, draft: Flow): Flow { + return { ...draft, id: slugId(draft.name, savedIds(view)) }; +} + // `flows` with `flow` in place of the one of its id, or added last. export function withFlow(flows: Flow[], flow: Flow): Flow[] { return flows.some((f) => f.id === flow.id) diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx index 41fc991f..3190e2ca 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -3,12 +3,21 @@ // document back at the revision it was read. Edits stay in a draft until // then, so a half-built flow never reaches the router. // +// The same page makes a flow: `/notifications/new` is an empty draft, and +// `?from=` a copy of a flow to start from. Nothing exists until Save, which +// gives the flow an id made of its name. +// // The controller is the authority on what may be saved; its refusal is shown // as it comes. A save at an old revision is told apart from that: the flows // were changed elsewhere, and the editor offers to reload or to keep the draft // on top of the latest. import { useCallback, useEffect, useState } from "preact/hooks"; -import { Link, useLocation, type RouteComponentProps } from "wouter-preact"; +import { + Link, + useLocation, + useSearch, + type RouteComponentProps, +} from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Button } from "../../ui/button/Button.js"; @@ -24,11 +33,14 @@ import { isDirty, limitsOf, moveAmongSiblings, + NEW_FLOW_ID, paletteKinds, removeNode, reparent, + savedAs, saveOutcome, saveRequest, + seedFlow, parentOf, withFlow, withoutFlow, @@ -49,9 +61,13 @@ export function FlowEditorPage({ params, }: RouteComponentProps<{ flowId: string }>) { const flowId = decodeURIComponent(params.flowId); + const isNew = flowId === NEW_FLOW_ID; + const from = new URLSearchParams(useSearch()).get("from"); const [, navigate] = useLocation(); const [view, setView] = useState(null); const [draft, setDraft] = useState(null); + // What a new draft started as, so leaving it untouched is not "unsaved". + const [seed, setSeed] = useState(null); const [selected, setSelected] = useState(null); const [loadError, setLoadError] = useState(null); const [saveError, setSaveError] = useState(null); @@ -74,7 +90,10 @@ export function FlowEditorPage({ setStale(false); setSaveError(null); if (!keepDraft) { - const flow = next.flows.find((f) => f.id === flowId); + const flow = isNew + ? seedFlow(next, from) + : next.flows.find((f) => f.id === flowId); + setSeed(isNew && flow ? structuredClone(flow) : null); setDraft(flow ? structuredClone(flow) : null); setSelected(flow ? rootOf(flow) : null); } @@ -84,7 +103,7 @@ export function FlowEditorPage({ ); } }, - [flowId], + [flowId, isNew, from], ); useEffect(() => { void load(false); @@ -93,7 +112,9 @@ export function FlowEditorPage({ const dirty = view !== null && draft !== null && - isDirty(view, withFlow(view.flows, draft), view.disabled_builtins); + (isNew + ? JSON.stringify(draft) !== JSON.stringify(seed) + : isDirty(view, withFlow(view.flows, draft), view.disabled_builtins)); // Leaving the page with edits not saved asks first, whether by closing the // tab, following a link or pressing back. @@ -138,6 +159,18 @@ export function FlowEditorPage({ } } + // Save the draft. A new flow is added under its name's id, and the page + // moves on to editing it. + async function save() { + if (!view || !draft) return; + const flow = isNew ? savedAs(view, draft) : draft; + if (!(await put(withFlow(view.flows, flow))) || !isNew) return; + leaving.release(); + navigate(`/notifications/${encodeURIComponent(flow.id)}`, { + replace: true, + }); + } + async function onDelete() { if (!view) return; setConfirmDelete(false); @@ -169,9 +202,11 @@ export function FlowEditorPage({ return (

- {builtin - ? "A built-in flow cannot be edited: duplicate it from the list and edit the copy." - : `There is no flow ${flowId}; it may have been deleted.`} + {isNew + ? `There is no flow ${from} to copy; it may have been deleted.` + : builtin + ? "A built-in flow cannot be edited: duplicate it from the list and edit the copy." + : `There is no flow ${flowId}; it may have been deleted.`}

); @@ -182,7 +217,11 @@ export function FlowEditorPage({ const node = draft.nodes.find((n) => n.id === selected) ?? null; return ( - + {stale ? ( @@ -221,28 +261,38 @@ export function FlowEditorPage({ checked={draft.enabled} onChange={(enabled) => setDraft({ ...draft, enabled })} /> - - - +
+ {dirty ? ( + + not saved yet + + ) : null} + + + {isNew ? null : ( + + )} +
- {dirty ?

Not saved yet.

: null}
โ€” a flow drawn as boxes and lines, to look at: the source on the +// left, its branches to the right. The list shows each flow with it; the +// editor's draws the same picture and adds the rewiring. +// +// `hits`, when the dry run covers this flow, puts the number of the newest +// events that reached each node in its box. +import { BoxFace } from "./FlowBox.js"; +import { layoutFlow } from "./FlowLayout.js"; +import { siblingPosition } from "./FlowDraft.js"; +import { describeNode, ordinal, type Flow } from "./FlowTypes.js"; + +export function FlowGraph({ + flow, + hits, +}: { + flow: Flow; + hits?: Map; +}) { + const layout = layoutFlow(flow); + const byId = new Map(flow.nodes.map((n) => [n.id, n])); + return ( +
+ + {layout.edges.map((e) => { + const mid = (e.x1 + e.x2) / 2; + return ( + ${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); + return ( + + {text} + 1 ? `runs ${ordinal(pos.index + 1)}` : undefined + } + hits={hits ? (hits.get(node.id) ?? 0) : undefined} + /> + + ); + })} + +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts index a84634cb..9362f91c 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts @@ -40,32 +40,6 @@ export interface FlowsView { schema?: FlowSchema; } -// One node of a flow with its depth below the source, in the order a reader -// walks the tree: a parent, then each child's subtree in edge order. -export interface TreeRow { - node: FlowNode; - depth: number; -} - -export function treeRows(flow: Flow): TreeRow[] { - const byId = new Map(flow.nodes.map((n) => [n.id, n])); - const root = flow.nodes.find((n) => n.kind === "source"); - if (!root) return []; - const rows: TreeRow[] = []; - const seen = new Set(); - const walk = (id: string, depth: number) => { - const node = byId.get(id); - // The controller refuses a flow that is not a tree; a repeat here would - // mean a cycle, which `seen` stops from looping. - if (!node || seen.has(id)) return; - seen.add(id); - rows.push({ node, depth }); - for (const e of flow.edges) if (e.from === id) walk(e.to, depth + 1); - }; - walk(root.id, 0); - return rows; -} - export function describeRecipients(to: Recipients): string { return "field" in to ? `{${to.field}}` : to.agent; } @@ -161,11 +135,6 @@ export function ordinal(n: number): string { 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)}โ€ฆ`; -} - // The values the controller allows for `field` of node kind `kind`, if its // schema lists them (the todo `op` does). export function kindValues( diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx index 2a205568..6bdd58dd 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx @@ -3,6 +3,7 @@ // 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 { Card } from "../../ui/card/Card.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"; @@ -74,7 +75,7 @@ export function NodeInspector({ const order = siblingPosition(flow, node.id); return ( -
+

{node.kind}{" "} {node.id !== node.kind ? ( @@ -190,7 +191,7 @@ export function NodeInspector({ onChange={(p) => onEdit(reparent(flow, node.id, p, limits))} /> ) : null} -

+ ); } diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css index 8c6f9f94..168dd388 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.css @@ -1,4 +1,4 @@ -/* NotificationsPage โ€” the flows drawn as indented trees. */ +/* NotificationsPage โ€” the flows, each drawn as its graph. */ .notify-flows { display: flex; @@ -15,55 +15,49 @@ } .notify-flow-name { + flex: 1 1 auto; + min-width: 0; font-weight: 600; + overflow-wrap: anywhere; } -.notify-flow-tree { - margin: 0.4em 0 0; - padding: 0; - list-style: none; - font-size: 0.9em; -} - -.notify-node { - display: flex; - gap: 0.5em; - padding: 0.1em 0; -} - -.notify-node-kind { - flex: none; - min-width: 3.5em; +.notify-flow-state { color: var(--muted); } -.notify-node-text { - overflow-wrap: anywhere; +.notify-flow-actions, +.notify-panel-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; +} + +.notify-graph { + margin-top: 0.4em; } .notify-off { opacity: 0.6; } -/* The flow list's new-flow row and each flow's actions. */ -.notify-new { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 0.5em; -} - -.notify-flow-head > * + * { - margin-left: 0.5em; -} - /* FlowEditorPage โ€” the head row, the canvas and the inspector beside it. */ .notify-editor-head { display: flex; flex-wrap: wrap; align-items: flex-end; - gap: 0.75em; - margin-bottom: 0.5em; + gap: 0.75em 1em; + margin-bottom: 1em; + padding-bottom: 1em; + border-bottom: 1px solid var(--border); +} + +.notify-editor-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5em; + margin-left: auto; } .notify-editor-body { @@ -208,13 +202,41 @@ font-size: 10px; } +/* The box's line of text is cut by the browser at the box's own edge. */ +.notify-box-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg); + font-size: 12px; + line-height: 16px; + pointer-events: none; +} + .notify-inspector { flex: 0 1 22em; min-width: 16em; + gap: 0.75em; +} + +.notify-inspector h4 { + margin: 0; } .notify-inspector fieldset { - margin: 0 0 0.75em; + display: flex; + flex-direction: column; + gap: 0.6em; + margin: 0; + padding: 0.6em 0.8em 0.8em; + border: 1px solid var(--border); + border-radius: 6px; +} + +.notify-inspector legend { + padding: 0 0.4em; + color: var(--muted); + font-size: 0.9em; } .notify-condition { diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx index c3360ef4..0758b81a 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -2,37 +2,27 @@ // swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only // writer of the stored document and the router reads it every few seconds. // -// A flow draws as an indented tree, the shape the API validates it to. A -// built-in flow has a switch and can be duplicated; a custom flow is edited -// on its own page (), and a new one starts here. +// A flow draws as its graph, read-only. A built-in flow has a switch and can +// be duplicated; a custom flow is edited on its own page (). +// A new flow and a duplicate both open that page on a draft: nothing is created +// until it is saved there. import { useState } from "preact/hooks"; -import { Link, useLocation } from "wouter-preact"; +import { useLocation } from "wouter-preact"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; import { Button } from "../../ui/button/Button.js"; import { Card } from "../../ui/card/Card.js"; import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js"; import { Panel } from "../../ui/panel/Panel.js"; -import { TextField } from "../../ui/text-field/TextField.js"; import { RefreshIntervalPicker, type RefreshIntervalMs, } from "../../ui/refresh-interval/RefreshInterval.js"; import { fetchJson, usePolled } from "../../ui/polled/usePolled.js"; import { Deliveries } from "./Deliveries.js"; -import { - duplicateFlow, - newFlow, - saveOutcome, - saveRequest, -} from "./FlowDraft.js"; -import { - describeNode, - treeRows, - withBuiltin, - type Flow, - type FlowsView, -} from "./FlowTypes.js"; +import { NEW_FLOW_ID } from "./FlowDraft.js"; +import { FlowGraph } from "./FlowGraph.js"; +import { withBuiltin, type FlowsView } from "./FlowTypes.js"; import { DELIVERIES_URL, DRY_RUN_URL, @@ -45,30 +35,6 @@ import "./NotificationsPage.css"; const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000; -// `hits`, when the dry run covers this flow, puts the number of the newest events -// that reached each node beside it. -function FlowTree({ flow, hits }: { flow: Flow; hits?: Map }) { - return ( -
    - {treeRows(flow).map(({ node, depth }) => ( -
  • - {node.kind} - {describeNode(node)} - {hits ? ( - - {hits.get(node.id) ?? 0} - - ) : null} -
  • - ))} -
- ); -} - export function NotificationsPage() { const [intervalMs, setIntervalMs] = useState(DEFAULT_INTERVAL_MS); @@ -93,7 +59,6 @@ export function NotificationsPage() { const shown = dryError ? null : dry; const [pending, setPending] = useState(false); const [actionError, setActionError] = useState(null); - const [newName, setNewName] = useState(""); const [, navigate] = useLocation(); // Switch one built-in on or off: the whole document goes back with the @@ -122,47 +87,29 @@ export function NotificationsPage() { } } - // Add `flow` to the custom flows and open it in the editor. Its id is - // made free of every flow's, the built-ins' too: the controller refuses a - // custom flow that shares a built-in's. - async function addFlow(make: (taken: string[]) => Flow) { - if (!view) return; - const taken = [...view.builtins, ...view.flows].map((f) => f.id); - const flow = make(taken); - setPending(true); - setActionError(null); - try { - const r = await fetch( - "/api/notify/flows", - saveRequest(view, [...view.flows, flow], view.disabled_builtins), - ); - if (saveOutcome(r.status) === "saved") { - setNewName(""); - navigate(`/notifications/${encodeURIComponent(flow.id)}`); - return; - } - setActionError(await readApiError(r)); - } catch (err) { - setActionError({ detail: String(err) }); - } finally { - setPending(false); - refresh(); - } - } - - const duplicate = (flow: Flow) => - addFlow((taken) => duplicateFlow(flow, `${flow.name} (copy)`, taken)); + // Open the editor on a draft: empty, or a copy of the flow `from`. + const draftFrom = (from?: string) => + navigate( + from === undefined + ? `/notifications/${NEW_FLOW_ID}` + : `/notifications/${NEW_FLOW_ID}?from=${encodeURIComponent(from)}`, + ); return ( +
+ + +
} > {error ? ( @@ -200,46 +147,37 @@ export function NotificationsPage() { disabled={pending} onChange={(checked) => setBuiltin(flow.id, checked)} /> +
+ +
- - + ); })}

custom flows

{view.flows.length === 0 ?

no custom flows

: null} -
- - -
{view.flows.map((flow) => (
{flow.name} - {flow.enabled ? "on" : "off"} - - edit - - + + {flow.enabled ? "on" : "off"} + +
+ + +
- +
))}

recent deliveries

diff --git a/frontend/packages/swarm-ui/test/flow-draft.test.mjs b/frontend/packages/swarm-ui/test/flow-draft.test.mjs index 34632ca1..f274f553 100644 --- a/frontend/packages/swarm-ui/test/flow-draft.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-draft.test.mjs @@ -12,14 +12,17 @@ import { isLeafKind, limitsOf, moveAmongSiblings, + NEW_FLOW_ID, newFlow, newNodeId, paletteKinds, removeNode, reparent, replaceNode, + savedAs, saveOutcome, saveRequest, + seedFlow, siblingPosition, slugId, subtreeIds, @@ -351,3 +354,43 @@ test("moveAmongSiblings swaps two branches and leaves other edges alone", () => assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last"); assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source"); }); + +test("seedFlow opens an empty or copied draft under the placeholder id", () => { + const view = { + revision: 1, + disabled_builtins: [], + builtins: [{ ...flow(), id: "assigned", name: "Assigned" }], + flows: [{ ...flow(), id: "mine", name: "Mine" }], + }; + const empty = seedFlow(view, null); + assert.equal(empty.id, NEW_FLOW_ID); + assert.equal(empty.name, ""); + assert.deepEqual( + empty.nodes.map((n) => n.kind), + ["source"], + ); + const copy = seedFlow(view, "assigned"); + assert.equal(copy.id, NEW_FLOW_ID); + assert.equal(copy.name, "Assigned (copy)"); + assert.equal(copy.nodes.length, flow().nodes.length); + assert.equal(seedFlow(view, "gone"), null); + assert.equal(view.flows.length, 1, "nothing is added to the view"); +}); + +test("savedAs takes the id from the name, clear of every saved id", () => { + const view = { + revision: 1, + disabled_builtins: [], + builtins: [{ ...flow(), id: "assigned" }], + flows: [{ ...flow(), id: "mine" }], + }; + const draft = { ...flow(), id: NEW_FLOW_ID }; + assert.equal(savedAs(view, { ...draft, name: "Closed" }).id, "closed"); + assert.equal(savedAs(view, { ...draft, name: "Assigned" }).id, "assigned-2"); + assert.equal(savedAs(view, { ...draft, name: "mine" }).id, "mine-2"); + assert.equal( + savedAs(view, { ...draft, name: "New" }).id, + "new-2", + "no flow takes the id of the new-flow route", + ); +}); diff --git a/frontend/packages/swarm-ui/test/flow-types.test.mjs b/frontend/packages/swarm-ui/test/flow-types.test.mjs index 6bca9976..ffe8b751 100644 --- a/frontend/packages/swarm-ui/test/flow-types.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-types.test.mjs @@ -3,10 +3,8 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { describeNode, - ellipsize, kindValues, ordinal, - treeRows, withBuiltin, } from "../src/pages/notifications/FlowTypes.ts"; @@ -45,25 +43,6 @@ const flow = { ], }; -test("treeRows: each subtree follows its parent, with its depth", () => { - assert.deepEqual( - treeRows(flow).map((r) => [r.node.id, r.depth]), - [ - ["s", 0], - ["m", 1], - ["t", 2], - ["m2", 1], - ["u", 2], - ], - ); -}); - -test("treeRows: a cycle ends the walk and a flow with no source is empty", () => { - const cyclic = { ...flow, edges: [...flow.edges, { from: "t", to: "s" }] }; - assert.equal(treeRows(cyclic).length, 5); - assert.deepEqual(treeRows({ ...flow, nodes: [] }), []); -}); - test("describeNode: says what each kind does", () => { assert.equal(describeNode(flow.nodes[0]), "forge issues, issue_assign"); assert.equal(describeNode(flow.nodes[1]), "action = closed"); @@ -90,13 +69,6 @@ test("withBuiltin: switching off adds once, switching on removes", () => { assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]); }); -test("ellipsize cuts long text and leaves short text alone", () => { - assert.equal(ellipsize("short", 10), "short"); - assert.equal(ellipsize("exactly10!", 10), "exactly10!"); - assert.equal(ellipsize("a rather long line", 10), "a rather โ€ฆ"); - assert.equal(ellipsize("a rather long line", 10).length, 10); -}); - test("kindValues lists the values a schema allows for a field", () => { const schema = { node_kinds: [