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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue