Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/notifications/FlowEditorPage.tsx

263 lines
8.3 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 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>
);
}