Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx
iris 6e7b5f84e9 swarm-ui: give the new-flow draft a route no flow id can take, and drop the dead hit-count rule
/notifications/new could be a saved flow's own page, so the draft now opens at
/notifications/draft/new, which has a segment more than any flow's route. The
placeholder id no longer needs reserving. The .notify-node-hits rule styled the
removed tree rows; nothing renders it.
2026-10-25 02:05:54 +02:00

350 lines
11 KiB
TypeScript

// <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 same page makes a flow: `NEW_FLOW_PATH` is an empty draft, and
// `?from=<id>` a copy of a flow to start from. Nothing exists until Save, which
// gives the flow an id made of its name.
//
// 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,
useSearch,
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 {
addChild,
blankNode,
isDirty,
limitsOf,
moveAmongSiblings,
paletteKinds,
removeNode,
reparent,
savedAs,
saveOutcome,
saveRequest,
seedFlow,
parentOf,
withFlow,
withoutFlow,
type Edit,
} from "./FlowDraft.js";
import { useLeaveGuard } from "./useLeaveGuard.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,
isNew = false,
}: RouteComponentProps<{ flowId: string }> & {
// The page for a flow not saved yet, which has no flow id.
isNew?: boolean;
}) {
const flowId = decodeURIComponent(params.flowId);
const from = new URLSearchParams(useSearch()).get("from");
const [, navigate] = useLocation();
const [view, setView] = useState<FlowsView | null>(null);
const [draft, setDraft] = useState<Flow | null>(null);
// What a new draft started as, so leaving it untouched is not "unsaved".
const [seed, setSeed] = 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 = isNew
? seedFlow(next, from)
: next.flows.find((f) => f.id === flowId);
setSeed(isNew && flow ? structuredClone(flow) : null);
setDraft(flow ? structuredClone(flow) : null);
setSelected(flow ? rootOf(flow) : null);
}
} catch (err) {
setLoadError(
err instanceof ProblemError ? err.problem : { detail: String(err) },
);
}
},
[flowId, isNew, from],
);
useEffect(() => {
void load(false);
}, [load]);
const dirty =
view !== null &&
draft !== null &&
(isNew
? JSON.stringify(draft) !== JSON.stringify(seed)
: isDirty(view, withFlow(view.flows, draft), view.disabled_builtins));
// Leaving the page with edits not saved asks first, whether by closing the
// tab, following a link or pressing back.
const leaving = useLeaveGuard(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));
}
}
// Save the draft. A new flow is added under its name's id, and the page
// moves on to editing it.
async function save() {
if (!view || !draft) return;
const flow = isNew ? savedAs(view, draft) : draft;
if (!(await put(withFlow(view.flows, flow))) || !isNew) return;
leaving.release();
navigate(`/notifications/${encodeURIComponent(flow.id)}`, {
replace: true,
});
}
async function onDelete() {
if (!view) return;
setConfirmDelete(false);
if (await put(withoutFlow(view.flows, flowId))) {
// The flow is gone; there is nothing left to lose by leaving.
leaving.release();
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>
{isNew
? `There is no flow ${from} to copy; it may have been deleted.`
: 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={isNew ? "new flow" : `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}
placeholder="what the flow is for"
onInput={(name) => setDraft({ ...draft, name })}
/>
</div>
<CheckboxField
id="flow-enabled"
label="on"
checked={draft.enabled}
onChange={(enabled) => setDraft({ ...draft, enabled })}
/>
<div class="notify-editor-actions">
{dirty ? (
<span class="notify-hint" role="status">
not saved yet
</span>
) : null}
<Button
variant="primary"
disabled={(!isNew && !dirty) || busy || draft.name.trim() === ""}
onClick={() => void save()}
>
save
</Button>
<Button
disabled={!dirty || busy}
onClick={() => {
const start = isNew
? seed
: view.flows.find((f) => f.id === flowId);
if (start) setDraft(structuredClone(start));
setNotice(null);
}}
>
discard edits
</Button>
{isNew ? null : (
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
delete flow
</Button>
)}
</div>
</div>
<div class="notify-editor-body">
<FlowCanvas
flow={draft}
selected={selected}
limits={limits}
palette={paletteKinds(schema)}
onSelect={setSelected}
onAdd={(kind, parentId) => {
const added = blankNode(draft, kind);
onEdit(addChild(draft, parentId, added, limits), added.id);
}}
onMove={(id, parentId) =>
onEdit(reparent(draft, id, parentId, limits), id)
}
onReorder={(id, delta) =>
onEdit(moveAmongSiblings(draft, id, delta), id)
}
onRemove={(id) => onEdit(removeNode(draft, id), parentOf(draft, id))}
onRefused={setNotice}
/>
{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>
<ConfirmDialog
open={leaving.pending}
label="leave without saving"
confirmLabel="leave and drop edits"
onCancel={leaving.stay}
onConfirm={leaving.leave}
>
<p>This flow has edits that are not saved. Leaving drops them.</p>
</ConfirmDialog>
</Panel>
);
}