swarm-wire, swarm-ui: the flow schema lists the values a field takes, and the editor picks them instead of asking for typed names
The schema's event fields gain values, a values_from marker (agents, repos) and a closed flag; a node field gains the marker and the flag. kind is now listed as issue and PR, what the router sends. The flow editor shows a dropdown where the list is complete, suggestions where it is only examples, and a plain box where there is none.
This commit is contained in:
parent
3d142feb6a
commit
b6da2bc9d0
8 changed files with 503 additions and 26 deletions
|
|
@ -0,0 +1,96 @@
|
|||
// What the editor offers for a value the operator would otherwise have to
|
||||
// know: the server's schema says which values a field takes, or where to ask
|
||||
// for them. Pure, so `npm test` can run it.
|
||||
import type { Described, FlowSchema } from "./FlowTypes.js";
|
||||
|
||||
// Where each `values_from` marker of the schema is fetched, a list of names.
|
||||
export const SOURCE_URLS: Record<string, string> = {
|
||||
agents: "/api/agents",
|
||||
repos: "/api/repos",
|
||||
};
|
||||
|
||||
// Names fetched so far per marker. A marker is absent until its list arrives,
|
||||
// and stays absent when the fetch fails.
|
||||
export type Sources = Record<string, string[]>;
|
||||
|
||||
/** What the schema says one field can hold. */
|
||||
export interface ValueSpec {
|
||||
values: Described[];
|
||||
values_from?: string | null;
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
export interface Offer {
|
||||
options: { value: string; label: string }[];
|
||||
// The options are every value there is, so a dropdown will do; otherwise
|
||||
// they are suggestions beside a box that takes any text.
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
/** The markers the schema names that this editor can fetch, once each. */
|
||||
export function markersIn(schema: FlowSchema): string[] {
|
||||
const specs: ValueSpec[] = [
|
||||
...schema.fields,
|
||||
...schema.node_kinds.flatMap((k) => k.fields),
|
||||
];
|
||||
const wanted = specs.flatMap((s) => (s.values_from ? [s.values_from] : []));
|
||||
return [...new Set(wanted)].filter((m) => m in SOURCE_URLS).sort();
|
||||
}
|
||||
|
||||
/** The spec of node field `field` of `kind`, if the schema has one. */
|
||||
export function kindSpec(
|
||||
schema: FlowSchema,
|
||||
kind: string,
|
||||
field: string,
|
||||
): ValueSpec | undefined {
|
||||
return schema.node_kinds
|
||||
.find((k) => k.kind === kind)
|
||||
?.fields.find((f) => f.name === field);
|
||||
}
|
||||
|
||||
/** The spec of event field `name`, if the schema has one. */
|
||||
export function eventSpec(
|
||||
schema: FlowSchema,
|
||||
name: string,
|
||||
): ValueSpec | undefined {
|
||||
return schema.fields.find((f) => f.name === name);
|
||||
}
|
||||
|
||||
/**
|
||||
* What to offer for a field that holds `current`, or null when it has no
|
||||
* known values and stays a plain text box.
|
||||
*
|
||||
* A dropdown needs every value, so one whose source has not arrived becomes
|
||||
* suggestions; a value already set that the list lacks (an agent since
|
||||
* removed) stays selectable so opening a flow never changes it.
|
||||
*/
|
||||
export function offerFor(
|
||||
spec: ValueSpec | undefined,
|
||||
sources: Sources,
|
||||
current: string,
|
||||
): Offer | null {
|
||||
if (!spec) return null;
|
||||
const from = spec.values_from;
|
||||
const fetched = from ? sources[from] : undefined;
|
||||
const named = spec.values.map((v) => ({
|
||||
value: v.name,
|
||||
label: v.description ? `${v.name} — ${v.description}` : v.name,
|
||||
}));
|
||||
const options = [
|
||||
...named,
|
||||
...(fetched ?? [])
|
||||
.filter((n) => !spec.values.some((v) => v.name === n))
|
||||
.map((n) => ({ value: n, label: n })),
|
||||
];
|
||||
if (options.length === 0) return null;
|
||||
const complete = spec.closed === true && (!from || fetched !== undefined);
|
||||
if (!complete) return { options, closed: false };
|
||||
const known = options.some((o) => o.value === current);
|
||||
return {
|
||||
options:
|
||||
current === "" || known
|
||||
? options
|
||||
: [{ value: current, label: `${current} (not listed)` }, ...options],
|
||||
closed: true,
|
||||
};
|
||||
}
|
||||
|
|
@ -48,6 +48,7 @@ import {
|
|||
} from "./FlowDraft.js";
|
||||
import { useLeaveGuard } from "./useLeaveGuard.js";
|
||||
import { NodeInspector } from "./NodeInspector.js";
|
||||
import { markersIn, SOURCE_URLS, type Sources } from "./FlowChoices.js";
|
||||
import { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
|
||||
import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js";
|
||||
import "./NotificationsPage.css";
|
||||
|
|
@ -85,6 +86,7 @@ export function FlowEditorPage({
|
|||
dry: DryRun;
|
||||
} | null>(null);
|
||||
const [previewError, setPreviewError] = useState<ProblemDetails | null>(null);
|
||||
const [sources, setSources] = useState<Sources>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
|
|
@ -121,6 +123,19 @@ export function FlowEditorPage({
|
|||
void load(false);
|
||||
}, [load]);
|
||||
|
||||
// The lists the schema's values come from. One that cannot be fetched is
|
||||
// left out and its field falls back to a box that takes any text.
|
||||
const markers = view?.schema ? markersIn(view.schema).join(",") : "";
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
for (const marker of markers === "" ? [] : markers.split(",")) {
|
||||
fetchJson<string[]>(SOURCE_URLS[marker], abort.signal)
|
||||
.then((names) => setSources((all) => ({ ...all, [marker]: names })))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
return () => abort.abort();
|
||||
}, [markers]);
|
||||
|
||||
const dirty =
|
||||
view !== null &&
|
||||
draft !== null &&
|
||||
|
|
@ -377,6 +392,7 @@ export function FlowEditorPage({
|
|||
node={node}
|
||||
schema={schema}
|
||||
limits={limits}
|
||||
sources={sources}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -83,9 +83,19 @@ export interface Described {
|
|||
description: string;
|
||||
}
|
||||
|
||||
export interface KindField extends Described {
|
||||
required: boolean;
|
||||
// What a field can hold: `values` it is known to take, and `values_from`, a
|
||||
// marker for where to fetch the rest (see FlowChoices.ts). `closed` says the
|
||||
// values are every one there is; absent from a controller that predates it.
|
||||
export interface Choices {
|
||||
values: Described[];
|
||||
values_from?: string | null;
|
||||
closed?: boolean;
|
||||
}
|
||||
|
||||
export interface EventField extends Described, Choices {}
|
||||
|
||||
export interface KindField extends Described, Choices {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface KindSchema {
|
||||
|
|
@ -96,7 +106,7 @@ export interface KindSchema {
|
|||
|
||||
export interface FlowSchema {
|
||||
source_events: Described[];
|
||||
fields: Described[];
|
||||
fields: EventField[];
|
||||
node_kinds: KindSchema[];
|
||||
limits: { max_nodes: number; max_depth: number; max_flows: number };
|
||||
}
|
||||
|
|
@ -123,7 +133,7 @@ export const FALLBACK_SCHEMA: FlowSchema = {
|
|||
"assignees_added",
|
||||
"assignees_removed",
|
||||
"todo_holders",
|
||||
].map((name) => ({ name, description: "" })),
|
||||
].map((name) => ({ name, description: "", values: [] })),
|
||||
node_kinds: [],
|
||||
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -19,6 +19,13 @@ import {
|
|||
type Limits,
|
||||
withOp,
|
||||
} from "./FlowDraft.js";
|
||||
import {
|
||||
eventSpec,
|
||||
kindSpec,
|
||||
offerFor,
|
||||
type Sources,
|
||||
type ValueSpec,
|
||||
} from "./FlowChoices.js";
|
||||
import {
|
||||
kindValues,
|
||||
ordinal,
|
||||
|
|
@ -36,6 +43,47 @@ const OPS_FALLBACK = ["upsert", "clear"];
|
|||
// The kinds this editor has fields for.
|
||||
const KNOWN_KINDS: string[] = ["source", "match", "todo", "request_reviewer"];
|
||||
|
||||
// A value box: a dropdown when the server lists every value, a box with the
|
||||
// known ones to pick from when it lists some, a plain box when it lists none.
|
||||
function ValueField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
spec,
|
||||
sources,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
spec: ValueSpec | undefined;
|
||||
sources: Sources;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const offer = offerFor(spec, sources, value);
|
||||
if (offer?.closed) {
|
||||
return (
|
||||
<SelectField
|
||||
id={id}
|
||||
label={label}
|
||||
value={value}
|
||||
options={offer.options}
|
||||
placeholder="pick one"
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TextField
|
||||
id={id}
|
||||
label={label}
|
||||
value={value}
|
||||
suggestions={offer?.options}
|
||||
onInput={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function options(list: Described[]) {
|
||||
return list.map((d) => ({
|
||||
value: d.name,
|
||||
|
|
@ -48,12 +96,15 @@ export function NodeInspector({
|
|||
node,
|
||||
schema,
|
||||
limits,
|
||||
sources,
|
||||
onEdit,
|
||||
}: {
|
||||
flow: Flow;
|
||||
node: FlowNode;
|
||||
schema: FlowSchema;
|
||||
limits: Limits;
|
||||
// The lists the schema's `values_from` markers name, as fetched.
|
||||
sources: Sources;
|
||||
// `select` is the node to pick once the edit is applied.
|
||||
onEdit: (edit: Edit, select?: string) => void;
|
||||
}) {
|
||||
|
|
@ -111,6 +162,8 @@ export function NodeInspector({
|
|||
<MatchFields
|
||||
node={node}
|
||||
keyPrefix={key}
|
||||
schema={schema}
|
||||
sources={sources}
|
||||
fieldOptions={fieldOptions}
|
||||
firstField={firstField}
|
||||
onChange={set}
|
||||
|
|
@ -122,6 +175,7 @@ export function NodeInspector({
|
|||
node={node}
|
||||
keyPrefix={key}
|
||||
schema={schema}
|
||||
sources={sources}
|
||||
fieldOptions={fieldOptions}
|
||||
firstField={firstField}
|
||||
onChange={set}
|
||||
|
|
@ -131,11 +185,13 @@ export function NodeInspector({
|
|||
{node.kind === "request_reviewer" ? (
|
||||
<fieldset>
|
||||
<legend>asks for a review</legend>
|
||||
<TextField
|
||||
<ValueField
|
||||
id={`${key}-reviewer`}
|
||||
label="reviewer (a forge login)"
|
||||
value={node.reviewer}
|
||||
onInput={(reviewer) => set({ ...node, reviewer })}
|
||||
spec={kindSpec(schema, "request_reviewer", "reviewer")}
|
||||
sources={sources}
|
||||
onChange={(reviewer) => set({ ...node, reviewer })}
|
||||
/>
|
||||
</fieldset>
|
||||
) : null}
|
||||
|
|
@ -198,12 +254,16 @@ export function NodeInspector({
|
|||
function MatchFields({
|
||||
node,
|
||||
keyPrefix,
|
||||
schema,
|
||||
sources,
|
||||
fieldOptions,
|
||||
firstField,
|
||||
onChange,
|
||||
}: {
|
||||
node: Extract<FlowNode, { kind: "match" }>;
|
||||
keyPrefix: string;
|
||||
schema: FlowSchema;
|
||||
sources: Sources;
|
||||
fieldOptions: { value: string; label: string }[];
|
||||
firstField: string;
|
||||
onChange: (next: FlowNode) => void;
|
||||
|
|
@ -224,11 +284,13 @@ function MatchFields({
|
|||
setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x)))
|
||||
}
|
||||
/>
|
||||
<TextField
|
||||
<ValueField
|
||||
id={`${keyPrefix}-c${i}-equals`}
|
||||
label="equals"
|
||||
value={c.equals}
|
||||
onInput={(equals) =>
|
||||
spec={eventSpec(schema, c.field)}
|
||||
sources={sources}
|
||||
onChange={(equals) =>
|
||||
setWhen(node.when.map((x, j) => (j === i ? { ...x, equals } : x)))
|
||||
}
|
||||
/>
|
||||
|
|
@ -252,6 +314,7 @@ function TodoFields({
|
|||
node,
|
||||
keyPrefix,
|
||||
schema,
|
||||
sources,
|
||||
fieldOptions,
|
||||
firstField,
|
||||
onChange,
|
||||
|
|
@ -259,6 +322,7 @@ function TodoFields({
|
|||
node: TodoNode;
|
||||
keyPrefix: string;
|
||||
schema: FlowSchema;
|
||||
sources: Sources;
|
||||
fieldOptions: { value: string; label: string }[];
|
||||
firstField: string;
|
||||
onChange: (next: FlowNode) => void;
|
||||
|
|
@ -304,11 +368,13 @@ function TodoFields({
|
|||
onChange={(field) => onChange({ ...node, to: { field } })}
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
<ValueField
|
||||
id={`${keyPrefix}-to-agent`}
|
||||
label="agent"
|
||||
value={node.to.agent}
|
||||
onInput={(agent) => onChange({ ...node, to: { agent } })}
|
||||
spec={kindSpec(schema, "todo", "to")}
|
||||
sources={sources}
|
||||
onChange={(agent) => onChange({ ...node, to: { agent } })}
|
||||
/>
|
||||
)}
|
||||
<TextField
|
||||
|
|
|
|||
148
frontend/packages/swarm-ui/test/flow-choices.test.mjs
Normal file
148
frontend/packages/swarm-ui/test/flow-choices.test.mjs
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
// What the flow editor offers for a value: dropdown, suggestions or plain
|
||||
// text, from the server's schema. Run by `npm test`.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
eventSpec,
|
||||
kindSpec,
|
||||
markersIn,
|
||||
offerFor,
|
||||
} from "../src/pages/notifications/FlowChoices.ts";
|
||||
|
||||
const d = (name, description = "") => ({ name, description });
|
||||
|
||||
const schema = {
|
||||
source_events: [d("issues")],
|
||||
fields: [
|
||||
{
|
||||
...d("kind"),
|
||||
values: [d("issue"), d("PR", "A pull request")],
|
||||
closed: true,
|
||||
},
|
||||
{ ...d("action"), values: [d("opened"), d("closed")], closed: false },
|
||||
{ ...d("repo"), values: [], values_from: "repos", closed: false },
|
||||
{ ...d("title"), values: [] },
|
||||
],
|
||||
node_kinds: [
|
||||
{
|
||||
kind: "todo",
|
||||
description: "",
|
||||
fields: [
|
||||
{
|
||||
...d("to"),
|
||||
required: true,
|
||||
values: [],
|
||||
values_from: "agents",
|
||||
closed: true,
|
||||
},
|
||||
{
|
||||
...d("op"),
|
||||
required: true,
|
||||
values: [d("upsert"), d("clear")],
|
||||
closed: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "request_reviewer",
|
||||
description: "",
|
||||
fields: [
|
||||
{
|
||||
...d("reviewer"),
|
||||
required: true,
|
||||
values: [],
|
||||
values_from: "agents",
|
||||
closed: false,
|
||||
},
|
||||
{
|
||||
...d("later"),
|
||||
required: false,
|
||||
values: [],
|
||||
values_from: "somewhere-new",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
limits: { max_nodes: 1, max_depth: 1, max_flows: 1 },
|
||||
};
|
||||
|
||||
test("a field with a complete list is a dropdown, its labels carrying the description", () => {
|
||||
const offer = offerFor(eventSpec(schema, "kind"), {}, "PR");
|
||||
assert.equal(offer.closed, true);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["issue", "PR"],
|
||||
);
|
||||
assert.equal(offer.options[1].label, "PR — A pull request");
|
||||
});
|
||||
|
||||
test("a list that is only examples stays a text box with suggestions", () => {
|
||||
const offer = offerFor(eventSpec(schema, "action"), {}, "");
|
||||
assert.equal(offer.closed, false);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["opened", "closed"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a field with no known values is plain text", () => {
|
||||
assert.equal(offerFor(eventSpec(schema, "title"), {}, ""), null);
|
||||
assert.equal(offerFor(undefined, {}, ""), null, "no schema entry at all");
|
||||
});
|
||||
|
||||
test("a list fetched from the swarm fills the options, and a closed one becomes a dropdown", () => {
|
||||
const spec = kindSpec(schema, "todo", "to");
|
||||
const offer = offerFor(spec, { agents: ["atlas", "iris"] }, "iris");
|
||||
assert.equal(offer.closed, true);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["atlas", "iris"],
|
||||
);
|
||||
});
|
||||
|
||||
test("until its list arrives, or when it cannot be fetched, there is nothing to offer", () => {
|
||||
const spec = kindSpec(schema, "todo", "to");
|
||||
assert.equal(offerFor(spec, {}, ""), null);
|
||||
// Open lists with names fetched are suggestions: any login still goes.
|
||||
const reviewer = kindSpec(schema, "request_reviewer", "reviewer");
|
||||
const offer = offerFor(reviewer, { agents: ["argus"] }, "review-classifier");
|
||||
assert.equal(offer.closed, false);
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["argus"],
|
||||
);
|
||||
});
|
||||
|
||||
test("a dropdown whose source has not arrived falls back to suggestions", () => {
|
||||
const spec = {
|
||||
values: [d("a")],
|
||||
values_from: "agents",
|
||||
closed: true,
|
||||
};
|
||||
assert.equal(offerFor(spec, {}, "").closed, false);
|
||||
assert.equal(offerFor(spec, { agents: ["b"] }, "").closed, true);
|
||||
});
|
||||
|
||||
test("a value the list lacks stays selectable, marked as not listed", () => {
|
||||
const spec = kindSpec(schema, "todo", "to");
|
||||
const offer = offerFor(spec, { agents: ["atlas"] }, "gone");
|
||||
assert.equal(offer.options[0].value, "gone");
|
||||
assert.equal(offer.options[0].label, "gone (not listed)");
|
||||
assert.equal(offer.options.length, 2);
|
||||
// Nothing set yet adds nothing.
|
||||
assert.equal(offerFor(spec, { agents: ["atlas"] }, "").options.length, 1);
|
||||
});
|
||||
|
||||
test("a name in both the fixed values and the fetched list is offered once", () => {
|
||||
const spec = { values: [d("x")], values_from: "agents" };
|
||||
const offer = offerFor(spec, { agents: ["x", "y"] }, "");
|
||||
assert.deepEqual(
|
||||
offer.options.map((o) => o.value),
|
||||
["x", "y"],
|
||||
);
|
||||
});
|
||||
|
||||
test("the markers to fetch are the ones the schema names and the editor knows, once each", () => {
|
||||
assert.deepEqual(markersIn(schema), ["agents", "repos"]);
|
||||
assert.deepEqual(markersIn({ ...schema, fields: [], node_kinds: [] }), []);
|
||||
});
|
||||
Loading…
Reference in a new issue