Watch
0
0
Fork
You've already forked hyperhive
0

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.
This commit is contained in:
iris 2026-10-25 01:27:50 +02:00 • committed by mara
commit d0a55502a8
12 changed files with 406 additions and 262 deletions

View file

@ -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/<flow id>` edits one custom flow. The canvas draws the flow left
`/notifications/<flow id>` edits one custom flow, and `/notifications/new` makes
one: it opens on an empty flow, or on a copy of a flow with `?from=<flow id>`, 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:

View file

@ -0,0 +1,58 @@
// <BoxFace> — 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
// `<g>` 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 (
<>
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
<text class="notify-box-kind" x={PAD} y="17">
{kind}
</text>
{aside ? (
<text
class="notify-box-kind notify-box-order"
x={NODE_WIDTH - PAD}
y="17"
text-anchor="end"
>
{aside}
</text>
) : null}
<foreignObject
x={PAD}
y="23"
width={NODE_WIDTH - 2 * PAD}
height={NODE_HEIGHT - 25}
>
<div class="notify-box-text">{text}</div>
</foreignObject>
</>
);
}

View file

@ -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)}
>
<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>
<BoxFace
kind={node.kind}
text={text}
order={
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
}
/>
</g>
);
})}

View file

@ -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)

View file

@ -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=<id>` 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<FlowsView | null>(null);
const [draft, setDraft] = useState<Flow | null>(null);
// What a new draft started as, so leaving it untouched is not "unsaved".
const [seed, setSeed] = useState<Flow | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
const [saveError, setSaveError] = useState<ProblemDetails | null>(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 (
<Panel title="notification flow" icon="🔔" actions={back}>
<p>
{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.`}
</p>
</Panel>
);
@ -182,7 +217,11 @@ export function FlowEditorPage({
const node = draft.nodes.find((n) => n.id === selected) ?? null;
return (
<Panel title={`flow ${draft.id}`} icon="🔔" actions={back}>
<Panel
title={isNew ? "new flow" : `flow ${draft.id}`}
icon="🔔"
actions={back}
>
{stale ? (
<div class="notify-stale" role="alert">
<p>
@ -212,6 +251,7 @@ export function FlowEditorPage({
id="flow-name"
label="name"
value={draft.name}
placeholder="what the flow is for"
onInput={(name) => setDraft({ ...draft, name })}
/>
</div>
@ -221,28 +261,38 @@ export function FlowEditorPage({
checked={draft.enabled}
onChange={(enabled) => setDraft({ ...draft, enabled })}
/>
<Button
variant="primary"
disabled={!dirty || busy}
onClick={() => void put(withFlow(view.flows, draft))}
>
save
</Button>
<Button
disabled={!dirty || busy}
onClick={() => {
const saved = view.flows.find((f) => f.id === flowId);
if (saved) setDraft(structuredClone(saved));
setNotice(null);
}}
>
discard edits
</Button>
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
delete flow
</Button>
<div class="notify-editor-actions">
{dirty ? (
<span class="notify-hint" role="status">
not saved yet
</span>
) : null}
<Button
variant="primary"
disabled={(!isNew && !dirty) || busy || draft.name.trim() === ""}
onClick={() => void save()}
>
save
</Button>
<Button
disabled={!dirty || busy}
onClick={() => {
const start = isNew
? seed
: view.flows.find((f) => f.id === flowId);
if (start) setDraft(structuredClone(start));
setNotice(null);
}}
>
discard edits
</Button>
{isNew ? null : (
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
delete flow
</Button>
)}
</div>
</div>
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
<div class="notify-editor-body">
<FlowCanvas

View file

@ -0,0 +1,66 @@
// <FlowGraph> — 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 <FlowCanvas> 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<string, number>;
}) {
const layout = layoutFlow(flow);
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
return (
<div class="notify-canvas notify-graph">
<svg
width={layout.width}
height={layout.height}
viewBox={`0 0 ${layout.width} ${layout.height}`}
role="img"
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);
const pos = siblingPosition(flow, p.id);
return (
<g
key={p.id}
class={`notify-box notify-box-${node.kind}`}
transform={`translate(${p.x} ${p.y})`}
>
<title>{text}</title>
<BoxFace
kind={node.kind}
text={text}
order={
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
}
hits={hits ? (hits.get(node.id) ?? 0) : undefined}
/>
</g>
);
})}
</svg>
</div>
);
}

View file

@ -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<string>();
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(

View file

@ -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 (
<div class="notify-inspector">
<Card class="notify-inspector">
<h4>
{node.kind}{" "}
{node.id !== node.kind ? (
@ -190,7 +191,7 @@ export function NodeInspector({
onChange={(p) => onEdit(reparent(flow, node.id, p, limits))}
/>
) : null}
</div>
</Card>
);
}

View file

@ -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 {

View file

@ -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 (<FlowEditorPage>), 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 (<FlowEditorPage>).
// 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<string, number> }) {
return (
<ul class="notify-flow-tree">
{treeRows(flow).map(({ node, depth }) => (
<li
class="notify-node"
key={node.id}
style={{ paddingLeft: `${depth * 1.5}em` }}
>
<span class="notify-node-kind">{node.kind}</span>
<span class="notify-node-text">{describeNode(node)}</span>
{hits ? (
<span class="notify-node-hits" title="events of the last dry run">
{hits.get(node.id) ?? 0}
</span>
) : null}
</li>
))}
</ul>
);
}
export function NotificationsPage() {
const [intervalMs, setIntervalMs] =
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
@ -93,7 +59,6 @@ export function NotificationsPage() {
const shown = dryError ? null : dry;
const [pending, setPending] = useState(false);
const [actionError, setActionError] = useState<ProblemDetails | null>(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 (
<Panel
title="notifications"
icon="🔔"
actions={
<RefreshIntervalPicker
id="notifications-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
<div class="notify-panel-actions">
<RefreshIntervalPicker
id="notifications-refresh"
value={intervalMs}
onChange={setIntervalMs}
/>
<Button variant="primary" onClick={() => draftFrom()}>
create flow
</Button>
</div>
}
>
{error ? (
@ -200,46 +147,37 @@ export function NotificationsPage() {
disabled={pending}
onChange={(checked) => setBuiltin(flow.id, checked)}
/>
<div class="notify-flow-actions">
<Button onClick={() => draftFrom(flow.id)}>
duplicate to edit
</Button>
</div>
</div>
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
<Button disabled={pending} onClick={() => void duplicate(flow)}>
duplicate to edit
</Button>
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
</Card>
);
})}
<h3>custom flows</h3>
{view.flows.length === 0 ? <p>no custom flows</p> : null}
<div class="notify-new">
<TextField
id="notify-new-name"
label="new flow"
placeholder="name"
value={newName}
onInput={setNewName}
/>
<Button
disabled={pending || newName.trim() === ""}
onClick={() =>
void addFlow((taken) => newFlow(newName.trim(), taken))
}
>
create
</Button>
</div>
{view.flows.map((flow) => (
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
<div class="notify-flow-head">
<span class="notify-flow-name">{flow.name}</span>
<span>{flow.enabled ? "on" : "off"}</span>
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
edit
</Link>
<Button disabled={pending} onClick={() => void duplicate(flow)}>
duplicate
</Button>
<span class="notify-flow-state">
{flow.enabled ? "on" : "off"}
</span>
<div class="notify-flow-actions">
<Button
onClick={() =>
navigate(`/notifications/${encodeURIComponent(flow.id)}`)
}
>
edit
</Button>
<Button onClick={() => draftFrom(flow.id)}>duplicate</Button>
</div>
</div>
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
</Card>
))}
<h3>recent deliveries</h3>

View file

@ -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",
);
});

View file

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