Watch
0
0
Fork
You've already forked hyperhive
0

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:
iris 2026-10-24 14:00:44 +02:00 • committed by mara
commit f28e1e9063
13 changed files with 1209 additions and 6 deletions

View file

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