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 |
|
| `/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,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
|
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'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
|
### 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}$/;
|
||||||
|
|
@ -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
|
// 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
|
// kind-class: the source stays the source, and nothing with children becomes
|
||||||
// a todo.
|
// 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[];
|
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,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 {
|
.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,
|
||||||
|
|
@ -19,6 +21,7 @@ import {
|
||||||
subtreeIds,
|
subtreeIds,
|
||||||
withFlow,
|
withFlow,
|
||||||
withoutFlow,
|
withoutFlow,
|
||||||
|
withOp,
|
||||||
} from "../src/pages/notifications/FlowDraft.ts";
|
} from "../src/pages/notifications/FlowDraft.ts";
|
||||||
|
|
||||||
const todo = (id) => ({
|
const todo = (id) => ({
|
||||||
|
|
@ -154,6 +157,16 @@ test("replaceNode keeps the place and the kind class", () => {
|
||||||
assert.equal(replaceNode(f, todo("nope")).ok, false);
|
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", () => {
|
test("new ids do not collide", () => {
|
||||||
const f = flow();
|
const f = flow();
|
||||||
assert.equal(newNodeId(f, "match"), "match1");
|
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(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