Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: rewire a notification flow by drag or keyboard

The flow editor can now change a flow's shape on the canvas, not only
through the inspector's fields:

- drag a box onto another to move it and its branch there; a drop the
  save would refuse shows the target dashed red and gives the reason;
- a palette of node kinds adds a node below the picked box on click, or
  below any box it is dragged onto; it offers what the controller lists;
- arrow keys walk the tree, M picks a box up and Enter drops it under
  the focused one, Alt+Up/Down reorder a node among its siblings, Delete
  removes a branch; focus follows the edit;
- each box with siblings says when it runs, and the inspector can move
  it earlier or later, since the router runs children in edge order;
- request_reviewer is a second leaf kind beside todo, with a reviewer
  field;
- leaving with unsaved edits asks first, also for in-app links and the
  back button (a router hook runs navigation past a page's guard).
This commit is contained in:
iris 2026-10-24 14:49:48 +02:00 • committed by mara
commit 68e289fff5
16 changed files with 1072 additions and 133 deletions

View file

@ -19,14 +19,22 @@ 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,
saveOutcome,
saveRequest,
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";
@ -87,13 +95,9 @@ export function FlowEditorPage({
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]);
// 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> {
@ -137,7 +141,11 @@ export function FlowEditorPage({
async function onDelete() {
if (!view) return;
setConfirmDelete(false);
if (await put(withoutFlow(view.flows, flowId))) navigate("/notifications");
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>;
@ -237,7 +245,25 @@ export function FlowEditorPage({
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
<div class="notify-editor-body">
<FlowCanvas flow={draft} selected={selected} onSelect={setSelected} />
<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}
@ -258,6 +284,15 @@ export function FlowEditorPage({
>
<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>
);
}