diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index c4a413a1a..8e45b7388 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 graph, and an editor for custom flows | +| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a tree, 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,25 +223,20 @@ 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 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. +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. -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 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 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. -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`). +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`). 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 @@ -251,10 +246,7 @@ works. See [deliveries and dry runs](notify-router.md#deliveries-and-dry-runs). #### Editing a flow -`/notifications/` edits one custom flow, and `/notifications/draft/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 +`/notifications/` edits one custom flow. 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/App.tsx b/frontend/packages/swarm-ui/src/App.tsx index af15f7244..2081e70fd 100644 --- a/frontend/packages/swarm-ui/src/App.tsx +++ b/frontend/packages/swarm-ui/src/App.tsx @@ -9,7 +9,6 @@ import { AlertsPage } from "./pages/AlertsPage.js"; import { BuildsPage } from "./pages/builds/BuildsPage.js"; import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js"; import { MessagesPage } from "./pages/messages/MessagesPage.js"; -import { NEW_FLOW_PATH } from "./pages/notifications/FlowDraft.js"; import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js"; import { NotificationsPage } from "./pages/notifications/NotificationsPage.js"; import { ComponentsPage } from "./pages/ComponentsPage.js"; @@ -49,9 +48,6 @@ export function App() { - - - diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx b/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx deleted file mode 100644 index 9630fc51d..000000000 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowBox.tsx +++ /dev/null @@ -1,58 +0,0 @@ -// โ€” 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 4022a68d9..d1988e028 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowCanvas.tsx @@ -21,10 +21,16 @@ import { type Edit, type Limits, } from "./FlowDraft.js"; -import { BoxFace } from "./FlowBox.js"; -import { layoutFlow, neighbour, nodeAt } from "./FlowLayout.js"; -import { describeNode, ordinal, type Flow } from "./FlowTypes.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; @@ -353,13 +359,23 @@ export function FlowCanvas({ onKeyDown={(e) => onKey(e, p.id)} > {text} - 1 ? `runs ${ordinal(pos.index + 1)}` : undefined - } - /> + + + {node.kind} + + {pos.count > 1 ? ( + + {`runs ${ordinal(pos.index + 1)}`} + + ) : null} + + {ellipsize(text, TEXT_CHARS)} +
); })} diff --git a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts index 3d7a0f3fc..47c95f513 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowDraft.ts @@ -356,38 +356,6 @@ export function duplicateFlow( }); } -// The editor's route for a flow nobody has saved yet. It has a segment more -// than a flow's own route, so no flow's id can reach it. -export const NEW_FLOW_PATH = "/notifications/draft/new"; - -// The id a draft carries until it is saved (see `savedAs`). -export const NEW_FLOW_ID = "new"; - -function savedIds(view: FlowsView): string[] { - return [...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 9c01d4fc5..41fc991f1 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx @@ -3,21 +3,12 @@ // 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: `NEW_FLOW_PATH` 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, - useSearch, - type RouteComponentProps, -} from "wouter-preact"; +import { Link, useLocation, 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"; @@ -36,10 +27,8 @@ import { paletteKinds, removeNode, reparent, - savedAs, saveOutcome, saveRequest, - seedFlow, parentOf, withFlow, withoutFlow, @@ -58,18 +47,11 @@ function rootOf(flow: Flow): string | null { export function FlowEditorPage({ params, - isNew = false, -}: RouteComponentProps<{ flowId: string }> & { - // The page for a flow not saved yet, which has no flow id. - isNew?: boolean; -}) { +}: RouteComponentProps<{ flowId: string }>) { const flowId = decodeURIComponent(params.flowId); - 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); @@ -92,10 +74,7 @@ export function FlowEditorPage({ setStale(false); setSaveError(null); if (!keepDraft) { - const flow = isNew - ? seedFlow(next, from) - : next.flows.find((f) => f.id === flowId); - setSeed(isNew && flow ? structuredClone(flow) : null); + const flow = next.flows.find((f) => f.id === flowId); setDraft(flow ? structuredClone(flow) : null); setSelected(flow ? rootOf(flow) : null); } @@ -105,7 +84,7 @@ export function FlowEditorPage({ ); } }, - [flowId, isNew, from], + [flowId], ); useEffect(() => { void load(false); @@ -114,9 +93,7 @@ export function FlowEditorPage({ const dirty = view !== null && draft !== null && - (isNew - ? JSON.stringify(draft) !== JSON.stringify(seed) - : 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, whether by closing the // tab, following a link or pressing back. @@ -161,18 +138,6 @@ 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); @@ -204,11 +169,9 @@ export function FlowEditorPage({ return (

