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