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:
parent
b93eaaff51
commit
d0a55502a8
12 changed files with 406 additions and 262 deletions
|
|
@ -2,37 +2,27 @@
|
|||
// 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.
|
||||
//
|
||||
// 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.
|
||||
// A flow draws as its graph, read-only. A built-in flow has a switch and can
|
||||
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
|
||||
// A new flow and a duplicate both open that page on a draft: nothing is created
|
||||
// until it is saved there.
|
||||
import { useState } from "preact/hooks";
|
||||
import { Link, useLocation } from "wouter-preact";
|
||||
import { 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 { Deliveries } from "./Deliveries.js";
|
||||
import {
|
||||
duplicateFlow,
|
||||
newFlow,
|
||||
saveOutcome,
|
||||
saveRequest,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
describeNode,
|
||||
treeRows,
|
||||
withBuiltin,
|
||||
type Flow,
|
||||
type FlowsView,
|
||||
} from "./FlowTypes.js";
|
||||
import { NEW_FLOW_ID } from "./FlowDraft.js";
|
||||
import { FlowGraph } from "./FlowGraph.js";
|
||||
import { withBuiltin, type FlowsView } from "./FlowTypes.js";
|
||||
import {
|
||||
DELIVERIES_URL,
|
||||
DRY_RUN_URL,
|
||||
|
|
@ -45,30 +35,6 @@ import "./NotificationsPage.css";
|
|||
|
||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
||||
|
||||
// `hits`, when the dry run covers this flow, puts the number of the newest events
|
||||
// that reached each node beside it.
|
||||
function FlowTree({ flow, hits }: { flow: Flow; hits?: Map<string, number> }) {
|
||||
return (
|
||||
<ul class="notify-flow-tree">
|
||||
{treeRows(flow).map(({ node, depth }) => (
|
||||
<li
|
||||
class="notify-node"
|
||||
key={node.id}
|
||||
style={{ paddingLeft: `${depth * 1.5}em` }}
|
||||
>
|
||||
<span class="notify-node-kind">{node.kind}</span>
|
||||
<span class="notify-node-text">{describeNode(node)}</span>
|
||||
{hits ? (
|
||||
<span class="notify-node-hits" title="events of the last dry run">
|
||||
{hits.get(node.id) ?? 0}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationsPage() {
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
|
|
@ -93,7 +59,6 @@ export function NotificationsPage() {
|
|||
const shown = dryError ? null : dry;
|
||||
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
|
||||
|
|
@ -122,47 +87,29 @@ 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));
|
||||
// Open the editor on a draft: empty, or a copy of the flow `from`.
|
||||
const draftFrom = (from?: string) =>
|
||||
navigate(
|
||||
from === undefined
|
||||
? `/notifications/${NEW_FLOW_ID}`
|
||||
: `/notifications/${NEW_FLOW_ID}?from=${encodeURIComponent(from)}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="notifications"
|
||||
icon="🔔"
|
||||
actions={
|
||||
<RefreshIntervalPicker
|
||||
id="notifications-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
<div class="notify-panel-actions">
|
||||
<RefreshIntervalPicker
|
||||
id="notifications-refresh"
|
||||
value={intervalMs}
|
||||
onChange={setIntervalMs}
|
||||
/>
|
||||
<Button variant="primary" onClick={() => draftFrom()}>
|
||||
create flow
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
|
|
@ -200,46 +147,37 @@ export function NotificationsPage() {
|
|||
disabled={pending}
|
||||
onChange={(checked) => setBuiltin(flow.id, checked)}
|
||||
/>
|
||||
<div class="notify-flow-actions">
|
||||
<Button onClick={() => draftFrom(flow.id)}>
|
||||
duplicate to edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
||||
duplicate to edit
|
||||
</Button>
|
||||
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
</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>
|
||||
<span class="notify-flow-state">
|
||||
{flow.enabled ? "on" : "off"}
|
||||
</span>
|
||||
<div class="notify-flow-actions">
|
||||
<Button
|
||||
onClick={() =>
|
||||
navigate(`/notifications/${encodeURIComponent(flow.id)}`)
|
||||
}
|
||||
>
|
||||
edit
|
||||
</Button>
|
||||
<Button onClick={() => draftFrom(flow.id)}>duplicate</Button>
|
||||
</div>
|
||||
</div>
|
||||
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||
</Card>
|
||||
))}
|
||||
<h3>recent deliveries</h3>
|
||||
|
|
|
|||
Loading…
Reference in a new issue