The editor's preview button posts the draft to POST /api/notify/dry-run and puts each box's hit count on the canvas; any edit hides the numbers. Boxes of kind request_reviewer label themselves "reviewer", so the long kind name no longer runs into the hit count shown beside it.
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
// The editor's draft of the notification router's flows: pure edits that
|
|
// each return a new flow or say why not.
|
|
//
|
|
// The controller validates a whole document on save and is the authority;
|
|
// these edits only refuse what could never be saved, so the canvas can
|
|
// turn a bad drop away with a reason instead of drawing a flow the save
|
|
// would reject. The tree rules mirror `Flow::validate` in swarm-wire.
|
|
import type { Flow, FlowNode, FlowSchema, FlowsView } from "./FlowTypes.js";
|
|
|
|
// Kinds that end a branch: nothing hangs below them.
|
|
export function isLeafKind(kind: string): boolean {
|
|
return kind === "todo" || kind === "request_reviewer";
|
|
}
|
|
|
|
export interface Limits {
|
|
maxNodes: number;
|
|
maxDepth: number;
|
|
maxFlows: number;
|
|
}
|
|
|
|
// What the controller enforces today. Its `schema.limits`, when it sends
|
|
// one, replaces these.
|
|
export const DEFAULT_LIMITS: Limits = {
|
|
maxNodes: 64,
|
|
maxDepth: 16,
|
|
maxFlows: 100,
|
|
};
|
|
|
|
// The caps the controller reports, or today's when it reports none.
|
|
export function limitsOf(schema?: FlowSchema): Limits {
|
|
const l = schema?.limits;
|
|
return l
|
|
? { maxNodes: l.max_nodes, maxDepth: l.max_depth, maxFlows: l.max_flows }
|
|
: DEFAULT_LIMITS;
|
|
}
|
|
|
|
export type Edit = { ok: true; flow: Flow } | { ok: false; reason: string };
|
|
|
|
const ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
|
|
export function isId(id: string): boolean {
|
|
return ID.test(id);
|
|
}
|
|
|
|
function refuse(reason: string): Edit {
|
|
return { ok: false, reason };
|
|
}
|
|
|
|
export function childrenOf(flow: Flow, id: string): string[] {
|
|
return flow.edges.filter((e) => e.from === id).map((e) => e.to);
|
|
}
|
|
|
|
export function parentOf(flow: Flow, id: string): string | undefined {
|
|
return flow.edges.find((e) => e.to === id)?.from;
|
|
}
|
|
|
|
// Edges from the source down to `id`; 0 for the source.
|
|
export function depthOf(flow: Flow, id: string): number {
|
|
let depth = 0;
|
|
const seen = new Set<string>([id]);
|
|
for (let at = parentOf(flow, id); at !== undefined; at = parentOf(flow, at)) {
|
|
// A cycle never leaves the editor, but a loop here would hang the tab.
|
|
if (seen.has(at)) break;
|
|
seen.add(at);
|
|
depth += 1;
|
|
}
|
|
return depth;
|
|
}
|
|
|
|
// `id` and every node below it.
|
|
export function subtreeIds(flow: Flow, id: string): string[] {
|
|
const out: string[] = [];
|
|
const seen = new Set<string>();
|
|
const stack = [id];
|
|
while (stack.length > 0) {
|
|
const at = stack.pop() as string;
|
|
if (seen.has(at)) continue;
|
|
seen.add(at);
|
|
out.push(at);
|
|
stack.push(...childrenOf(flow, at));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// How many levels lie below `id`: 0 for a leaf.
|
|
export function heightBelow(flow: Flow, id: string): number {
|
|
let height = 0;
|
|
let level = [id];
|
|
const seen = new Set<string>(level);
|
|
for (;;) {
|
|
const next = level
|
|
.flatMap((n) => childrenOf(flow, n))
|
|
.filter((n) => !seen.has(n));
|
|
if (next.length === 0) return height;
|
|
next.forEach((n) => seen.add(n));
|
|
height += 1;
|
|
level = next;
|
|
}
|
|
}
|
|
|
|
function node(flow: Flow, id: string): FlowNode | undefined {
|
|
return flow.nodes.find((n) => n.id === id);
|
|
}
|
|
|
|
// A node id not yet in `flow`, such as `match2`.
|
|
export function newNodeId(flow: Flow, prefix: string): string {
|
|
const taken = new Set(flow.nodes.map((n) => n.id));
|
|
for (let n = 1; ; n += 1) {
|
|
if (!taken.has(`${prefix}${n}`)) return `${prefix}${n}`;
|
|
}
|
|
}
|
|
|
|
// The kinds a user can add; the source is only ever the root.
|
|
export type AddableKind = "match" | "todo" | "request_reviewer";
|
|
|
|
export const ADDABLE_KINDS: AddableKind[] = [
|
|
"match",
|
|
"todo",
|
|
"request_reviewer",
|
|
];
|
|
|
|
// The kinds the palette offers, each with the controller's description of it.
|
|
// A controller that sends no kinds predates `request_reviewer`, so only the
|
|
// two it always had are offered.
|
|
export function paletteKinds(
|
|
schema: FlowSchema,
|
|
): { kind: AddableKind; description: string }[] {
|
|
const listed = new Map(schema.node_kinds.map((k) => [k.kind, k.description]));
|
|
return ADDABLE_KINDS.filter((kind) =>
|
|
listed.size === 0 ? kind !== "request_reviewer" : listed.has(kind),
|
|
).map((kind) => ({ kind, description: listed.get(kind) ?? "" }));
|
|
}
|
|
|
|
// The word a new node's id starts with, such as `reviewer` for `reviewer1`.
|
|
const ID_PREFIX: Record<AddableKind, string> = {
|
|
match: "match",
|
|
todo: "todo",
|
|
request_reviewer: "reviewer",
|
|
};
|
|
|
|
// A node of `kind` with the fields a user is asked to fill in left empty.
|
|
export function blankNode(
|
|
flow: Flow,
|
|
kind: AddableKind,
|
|
): Extract<FlowNode, { kind: AddableKind }> {
|
|
const id = newNodeId(flow, ID_PREFIX[kind]);
|
|
switch (kind) {
|
|
case "match":
|
|
return { id, kind, when: [] };
|
|
case "request_reviewer":
|
|
return { id, kind, reviewer: "" };
|
|
case "todo":
|
|
return {
|
|
id,
|
|
kind,
|
|
to: { field: "assignees" },
|
|
op: "upsert",
|
|
key: "",
|
|
prefix: false,
|
|
text: "",
|
|
};
|
|
}
|
|
}
|
|
|
|
// `added` under `parentId`.
|
|
export function addChild(
|
|
flow: Flow,
|
|
parentId: string,
|
|
added: FlowNode,
|
|
limits: Limits = DEFAULT_LIMITS,
|
|
): Edit {
|
|
const parent = node(flow, parentId);
|
|
if (!parent) return refuse("that parent is not in the flow");
|
|
if (isLeafKind(parent.kind)) {
|
|
return refuse(`a ${parent.kind} ends a branch`);
|
|
}
|
|
if (added.kind === "source") return refuse("a flow has one source");
|
|
if (!isId(added.id) || node(flow, added.id)) {
|
|
return refuse(`${added.id} is not a free node id`);
|
|
}
|
|
if (flow.nodes.length >= limits.maxNodes) {
|
|
return refuse(`a flow holds at most ${limits.maxNodes} nodes`);
|
|
}
|
|
if (depthOf(flow, parentId) + 1 > limits.maxDepth) {
|
|
return refuse(`a flow is at most ${limits.maxDepth} levels deep`);
|
|
}
|
|
return {
|
|
ok: true,
|
|
flow: {
|
|
...flow,
|
|
nodes: [...flow.nodes, added],
|
|
edges: [...flow.edges, { from: parentId, to: added.id }],
|
|
},
|
|
};
|
|
}
|
|
|
|
// `id` and everything below it taken out. The source stays.
|
|
export function removeNode(flow: Flow, id: string): Edit {
|
|
const target = node(flow, id);
|
|
if (!target) return refuse("that node is not in the flow");
|
|
if (target.kind === "source") return refuse("the source cannot be removed");
|
|
const gone = new Set(subtreeIds(flow, id));
|
|
return {
|
|
ok: true,
|
|
flow: {
|
|
...flow,
|
|
nodes: flow.nodes.filter((n) => !gone.has(n.id)),
|
|
edges: flow.edges.filter((e) => !gone.has(e.from) && !gone.has(e.to)),
|
|
},
|
|
};
|
|
}
|
|
|
|
// `id`, with its subtree, moved under `parentId`.
|
|
export function reparent(
|
|
flow: Flow,
|
|
id: string,
|
|
parentId: string,
|
|
limits: Limits = DEFAULT_LIMITS,
|
|
): Edit {
|
|
const target = node(flow, id);
|
|
const parent = node(flow, parentId);
|
|
if (!target || !parent) return refuse("that node is not in the flow");
|
|
if (target.kind === "source") return refuse("the source stays the root");
|
|
if (isLeafKind(parent.kind)) {
|
|
return refuse(`a ${parent.kind} ends a branch`);
|
|
}
|
|
if (subtreeIds(flow, id).includes(parentId)) {
|
|
return refuse("a node cannot go under itself or its own branch");
|
|
}
|
|
if (parentOf(flow, id) === parentId) {
|
|
return refuse(`${id} is already under ${parentId}`);
|
|
}
|
|
if (depthOf(flow, parentId) + 1 + heightBelow(flow, id) > limits.maxDepth) {
|
|
return refuse(`a flow is at most ${limits.maxDepth} levels deep`);
|
|
}
|
|
return {
|
|
ok: true,
|
|
flow: {
|
|
...flow,
|
|
edges: [
|
|
...flow.edges.filter((e) => e.to !== id),
|
|
{ from: parentId, to: id },
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
// Where `id` runs among the nodes sharing its parent: `index` from 0 of
|
|
// `count`. A node with no parent (the source) is the only one of its kind.
|
|
export function siblingPosition(
|
|
flow: Flow,
|
|
id: string,
|
|
): { index: number; count: number } {
|
|
const parent = parentOf(flow, id);
|
|
if (parent === undefined) return { index: 0, count: 1 };
|
|
const siblings = childrenOf(flow, parent);
|
|
return { index: Math.max(0, siblings.indexOf(id)), count: siblings.length };
|
|
}
|
|
|
|
// `id` swapped with its neighbour that runs `delta` places earlier (-1) or
|
|
// later (+1). The router runs a node's children in edge order, so this changes
|
|
// which branch acts first; other edges keep their places.
|
|
export function moveAmongSiblings(flow: Flow, id: string, delta: -1 | 1): Edit {
|
|
const parent = parentOf(flow, id);
|
|
if (parent === undefined) return refuse("only a branch can be reordered");
|
|
const at: number[] = [];
|
|
flow.edges.forEach((e, i) => {
|
|
if (e.from === parent) at.push(i);
|
|
});
|
|
const here = at.findIndex((i) => flow.edges[i].to === id);
|
|
const there = here + delta;
|
|
if (there < 0 || there >= at.length) {
|
|
return refuse(
|
|
delta < 0 ? `${id} already runs first` : `${id} already runs last`,
|
|
);
|
|
}
|
|
const edges = [...flow.edges];
|
|
[edges[at[here]], edges[at[there]]] = [edges[at[there]], edges[at[here]]];
|
|
return { ok: true, flow: { ...flow, edges } };
|
|
}
|
|
|
|
// A todo with its op switched. A clear carries no text, and only a clear may
|
|
// be a prefix, so each is reset where the new op has no place for it.
|
|
export function withOp(
|
|
node: Extract<FlowNode, { kind: "todo" }>,
|
|
op: "upsert" | "clear",
|
|
): Extract<FlowNode, { kind: "todo" }> {
|
|
return {
|
|
...node,
|
|
op,
|
|
text: op === "clear" ? "" : node.text,
|
|
prefix: op === "clear" ? node.prefix : false,
|
|
};
|
|
}
|
|
|
|
// The node with `next.id` swapped for `next`. A node keeps its place and its
|
|
// kind-class: the source stays the source, and nothing with children becomes
|
|
// a todo.
|
|
export function replaceNode(flow: Flow, next: FlowNode): Edit {
|
|
const old = node(flow, next.id);
|
|
if (!old) return refuse("that node is not in the flow");
|
|
if ((old.kind === "source") !== (next.kind === "source")) {
|
|
return refuse(
|
|
"the source cannot change kind, and no other node can become one",
|
|
);
|
|
}
|
|
if (isLeafKind(next.kind) && childrenOf(flow, next.id).length > 0) {
|
|
return refuse(`a node with children cannot become a ${next.kind}`);
|
|
}
|
|
return {
|
|
ok: true,
|
|
flow: {
|
|
...flow,
|
|
nodes: flow.nodes.map((n) => (n.id === next.id ? next : n)),
|
|
},
|
|
};
|
|
}
|
|
|
|
// A flow id made of `name`, not in `taken`: `my-flow`, `my-flow-2`.
|
|
export function slugId(name: string, taken: Iterable<string>): string {
|
|
const used = new Set(taken);
|
|
const base =
|
|
name
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 56) || "flow";
|
|
if (!used.has(base)) return base;
|
|
for (let n = 2; ; n += 1) {
|
|
if (!used.has(`${base}-${n}`)) return `${base}-${n}`;
|
|
}
|
|
}
|
|
|
|
// An empty flow: just a source on issue events.
|
|
export function newFlow(name: string, taken: Iterable<string>): Flow {
|
|
return {
|
|
id: slugId(name, taken),
|
|
name,
|
|
enabled: true,
|
|
nodes: [{ id: "source", kind: "source", events: ["issues"] }],
|
|
edges: [],
|
|
};
|
|
}
|
|
|
|
// A copy of `flow` (a built-in or a custom one) as a custom flow, on.
|
|
export function duplicateFlow(
|
|
flow: Flow,
|
|
name: string,
|
|
taken: Iterable<string>,
|
|
): Flow {
|
|
return structuredClone({
|
|
...flow,
|
|
id: slugId(name, taken),
|
|
name,
|
|
enabled: true,
|
|
});
|
|
}
|
|
|
|
// The editor's route for a flow nobody has saved yet. It has a segment more
|
|
// than a flow's own route, so no flow's id can reach it.
|
|
export const NEW_FLOW_PATH = "/notifications/draft/new";
|
|
|
|
// The id a draft carries until it is saved (see `savedAs`).
|
|
export const NEW_FLOW_ID = "new";
|
|
|
|
function savedIds(view: FlowsView): string[] {
|
|
return [...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.
|
|
export function withFlow(flows: Flow[], flow: Flow): Flow[] {
|
|
return flows.some((f) => f.id === flow.id)
|
|
? flows.map((f) => (f.id === flow.id ? flow : f))
|
|
: [...flows, flow];
|
|
}
|
|
|
|
export function withoutFlow(flows: Flow[], id: string): Flow[] {
|
|
return flows.filter((f) => f.id !== id);
|
|
}
|
|
|
|
// Whether the draft differs from what the controller sent.
|
|
export function isDirty(
|
|
view: FlowsView,
|
|
flows: Flow[],
|
|
disabledBuiltins: string[],
|
|
): boolean {
|
|
return (
|
|
JSON.stringify(flows) !== JSON.stringify(view.flows) ||
|
|
JSON.stringify(disabledBuiltins) !== JSON.stringify(view.disabled_builtins)
|
|
);
|
|
}
|
|
|
|
// The `PUT /api/notify/flows` request for the draft: the whole document, with
|
|
// the revision it was read at.
|
|
export function saveRequest(
|
|
view: FlowsView,
|
|
flows: Flow[],
|
|
disabledBuiltins: string[],
|
|
): { method: "PUT"; headers: Record<string, string>; body: string } {
|
|
return {
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
revision: view.revision,
|
|
disabled_builtins: disabledBuiltins,
|
|
flows,
|
|
}),
|
|
};
|
|
}
|
|
|
|
// The `POST /api/notify/dry-run` request for the draft: the same document a
|
|
// save sends, without a revision since nothing is stored.
|
|
export function previewRequest(
|
|
flows: Flow[],
|
|
disabledBuiltins: string[],
|
|
): { method: "POST"; headers: Record<string, string>; body: string } {
|
|
return {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({ disabled_builtins: disabledBuiltins, flows }),
|
|
};
|
|
}
|
|
|
|
export type SaveOutcome = "saved" | "conflict" | "invalid" | "error";
|
|
|
|
// What a save's HTTP status means to the editor: 409 is a stale revision (read
|
|
// again, keep the draft or drop it), 400 is a document the controller refuses
|
|
// and says why.
|
|
export function saveOutcome(status: number): SaveOutcome {
|
|
if (status >= 200 && status < 300) return "saved";
|
|
if (status === 409) return "conflict";
|
|
if (status === 400) return "invalid";
|
|
return "error";
|
|
}
|