Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb8aa23e66 | ||
|
|
06f3e76329 | ||
|
|
f28e1e9063 |
13 changed files with 1232 additions and 6 deletions
|
|
@ -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, and every flow drawn as a tree |
|
||||
| `/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 |
|
||||
|
|
@ -226,13 +226,45 @@ 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 each branch ends in.
|
||||
|
||||
The page edits nothing else. Add custom flows with `PUT /api/notify/flows`.
|
||||
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.
|
||||
|
||||
#### Editing a flow
|
||||
|
||||
`/notifications/<flow id>` edits one custom flow. The canvas draws the flow left
|
||||
to right: the source on the left, each match to its right, and the todos at the
|
||||
ends. Pick a node to edit it in the panel beside the canvas:
|
||||
|
||||
- a **source** picks the forge events the flow starts from;
|
||||
- a **match** lists conditions, each an event field and the value it must equal;
|
||||
- a **todo** says who gets it (the agents an event field names, or one agent),
|
||||
whether it sets or clears a todo, the todo's key, and its text. The key and the
|
||||
text can use `{field}` placeholders.
|
||||
|
||||
Beside the fields, **add a match below** and **add a todo below** grow the
|
||||
branch, **move under** moves a node and what's below it to another match, and
|
||||
**remove** takes a node and its branch out. The editor turns away any edit the
|
||||
controller would refuse to save, and says why: a todo with children, a second
|
||||
parent, a node under its own branch, or more nodes or levels than the
|
||||
controller allows.
|
||||
|
||||
Edits stay in a draft until **save**, which sends the whole flows document with
|
||||
the revision the editor read. **discard edits** goes back to the saved flow and
|
||||
**delete flow** removes it, after a confirmation. If someone changed the flows
|
||||
elsewhere in the meantime, the controller turns the save down as stale, and the
|
||||
editor offers to reload and drop your edits, or to keep them and replace this
|
||||
flow's latest version with them. For any other refusal the editor shows the
|
||||
controller's own message. The router runs a saved flow from its next event.
|
||||
|
||||
Closing or reloading the tab with unsaved edits asks first. Leaving through a
|
||||
link in the app, or the browser's back button, drops the draft without asking.
|
||||
|
||||
### Sending a message to an agent
|
||||
|
||||
Under an agent's terminal, in the preview and in its own tab, is a **message**
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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 { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
|
||||
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
|
||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
|
|
@ -45,6 +46,7 @@ export function App() {
|
|||
<Route path="/broadcast" component={BroadcastPage} />
|
||||
<Route path="/messages" component={MessagesPage} />
|
||||
<Route path="/notifications" component={NotificationsPage} />
|
||||
<Route path="/notifications/:flowId" component={FlowEditorPage} />
|
||||
<Route path="/issues" component={IssueReportPage} />
|
||||
<Route path="/review" component={ReviewPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
// <FlowCanvas> — a flow drawn as boxes and lines: the source on the left, its
|
||||
// branches to the right. Pick a node to edit it; the inspector beside the
|
||||
// canvas does the editing.
|
||||
import { layoutFlow, NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
|
||||
import { describeNode, ellipsize, type Flow } from "./FlowTypes.js";
|
||||
|
||||
const TEXT_CHARS = 30;
|
||||
|
||||
export function FlowCanvas({
|
||||
flow,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
flow: Flow;
|
||||
selected: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const layout = layoutFlow(flow);
|
||||
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
||||
return (
|
||||
<div class="notify-canvas">
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
role="group"
|
||||
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
|
||||
>
|
||||
{layout.edges.map((e) => {
|
||||
const mid = (e.x1 + e.x2) / 2;
|
||||
return (
|
||||
<path
|
||||
key={`${e.from}>${e.to}`}
|
||||
class="notify-edge"
|
||||
d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{layout.nodes.map((p) => {
|
||||
const node = byId.get(p.id);
|
||||
if (!node) return null;
|
||||
const text = describeNode(node);
|
||||
return (
|
||||
<g
|
||||
key={p.id}
|
||||
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}`}
|
||||
transform={`translate(${p.x} ${p.y})`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-pressed={p.id === selected}
|
||||
aria-label={`${node.kind} ${p.id}: ${text}`}
|
||||
onClick={() => onSelect(p.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(p.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{text}</title>
|
||||
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
|
||||
<text class="notify-box-kind" x="10" y="17">
|
||||
{node.kind}
|
||||
</text>
|
||||
<text class="notify-box-text" x="10" y="35">
|
||||
{ellipsize(text, TEXT_CHARS)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
// these edits only refuse what could never be saved, so the canvas can
|
||||
// turn a bad drop away with a reason instead of drawing a flow the save
|
||||
// would reject. The tree rules mirror `Flow::validate` in swarm-wire.
|
||||
import type { Flow, FlowNode, FlowsView } from "./FlowTypes.js";
|
||||
import type { Flow, FlowNode, FlowSchema, FlowsView } from "./FlowTypes.js";
|
||||
|
||||
export interface Limits {
|
||||
maxNodes: number;
|
||||
|
|
@ -21,6 +21,14 @@ export const DEFAULT_LIMITS: Limits = {
|
|||
maxFlows: 100,
|
||||
};
|
||||
|
||||
// The caps the controller reports, or today's when it reports none.
|
||||
export function limitsOf(schema?: FlowSchema): Limits {
|
||||
const l = schema?.limits;
|
||||
return l
|
||||
? { maxNodes: l.max_nodes, maxDepth: l.max_depth, maxFlows: l.max_flows }
|
||||
: DEFAULT_LIMITS;
|
||||
}
|
||||
|
||||
export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string };
|
||||
|
||||
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
|
@ -192,6 +200,20 @@ export function reparent(
|
|||
};
|
||||
}
|
||||
|
||||
// A todo with its op switched. A clear carries no text, and only a clear may
|
||||
// be a prefix, so each is reset where the new op has no place for it.
|
||||
export function withOp(
|
||||
node: Extract<FlowNode, { kind: "todo" }>,
|
||||
op: "upsert" | "clear",
|
||||
): Extract<FlowNode, { kind: "todo" }> {
|
||||
return {
|
||||
...node,
|
||||
op,
|
||||
text: op === "clear" ? "" : node.text,
|
||||
prefix: op === "clear" ? node.prefix : false,
|
||||
};
|
||||
}
|
||||
|
||||
// The node with `next.id` swapped for `next`. A node keeps its place and its
|
||||
// kind-class: the source stays the source, and nothing with children becomes
|
||||
// a todo.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,263 @@
|
|||
// <FlowEditorPage> — edit one custom notification flow: a canvas of its
|
||||
// nodes, an inspector for the picked one, and a Save that sends the whole
|
||||
// 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 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 { 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 { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import { fetchJson, ProblemError } from "../../ui/polled/usePolled.js";
|
||||
import { FlowCanvas } from "./FlowCanvas.js";
|
||||
import {
|
||||
isDirty,
|
||||
limitsOf,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
withFlow,
|
||||
withoutFlow,
|
||||
type Edit,
|
||||
} from "./FlowDraft.js";
|
||||
import { NodeInspector } from "./NodeInspector.js";
|
||||
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
|
||||
import "./NotificationsPage.css";
|
||||
|
||||
const FLOWS_URL = "/api/notify/flows";
|
||||
|
||||
function rootOf(flow: Flow): string | null {
|
||||
return flow.nodes.find((n) => n.kind === "source")?.id ?? null;
|
||||
}
|
||||
|
||||
export function FlowEditorPage({
|
||||
params,
|
||||
}: RouteComponentProps<{ flowId: string }>) {
|
||||
const flowId = decodeURIComponent(params.flowId);
|
||||
const [, navigate] = useLocation();
|
||||
const [view, setView] = useState<FlowsView | null>(null);
|
||||
const [draft, setDraft] = 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);
|
||||
const [stale, setStale] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
// Read the flows. With `keepDraft` the edits stay and only the revision
|
||||
// they will be saved at moves on.
|
||||
const load = useCallback(
|
||||
async (keepDraft: boolean) => {
|
||||
try {
|
||||
const next = await fetchJson<FlowsView>(
|
||||
FLOWS_URL,
|
||||
new AbortController().signal,
|
||||
);
|
||||
setView(next);
|
||||
setLoadError(null);
|
||||
setStale(false);
|
||||
setSaveError(null);
|
||||
if (!keepDraft) {
|
||||
const flow = next.flows.find((f) => f.id === flowId);
|
||||
setDraft(flow ? structuredClone(flow) : null);
|
||||
setSelected(flow ? rootOf(flow) : null);
|
||||
}
|
||||
} catch (err) {
|
||||
setLoadError(
|
||||
err instanceof ProblemError ? err.problem : { detail: String(err) },
|
||||
);
|
||||
}
|
||||
},
|
||||
[flowId],
|
||||
);
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
const dirty =
|
||||
view !== null &&
|
||||
draft !== null &&
|
||||
isDirty(view, withFlow(view.flows, draft), view.disabled_builtins);
|
||||
|
||||
// Leaving the page with edits not saved asks first.
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const warn = (e: BeforeUnloadEvent) => e.preventDefault();
|
||||
window.addEventListener("beforeunload", warn);
|
||||
return () => window.removeEventListener("beforeunload", warn);
|
||||
}, [dirty]);
|
||||
|
||||
// Send `flows` as the document; true when the controller took it.
|
||||
async function put(flows: Flow[]): Promise<boolean> {
|
||||
if (!view) return false;
|
||||
setBusy(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const r = await fetch(
|
||||
FLOWS_URL,
|
||||
saveRequest(view, flows, view.disabled_builtins),
|
||||
);
|
||||
const outcome = saveOutcome(r.status);
|
||||
if (outcome === "saved") {
|
||||
const next = (await r.json()) as FlowsView;
|
||||
setView({ ...next, schema: next.schema ?? view.schema });
|
||||
return true;
|
||||
}
|
||||
if (outcome === "conflict") setStale(true);
|
||||
else setSaveError(await readApiError(r));
|
||||
} catch (err) {
|
||||
setSaveError({ detail: String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onEdit(result: Edit, select?: string) {
|
||||
if (!result.ok) {
|
||||
setNotice(result.reason);
|
||||
return;
|
||||
}
|
||||
setNotice(null);
|
||||
setDraft(result.flow);
|
||||
if (select !== undefined) setSelected(select);
|
||||
else if (!result.flow.nodes.some((n) => n.id === selected)) {
|
||||
setSelected(rootOf(result.flow));
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!view) return;
|
||||
setConfirmDelete(false);
|
||||
if (await put(withoutFlow(view.flows, flowId))) navigate("/notifications");
|
||||
}
|
||||
|
||||
const back = <Link href="/notifications">all flows</Link>;
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<Panel title="notification flow" icon="🔔" actions={back}>
|
||||
<ApiErrorPanel context="failed to load the flows" problem={loadError} />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
if (!view) {
|
||||
return (
|
||||
<Panel title="notification flow" icon="🔔" actions={back}>
|
||||
<p>loading…</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
if (!draft) {
|
||||
const builtin = view.builtins.some((b) => b.id === flowId);
|
||||
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.`}
|
||||
</p>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
const schema = view.schema ?? FALLBACK_SCHEMA;
|
||||
const limits = limitsOf(view.schema);
|
||||
const node = draft.nodes.find((n) => n.id === selected) ?? null;
|
||||
|
||||
return (
|
||||
<Panel title={`flow ${draft.id}`} icon="🔔" actions={back}>
|
||||
{stale ? (
|
||||
<div class="notify-stale" role="alert">
|
||||
<p>
|
||||
The flows were changed somewhere else since you opened this one, so
|
||||
this was not saved.
|
||||
</p>
|
||||
<Button onClick={() => void load(false)}>
|
||||
reload and drop my edits
|
||||
</Button>
|
||||
<Button onClick={() => void load(true)}>
|
||||
keep my edits (replaces this flow's latest version)
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{saveError ? (
|
||||
<ApiErrorPanel context="the flow was not saved" problem={saveError} />
|
||||
) : null}
|
||||
{notice ? (
|
||||
<p class="notify-notice" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div class="notify-editor-head">
|
||||
<div class="notify-name">
|
||||
<TextField
|
||||
id="flow-name"
|
||||
label="name"
|
||||
value={draft.name}
|
||||
onInput={(name) => setDraft({ ...draft, name })}
|
||||
/>
|
||||
</div>
|
||||
<CheckboxField
|
||||
id="flow-enabled"
|
||||
label="on"
|
||||
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>
|
||||
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
|
||||
|
||||
<div class="notify-editor-body">
|
||||
<FlowCanvas flow={draft} selected={selected} onSelect={setSelected} />
|
||||
{node ? (
|
||||
<NodeInspector
|
||||
flow={draft}
|
||||
node={node}
|
||||
schema={schema}
|
||||
limits={limits}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
label="delete this flow"
|
||||
confirmLabel="delete"
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
onConfirm={() => void onDelete()}
|
||||
>
|
||||
<p>Delete the flow {draft.name}? The router stops running it.</p>
|
||||
</ConfirmDialog>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
// Where the flow editor's canvas puts each node: the tree runs left to
|
||||
// right, one column per level, a leaf per row, and a parent sits at the
|
||||
// middle of its children. Computed from the flow on every edit, so nothing
|
||||
// about the layout is stored.
|
||||
import type { Flow } from "./FlowTypes.js";
|
||||
|
||||
export const NODE_WIDTH = 190;
|
||||
export const NODE_HEIGHT = 46;
|
||||
const GAP_X = 56;
|
||||
const GAP_Y = 14;
|
||||
const PAD = 12;
|
||||
|
||||
export interface PlacedNode {
|
||||
id: string;
|
||||
depth: number;
|
||||
// Top-left corner.
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface PlacedEdge {
|
||||
from: string;
|
||||
to: string;
|
||||
// Right-middle of the parent, left-middle of the child.
|
||||
x1: number;
|
||||
y1: number;
|
||||
x2: number;
|
||||
y2: number;
|
||||
}
|
||||
|
||||
export interface Layout {
|
||||
nodes: PlacedNode[];
|
||||
edges: PlacedEdge[];
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export function layoutFlow(flow: Flow): Layout {
|
||||
const children = new Map<string, string[]>();
|
||||
for (const e of flow.edges) {
|
||||
children.set(e.from, [...(children.get(e.from) ?? []), e.to]);
|
||||
}
|
||||
const known = new Set(flow.nodes.map((n) => n.id));
|
||||
const placed = new Map<string, PlacedNode>();
|
||||
let row = 0;
|
||||
|
||||
// Depth first, so a leaf takes the next row and a parent the middle of its
|
||||
// first and last child. `placed` also stops a cycle from looping.
|
||||
const place = (id: string, depth: number): number => {
|
||||
const kids = (children.get(id) ?? []).filter(
|
||||
(k) => known.has(k) && !placed.has(k),
|
||||
);
|
||||
const at = (r: number): number => PAD + r * (NODE_HEIGHT + GAP_Y);
|
||||
const x = PAD + depth * (NODE_WIDTH + GAP_X);
|
||||
// Reserve the spot first so a node cannot be reached twice.
|
||||
placed.set(id, { id, depth, x, y: 0 });
|
||||
// A node with two parents (a draft the save would refuse) is placed under
|
||||
// the first that reaches it.
|
||||
const ys: number[] = [];
|
||||
for (const k of kids) if (!placed.has(k)) ys.push(place(k, depth + 1));
|
||||
const y = ys.length === 0 ? at(row++) : (ys[0] + ys[ys.length - 1]) / 2;
|
||||
placed.set(id, { id, depth, x, y });
|
||||
return y;
|
||||
};
|
||||
|
||||
const root = flow.nodes.find((n) => n.kind === "source");
|
||||
if (root) place(root.id, 0);
|
||||
// A draft being rewired can hold a node nothing reaches yet: show it below
|
||||
// rather than lose it, until it is attached.
|
||||
for (const n of flow.nodes) {
|
||||
if (!placed.has(n.id)) place(n.id, 0);
|
||||
}
|
||||
|
||||
const nodes = flow.nodes.map((n) => placed.get(n.id) as PlacedNode);
|
||||
const edges = flow.edges
|
||||
.filter((e) => placed.has(e.from) && placed.has(e.to))
|
||||
.map((e) => {
|
||||
const a = placed.get(e.from) as PlacedNode;
|
||||
const b = placed.get(e.to) as PlacedNode;
|
||||
return {
|
||||
from: e.from,
|
||||
to: e.to,
|
||||
x1: a.x + NODE_WIDTH,
|
||||
y1: a.y + NODE_HEIGHT / 2,
|
||||
x2: b.x,
|
||||
y2: b.y + NODE_HEIGHT / 2,
|
||||
};
|
||||
});
|
||||
return {
|
||||
nodes,
|
||||
edges,
|
||||
width: Math.max(0, ...nodes.map((n) => n.x + NODE_WIDTH)) + PAD,
|
||||
height: Math.max(0, ...nodes.map((n) => n.y + NODE_HEIGHT)) + PAD,
|
||||
};
|
||||
}
|
||||
|
|
@ -35,6 +35,8 @@ export interface FlowsView {
|
|||
disabled_builtins: string[];
|
||||
builtins: Flow[];
|
||||
flows: Flow[];
|
||||
// Absent from a controller that predates it.
|
||||
schema?: FlowSchema;
|
||||
}
|
||||
|
||||
// One node of a flow with its depth below the source, in the order a reader
|
||||
|
|
@ -93,3 +95,73 @@ export function withBuiltin(
|
|||
const rest = disabled.filter((d) => d !== id);
|
||||
return (enabled ? rest : [...rest, id]).sort();
|
||||
}
|
||||
|
||||
// What the controller's `schema` says a flow may hold, so the editor offers
|
||||
// only what a save accepts. Controllers that predate the schema send none.
|
||||
export interface Described {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface KindField extends Described {
|
||||
required: boolean;
|
||||
values: Described[];
|
||||
}
|
||||
|
||||
export interface KindSchema {
|
||||
kind: string;
|
||||
description: string;
|
||||
fields: KindField[];
|
||||
}
|
||||
|
||||
export interface FlowSchema {
|
||||
source_events: Described[];
|
||||
fields: Described[];
|
||||
node_kinds: KindSchema[];
|
||||
limits: { max_nodes: number; max_depth: number; max_flows: number };
|
||||
}
|
||||
|
||||
// The same names as the controller's today, for when it sends no schema. The
|
||||
// controller still refuses anything wrong here on save.
|
||||
export const FALLBACK_SCHEMA: FlowSchema = {
|
||||
source_events: [
|
||||
"issues",
|
||||
"issue_assign",
|
||||
"pull_request",
|
||||
"pull_request_assign",
|
||||
].map((name) => ({ name, description: "" })),
|
||||
fields: [
|
||||
"event",
|
||||
"action",
|
||||
"kind",
|
||||
"repo",
|
||||
"number",
|
||||
"title",
|
||||
"url",
|
||||
"sender",
|
||||
"assignees",
|
||||
"assignees_added",
|
||||
"assignees_removed",
|
||||
"todo_holders",
|
||||
].map((name) => ({ name, description: "" })),
|
||||
node_kinds: [],
|
||||
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
|
||||
};
|
||||
|
||||
// `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(
|
||||
schema: FlowSchema,
|
||||
kind: string,
|
||||
field: string,
|
||||
): string[] {
|
||||
const values = schema.node_kinds
|
||||
.find((k) => k.kind === kind)
|
||||
?.fields.find((f) => f.name === field)?.values;
|
||||
return values && values.length > 0 ? values.map((v) => v.name) : [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,299 @@
|
|||
// <NodeInspector> — the fields of the picked node, and what can be done to
|
||||
// it: add a child, move it under another node, remove it. Every change goes
|
||||
// out as an `Edit` of the flow; the editor shows the reason when one is
|
||||
// refused.
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import {
|
||||
addChild,
|
||||
blankNode,
|
||||
parentOf,
|
||||
removeNode,
|
||||
reparent,
|
||||
replaceNode,
|
||||
type Edit,
|
||||
type Limits,
|
||||
withOp,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
kindValues,
|
||||
type Condition,
|
||||
type Described,
|
||||
type Flow,
|
||||
type FlowNode,
|
||||
type FlowSchema,
|
||||
} from "./FlowTypes.js";
|
||||
|
||||
type TodoNode = Extract<FlowNode, { kind: "todo" }>;
|
||||
|
||||
const OPS_FALLBACK = ["upsert", "clear"];
|
||||
|
||||
function options(list: Described[]) {
|
||||
return list.map((d) => ({
|
||||
value: d.name,
|
||||
label: d.description ? `${d.name} — ${d.description}` : d.name,
|
||||
}));
|
||||
}
|
||||
|
||||
export function NodeInspector({
|
||||
flow,
|
||||
node,
|
||||
schema,
|
||||
limits,
|
||||
onEdit,
|
||||
}: {
|
||||
flow: Flow;
|
||||
node: FlowNode;
|
||||
schema: FlowSchema;
|
||||
limits: Limits;
|
||||
// `select` is the node to pick once the edit is applied.
|
||||
onEdit: (edit: Edit, select?: string) => void;
|
||||
}) {
|
||||
const set = (next: FlowNode) => onEdit(replaceNode(flow, next));
|
||||
const fieldOptions = options(schema.fields);
|
||||
const firstField = schema.fields[0]?.name ?? "";
|
||||
const key = `ne-${node.id}`;
|
||||
|
||||
const add = (kind: "match" | "todo") => {
|
||||
const added = blankNode(flow, kind);
|
||||
onEdit(addChild(flow, node.id, added, limits), added.id);
|
||||
};
|
||||
|
||||
const parent = parentOf(flow, node.id);
|
||||
const parents = flow.nodes
|
||||
.filter((n) => n.id !== node.id && reparent(flow, node.id, n.id, limits).ok)
|
||||
.map((n) => ({ value: n.id, label: `${n.kind} ${n.id}` }));
|
||||
|
||||
return (
|
||||
<div class="notify-inspector">
|
||||
<h4>
|
||||
{node.kind}{" "}
|
||||
{node.id !== node.kind ? (
|
||||
<span class="notify-node-id">{node.id}</span>
|
||||
) : null}
|
||||
</h4>
|
||||
|
||||
{node.kind === "source" ? (
|
||||
<fieldset>
|
||||
<legend>forge events</legend>
|
||||
{schema.source_events.map((ev) => (
|
||||
<CheckboxField
|
||||
key={ev.name}
|
||||
id={`${key}-ev-${ev.name}`}
|
||||
label={
|
||||
ev.description ? `${ev.name} — ${ev.description}` : ev.name
|
||||
}
|
||||
checked={node.events.includes(ev.name)}
|
||||
onChange={(on) =>
|
||||
set({
|
||||
...node,
|
||||
events: on
|
||||
? [...node.events, ev.name]
|
||||
: node.events.filter((e) => e !== ev.name),
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
||||
{node.kind === "match" ? (
|
||||
<MatchFields
|
||||
node={node}
|
||||
keyPrefix={key}
|
||||
fieldOptions={fieldOptions}
|
||||
firstField={firstField}
|
||||
onChange={set}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{node.kind === "todo" ? (
|
||||
<TodoFields
|
||||
node={node}
|
||||
keyPrefix={key}
|
||||
schema={schema}
|
||||
fieldOptions={fieldOptions}
|
||||
firstField={firstField}
|
||||
onChange={set}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div class="notify-inspector-actions">
|
||||
{node.kind !== "todo" ? (
|
||||
<>
|
||||
<Button onClick={() => add("match")}>add a match below</Button>
|
||||
<Button onClick={() => add("todo")}>add a todo below</Button>
|
||||
</>
|
||||
) : null}
|
||||
{node.kind !== "source" ? (
|
||||
<Button
|
||||
onClick={() =>
|
||||
onEdit(removeNode(flow, node.id), parent ?? undefined)
|
||||
}
|
||||
>
|
||||
remove
|
||||
{flow.edges.some((e) => e.from === node.id)
|
||||
? " with its branch"
|
||||
: ""}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{node.kind !== "source" && parent !== undefined ? (
|
||||
<SelectField
|
||||
id={`${key}-parent`}
|
||||
label="move under"
|
||||
value={parent}
|
||||
options={parents}
|
||||
onChange={(p) => onEdit(reparent(flow, node.id, p, limits))}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MatchFields({
|
||||
node,
|
||||
keyPrefix,
|
||||
fieldOptions,
|
||||
firstField,
|
||||
onChange,
|
||||
}: {
|
||||
node: Extract<FlowNode, { kind: "match" }>;
|
||||
keyPrefix: string;
|
||||
fieldOptions: { value: string; label: string }[];
|
||||
firstField: string;
|
||||
onChange: (next: FlowNode) => void;
|
||||
}) {
|
||||
const setWhen = (when: Condition[]) => onChange({ ...node, when });
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>passes when all of these hold</legend>
|
||||
{node.when.length === 0 ? <p>no condition: passes everything</p> : null}
|
||||
{node.when.map((c, i) => (
|
||||
<div class="notify-condition" key={i}>
|
||||
<SelectField
|
||||
id={`${keyPrefix}-c${i}-field`}
|
||||
label="field"
|
||||
value={c.field}
|
||||
options={fieldOptions}
|
||||
onChange={(field) =>
|
||||
setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x)))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
id={`${keyPrefix}-c${i}-equals`}
|
||||
label="equals"
|
||||
value={c.equals}
|
||||
onInput={(equals) =>
|
||||
setWhen(node.when.map((x, j) => (j === i ? { ...x, equals } : x)))
|
||||
}
|
||||
/>
|
||||
<Button onClick={() => setWhen(node.when.filter((_, j) => j !== i))}>
|
||||
remove condition
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={() =>
|
||||
setWhen([...node.when, { field: firstField, equals: "" }])
|
||||
}
|
||||
>
|
||||
add a condition
|
||||
</Button>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
function TodoFields({
|
||||
node,
|
||||
keyPrefix,
|
||||
schema,
|
||||
fieldOptions,
|
||||
firstField,
|
||||
onChange,
|
||||
}: {
|
||||
node: TodoNode;
|
||||
keyPrefix: string;
|
||||
schema: FlowSchema;
|
||||
fieldOptions: { value: string; label: string }[];
|
||||
firstField: string;
|
||||
onChange: (next: FlowNode) => void;
|
||||
}) {
|
||||
const ops = kindValues(schema, "todo", "op");
|
||||
const opList = (ops.length > 0 ? ops : OPS_FALLBACK).map((o) => ({
|
||||
value: o,
|
||||
label: o,
|
||||
}));
|
||||
const byField = "field" in node.to;
|
||||
const hint = schema.fields.map((f) => `{${f.name}}`).join(" ");
|
||||
return (
|
||||
<fieldset>
|
||||
<legend>emits a todo</legend>
|
||||
<SelectField
|
||||
id={`${keyPrefix}-op`}
|
||||
label="op"
|
||||
value={node.op}
|
||||
options={opList}
|
||||
onChange={(op) => onChange(withOp(node, op as TodoNode["op"]))}
|
||||
/>
|
||||
<SelectField
|
||||
id={`${keyPrefix}-to-kind`}
|
||||
label="to"
|
||||
value={byField ? "field" : "agent"}
|
||||
options={[
|
||||
{ value: "field", label: "the agents an event field names" },
|
||||
{ value: "agent", label: "one agent" },
|
||||
]}
|
||||
onChange={(kind) =>
|
||||
onChange({
|
||||
...node,
|
||||
to: kind === "field" ? { field: firstField } : { agent: "" },
|
||||
})
|
||||
}
|
||||
/>
|
||||
{"field" in node.to ? (
|
||||
<SelectField
|
||||
id={`${keyPrefix}-to-field`}
|
||||
label="event field"
|
||||
value={node.to.field}
|
||||
options={fieldOptions}
|
||||
onChange={(field) => onChange({ ...node, to: { field } })}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
id={`${keyPrefix}-to-agent`}
|
||||
label="agent"
|
||||
value={node.to.agent}
|
||||
onInput={(agent) => onChange({ ...node, to: { agent } })}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
id={`${keyPrefix}-key`}
|
||||
label="todo key"
|
||||
value={node.key}
|
||||
onInput={(k) => onChange({ ...node, key: k })}
|
||||
/>
|
||||
{node.op === "clear" ? (
|
||||
<CheckboxField
|
||||
id={`${keyPrefix}-prefix`}
|
||||
label="the key is a prefix"
|
||||
checked={node.prefix}
|
||||
onChange={(prefix) => onChange({ ...node, prefix })}
|
||||
/>
|
||||
) : (
|
||||
<TextAreaField
|
||||
id={`${keyPrefix}-text`}
|
||||
label="todo text"
|
||||
value={node.text}
|
||||
rows={3}
|
||||
onInput={(text) => onChange({ ...node, text })}
|
||||
/>
|
||||
)}
|
||||
<p class="notify-hint">The key and text can use {hint}</p>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
|
@ -44,3 +44,132 @@
|
|||
.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;
|
||||
}
|
||||
|
||||
.notify-editor-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.notify-canvas {
|
||||
flex: 1 1 24em;
|
||||
min-width: 0;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.notify-edge {
|
||||
fill: none;
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.notify-box {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notify-box rect {
|
||||
fill: var(--bg-elev);
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.notify-box-source rect {
|
||||
stroke: var(--blue);
|
||||
}
|
||||
|
||||
.notify-box-match rect {
|
||||
stroke: var(--purple);
|
||||
}
|
||||
|
||||
.notify-box-todo rect {
|
||||
stroke: var(--green);
|
||||
}
|
||||
|
||||
.notify-box:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notify-box:focus rect,
|
||||
.notify-box-selected rect {
|
||||
stroke: var(--fg);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.notify-box text {
|
||||
fill: var(--fg);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notify-box .notify-box-kind {
|
||||
fill: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.notify-inspector {
|
||||
flex: 0 1 22em;
|
||||
min-width: 16em;
|
||||
}
|
||||
|
||||
.notify-inspector fieldset {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
|
||||
.notify-condition {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.notify-inspector-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.notify-node-id,
|
||||
.notify-hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.notify-stale,
|
||||
.notify-notice {
|
||||
margin-bottom: 0.5em;
|
||||
padding: 0.4em 0.6em;
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.notify-name {
|
||||
flex: 0 1 24em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,29 @@
|
|||
// 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.
|
||||
//
|
||||
// Read-only except for one switch per built-in flow. A flow draws as an
|
||||
// indented tree, the shape the API validates it to. Custom flows are not
|
||||
// editable here: they arrive through the API.
|
||||
// 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.
|
||||
import { useState } from "preact/hooks";
|
||||
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 {
|
||||
duplicateFlow,
|
||||
newFlow,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
describeNode,
|
||||
treeRows,
|
||||
|
|
@ -56,6 +65,8 @@ export function NotificationsPage() {
|
|||
);
|
||||
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
|
||||
// revision this page read (0 for none), so a change made elsewhere since is refused
|
||||
|
|
@ -83,6 +94,37 @@ 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));
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="notifications"
|
||||
|
|
@ -123,16 +165,42 @@ export function NotificationsPage() {
|
|||
/>
|
||||
</div>
|
||||
<FlowTree flow={flow} />
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate to edit
|
||||
</Button>
|
||||
</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>
|
||||
</div>
|
||||
<FlowTree flow={flow} />
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { test } from "node:test";
|
|||
import {
|
||||
addChild,
|
||||
blankNode,
|
||||
DEFAULT_LIMITS,
|
||||
depthOf,
|
||||
duplicateFlow,
|
||||
heightBelow,
|
||||
isDirty,
|
||||
limitsOf,
|
||||
newFlow,
|
||||
newNodeId,
|
||||
removeNode,
|
||||
|
|
@ -19,6 +21,7 @@ import {
|
|||
subtreeIds,
|
||||
withFlow,
|
||||
withoutFlow,
|
||||
withOp,
|
||||
} from "../src/pages/notifications/FlowDraft.ts";
|
||||
|
||||
const todo = (id) => ({
|
||||
|
|
@ -154,6 +157,16 @@ test("replaceNode keeps the place and the kind class", () => {
|
|||
assert.equal(replaceNode(f, todo("nope")).ok, false);
|
||||
});
|
||||
|
||||
test("withOp drops what the new op has no place for", () => {
|
||||
const clearing = { ...todo("t"), op: "clear", prefix: true, text: "" };
|
||||
const up = withOp(clearing, "upsert");
|
||||
assert.equal(up.op, "upsert");
|
||||
assert.equal(up.prefix, false, "only a clear may be a prefix");
|
||||
const back = withOp({ ...todo("t"), prefix: false }, "clear");
|
||||
assert.equal(back.text, "", "a clear carries no text");
|
||||
assert.equal(withOp(clearing, "clear").prefix, true, "kept on a clear");
|
||||
});
|
||||
|
||||
test("new ids do not collide", () => {
|
||||
const f = flow();
|
||||
assert.equal(newNodeId(f, "match"), "match1");
|
||||
|
|
@ -233,3 +246,11 @@ test("saveOutcome tells a stale revision from a refused document", () => {
|
|||
assert.equal(saveOutcome(503), "error");
|
||||
assert.equal(saveOutcome(500), "error");
|
||||
});
|
||||
|
||||
test("limitsOf takes the controller's caps and falls back to today's", () => {
|
||||
assert.deepEqual(
|
||||
limitsOf({ limits: { max_nodes: 5, max_depth: 3, max_flows: 9 } }),
|
||||
{ maxNodes: 5, maxDepth: 3, maxFlows: 9 },
|
||||
);
|
||||
assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS);
|
||||
});
|
||||
|
|
|
|||
122
frontend/packages/swarm-ui/test/flow-layout.test.mjs
Normal file
122
frontend/packages/swarm-ui/test/flow-layout.test.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Where the flow editor's canvas puts each node. Run by `npm test`.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
layoutFlow,
|
||||
NODE_HEIGHT,
|
||||
NODE_WIDTH,
|
||||
} from "../src/pages/notifications/FlowLayout.ts";
|
||||
|
||||
const match = (id) => ({ id, kind: "match", when: [] });
|
||||
const source = { id: "s", kind: "source", events: ["issues"] };
|
||||
const flow = (nodes, edges) => ({
|
||||
id: "f",
|
||||
name: "f",
|
||||
enabled: true,
|
||||
nodes,
|
||||
edges: edges.map(([from, to]) => ({ from, to })),
|
||||
});
|
||||
const at = (layout, id) => layout.nodes.find((n) => n.id === id);
|
||||
|
||||
test("a lone source sits top left", () => {
|
||||
const l = layoutFlow(flow([source], []));
|
||||
assert.equal(l.nodes.length, 1);
|
||||
assert.equal(at(l, "s").depth, 0);
|
||||
assert.equal(l.edges.length, 0);
|
||||
assert.ok(l.width > NODE_WIDTH && l.height > NODE_HEIGHT);
|
||||
});
|
||||
|
||||
test("leaves take rows in edge order and a parent sits between its children", () => {
|
||||
const l = layoutFlow(
|
||||
flow(
|
||||
[source, match("a"), match("b"), match("c")],
|
||||
[
|
||||
["s", "a"],
|
||||
["s", "b"],
|
||||
["a", "c"],
|
||||
],
|
||||
),
|
||||
);
|
||||
// c (under a) is the first leaf, b the second; a follows c, s the middle.
|
||||
assert.ok(at(l, "c").y < at(l, "b").y);
|
||||
assert.equal(at(l, "a").y, at(l, "c").y);
|
||||
assert.equal(at(l, "s").y, (at(l, "a").y + at(l, "b").y) / 2);
|
||||
assert.equal(at(l, "c").depth, 2);
|
||||
assert.ok(at(l, "c").x > at(l, "a").x && at(l, "a").x > at(l, "s").x);
|
||||
});
|
||||
|
||||
test("no two nodes overlap", () => {
|
||||
const nodes = [source];
|
||||
const edges = [];
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
nodes.push(match(`m${i}`), match(`n${i}`));
|
||||
edges.push(["s", `m${i}`], [`m${i}`, `n${i}`]);
|
||||
}
|
||||
const l = layoutFlow(flow(nodes, edges));
|
||||
for (const a of l.nodes) {
|
||||
for (const b of l.nodes) {
|
||||
if (a === b) continue;
|
||||
const apart =
|
||||
a.x + NODE_WIDTH <= b.x ||
|
||||
b.x + NODE_WIDTH <= a.x ||
|
||||
a.y + NODE_HEIGHT <= b.y ||
|
||||
b.y + NODE_HEIGHT <= a.y;
|
||||
assert.ok(apart, `${a.id} overlaps ${b.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an edge runs from the parent's right side to the child's left", () => {
|
||||
const l = layoutFlow(flow([source, match("a")], [["s", "a"]]));
|
||||
const [e] = l.edges;
|
||||
assert.equal(e.x1, at(l, "s").x + NODE_WIDTH);
|
||||
assert.equal(e.x2, at(l, "a").x);
|
||||
assert.equal(e.y1, at(l, "s").y + NODE_HEIGHT / 2);
|
||||
assert.equal(e.y2, at(l, "a").y + NODE_HEIGHT / 2);
|
||||
});
|
||||
|
||||
test("a node nothing reaches yet still gets a place, below the tree", () => {
|
||||
const l = layoutFlow(flow([source, match("a"), match("lost")], [["s", "a"]]));
|
||||
assert.equal(l.nodes.length, 3);
|
||||
assert.ok(at(l, "lost").y > at(l, "a").y);
|
||||
assert.equal(l.edges.length, 1);
|
||||
});
|
||||
|
||||
test("edges to unknown nodes are left out, and a cycle terminates", () => {
|
||||
const l = layoutFlow(
|
||||
flow(
|
||||
[source, match("a"), match("b")],
|
||||
[
|
||||
["s", "a"],
|
||||
["a", "b"],
|
||||
["b", "a"],
|
||||
["s", "ghost"],
|
||||
],
|
||||
),
|
||||
);
|
||||
assert.equal(l.nodes.length, 3);
|
||||
assert.ok(l.edges.every((e) => e.to !== "ghost"));
|
||||
});
|
||||
|
||||
test("a node with two parents is placed once", () => {
|
||||
const l = layoutFlow(
|
||||
flow(
|
||||
[source, match("a"), match("b"), match("x")],
|
||||
[
|
||||
["s", "a"],
|
||||
["s", "b"],
|
||||
["a", "x"],
|
||||
["b", "x"],
|
||||
],
|
||||
),
|
||||
);
|
||||
assert.equal(l.nodes.length, 4);
|
||||
const spots = new Set(l.nodes.map((n) => `${n.x},${n.y}`));
|
||||
assert.equal(spots.size, 4, "no two nodes share a spot");
|
||||
});
|
||||
|
||||
test("an empty flow has an empty layout", () => {
|
||||
const l = layoutFlow(flow([], []));
|
||||
assert.deepEqual(l.nodes, []);
|
||||
assert.ok(l.width >= 0 && l.height >= 0);
|
||||
});
|
||||
|
|
@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
|||
import { test } from "node:test";
|
||||
import {
|
||||
describeNode,
|
||||
ellipsize,
|
||||
kindValues,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
} from "../src/pages/notifications/FlowTypes.ts";
|
||||
|
|
@ -78,3 +80,28 @@ test("withBuiltin: switching off adds once, switching on removes", () => {
|
|||
assert.deepEqual(withBuiltin(["c"], "a", false), ["a", "c"]);
|
||||
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: [
|
||||
{
|
||||
kind: "todo",
|
||||
fields: [
|
||||
{ name: "op", values: [{ name: "upsert" }, { name: "clear" }] },
|
||||
{ name: "key", values: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(kindValues(schema, "todo", "op"), ["upsert", "clear"]);
|
||||
assert.deepEqual(kindValues(schema, "todo", "key"), []);
|
||||
assert.deepEqual(kindValues(schema, "match", "when"), []);
|
||||
assert.deepEqual(kindValues({ node_kinds: [] }, "todo", "op"), []);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue