Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: draw notification flows as graphs in the list and make a new or copied flow a draft until saved

The flows list draws each flow as the editor's graph, read-only and with the
dry run's hit counts in the boxes, in place of the indented tree. A box's line
of text is cut by the browser at the box's edge, so it no longer runs out of it.

create flow (a primary button beside the refresh interval) and duplicate open
the editor on an unsaved draft at /notifications/new, with ?from= naming the
flow to copy; the flow is made by save, under an id taken from its name. Edit
and duplicate are buttons in the flow's header row. The editor's head and
inspector are laid out as sections.
This commit is contained in:
iris 2026-10-25 01:27:50 +02:00 • committed by mara
commit d0a55502a8
12 changed files with 406 additions and 262 deletions

View file

@ -3,12 +3,21 @@
// 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: `/notifications/new` 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, type RouteComponentProps } from "wouter-preact";
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";
@ -24,11 +33,14 @@ import {
isDirty,
limitsOf,
moveAmongSiblings,
NEW_FLOW_ID,
paletteKinds,
removeNode,
reparent,
savedAs,
saveOutcome,
saveRequest,
seedFlow,
parentOf,
withFlow,
withoutFlow,
@ -49,9 +61,13 @@ export function FlowEditorPage({
params,
}: RouteComponentProps<{ flowId: string }>) {
const flowId = decodeURIComponent(params.flowId);
const isNew = flowId === NEW_FLOW_ID;
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);
@ -74,7 +90,10 @@ export function FlowEditorPage({
setStale(false);
setSaveError(null);
if (!keepDraft) {
const flow = next.flows.find((f) => f.id === flowId);
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);
}
@ -84,7 +103,7 @@ export function FlowEditorPage({
);
}
},
[flowId],
[flowId, isNew, from],
);
useEffect(() => {
void load(false);
@ -93,7 +112,9 @@ export function FlowEditorPage({
const dirty =
view !== null &&
draft !== null &&
isDirty(view, withFlow(view.flows, draft), view.disabled_builtins);
(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.
@ -138,6 +159,18 @@ export function FlowEditorPage({
}
}
// 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);
@ -169,9 +202,11 @@ export function FlowEditorPage({
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.`}
{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>
);
@ -182,7 +217,11 @@ export function FlowEditorPage({
const node = draft.nodes.find((n) => n.id === selected) ?? null;
return (
<Panel title={`flow ${draft.id}`} icon="🔔" actions={back}>
<Panel
title={isNew ? "new flow" : `flow ${draft.id}`}
icon="🔔"
actions={back}
>
{stale ? (
<div class="notify-stale" role="alert">
<p>
@ -212,6 +251,7 @@ export function FlowEditorPage({
id="flow-name"
label="name"
value={draft.name}
placeholder="what the flow is for"
onInput={(name) => setDraft({ ...draft, name })}
/>
</div>
@ -221,28 +261,38 @@ export function FlowEditorPage({
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 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>
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
<div class="notify-editor-body">
<FlowCanvas