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
|
|
@ -19,7 +19,7 @@ domain, covers host-level detail for one hive.
|
||||||
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||||
| `/broadcast` | send one message to several agents at once, picked from a grid of their icons |
|
| `/broadcast` | send one message to several agents at once, picked from a grid of their icons |
|
||||||
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient |
|
| `/messages` | the swarm-wide message feed: every message between agents, and to them from the operator, reminders and compaction, with a history window and a filter on sender or recipient |
|
||||||
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a tree, and an editor for custom flows |
|
| `/notifications` | the notification router's flows: a switch for each built-in, every flow drawn as a graph, and an editor for custom flows |
|
||||||
| `/issues` | a cross-repo issue report |
|
| `/issues` | a cross-repo issue report |
|
||||||
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
|
| `/review` | the review classifier: its recent runs, a try-out on a real PR, and its settings |
|
||||||
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
|
| `/builds` | the swarm builder's recent builds of agent config PRs, with what nix printed when one failed |
|
||||||
|
|
@ -223,20 +223,25 @@ routes.
|
||||||
in two groups. Each **built-in** flow has an on/off switch; switching one off
|
in two groups. Each **built-in** flow has an on/off switch; switching one off
|
||||||
stops the router running it from its next event, and switching it on again
|
stops the router running it from its next event, and switching it on again
|
||||||
restores it. Each **custom** flow shows its name and whether it's on. Every
|
restores it. Each **custom** flow shows its name and whether it's on. Every
|
||||||
flow draws as an indented tree: the forge events it starts from, the
|
flow draws as a graph, the one the editor shows but read-only: the forge events
|
||||||
conditions under it, and the todo or review request each branch ends in.
|
it starts from on the left, the conditions after them, and the todo or review
|
||||||
|
request each branch ends in. A box cuts a long line of text with an ellipsis;
|
||||||
|
hover over the box to read all of it.
|
||||||
|
|
||||||
A built-in flow can be **duplicated** into a custom one, and **new flow** starts a
|
A built-in flow has a **duplicate to edit** button and a custom flow has **edit**
|
||||||
custom flow with just a source; either opens the flow in the editor. A custom
|
and **duplicate** buttons. **create flow**, at the top of the page beside the
|
||||||
flow has an **edit** link and a **duplicate** button.
|
refresh interval, starts a custom flow with just a source. Creating and
|
||||||
|
duplicating open the editor on a draft and make nothing: the flow exists once
|
||||||
|
you **save** it there. Leaving the draft without saving, or discarding it, leaves
|
||||||
|
the flows as they were.
|
||||||
|
|
||||||
A switch sends the whole flows document with the revision the page read, `0` if
|
A switch sends the whole flows document with the revision the page read, `0` if
|
||||||
it read no document. The controller refuses the switch when the stored revision
|
it read no document. The controller refuses the switch when the stored revision
|
||||||
is another, including when someone wrote a document after the page read none, and
|
is another, including when someone wrote a document after the page read none, and
|
||||||
the page shows why. Reload to switch against the current document.
|
the page shows why. Reload to switch against the current document.
|
||||||
|
|
||||||
Beside each node in the trees, a number says how many of the router's newest 50
|
Each box in a graph says how many of the router's newest 50 forge events reached
|
||||||
forge events reached it, from a dry run of the saved flows (`GET /api/notify/dry-run`).
|
it (**12 hits**, say), from a dry run of the saved flows (`GET /api/notify/dry-run`).
|
||||||
Below the flows, **recent deliveries** lists what the router delivered, newest
|
Below the flows, **recent deliveries** lists what the router delivered, newest
|
||||||
first: when, which flow and node, the event and issue, who got it, and whether it
|
first: when, which flow and node, the event and issue, who got it, and whether it
|
||||||
went through (`GET /api/notify/deliveries`). Both come from the router; while the
|
went through (`GET /api/notify/deliveries`). Both come from the router; while the
|
||||||
|
|
@ -246,7 +251,10 @@ works. See [deliveries and dry runs](notify-router.md#deliveries-and-dry-runs).
|
||||||
|
|
||||||
#### Editing a flow
|
#### Editing a flow
|
||||||
|
|
||||||
`/notifications/<flow id>` edits one custom flow. The canvas draws the flow left
|
`/notifications/<flow id>` edits one custom flow, and `/notifications/new` makes
|
||||||
|
one: it opens on an empty flow, or on a copy of a flow with `?from=<flow id>`, as
|
||||||
|
**create flow** and **duplicate** do. A new flow takes its id from its name when
|
||||||
|
you save it, and **save** needs a name. The canvas draws the flow left
|
||||||
to right: the source on the left, each match to its right, and the leaf nodes at
|
to right: the source on the left, each match to its right, and the leaf nodes at
|
||||||
the ends. Pick a node to edit it in the panel beside the canvas:
|
the ends. Pick a node to edit it in the panel beside the canvas:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
// <BoxFace> — what one node's box shows: its kind, when it runs among its
|
||||||
|
// siblings, how many events reached it, and what it does. The editor's canvas
|
||||||
|
// and the list's read-only graph both draw their boxes with it, inside a
|
||||||
|
// `<g>` of their own that carries the pointer and key handling.
|
||||||
|
//
|
||||||
|
// The line of text is cut by the browser to the box's own width, so a long
|
||||||
|
// description ends in an ellipsis inside the box instead of running out of it;
|
||||||
|
// the full text is the box's tooltip.
|
||||||
|
import { NODE_HEIGHT, NODE_WIDTH } from "./FlowLayout.js";
|
||||||
|
|
||||||
|
const PAD = 10;
|
||||||
|
|
||||||
|
export function BoxFace({
|
||||||
|
kind,
|
||||||
|
text,
|
||||||
|
order,
|
||||||
|
hits,
|
||||||
|
}: {
|
||||||
|
kind: string;
|
||||||
|
text: string;
|
||||||
|
// "runs 2nd" when the node has siblings, else nothing.
|
||||||
|
order?: string;
|
||||||
|
// The events of the last dry run that reached the node, when one covers it.
|
||||||
|
hits?: number;
|
||||||
|
}) {
|
||||||
|
const aside = [
|
||||||
|
order,
|
||||||
|
hits === undefined ? undefined : `${hits} ${hits === 1 ? "hit" : "hits"}`,
|
||||||
|
]
|
||||||
|
.filter((part) => part !== undefined)
|
||||||
|
.join(" · ");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
|
||||||
|
<text class="notify-box-kind" x={PAD} y="17">
|
||||||
|
{kind}
|
||||||
|
</text>
|
||||||
|
{aside ? (
|
||||||
|
<text
|
||||||
|
class="notify-box-kind notify-box-order"
|
||||||
|
x={NODE_WIDTH - PAD}
|
||||||
|
y="17"
|
||||||
|
text-anchor="end"
|
||||||
|
>
|
||||||
|
{aside}
|
||||||
|
</text>
|
||||||
|
) : null}
|
||||||
|
<foreignObject
|
||||||
|
x={PAD}
|
||||||
|
y="23"
|
||||||
|
width={NODE_WIDTH - 2 * PAD}
|
||||||
|
height={NODE_HEIGHT - 25}
|
||||||
|
>
|
||||||
|
<div class="notify-box-text">{text}</div>
|
||||||
|
</foreignObject>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -21,16 +21,10 @@ import {
|
||||||
type Edit,
|
type Edit,
|
||||||
type Limits,
|
type Limits,
|
||||||
} from "./FlowDraft.js";
|
} from "./FlowDraft.js";
|
||||||
import {
|
import { BoxFace } from "./FlowBox.js";
|
||||||
layoutFlow,
|
import { layoutFlow, neighbour, nodeAt } from "./FlowLayout.js";
|
||||||
neighbour,
|
import { describeNode, ordinal, type Flow } from "./FlowTypes.js";
|
||||||
nodeAt,
|
|
||||||
NODE_HEIGHT,
|
|
||||||
NODE_WIDTH,
|
|
||||||
} from "./FlowLayout.js";
|
|
||||||
import { describeNode, ellipsize, ordinal, type Flow } from "./FlowTypes.js";
|
|
||||||
|
|
||||||
const TEXT_CHARS = 30;
|
|
||||||
// How far the pointer must travel before a press counts as a drag.
|
// How far the pointer must travel before a press counts as a drag.
|
||||||
const DRAG_PX = 4;
|
const DRAG_PX = 4;
|
||||||
|
|
||||||
|
|
@ -359,23 +353,13 @@ export function FlowCanvas({
|
||||||
onKeyDown={(e) => onKey(e, p.id)}
|
onKeyDown={(e) => onKey(e, p.id)}
|
||||||
>
|
>
|
||||||
<title>{text}</title>
|
<title>{text}</title>
|
||||||
<rect width={NODE_WIDTH} height={NODE_HEIGHT} rx="6" />
|
<BoxFace
|
||||||
<text class="notify-box-kind" x="10" y="17">
|
kind={node.kind}
|
||||||
{node.kind}
|
text={text}
|
||||||
</text>
|
order={
|
||||||
{pos.count > 1 ? (
|
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
|
||||||
<text
|
}
|
||||||
class="notify-box-kind notify-box-order"
|
/>
|
||||||
x={NODE_WIDTH - 10}
|
|
||||||
y="17"
|
|
||||||
text-anchor="end"
|
|
||||||
>
|
|
||||||
{`runs ${ordinal(pos.index + 1)}`}
|
|
||||||
</text>
|
|
||||||
) : null}
|
|
||||||
<text class="notify-box-text" x="10" y="35">
|
|
||||||
{ellipsize(text, TEXT_CHARS)}
|
|
||||||
</text>
|
|
||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -356,6 +356,39 @@ export function duplicateFlow(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The id of a flow nobody has saved yet. It is the editor's route for one, so
|
||||||
|
// no saved flow may take it.
|
||||||
|
export const NEW_FLOW_ID = "new";
|
||||||
|
|
||||||
|
function savedIds(view: FlowsView): string[] {
|
||||||
|
return [
|
||||||
|
NEW_FLOW_ID,
|
||||||
|
...view.builtins.map((f) => f.id),
|
||||||
|
...view.flows.map((f) => f.id),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The draft the editor opens for a flow not saved yet: an empty one, or a copy
|
||||||
|
// of the built-in or custom flow `from`. Nothing is sent until it is saved, so
|
||||||
|
// the id is a placeholder (see `savedAs`). Null when `from` names no flow.
|
||||||
|
export function seedFlow(view: FlowsView, from: string | null): Flow | null {
|
||||||
|
const taken = savedIds(view);
|
||||||
|
if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID };
|
||||||
|
const source = [...view.builtins, ...view.flows].find((f) => f.id === from);
|
||||||
|
if (!source) return null;
|
||||||
|
return {
|
||||||
|
...duplicateFlow(source, `${source.name} (copy)`, taken),
|
||||||
|
id: NEW_FLOW_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// The draft as it is saved: with an id made of its name that no flow has,
|
||||||
|
// the built-ins' included (the controller refuses a custom flow that shares a
|
||||||
|
// built-in's).
|
||||||
|
export function savedAs(view: FlowsView, draft: Flow): Flow {
|
||||||
|
return { ...draft, id: slugId(draft.name, savedIds(view)) };
|
||||||
|
}
|
||||||
|
|
||||||
// `flows` with `flow` in place of the one of its id, or added last.
|
// `flows` with `flow` in place of the one of its id, or added last.
|
||||||
export function withFlow(flows: Flow[], flow: Flow): Flow[] {
|
export function withFlow(flows: Flow[], flow: Flow): Flow[] {
|
||||||
return flows.some((f) => f.id === flow.id)
|
return flows.some((f) => f.id === flow.id)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,21 @@
|
||||||
// document back at the revision it was read. Edits stay in a draft until
|
// document back at the revision it was read. Edits stay in a draft until
|
||||||
// then, so a half-built flow never reaches the router.
|
// 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
|
// 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
|
// 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
|
// were changed elsewhere, and the editor offers to reload or to keep the draft
|
||||||
// on top of the latest.
|
// on top of the latest.
|
||||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
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 { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
|
@ -24,11 +33,14 @@ import {
|
||||||
isDirty,
|
isDirty,
|
||||||
limitsOf,
|
limitsOf,
|
||||||
moveAmongSiblings,
|
moveAmongSiblings,
|
||||||
|
NEW_FLOW_ID,
|
||||||
paletteKinds,
|
paletteKinds,
|
||||||
removeNode,
|
removeNode,
|
||||||
reparent,
|
reparent,
|
||||||
|
savedAs,
|
||||||
saveOutcome,
|
saveOutcome,
|
||||||
saveRequest,
|
saveRequest,
|
||||||
|
seedFlow,
|
||||||
parentOf,
|
parentOf,
|
||||||
withFlow,
|
withFlow,
|
||||||
withoutFlow,
|
withoutFlow,
|
||||||
|
|
@ -49,9 +61,13 @@ export function FlowEditorPage({
|
||||||
params,
|
params,
|
||||||
}: RouteComponentProps<{ flowId: string }>) {
|
}: RouteComponentProps<{ flowId: string }>) {
|
||||||
const flowId = decodeURIComponent(params.flowId);
|
const flowId = decodeURIComponent(params.flowId);
|
||||||
|
const isNew = flowId === NEW_FLOW_ID;
|
||||||
|
const from = new URLSearchParams(useSearch()).get("from");
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
const [view, setView] = useState<FlowsView | null>(null);
|
const [view, setView] = useState<FlowsView | null>(null);
|
||||||
const [draft, setDraft] = useState<Flow | 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 [selected, setSelected] = useState<string | null>(null);
|
||||||
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
|
const [loadError, setLoadError] = useState<ProblemDetails | null>(null);
|
||||||
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
const [saveError, setSaveError] = useState<ProblemDetails | null>(null);
|
||||||
|
|
@ -74,7 +90,10 @@ export function FlowEditorPage({
|
||||||
setStale(false);
|
setStale(false);
|
||||||
setSaveError(null);
|
setSaveError(null);
|
||||||
if (!keepDraft) {
|
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);
|
setDraft(flow ? structuredClone(flow) : null);
|
||||||
setSelected(flow ? rootOf(flow) : null);
|
setSelected(flow ? rootOf(flow) : null);
|
||||||
}
|
}
|
||||||
|
|
@ -84,7 +103,7 @@ export function FlowEditorPage({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[flowId],
|
[flowId, isNew, from],
|
||||||
);
|
);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load(false);
|
void load(false);
|
||||||
|
|
@ -93,7 +112,9 @@ export function FlowEditorPage({
|
||||||
const dirty =
|
const dirty =
|
||||||
view !== null &&
|
view !== null &&
|
||||||
draft !== 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
|
// Leaving the page with edits not saved asks first, whether by closing the
|
||||||
// tab, following a link or pressing back.
|
// 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() {
|
async function onDelete() {
|
||||||
if (!view) return;
|
if (!view) return;
|
||||||
setConfirmDelete(false);
|
setConfirmDelete(false);
|
||||||
|
|
@ -169,9 +202,11 @@ export function FlowEditorPage({
|
||||||
return (
|
return (
|
||||||
<Panel title="notification flow" icon="🔔" actions={back}>
|
<Panel title="notification flow" icon="🔔" actions={back}>
|
||||||
<p>
|
<p>
|
||||||
{builtin
|
{isNew
|
||||||
? "A built-in flow cannot be edited: duplicate it from the list and edit the copy."
|
? `There is no flow ${from} to copy; it may have been deleted.`
|
||||||
: `There is no flow ${flowId}; 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>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
|
|
@ -182,7 +217,11 @@ export function FlowEditorPage({
|
||||||
const node = draft.nodes.find((n) => n.id === selected) ?? null;
|
const node = draft.nodes.find((n) => n.id === selected) ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel title={`flow ${draft.id}`} icon="🔔" actions={back}>
|
<Panel
|
||||||
|
title={isNew ? "new flow" : `flow ${draft.id}`}
|
||||||
|
icon="🔔"
|
||||||
|
actions={back}
|
||||||
|
>
|
||||||
{stale ? (
|
{stale ? (
|
||||||
<div class="notify-stale" role="alert">
|
<div class="notify-stale" role="alert">
|
||||||
<p>
|
<p>
|
||||||
|
|
@ -212,6 +251,7 @@ export function FlowEditorPage({
|
||||||
id="flow-name"
|
id="flow-name"
|
||||||
label="name"
|
label="name"
|
||||||
value={draft.name}
|
value={draft.name}
|
||||||
|
placeholder="what the flow is for"
|
||||||
onInput={(name) => setDraft({ ...draft, name })}
|
onInput={(name) => setDraft({ ...draft, name })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -221,28 +261,38 @@ export function FlowEditorPage({
|
||||||
checked={draft.enabled}
|
checked={draft.enabled}
|
||||||
onChange={(enabled) => setDraft({ ...draft, enabled })}
|
onChange={(enabled) => setDraft({ ...draft, enabled })}
|
||||||
/>
|
/>
|
||||||
<Button
|
<div class="notify-editor-actions">
|
||||||
variant="primary"
|
{dirty ? (
|
||||||
disabled={!dirty || busy}
|
<span class="notify-hint" role="status">
|
||||||
onClick={() => void put(withFlow(view.flows, draft))}
|
not saved yet
|
||||||
>
|
</span>
|
||||||
save
|
) : null}
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="primary"
|
||||||
disabled={!dirty || busy}
|
disabled={(!isNew && !dirty) || busy || draft.name.trim() === ""}
|
||||||
onClick={() => {
|
onClick={() => void save()}
|
||||||
const saved = view.flows.find((f) => f.id === flowId);
|
>
|
||||||
if (saved) setDraft(structuredClone(saved));
|
save
|
||||||
setNotice(null);
|
</Button>
|
||||||
}}
|
<Button
|
||||||
>
|
disabled={!dirty || busy}
|
||||||
discard edits
|
onClick={() => {
|
||||||
</Button>
|
const start = isNew
|
||||||
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
|
? seed
|
||||||
delete flow
|
: view.flows.find((f) => f.id === flowId);
|
||||||
</Button>
|
if (start) setDraft(structuredClone(start));
|
||||||
|
setNotice(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
discard edits
|
||||||
|
</Button>
|
||||||
|
{isNew ? null : (
|
||||||
|
<Button disabled={busy} onClick={() => setConfirmDelete(true)}>
|
||||||
|
delete flow
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{dirty ? <p class="notify-hint">Not saved yet.</p> : null}
|
|
||||||
|
|
||||||
<div class="notify-editor-body">
|
<div class="notify-editor-body">
|
||||||
<FlowCanvas
|
<FlowCanvas
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,66 @@
|
||||||
|
// <FlowGraph> — a flow drawn as boxes and lines, to look at: the source on the
|
||||||
|
// left, its branches to the right. The list shows each flow with it; the
|
||||||
|
// editor's <FlowCanvas> draws the same picture and adds the rewiring.
|
||||||
|
//
|
||||||
|
// `hits`, when the dry run covers this flow, puts the number of the newest
|
||||||
|
// events that reached each node in its box.
|
||||||
|
import { BoxFace } from "./FlowBox.js";
|
||||||
|
import { layoutFlow } from "./FlowLayout.js";
|
||||||
|
import { siblingPosition } from "./FlowDraft.js";
|
||||||
|
import { describeNode, ordinal, type Flow } from "./FlowTypes.js";
|
||||||
|
|
||||||
|
export function FlowGraph({
|
||||||
|
flow,
|
||||||
|
hits,
|
||||||
|
}: {
|
||||||
|
flow: Flow;
|
||||||
|
hits?: Map<string, number>;
|
||||||
|
}) {
|
||||||
|
const layout = layoutFlow(flow);
|
||||||
|
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
||||||
|
return (
|
||||||
|
<div class="notify-canvas notify-graph">
|
||||||
|
<svg
|
||||||
|
width={layout.width}
|
||||||
|
height={layout.height}
|
||||||
|
viewBox={`0 0 ${layout.width} ${layout.height}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={`${flow.name}, ${flow.nodes.length} nodes`}
|
||||||
|
>
|
||||||
|
{layout.edges.map((e) => {
|
||||||
|
const mid = (e.x1 + e.x2) / 2;
|
||||||
|
return (
|
||||||
|
<path
|
||||||
|
key={`${e.from}>${e.to}`}
|
||||||
|
class="notify-edge"
|
||||||
|
d={`M${e.x1} ${e.y1} C${mid} ${e.y1} ${mid} ${e.y2} ${e.x2} ${e.y2}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{layout.nodes.map((p) => {
|
||||||
|
const node = byId.get(p.id);
|
||||||
|
if (!node) return null;
|
||||||
|
const text = describeNode(node);
|
||||||
|
const pos = siblingPosition(flow, p.id);
|
||||||
|
return (
|
||||||
|
<g
|
||||||
|
key={p.id}
|
||||||
|
class={`notify-box notify-box-${node.kind}`}
|
||||||
|
transform={`translate(${p.x} ${p.y})`}
|
||||||
|
>
|
||||||
|
<title>{text}</title>
|
||||||
|
<BoxFace
|
||||||
|
kind={node.kind}
|
||||||
|
text={text}
|
||||||
|
order={
|
||||||
|
pos.count > 1 ? `runs ${ordinal(pos.index + 1)}` : undefined
|
||||||
|
}
|
||||||
|
hits={hits ? (hits.get(node.id) ?? 0) : undefined}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -40,32 +40,6 @@ export interface FlowsView {
|
||||||
schema?: FlowSchema;
|
schema?: FlowSchema;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One node of a flow with its depth below the source, in the order a reader
|
|
||||||
// walks the tree: a parent, then each child's subtree in edge order.
|
|
||||||
export interface TreeRow {
|
|
||||||
node: FlowNode;
|
|
||||||
depth: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function treeRows(flow: Flow): TreeRow[] {
|
|
||||||
const byId = new Map(flow.nodes.map((n) => [n.id, n]));
|
|
||||||
const root = flow.nodes.find((n) => n.kind === "source");
|
|
||||||
if (!root) return [];
|
|
||||||
const rows: TreeRow[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const walk = (id: string, depth: number) => {
|
|
||||||
const node = byId.get(id);
|
|
||||||
// The controller refuses a flow that is not a tree; a repeat here would
|
|
||||||
// mean a cycle, which `seen` stops from looping.
|
|
||||||
if (!node || seen.has(id)) return;
|
|
||||||
seen.add(id);
|
|
||||||
rows.push({ node, depth });
|
|
||||||
for (const e of flow.edges) if (e.from === id) walk(e.to, depth + 1);
|
|
||||||
};
|
|
||||||
walk(root.id, 0);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function describeRecipients(to: Recipients): string {
|
export function describeRecipients(to: Recipients): string {
|
||||||
return "field" in to ? `{${to.field}}` : to.agent;
|
return "field" in to ? `{${to.field}}` : to.agent;
|
||||||
}
|
}
|
||||||
|
|
@ -161,11 +135,6 @@ export function ordinal(n: number): string {
|
||||||
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
|
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `text` cut to `max` characters, with an ellipsis if it was longer.
|
|
||||||
export function ellipsize(text: string, max: number): string {
|
|
||||||
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The values the controller allows for `field` of node kind `kind`, if its
|
// The values the controller allows for `field` of node kind `kind`, if its
|
||||||
// schema lists them (the todo `op` does).
|
// schema lists them (the todo `op` does).
|
||||||
export function kindValues(
|
export function kindValues(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
// remove it. Every change goes out as an `Edit` of the flow; the editor shows
|
// remove it. Every change goes out as an `Edit` of the flow; the editor shows
|
||||||
// the reason when one is refused. New nodes come from the canvas palette.
|
// the reason when one is refused. New nodes come from the canvas palette.
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
import { Card } from "../../ui/card/Card.js";
|
||||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||||
import { SelectField } from "../../ui/select-field/SelectField.js";
|
import { SelectField } from "../../ui/select-field/SelectField.js";
|
||||||
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js";
|
||||||
|
|
@ -74,7 +75,7 @@ export function NodeInspector({
|
||||||
const order = siblingPosition(flow, node.id);
|
const order = siblingPosition(flow, node.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="notify-inspector">
|
<Card class="notify-inspector">
|
||||||
<h4>
|
<h4>
|
||||||
{node.kind}{" "}
|
{node.kind}{" "}
|
||||||
{node.id !== node.kind ? (
|
{node.id !== node.kind ? (
|
||||||
|
|
@ -190,7 +191,7 @@ export function NodeInspector({
|
||||||
onChange={(p) => onEdit(reparent(flow, node.id, p, limits))}
|
onChange={(p) => onEdit(reparent(flow, node.id, p, limits))}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
/* NotificationsPage — the flows drawn as indented trees. */
|
/* NotificationsPage — the flows, each drawn as its graph. */
|
||||||
|
|
||||||
.notify-flows {
|
.notify-flows {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -15,55 +15,49 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-flow-name {
|
.notify-flow-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-flow-tree {
|
.notify-flow-state {
|
||||||
margin: 0.4em 0 0;
|
|
||||||
padding: 0;
|
|
||||||
list-style: none;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notify-node {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.5em;
|
|
||||||
padding: 0.1em 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notify-node-kind {
|
|
||||||
flex: none;
|
|
||||||
min-width: 3.5em;
|
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-node-text {
|
.notify-flow-actions,
|
||||||
overflow-wrap: anywhere;
|
.notify-panel-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-graph {
|
||||||
|
margin-top: 0.4em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-off {
|
.notify-off {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* The flow list's new-flow row and each flow's actions. */
|
|
||||||
.notify-new {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: flex-end;
|
|
||||||
gap: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notify-flow-head > * + * {
|
|
||||||
margin-left: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* FlowEditorPage — the head row, the canvas and the inspector beside it. */
|
/* FlowEditorPage — the head row, the canvas and the inspector beside it. */
|
||||||
.notify-editor-head {
|
.notify-editor-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 0.75em;
|
gap: 0.75em 1em;
|
||||||
margin-bottom: 0.5em;
|
margin-bottom: 1em;
|
||||||
|
padding-bottom: 1em;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-editor-body {
|
.notify-editor-body {
|
||||||
|
|
@ -208,13 +202,41 @@
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The box's line of text is cut by the browser at the box's own edge. */
|
||||||
|
.notify-box-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fg);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.notify-inspector {
|
.notify-inspector {
|
||||||
flex: 0 1 22em;
|
flex: 0 1 22em;
|
||||||
min-width: 16em;
|
min-width: 16em;
|
||||||
|
gap: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-inspector h4 {
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-inspector fieldset {
|
.notify-inspector fieldset {
|
||||||
margin: 0 0 0.75em;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6em;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.6em 0.8em 0.8em;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-inspector legend {
|
||||||
|
padding: 0 0.4em;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notify-condition {
|
.notify-condition {
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,27 @@
|
||||||
// swarm-controller's `GET/PUT /api/notify/flows`; the controller is the only
|
// 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.
|
// 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
|
// A flow draws as its graph, read-only. A built-in flow has a switch and can
|
||||||
// built-in flow has a switch and can be duplicated; a custom flow is edited
|
// be duplicated; a custom flow is edited on its own page (<FlowEditorPage>).
|
||||||
// on its own page (<FlowEditorPage>), and a new one starts here.
|
// 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 { 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 { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
import { Card } from "../../ui/card/Card.js";
|
import { Card } from "../../ui/card/Card.js";
|
||||||
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
|
||||||
import { Panel } from "../../ui/panel/Panel.js";
|
import { Panel } from "../../ui/panel/Panel.js";
|
||||||
import { TextField } from "../../ui/text-field/TextField.js";
|
|
||||||
import {
|
import {
|
||||||
RefreshIntervalPicker,
|
RefreshIntervalPicker,
|
||||||
type RefreshIntervalMs,
|
type RefreshIntervalMs,
|
||||||
} from "../../ui/refresh-interval/RefreshInterval.js";
|
} from "../../ui/refresh-interval/RefreshInterval.js";
|
||||||
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
import { fetchJson, usePolled } from "../../ui/polled/usePolled.js";
|
||||||
import { Deliveries } from "./Deliveries.js";
|
import { Deliveries } from "./Deliveries.js";
|
||||||
import {
|
import { NEW_FLOW_ID } from "./FlowDraft.js";
|
||||||
duplicateFlow,
|
import { FlowGraph } from "./FlowGraph.js";
|
||||||
newFlow,
|
import { withBuiltin, type FlowsView } from "./FlowTypes.js";
|
||||||
saveOutcome,
|
|
||||||
saveRequest,
|
|
||||||
} from "./FlowDraft.js";
|
|
||||||
import {
|
|
||||||
describeNode,
|
|
||||||
treeRows,
|
|
||||||
withBuiltin,
|
|
||||||
type Flow,
|
|
||||||
type FlowsView,
|
|
||||||
} from "./FlowTypes.js";
|
|
||||||
import {
|
import {
|
||||||
DELIVERIES_URL,
|
DELIVERIES_URL,
|
||||||
DRY_RUN_URL,
|
DRY_RUN_URL,
|
||||||
|
|
@ -45,30 +35,6 @@ import "./NotificationsPage.css";
|
||||||
|
|
||||||
const DEFAULT_INTERVAL_MS: RefreshIntervalMs = 30_000;
|
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() {
|
export function NotificationsPage() {
|
||||||
const [intervalMs, setIntervalMs] =
|
const [intervalMs, setIntervalMs] =
|
||||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
|
|
@ -93,7 +59,6 @@ export function NotificationsPage() {
|
||||||
const shown = dryError ? null : dry;
|
const shown = dryError ? null : dry;
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
const [actionError, setActionError] = useState<ProblemDetails | null>(null);
|
||||||
const [newName, setNewName] = useState("");
|
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
|
|
||||||
// Switch one built-in on or off: the whole document goes back with the
|
// 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
|
// Open the editor on a draft: empty, or a copy of the flow `from`.
|
||||||
// made free of every flow's, the built-ins' too: the controller refuses a
|
const draftFrom = (from?: string) =>
|
||||||
// custom flow that shares a built-in's.
|
navigate(
|
||||||
async function addFlow(make: (taken: string[]) => Flow) {
|
from === undefined
|
||||||
if (!view) return;
|
? `/notifications/${NEW_FLOW_ID}`
|
||||||
const taken = [...view.builtins, ...view.flows].map((f) => f.id);
|
: `/notifications/${NEW_FLOW_ID}?from=${encodeURIComponent(from)}`,
|
||||||
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 (
|
return (
|
||||||
<Panel
|
<Panel
|
||||||
title="notifications"
|
title="notifications"
|
||||||
icon="🔔"
|
icon="🔔"
|
||||||
actions={
|
actions={
|
||||||
<RefreshIntervalPicker
|
<div class="notify-panel-actions">
|
||||||
id="notifications-refresh"
|
<RefreshIntervalPicker
|
||||||
value={intervalMs}
|
id="notifications-refresh"
|
||||||
onChange={setIntervalMs}
|
value={intervalMs}
|
||||||
/>
|
onChange={setIntervalMs}
|
||||||
|
/>
|
||||||
|
<Button variant="primary" onClick={() => draftFrom()}>
|
||||||
|
create flow
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{error ? (
|
{error ? (
|
||||||
|
|
@ -200,46 +147,37 @@ export function NotificationsPage() {
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
onChange={(checked) => setBuiltin(flow.id, checked)}
|
onChange={(checked) => setBuiltin(flow.id, checked)}
|
||||||
/>
|
/>
|
||||||
|
<div class="notify-flow-actions">
|
||||||
|
<Button onClick={() => draftFrom(flow.id)}>
|
||||||
|
duplicate to edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
|
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
|
||||||
duplicate to edit
|
|
||||||
</Button>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<h3>custom flows</h3>
|
<h3>custom flows</h3>
|
||||||
{view.flows.length === 0 ? <p>no custom flows</p> : null}
|
{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) => (
|
{view.flows.map((flow) => (
|
||||||
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
|
<Card key={flow.id} class={flow.enabled ? "" : "notify-off"}>
|
||||||
<div class="notify-flow-head">
|
<div class="notify-flow-head">
|
||||||
<span class="notify-flow-name">{flow.name}</span>
|
<span class="notify-flow-name">{flow.name}</span>
|
||||||
<span>{flow.enabled ? "on" : "off"}</span>
|
<span class="notify-flow-state">
|
||||||
<Link href={`/notifications/${encodeURIComponent(flow.id)}`}>
|
{flow.enabled ? "on" : "off"}
|
||||||
edit
|
</span>
|
||||||
</Link>
|
<div class="notify-flow-actions">
|
||||||
<Button disabled={pending} onClick={() => void duplicate(flow)}>
|
<Button
|
||||||
duplicate
|
onClick={() =>
|
||||||
</Button>
|
navigate(`/notifications/${encodeURIComponent(flow.id)}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
edit
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => draftFrom(flow.id)}>duplicate</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FlowTree flow={flow} hits={hitsOf(shown, flow.id)} />
|
<FlowGraph flow={flow} hits={hitsOf(shown, flow.id)} />
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
<h3>recent deliveries</h3>
|
<h3>recent deliveries</h3>
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,17 @@ import {
|
||||||
isLeafKind,
|
isLeafKind,
|
||||||
limitsOf,
|
limitsOf,
|
||||||
moveAmongSiblings,
|
moveAmongSiblings,
|
||||||
|
NEW_FLOW_ID,
|
||||||
newFlow,
|
newFlow,
|
||||||
newNodeId,
|
newNodeId,
|
||||||
paletteKinds,
|
paletteKinds,
|
||||||
removeNode,
|
removeNode,
|
||||||
reparent,
|
reparent,
|
||||||
replaceNode,
|
replaceNode,
|
||||||
|
savedAs,
|
||||||
saveOutcome,
|
saveOutcome,
|
||||||
saveRequest,
|
saveRequest,
|
||||||
|
seedFlow,
|
||||||
siblingPosition,
|
siblingPosition,
|
||||||
slugId,
|
slugId,
|
||||||
subtreeIds,
|
subtreeIds,
|
||||||
|
|
@ -351,3 +354,43 @@ test("moveAmongSiblings swaps two branches and leaves other edges alone", () =>
|
||||||
assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last");
|
assert.equal(moveAmongSiblings(f, "c", 1).ok, false, "already last");
|
||||||
assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source");
|
assert.equal(moveAmongSiblings(f, "source", 1).ok, false, "the source");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("seedFlow opens an empty or copied draft under the placeholder id", () => {
|
||||||
|
const view = {
|
||||||
|
revision: 1,
|
||||||
|
disabled_builtins: [],
|
||||||
|
builtins: [{ ...flow(), id: "assigned", name: "Assigned" }],
|
||||||
|
flows: [{ ...flow(), id: "mine", name: "Mine" }],
|
||||||
|
};
|
||||||
|
const empty = seedFlow(view, null);
|
||||||
|
assert.equal(empty.id, NEW_FLOW_ID);
|
||||||
|
assert.equal(empty.name, "");
|
||||||
|
assert.deepEqual(
|
||||||
|
empty.nodes.map((n) => n.kind),
|
||||||
|
["source"],
|
||||||
|
);
|
||||||
|
const copy = seedFlow(view, "assigned");
|
||||||
|
assert.equal(copy.id, NEW_FLOW_ID);
|
||||||
|
assert.equal(copy.name, "Assigned (copy)");
|
||||||
|
assert.equal(copy.nodes.length, flow().nodes.length);
|
||||||
|
assert.equal(seedFlow(view, "gone"), null);
|
||||||
|
assert.equal(view.flows.length, 1, "nothing is added to the view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("savedAs takes the id from the name, clear of every saved id", () => {
|
||||||
|
const view = {
|
||||||
|
revision: 1,
|
||||||
|
disabled_builtins: [],
|
||||||
|
builtins: [{ ...flow(), id: "assigned" }],
|
||||||
|
flows: [{ ...flow(), id: "mine" }],
|
||||||
|
};
|
||||||
|
const draft = { ...flow(), id: NEW_FLOW_ID };
|
||||||
|
assert.equal(savedAs(view, { ...draft, name: "Closed" }).id, "closed");
|
||||||
|
assert.equal(savedAs(view, { ...draft, name: "Assigned" }).id, "assigned-2");
|
||||||
|
assert.equal(savedAs(view, { ...draft, name: "mine" }).id, "mine-2");
|
||||||
|
assert.equal(
|
||||||
|
savedAs(view, { ...draft, name: "New" }).id,
|
||||||
|
"new-2",
|
||||||
|
"no flow takes the id of the new-flow route",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,8 @@ import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
describeNode,
|
describeNode,
|
||||||
ellipsize,
|
|
||||||
kindValues,
|
kindValues,
|
||||||
ordinal,
|
ordinal,
|
||||||
treeRows,
|
|
||||||
withBuiltin,
|
withBuiltin,
|
||||||
} from "../src/pages/notifications/FlowTypes.ts";
|
} from "../src/pages/notifications/FlowTypes.ts";
|
||||||
|
|
||||||
|
|
@ -45,25 +43,6 @@ const flow = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
test("treeRows: each subtree follows its parent, with its depth", () => {
|
|
||||||
assert.deepEqual(
|
|
||||||
treeRows(flow).map((r) => [r.node.id, r.depth]),
|
|
||||||
[
|
|
||||||
["s", 0],
|
|
||||||
["m", 1],
|
|
||||||
["t", 2],
|
|
||||||
["m2", 1],
|
|
||||||
["u", 2],
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treeRows: a cycle ends the walk and a flow with no source is empty", () => {
|
|
||||||
const cyclic = { ...flow, edges: [...flow.edges, { from: "t", to: "s" }] };
|
|
||||||
assert.equal(treeRows(cyclic).length, 5);
|
|
||||||
assert.deepEqual(treeRows({ ...flow, nodes: [] }), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("describeNode: says what each kind does", () => {
|
test("describeNode: says what each kind does", () => {
|
||||||
assert.equal(describeNode(flow.nodes[0]), "forge issues, issue_assign");
|
assert.equal(describeNode(flow.nodes[0]), "forge issues, issue_assign");
|
||||||
assert.equal(describeNode(flow.nodes[1]), "action = closed");
|
assert.equal(describeNode(flow.nodes[1]), "action = closed");
|
||||||
|
|
@ -90,13 +69,6 @@ test("withBuiltin: switching off adds once, switching on removes", () => {
|
||||||
assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]);
|
assert.deepEqual(withBuiltin(["a", "b"], "a", true), ["b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ellipsize cuts long text and leaves short text alone", () => {
|
|
||||||
assert.equal(ellipsize("short", 10), "short");
|
|
||||||
assert.equal(ellipsize("exactly10!", 10), "exactly10!");
|
|
||||||
assert.equal(ellipsize("a rather long line", 10), "a rather …");
|
|
||||||
assert.equal(ellipsize("a rather long line", 10).length, 10);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("kindValues lists the values a schema allows for a field", () => {
|
test("kindValues lists the values a schema allows for a field", () => {
|
||||||
const schema = {
|
const schema = {
|
||||||
node_kinds: [
|
node_kinds: [
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue