// β€” 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(null); const [draft, setDraft] = useState(null); const [selected, setSelected] = useState(null); const [loadError, setLoadError] = useState(null); const [saveError, setSaveError] = useState(null); const [stale, setStale] = useState(false); const [notice, setNotice] = useState(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( 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 { 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 = all flows; if (loadError) { return ( ); } if (!view) { return (

loading…

); } if (!draft) { const builtin = view.builtins.some((b) => b.id === flowId); return (

{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.`}

); } const schema = view.schema ?? FALLBACK_SCHEMA; const limits = limitsOf(view.schema); const node = draft.nodes.find((n) => n.id === selected) ?? null; return ( {stale ? ( ) : null} {saveError ? ( ) : null} {notice ? (

{notice}

) : null}
setDraft({ ...draft, name })} />
setDraft({ ...draft, enabled })} />
{dirty ?

Not saved yet.

: null}
{node ? ( ) : null}
setConfirmDelete(false)} onConfirm={() => void onDelete()} >

Delete the flow {draft.name}? The router stops running it.

); }