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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue