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: [