/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.
350 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|