swarm-ui: edit a notification flow on a canvas
A custom flow opens at /notifications/<id>: its nodes drawn left to right (source, then matches, then todos), and a panel to edit the picked node: the source's events, a match's conditions, a todo's recipients, op, key and text. Add a match or todo below a node, move a branch under another match, or remove it; an edit the controller would refuse (a todo with children, a second parent, a cycle, over the node or depth cap) is turned away with the reason. Edits stay in a draft until save, which sends the whole document at the revision read. A stale revision is told apart from a refused document: the editor offers to reload or to keep the draft on top of the latest. The form choices come from the controller's schema when it sends one. The list page gains edit and duplicate on custom flows, duplicate on the built-ins, and a new-flow box; a new flow's id avoids the built-ins' too.
This commit is contained in:
parent
c00fd99112
commit
f28e1e9063
13 changed files with 1209 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 |
|
| `/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 |
|
| `/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 |
|
| `/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 |
|
| `/issues` | a cross-repo issue report |
|
||||||
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
|
| `/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 |
|
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
|
||||||
|
|
@ -226,13 +226,41 @@ 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
|
flow draws as an indented tree: the forge events it starts from, the
|
||||||
conditions under it, and the todo each branch ends in.
|
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
|
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
|
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
|
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.
|
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 is below it to another match, and
|
||||||
|
**remove** takes a node and its branch out. The editor turns away what could
|
||||||
|
never be saved, with the reason: 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 the flows were changed
|
||||||
|
somewhere else in the meantime the save is refused as stale, and the editor
|
||||||
|
offers to reload and drop your edits, or to keep them on top of the latest. A
|
||||||
|
document the controller refuses for any other reason is shown as the controller
|
||||||
|
says. The router runs a saved flow from its next event.
|
||||||
|
|
||||||
### Sending a message to an agent
|
### Sending a message to an agent
|
||||||
|
|
||||||
Under an agent's terminal, in the preview and in its own tab, is a **message**
|
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 { BuildsPage } from "./pages/builds/BuildsPage.js";
|
||||||
import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
|
import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
|
||||||
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||||
|
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
|
||||||
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
|
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
|
||||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||||
import { JobsPage } from "./pages/JobsPage.js";
|
import { JobsPage } from "./pages/JobsPage.js";
|
||||||
|
|
@ -45,6 +46,7 @@ export function App() {
|
||||||
<Route path="/broadcast" component={BroadcastPage} />
|
<Route path="/broadcast" component={BroadcastPage} />
|
||||||
<Route path="/messages" component={MessagesPage} />
|
<Route path="/messages" component={MessagesPage} />
|
||||||
<Route path="/notifications" component={NotificationsPage} />
|
<Route path="/notifications" component={NotificationsPage} />
|
||||||
|
<Route path="/notifications/:flowId" component={FlowEditorPage} />
|
||||||
<Route path="/issues" component={IssueReportPage} />
|
<Route path="/issues" component={IssueReportPage} />
|
||||||
<Route path="/review" component={ReviewPage} />
|
<Route path="/review" component={ReviewPage} />
|
||||||
<Route path="/components" component={ComponentsPage} />
|
<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
|
// 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
|
// 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.
|
// 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 {
|
export interface Limits {
|
||||||
maxNodes: number;
|
maxNodes: number;
|
||||||
|
|
@ -21,6 +21,14 @@ export const DEFAULT_LIMITS: Limits = {
|
||||||
maxFlows: 100,
|
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 };
|
export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string };
|
||||||
|
|
||||||
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
|
|
||||||
|
|
@ -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 on top of the latest
|
||||||
|
</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[];
|
disabled_builtins: string[];
|
||||||
builtins: Flow[];
|
builtins: Flow[];
|
||||||
flows: 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
|
// 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);
|
const rest = disabled.filter((d) => d !== id);
|
||||||
return (enabled ? rest : [...rest, id]).sort();
|
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,305 @@
|
||||||
|
// <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,
|
||||||
|
} 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({
|
||||||
|
...node,
|
||||||
|
op: op as TodoNode["op"],
|
||||||
|
// A clear carries no text.
|
||||||
|
text: op === "clear" ? "" : node.text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<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 {
|
.notify-off {
|
||||||
opacity: 0.6;
|
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
|
// 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.
|
// 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
|
// A flow draws as an indented tree, the shape the API validates it to. A
|
||||||
// indented tree, the shape the API validates it to. Custom flows are not
|
// built-in flow has a switch and can be duplicated; a custom flow is edited
|
||||||
// editable here: they arrive through the API.
|
// on its own page (<FlowEditorPage>), and a new one starts here.
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
|
import { Link, useLocation } from "wouter-preact";
|
||||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.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 { Card } from "../../ui/card/Card.js";
|
||||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
|
import { TextField } from "../../ui/text-field/TextField.js";
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||||
|
import {
|
||||||
|
duplicateFlow,
|
||||||
|
newFlow,
|
||||||
|
saveOutcome,
|
||||||
|
saveRequest,
|
||||||
|
} from "./FlowDraft.js";
|
||||||
import {
|
import {
|
||||||
describeNode,
|
describeNode,
|
||||||
treeRows,
|
treeRows,
|
||||||
|
|
@ -56,6 +65,8 @@ export function NotificationsPage() {
|
||||||
);
|
);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
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
|
// 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
|
// 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 (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title="notifications"
|
title="notifications"
|
||||||
|
|
@ -123,16 +165,42 @@ export function NotificationsPage() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<FlowTree flow={flow} />
|
<FlowTree flow={flow} />
|
||||||
|
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||||
|
duplicate to edit
|
||||||
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<h3>custom flows</h3>
|
<h3>custom flows</h3>
|
||||||
{view.flows.length === 0 ? <p>no custom flows</p> : null}
|
{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) => (
|
{view.flows.map((flow) => (
|
||||||
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
|
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
|
||||||
<div class="notify-flow-head">
|
<div class="notify-flow-head">
|
||||||
<span class="notify-flow-name">{flow.name}</span>
|
<span class="notify-flow-name">{flow.name}</span>
|
||||||
<span>{flow.enabled ? "on" : "off"}</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>
|
</div>
|
||||||
<FlowTree flow={flow} />
|
<FlowTree flow={flow} />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,12 @@ import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
addChild,
|
addChild,
|
||||||
blankNode,
|
blankNode,
|
||||||
|
DEFAULT_LIMITS,
|
||||||
depthOf,
|
depthOf,
|
||||||
duplicateFlow,
|
duplicateFlow,
|
||||||
heightBelow,
|
heightBelow,
|
||||||
isDirty,
|
isDirty,
|
||||||
|
limitsOf,
|
||||||
newFlow,
|
newFlow,
|
||||||
newNodeId,
|
newNodeId,
|
||||||
removeNode,
|
removeNode,
|
||||||
|
|
@ -233,3 +235,11 @@ test("saveOutcome tells a stale revision from a refused document", () => {
|
||||||
assert.equal(saveOutcome(503), "error");
|
assert.equal(saveOutcome(503), "error");
|
||||||
assert.equal(saveOutcome(500), "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 { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
describeNode,
|
describeNode,
|
||||||
|
ellipsize,
|
||||||
|
kindValues,
|
||||||
treeRows,
|
treeRows,
|
||||||
withBuiltin,
|
withBuiltin,
|
||||||
} from "../src/pages/notifications/FlowTypes.ts";
|
} 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(["c"], "a", false), ["a", "c"]);
|
||||||
assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]);
|
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