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
|
|
@ -8,6 +8,7 @@ import { AlertsPage } from "./pages/AlertsPage.js";
|
|||
import { BuildsPage } from "./pages/builds/BuildsPage.js";
|
||||
import { BroadcastPage } from "./pages/broadcast/BroadcastPage.js";
|
||||
import { MessagesPage } from "./pages/messages/MessagesPage.js";
|
||||
import { FlowEditorPage } from "./pages/notifications/FlowEditorPage.js";
|
||||
import { NotificationsPage } from "./pages/notifications/NotificationsPage.js";
|
||||
import { ComponentsPage } from "./pages/ComponentsPage.js";
|
||||
import { JobsPage } from "./pages/JobsPage.js";
|
||||
|
|
@ -45,6 +46,7 @@ export function App() {
|
|||
<Route path="/broadcast" component={BroadcastPage} />
|
||||
<Route path="/messages" component={MessagesPage} />
|
||||
<Route path="/notifications" component={NotificationsPage} />
|
||||
<Route path="/notifications/:flowId" component={FlowEditorPage} />
|
||||
<Route path="/issues" component={IssueReportPage} />
|
||||
<Route path="/review" component={ReviewPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
// <FlowCanvas> — a flow drawn as boxes and lines: the source on the left, its
|
||||
// branches to the right. Pick a node to edit it; the inspector beside the
|
||||
// canvas does the editing.
|
||||
import { layoutFlow, NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
|
||||
import { describeNode, ellipsize, type Flow } from "./FlowTypes.js";
|
||||
|
||||
const TEXT_CHARS = 30;
|
||||
|
||||
export function FlowCanvas({
|
||||
flow,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
flow: Flow;
|
||||
selected: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const layout = layoutFlow(flow);
|
||||
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
||||
return (
|
||||
<div class="notify-canvas">
|
||||
<svg
|
||||
width={layout.width}
|
||||
height={layout.height}
|
||||
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||
role="group"
|
||||
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
|
||||
>
|
||||
{layout.edges.map((e) => {
|
||||
const mid = (e.x1 + e.x2) / 2;
|
||||
return (
|
||||
<path
|
||||
key={`${e.from}>${e.to}`}
|
||||
class="notify-edge"
|
||||
d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{layout.nodes.map((p) => {
|
||||
const node = byId.get(p.id);
|
||||
if (!node) return null;
|
||||
const text = describeNode(node);
|
||||
return (
|
||||
<g
|
||||
key={p.id}
|
||||
class={`notify-box notify-box-${node.kind}${p.id === selected ? " notify-box-selected" : ""}`}
|
||||
transform={`translate(${p.x} ${p.y})`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-pressed={p.id === selected}
|
||||
aria-label={`${node.kind} ${p.id}: ${text}`}
|
||||
onClick={() => onSelect(p.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
onSelect(p.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>{text}</title>
|
||||
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
|
||||
<text class="notify-box-kind" x="10" y="17">
|
||||
{node.kind}
|
||||
</text>
|
||||
<text class="notify-box-text" x="10" y="35">
|
||||
{ellipsize(text, TEXT_CHARS)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
// these edits only refuse what could never be saved, so the canvas can
|
||||
// turn a bad drop away with a reason instead of drawing a flow the save
|
||||
// would reject. The tree rules mirror `Flow::validate` in swarm-wire.
|
||||
import type { Flow, FlowNode, FlowsView } from "./FlowTypes.js";
|
||||
import type { Flow, FlowNode, FlowSchema, FlowsView } from "./FlowTypes.js";
|
||||
|
||||
export interface Limits {
|
||||
maxNodes: number;
|
||||
|
|
@ -21,6 +21,14 @@ export const DEFAULT_LIMITS: Limits = {
|
|||
maxFlows: 100,
|
||||
};
|
||||
|
||||
// The caps the controller reports, or today's when it reports none.
|
||||
export function limitsOf(schema?: FlowSchema): Limits {
|
||||
const l = schema?.limits;
|
||||
return l
|
||||
? { maxNodes: l.max_nodes, maxDepth: l.max_depth, maxFlows: l.max_flows }
|
||||
: DEFAULT_LIMITS;
|
||||
}
|
||||
|
||||
export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string };
|
||||
|
||||
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
builtins: Flow[];
|
||||
flows: Flow[];
|
||||
// Absent from a controller that predates it.
|
||||
schema?: FlowSchema;
|
||||
}
|
||||
|
||||
// One node of a flow with its depth below the source, in the order a reader
|
||||
|
|
@ -93,3 +95,73 @@ export function withBuiltin(
|
|||
const rest = disabled.filter((d) => d !== id);
|
||||
return (enabled ? rest : [...rest, id]).sort();
|
||||
}
|
||||
|
||||
// What the controller's `schema` says a flow may hold, so the editor offers
|
||||
// only what a save accepts. Controllers that predate the schema send none.
|
||||
export interface Described {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface KindField extends Described {
|
||||
required: boolean;
|
||||
values: Described[];
|
||||
}
|
||||
|
||||
export interface KindSchema {
|
||||
kind: string;
|
||||
description: string;
|
||||
fields: KindField[];
|
||||
}
|
||||
|
||||
export interface FlowSchema {
|
||||
source_events: Described[];
|
||||
fields: Described[];
|
||||
node_kinds: KindSchema[];
|
||||
limits: { max_nodes: number; max_depth: number; max_flows: number };
|
||||
}
|
||||
|
||||
// The same names as the controller's today, for when it sends no schema. The
|
||||
// controller still refuses anything wrong here on save.
|
||||
export const FALLBACK_SCHEMA: FlowSchema = {
|
||||
source_events: [
|
||||
"issues",
|
||||
"issue_assign",
|
||||
"pull_request",
|
||||
"pull_request_assign",
|
||||
].map((name) => ({ name, description: "" })),
|
||||
fields: [
|
||||
"event",
|
||||
"action",
|
||||
"kind",
|
||||
"repo",
|
||||
"number",
|
||||
"title",
|
||||
"url",
|
||||
"sender",
|
||||
"assignees",
|
||||
"assignees_added",
|
||||
"assignees_removed",
|
||||
"todo_holders",
|
||||
].map((name) => ({ name, description: "" })),
|
||||
node_kinds: [],
|
||||
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
|
||||
};
|
||||
|
||||
// `text` cut to `max` characters, with an ellipsis if it was longer.
|
||||
export function ellipsize(text: string, max: number): string {
|
||||
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
||||
}
|
||||
|
||||
// The values the controller allows for `field` of node kind `kind`, if its
|
||||
// schema lists them (the todo `op` does).
|
||||
export function kindValues(
|
||||
schema: FlowSchema,
|
||||
kind: string,
|
||||
field: string,
|
||||
): string[] {
|
||||
const values = schema.node_kinds
|
||||
.find((k) => k.kind === kind)
|
||||
?.fields.find((f) => f.name === field)?.values;
|
||||
return values && values.length > 0 ? values.map((v) => v.name) : [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,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 {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* The flow list's new-flow row and each flow's actions. */
|
||||
.notify-new {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.notify-flow-head > * + * {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
/* FlowEditorPage — the head row, the canvas and the inspector beside it. */
|
||||
.notify-editor-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.75em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
.notify-editor-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.notify-canvas {
|
||||
flex: 1 1 24em;
|
||||
min-width: 0;
|
||||
max-height: 70vh;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.notify-edge {
|
||||
fill: none;
|
||||
stroke: var(--muted);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.notify-box {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notify-box rect {
|
||||
fill: var(--bg-elev);
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
|
||||
.notify-box-source rect {
|
||||
stroke: var(--blue);
|
||||
}
|
||||
|
||||
.notify-box-match rect {
|
||||
stroke: var(--purple);
|
||||
}
|
||||
|
||||
.notify-box-todo rect {
|
||||
stroke: var(--green);
|
||||
}
|
||||
|
||||
.notify-box:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.notify-box:focus rect,
|
||||
.notify-box-selected rect {
|
||||
stroke: var(--fg);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
|
||||
.notify-box text {
|
||||
fill: var(--fg);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notify-box .notify-box-kind {
|
||||
fill: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.notify-inspector {
|
||||
flex: 0 1 22em;
|
||||
min-width: 16em;
|
||||
}
|
||||
|
||||
.notify-inspector fieldset {
|
||||
margin: 0 0 0.75em;
|
||||
}
|
||||
|
||||
.notify-condition {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.4em;
|
||||
}
|
||||
|
||||
.notify-inspector-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
|
||||
.notify-node-id,
|
||||
.notify-hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.notify-stale,
|
||||
.notify-notice {
|
||||
margin-bottom: 0.5em;
|
||||
padding: 0.4em 0.6em;
|
||||
border: 1px solid var(--red);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.notify-name {
|
||||
flex: 0 1 24em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,20 +2,29 @@
|
|||
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
|
||||
// writer of the stored document and the router reads it every few seconds.
|
||||
//
|
||||
// Read-only except for one switch per built-in flow. A flow draws as an
|
||||
// indented tree, the shape the API validates it to. Custom flows are not
|
||||
// editable here: they arrive through the API.
|
||||
// A flow draws as an indented tree, the shape the API validates it to. A
|
||||
// built-in flow has a switch and can be duplicated; a custom flow is edited
|
||||
// on its own page (<FlowEditorPage>), and a new one starts here.
|
||||
import { useState } from "preact/hooks";
|
||||
import { Link, useLocation } from "wouter-preact";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
import { Card } from "../../ui/card/Card.js";
|
||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import {
|
||||
duplicateFlow,
|
||||
newFlow,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
describeNode,
|
||||
treeRows,
|
||||
|
|
@ -56,6 +65,8 @@ export function NotificationsPage() {
|
|||
);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
// Switch one built-in on or off: the whole document goes back with the
|
||||
// revision this page read (0 for none), so a change made elsewhere since is refused
|
||||
|
|
@ -83,6 +94,37 @@ export function NotificationsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Add `flow` to the custom flows and open it in the editor. Its id is
|
||||
// made free of every flow's, the built-ins' too: the controller refuses a
|
||||
// custom flow that shares a built-in's.
|
||||
async function addFlow(make: (taken: string[]) => Flow) {
|
||||
if (!view) return;
|
||||
const taken = [...view.builtins, ...view.flows].map((f) => f.id);
|
||||
const flow = make(taken);
|
||||
setPending(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/notify/flows",
|
||||
saveRequest(view, [...view.flows, flow], view.disabled_builtins),
|
||||
);
|
||||
if (saveOutcome(r.status) === "saved") {
|
||||
setNewName("");
|
||||
navigate(`/notifications/${encodeURIComponent(flow.id)}`);
|
||||
return;
|
||||
}
|
||||
setActionError(await readApiError(r));
|
||||
} catch (err) {
|
||||
setActionError({ detail: String(err) });
|
||||
} finally {
|
||||
setPending(false);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const duplicate = (flow: Flow) =>
|
||||
addFlow((taken) => duplicateFlow(flow, `${flow.name} (copy)`, taken));
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="notifications"
|
||||
|
|
@ -123,16 +165,42 @@ export function NotificationsPage() {
|
|||
/>
|
||||
</div>
|
||||
<FlowTree flow={flow} />
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate to edit
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<h3>custom flows</h3>
|
||||
{view.flows.length === 0 ? <p>no custom flows</p> : null}
|
||||
<div class="notify-new">
|
||||
<TextField
|
||||
id="notify-new-name"
|
||||
label="new flow"
|
||||
placeholder="name"
|
||||
value={newName}
|
||||
onInput={setNewName}
|
||||
/>
|
||||
<Button
|
||||
disabled={pending || newName.trim() === ""}
|
||||
onClick={() =>
|
||||
void addFlow((taken) => newFlow(newName.trim(), taken))
|
||||
}
|
||||
>
|
||||
create
|
||||
</Button>
|
||||
</div>
|
||||
{view.flows.map((flow) => (
|
||||
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
|
||||
<div class="notify-flow-head">
|
||||
<span class="notify-flow-name">{flow.name}</span>
|
||||
<span>{flow.enabled ? "on" : "off"}</span>
|
||||
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
|
||||
edit
|
||||
</Link>
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate
|
||||
</Button>
|
||||
</div>
|
||||
<FlowTree flow={flow} />
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import { test } from "node:test";
|
|||
import {
|
||||
addChild,
|
||||
blankNode,
|
||||
DEFAULT_LIMITS,
|
||||
depthOf,
|
||||
duplicateFlow,
|
||||
heightBelow,
|
||||
isDirty,
|
||||
limitsOf,
|
||||
newFlow,
|
||||
newNodeId,
|
||||
removeNode,
|
||||
|
|
@ -233,3 +235,11 @@ test("saveOutcome tells a stale revision from a refused document", () => {
|
|||
assert.equal(saveOutcome(503), "error");
|
||||
assert.equal(saveOutcome(500), "error");
|
||||
});
|
||||
|
||||
test("limitsOf takes the controller's caps and falls back to today's", () => {
|
||||
assert.deepEqual(
|
||||
limitsOf({ limits: { max_nodes: 5, max_depth: 3, max_flows: 9 } }),
|
||||
{ maxNodes: 5, maxDepth: 3, maxFlows: 9 },
|
||||
);
|
||||
assert.deepEqual(limitsOf(undefined), DEFAULT_LIMITS);
|
||||
});
|
||||
|
|
|
|||
122
frontend/packages/swarm-ui/test/flow-layout.test.mjs
Normal file
122
frontend/packages/swarm-ui/test/flow-layout.test.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Where the flow editor's canvas puts each node. Run by `npm test`.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
layoutFlow,
|
||||
NODE_HEIGHT,
|
||||
NODE_WIDTH,
|
||||
} from "../src/pages/notifications/FlowLayout.ts";
|
||||
|
||||
const match = (id) => ({ id, kind: "match", when: [] });
|
||||
const source = { id: "s", kind: "source", events: ["issues"] };
|
||||
const flow = (nodes, edges) => ({
|
||||
id: "f",
|
||||
name: "f",
|
||||
enabled: true,
|
||||
nodes,
|
||||
edges: edges.map(([from, to]) => ({ from, to })),
|
||||
});
|
||||
const at = (layout, id) => layout.nodes.find((n) => n.id === id);
|
||||
|
||||
test("a lone source sits top left", () => {
|
||||
const l = layoutFlow(flow([source], []));
|
||||
assert.equal(l.nodes.length, 1);
|
||||
assert.equal(at(l, "s").depth, 0);
|
||||
assert.equal(l.edges.length, 0);
|
||||
assert.ok(l.width > NODE_WIDTH && l.height > NODE_HEIGHT);
|
||||
});
|
||||
|
||||
test("leaves take rows in edge order and a parent sits between its children", () => {
|
||||
const l = layoutFlow(
|
||||
flow(
|
||||
[source, match("a"), match("b"), match("c")],
|
||||
[
|
||||
["s", "a"],
|
||||
["s", "b"],
|
||||
["a", "c"],
|
||||
],
|
||||
),
|
||||
);
|
||||
// c (under a) is the first leaf, b the second; a follows c, s the middle.
|
||||
assert.ok(at(l, "c").y < at(l, "b").y);
|
||||
assert.equal(at(l, "a").y, at(l, "c").y);
|
||||
assert.equal(at(l, "s").y, (at(l, "a").y + at(l, "b").y) / 2);
|
||||
assert.equal(at(l, "c").depth, 2);
|
||||
assert.ok(at(l, "c").x > at(l, "a").x && at(l, "a").x > at(l, "s").x);
|
||||
});
|
||||
|
||||
test("no two nodes overlap", () => {
|
||||
const nodes = [source];
|
||||
const edges = [];
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
nodes.push(match(`m${i}`), match(`n${i}`));
|
||||
edges.push(["s", `m${i}`], [`m${i}`, `n${i}`]);
|
||||
}
|
||||
const l = layoutFlow(flow(nodes, edges));
|
||||
for (const a of l.nodes) {
|
||||
for (const b of l.nodes) {
|
||||
if (a === b) continue;
|
||||
const apart =
|
||||
a.x + NODE_WIDTH <= b.x ||
|
||||
b.x + NODE_WIDTH <= a.x ||
|
||||
a.y + NODE_HEIGHT <= b.y ||
|
||||
b.y + NODE_HEIGHT <= a.y;
|
||||
assert.ok(apart, `${a.id} overlaps ${b.id}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("an edge runs from the parent's right side to the child's left", () => {
|
||||
const l = layoutFlow(flow([source, match("a")], [["s", "a"]]));
|
||||
const [e] = l.edges;
|
||||
assert.equal(e.x1, at(l, "s").x + NODE_WIDTH);
|
||||
assert.equal(e.x2, at(l, "a").x);
|
||||
assert.equal(e.y1, at(l, "s").y + NODE_HEIGHT / 2);
|
||||
assert.equal(e.y2, at(l, "a").y + NODE_HEIGHT / 2);
|
||||
});
|
||||
|
||||
test("a node nothing reaches yet still gets a place, below the tree", () => {
|
||||
const l = layoutFlow(flow([source, match("a"), match("lost")], [["s", "a"]]));
|
||||
assert.equal(l.nodes.length, 3);
|
||||
assert.ok(at(l, "lost").y > at(l, "a").y);
|
||||
assert.equal(l.edges.length, 1);
|
||||
});
|
||||
|
||||
test("edges to unknown nodes are left out, and a cycle terminates", () => {
|
||||
const l = layoutFlow(
|
||||
flow(
|
||||
[source, match("a"), match("b")],
|
||||
[
|
||||
["s", "a"],
|
||||
["a", "b"],
|
||||
["b", "a"],
|
||||
["s", "ghost"],
|
||||
],
|
||||
),
|
||||
);
|
||||
assert.equal(l.nodes.length, 3);
|
||||
assert.ok(l.edges.every((e) => e.to !== "ghost"));
|
||||
});
|
||||
|
||||
test("a node with two parents is placed once", () => {
|
||||
const l = layoutFlow(
|
||||
flow(
|
||||
[source, match("a"), match("b"), match("x")],
|
||||
[
|
||||
["s", "a"],
|
||||
["s", "b"],
|
||||
["a", "x"],
|
||||
["b", "x"],
|
||||
],
|
||||
),
|
||||
);
|
||||
assert.equal(l.nodes.length, 4);
|
||||
const spots = new Set(l.nodes.map((n) => `${n.x},${n.y}`));
|
||||
assert.equal(spots.size, 4, "no two nodes share a spot");
|
||||
});
|
||||
|
||||
test("an empty flow has an empty layout", () => {
|
||||
const l = layoutFlow(flow([], []));
|
||||
assert.deepEqual(l.nodes, []);
|
||||
assert.ok(l.width >= 0 && l.height >= 0);
|
||||
});
|
||||
|
|
@ -3,6 +3,8 @@ import assert from "node:assert/strict";
|
|||
import { test } from "node:test";
|
||||
import {
|
||||
describeNode,
|
||||
ellipsize,
|
||||
kindValues,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
} from "../src/pages/notifications/FlowTypes.ts";
|
||||
|
|
@ -78,3 +80,28 @@ test("withBuiltin: switching off adds once, switching on removes", () => {
|
|||
assert.deepEqual(withBuiltin(["c"], "a", false), ["a", "c"]);
|
||||
assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]);
|
||||
});
|
||||
|
||||
test("ellipsize cuts long text and leaves short text alone", () => {
|
||||
assert.equal(ellipsize("short", 10), "short");
|
||||
assert.equal(ellipsize("exactly10!", 10), "exactly10!");
|
||||
assert.equal(ellipsize("a rather long line", 10), "a rather …");
|
||||
assert.equal(ellipsize("a rather long line", 10).length, 10);
|
||||
});
|
||||
|
||||
test("kindValues lists the values a schema allows for a field", () => {
|
||||
const schema = {
|
||||
node_kinds: [
|
||||
{
|
||||
kind: "todo",
|
||||
fields: [
|
||||
{ name: "op", values: [{ name: "upsert" }, { name: "clear" }] },
|
||||
{ name: "key", values: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
assert.deepEqual(kindValues(schema, "todo", "op"), ["upsert", "clear"]);
|
||||
assert.deepEqual(kindValues(schema, "todo", "key"), []);
|
||||
assert.deepEqual(kindValues(schema, "match", "when"), []);
|
||||
assert.deepEqual(kindValues({ node_kinds: [] }, "todo", "op"), []);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue