swarm-ui: edit a notification flow on a canvas
A custom flow opens at /notifications/<id>: its nodes drawn left to right (source, then matches, then todos), and a panel to edit the picked node: the source's events, a match's conditions, a todo's recipients, op, key and text. Add a match or todo below a node, move a branch under another match, or remove it; an edit the controller would refuse (a todo with children, a second parent, a cycle, over the node or depth cap) is turned away with the reason. Edits stay in a draft until save, which sends the whole document at the revision read. A stale revision is told apart from a refused document: the editor offers to reload or to keep the draft on top of the latest. The form choices come from the controller's schema when it sends one. The list page gains edit and duplicate on custom flows, duplicate on the built-ins, and a new-flow box; a new flow's id avoids the built-ins' too.
This commit is contained in:
parent
c00fd99112
commit
f28e1e9063
1 changed files with 1209 additions and 6 deletions
|
|
@ -2,20 +2,29 @@
|
|||
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
|
||||
// writer of the stored document and the router reads it every few seconds.
|
||||
//
|
||||
// Read-only except for one switch per built-in flow. A flow draws as an
|
||||
// indented tree, the shape the API validates it to. Custom flows are not
|
||||
// editable here: they arrive through the API.
|
||||
// A flow draws as an indented tree, the shape the API validates it to. A
|
||||
// built-in flow has a switch and can be duplicated; a custom flow is edited
|
||||
// on its own page (<FlowEditorPage>), and a new one starts here.
|
||||
import { useState } from "preact/hooks";
|
||||
import { Link, useLocation } 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 { Card } from "../../ui/card/Card.js";
|
||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||
import { Panel } from "../../ui/panel/Panel.js";
|
||||
import { TextField } from "../../ui/text-field/TextField.js";
|
||||
import {
|
||||
RefreshIntervalPicker,
|
||||
type RefreshIntervalMs,
|
||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||
import {
|
||||
duplicateFlow,
|
||||
newFlow,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
describeNode,
|
||||
treeRows,
|
||||
|
|
@ -56,6 +65,8 @@ export function NotificationsPage() {
|
|||
);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [, navigate] = useLocation();
|
||||
|
||||
// Switch one built-in on or off: the whole document goes back with the
|
||||
// revision this page read (0 for none), so a change made elsewhere since is refused
|
||||
|
|
@ -83,6 +94,37 @@ export function NotificationsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
// Add `flow` to the custom flows and open it in the editor. Its id is
|
||||
// made free of every flow's, the built-ins' too: the controller refuses a
|
||||
// custom flow that shares a built-in's.
|
||||
async function addFlow(make: (taken: string[]) => Flow) {
|
||||
if (!view) return;
|
||||
const taken = [...view.builtins, ...view.flows].map((f) => f.id);
|
||||
const flow = make(taken);
|
||||
setPending(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const r = await fetch(
|
||||
"/api/notify/flows",
|
||||
saveRequest(view, [...view.flows, flow], view.disabled_builtins),
|
||||
);
|
||||
if (saveOutcome(r.status) === "saved") {
|
||||
setNewName("");
|
||||
navigate(`/notifications/${encodeURIComponent(flow.id)}`);
|
||||
return;
|
||||
}
|
||||
setActionError(await readApiError(r));
|
||||
} catch (err) {
|
||||
setActionError({ detail: String(err) });
|
||||
} finally {
|
||||
setPending(false);
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
const duplicate = (flow: Flow) =>
|
||||
addFlow((taken) => duplicateFlow(flow, `${flow.name} (copy)`, taken));
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="notifications"
|
||||
|
|
@ -123,16 +165,42 @@ export function NotificationsPage() {
|
|||
/>
|
||||
</div>
|
||||
<FlowTree flow={flow} />
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate to edit
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
<h3>custom flows</h3>
|
||||
{view.flows.length === 0 ? <p>no custom flows</p> : null}
|
||||
<div class="notify-new">
|
||||
<TextField
|
||||
id="notify-new-name"
|
||||
label="new flow"
|
||||
placeholder="name"
|
||||
value={newName}
|
||||
onInput={setNewName}
|
||||
/>
|
||||
<Button
|
||||
disabled={pending || newName.trim() === ""}
|
||||
onClick={() =>
|
||||
void addFlow((taken) => newFlow(newName.trim(), taken))
|
||||
}
|
||||
>
|
||||
create
|
||||
</Button>
|
||||
</div>
|
||||
{view.flows.map((flow) => (
|
||||
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
|
||||
<div class="notify-flow-head">
|
||||
<span class="notify-flow-name">{flow.name}</span>
|
||||
<span>{flow.enabled ? "on" : "off"}</span>
|
||||
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
|
||||
edit
|
||||
</Link>
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate
|
||||
</Button>
|
||||
</div>
|
||||
<FlowTree flow={flow} />
|
||||
</Card>
|
||||
|
|
|
|||
Loading…
Reference in a new issue