- {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.`} + {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.`}

); @@ -219,11 +182,7 @@ export function FlowEditorPage({ const node = draft.nodes.find((n) => n.id === selected) ?? null; return ( - + {stale ? ( @@ -263,38 +221,28 @@ 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 9362f91c0..a84634cb4 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts +++ b/frontend/packages/swarm-ui/src/pages/notifications/FlowTypes.ts @@ -40,6 +40,32 @@ 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; } @@ -135,6 +161,11 @@ 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 6bdd58dd3..2a205568b 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NodeInspector.tsx @@ -3,7 +3,6 @@ // 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"; @@ -75,7 +74,7 @@ export function NodeInspector({ const order = siblingPosition(flow, node.id); return ( - +

{node.kind}{" "} {node.id !== node.kind ? ( @@ -191,7 +190,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 a25615043..8c6f9f949 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, each drawn as its graph. */ +/* NotificationsPage โ€” the flows drawn as indented trees. */ .notify-flows { display: flex; @@ -15,49 +15,55 @@ } .notify-flow-name { - flex: 1 1 auto; - min-width: 0; font-weight: 600; - overflow-wrap: anywhere; } -.notify-flow-state { +.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; color: var(--muted); } -.notify-flow-actions, -.notify-panel-actions { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 0.5em; -} - -.notify-graph { - margin-top: 0.4em; +.notify-node-text { + overflow-wrap: anywhere; } .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 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; + gap: 0.75em; + margin-bottom: 0.5em; } .notify-editor-body { @@ -202,41 +208,13 @@ 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 { - 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; + margin: 0 0 0.75em; } .notify-condition { @@ -272,7 +250,14 @@ flex: 0 1 24em; } -/* The dry run's header line, and the router's "not available". */ +/* The dry run's counts beside each node, and the router's "not available". */ +.notify-node-hits { + margin-left: auto; + flex: none; + color: var(--muted); + font-variant-numeric: tabular-nums; +} + .notify-dry-run { margin: 0; color: var(--muted); diff --git a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx index ef2c50266..c3360ef47 100644 --- a/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/notifications/NotificationsPage.tsx @@ -2,27 +2,37 @@ // 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 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. +// 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. import { useState } from "preact/hooks"; -import { useLocation } from "wouter-preact"; +import { Link, 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 { NEW_FLOW_PATH } from "./FlowDraft.js"; -import { FlowGraph } from "./FlowGraph.js"; -import { withBuiltin, type FlowsView } from "./FlowTypes.js"; +import { + duplicateFlow, + newFlow, + saveOutcome, + saveRequest, +} from "./FlowDraft.js"; +import { + describeNode, + treeRows, + withBuiltin, + type Flow, + type FlowsView, +} from "./FlowTypes.js"; import { DELIVERIES_URL, DRY_RUN_URL, @@ -35,6 +45,30 @@ 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); @@ -59,6 +93,7 @@ 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 @@ -87,29 +122,47 @@ export function NotificationsPage() { } } - // Open the editor on a draft: empty, or a copy of the flow `from`. - const draftFrom = (from?: string) => - navigate( - from === undefined - ? NEW_FLOW_PATH - : `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}`, - ); + // 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)); return ( - - -
+ } > {error ? ( @@ -147,37 +200,46 @@ 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"} - -
- - -
+ {flow.enabled ? "on" : "off"} + + edit + +
- +
))}

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 27c28909b..34632ca18 100644 --- a/frontend/packages/swarm-ui/test/flow-draft.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-draft.test.mjs @@ -12,17 +12,14 @@ import { isLeafKind, limitsOf, moveAmongSiblings, - NEW_FLOW_ID, newFlow, newNodeId, paletteKinds, removeNode, reparent, replaceNode, - savedAs, saveOutcome, saveRequest, - seedFlow, siblingPosition, slugId, subtreeIds, @@ -354,38 +351,3 @@ 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"); -}); diff --git a/frontend/packages/swarm-ui/test/flow-types.test.mjs b/frontend/packages/swarm-ui/test/flow-types.test.mjs index ffe8b7514..6bca99760 100644 --- a/frontend/packages/swarm-ui/test/flow-types.test.mjs +++ b/frontend/packages/swarm-ui/test/flow-types.test.mjs @@ -3,8 +3,10 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { describeNode, + ellipsize, kindValues, ordinal, + treeRows, withBuiltin, } from "../src/pages/notifications/FlowTypes.ts"; @@ -43,6 +45,25 @@ 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"); @@ -69,6 +90,13 @@ 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: [