// β€” 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=` 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(null); const [draft, setDraft] = useState(null); // What a new draft started as, so leaving it untouched is not "unsaved". const [seed, setSeed] = 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 = 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 { 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 = all flows; if (loadError) { return ( ); } if (!view) { return (

loading…

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

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

); } 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} {isNew ? null : ( )}
{ 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 ? ( ) : null}
setConfirmDelete(false)} onConfirm={() => void onDelete()} >

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

This flow has edits that are not saved. Leaving drops them.

); }