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
|
|
@ -102,12 +102,21 @@ from the constants the check reads, so an editor offers only what a `PUT`
|
||||||
accepts:
|
accepts:
|
||||||
|
|
||||||
- `source_events`: `[{name, description}]`, the source events.
|
- `source_events`: `[{name, description}]`, the source events.
|
||||||
- `fields`: `[{name, description}]`, the condition and recipient fields, and
|
- `fields`: `[{name, description, values, values_from?, closed}]`, the condition
|
||||||
the `{field}` placeholders.
|
and recipient fields, and the `{field}` placeholders. `values` lists what the
|
||||||
|
field holds, as far as the controller knows, as `{name, description}` pairs, such as the `issue` and
|
||||||
|
`PR` of `kind`.
|
||||||
- `node_kinds`: `[{kind, description, fields}]` for `source`, `match`, `todo`
|
- `node_kinds`: `[{kind, description, fields}]` for `source`, `match`, `todo`
|
||||||
and `request_reviewer`; each field is `{name, description, required, values}`, and `values`
|
and `request_reviewer`; each field is
|
||||||
|
`{name, description, required, values, values_from?, closed}`, and `values`
|
||||||
lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a
|
lists the accepted `{name, description}` pairs (the `upsert` and `clear` of a
|
||||||
todo's `op`), empty for other fields.
|
todo's `op`), empty for other fields.
|
||||||
|
- `values_from` on a field says where an editor asks for values that are only
|
||||||
|
known live: `agents` is the names `GET /api/agents` lists, `repos` the ones
|
||||||
|
`GET /api/repos` lists. `closed` says `values` and that source are every value
|
||||||
|
the field takes, which an editor can show as a dropdown. When false, they
|
||||||
|
are suggestions: the field still takes any other text, such as an `action` the
|
||||||
|
forge adds later or the login of a reviewer who is no agent.
|
||||||
- `limits`: `{max_nodes, max_depth, max_flows}`.
|
- `limits`: `{max_nodes, max_depth, max_flows}`.
|
||||||
|
|
||||||
A controller that predates `schema` leaves it out of the answer; a client reads
|
A controller that predates `schema` leaves it out of the answer; a client reads
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ the ends. Pick a node to edit it in the panel beside the canvas:
|
||||||
|
|
||||||
- a **source** picks the forge events the flow starts from;
|
- a **source** picks the forge events the flow starts from;
|
||||||
- a **match** lists conditions, each an event field and the value it must equal;
|
- a **match** lists conditions, each an event field and the value it must equal;
|
||||||
- a **todo** says who gets it (the agents an event field names, or one agent),
|
- a **todo** says who gets it (the agents an event field names, or one agent, picked from the agents the swarm has),
|
||||||
whether it sets or clears a todo, the todo's key, and its text. The key and the
|
whether it sets or clears a todo, the todo's key, and its text. The key and the
|
||||||
text can use `{field}` placeholders;
|
text can use `{field}` placeholders;
|
||||||
- a **request_reviewer** names the forge login the router asks to review the
|
- a **request_reviewer** names the forge login the router asks to review the
|
||||||
|
|
@ -296,6 +296,16 @@ editor offers to reload and drop your edits, or to keep them and replace this
|
||||||
flow's latest version with them. For any other refusal the editor shows the
|
flow's latest version with them. For any other refusal the editor shows the
|
||||||
controller's own message. The router runs a saved flow from its next event.
|
controller's own message. The router runs a saved flow from its next event.
|
||||||
|
|
||||||
|
Where the controller's schema knows the values, the editor offers them instead of
|
||||||
|
asking you to type a name: `kind` and `event` conditions and the todo's `op` are
|
||||||
|
dropdowns, `action` and `repo` conditions and a reviewer are boxes that list the
|
||||||
|
known values to pick from and still take any other text, and a todo's agent is a
|
||||||
|
dropdown of the swarm's agents. A value set in a flow that the list no longer
|
||||||
|
holds stays selected, marked "not listed." If a list fails to load, or the
|
||||||
|
controller predates the schema, the box is plain text. The values come from the
|
||||||
|
controller's schema ([notify-router](notify-router.md)), so a new one shows up
|
||||||
|
without a UI change.
|
||||||
|
|
||||||
**preview** runs the draft, saved or not, over the newest forge events and
|
**preview** runs the draft, saved or not, over the newest forge events and
|
||||||
shows on each box how many of them reached it, as a dry run does for the saved
|
shows on each box how many of them reached it, as a dry run does for the saved
|
||||||
flows in the list. It needs no save, so you can try a half-built flow. The
|
flows in the list. It needs no save, so you can try a half-built flow. The
|
||||||
|
|
|
||||||
|
|
@ -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";
|
} from "./FlowDraft.js";
|
||||||
import { useLeaveGuard } from "./useLeaveGuard.js";
|
import { useLeaveGuard } from "./useLeaveGuard.js";
|
||||||
import { NodeInspector } from "./NodeInspector.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 { FALLBACK_SCHEMA, type Flow, type FlowsView } from "./FlowTypes.js";
|
||||||
import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js";
|
import { DRY_RUN_URL, hitsOf, type DryRun } from "./RouterLive.js";
|
||||||
import "./NotificationsPage.css";
|
import "./NotificationsPage.css";
|
||||||
|
|
@ -85,6 +86,7 @@ export function FlowEditorPage({
|
||||||
dry: DryRun;
|
dry: DryRun;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [previewError, setPreviewError] = useState<ProblemDetails | null>(null);
|
const [previewError, setPreviewError] = useState<ProblemDetails | null>(null);
|
||||||
|
const [sources, setSources] = useState<Sources>({});
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
|
|
@ -121,6 +123,19 @@ export function FlowEditorPage({
|
||||||
void load(false);
|
void load(false);
|
||||||
}, [load]);
|
}, [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 =
|
const dirty =
|
||||||
view !== null &&
|
view !== null &&
|
||||||
draft !== null &&
|
draft !== null &&
|
||||||
|
|
@ -377,6 +392,7 @@ export function FlowEditorPage({
|
||||||
node={node}
|
node={node}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
limits={limits}
|
limits={limits}
|
||||||
|
sources={sources}
|
||||||
onEdit={onEdit}
|
onEdit={onEdit}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -83,9 +83,19 @@ export interface Described {
|
||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KindField extends Described {
|
// What a field can hold: `values` it is known to take, and `values_from`, a
|
||||||
required: boolean;
|
// 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: Described[];
|
||||||
|
values_from?: string | null;
|
||||||
|
closed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventField extends Described, Choices {}
|
||||||
|
|
||||||
|
export interface KindField extends Described, Choices {
|
||||||
|
required: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KindSchema {
|
export interface KindSchema {
|
||||||
|
|
@ -96,7 +106,7 @@ export interface KindSchema {
|
||||||
|
|
||||||
export interface FlowSchema {
|
export interface FlowSchema {
|
||||||
source_events: Described[];
|
source_events: Described[];
|
||||||
fields: Described[];
|
fields: EventField[];
|
||||||
node_kinds: KindSchema[];
|
node_kinds: KindSchema[];
|
||||||
limits: { max_nodes: number; max_depth: number; max_flows: number };
|
limits: { max_nodes: number; max_depth: number; max_flows: number };
|
||||||
}
|
}
|
||||||
|
|
@ -123,7 +133,7 @@ export const FALLBACK_SCHEMA: FlowSchema = {
|
||||||
"assignees_added",
|
"assignees_added",
|
||||||
"assignees_removed",
|
"assignees_removed",
|
||||||
"todo_holders",
|
"todo_holders",
|
||||||
].map((name) => ({ name, description: "" })),
|
].map((name) => ({ name, description: "", values: [] })),
|
||||||
node_kinds: [],
|
node_kinds: [],
|
||||||
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
|
limits: { max_nodes: 64, max_depth: 16, max_flows: 100 },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@ import {
|
||||||
type Limits,
|
type Limits,
|
||||||
withOp,
|
withOp,
|
||||||
} from "./FlowDraft.js";
|
} from "./FlowDraft.js";
|
||||||
|
import {
|
||||||
|
eventSpec,
|
||||||
|
kindSpec,
|
||||||
|
offerFor,
|
||||||
|
type Sources,
|
||||||
|
type ValueSpec,
|
||||||
|
} from "./FlowChoices.js";
|
||||||
import {
|
import {
|
||||||
kindValues,
|
kindValues,
|
||||||
ordinal,
|
ordinal,
|
||||||
|
|
@ -36,6 +43,47 @@ const OPS_FALLBACK = ["upsert", "clear"];
|
||||||
// The kinds this editor has fields for.
|
// The kinds this editor has fields for.
|
||||||
const KNOWN_KINDS: string[] = ["source", "match", "todo", "request_reviewer"];
|
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[]) {
|
function options(list: Described[]) {
|
||||||
return list.map((d) => ({
|
return list.map((d) => ({
|
||||||
value: d.name,
|
value: d.name,
|
||||||
|
|
@ -48,12 +96,15 @@ export function NodeInspector({
|
||||||
node,
|
node,
|
||||||
schema,
|
schema,
|
||||||
limits,
|
limits,
|
||||||
|
sources,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
flow: Flow;
|
flow: Flow;
|
||||||
node: FlowNode;
|
node: FlowNode;
|
||||||
schema: FlowSchema;
|
schema: FlowSchema;
|
||||||
limits: Limits;
|
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.
|
// `select` is the node to pick once the edit is applied.
|
||||||
onEdit: (edit: Edit, select?: string) => void;
|
onEdit: (edit: Edit, select?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -111,6 +162,8 @@ export function NodeInspector({
|
||||||
<MatchFields
|
<MatchFields
|
||||||
node={node}
|
node={node}
|
||||||
keyPrefix={key}
|
keyPrefix={key}
|
||||||
|
schema={schema}
|
||||||
|
sources={sources}
|
||||||
fieldOptions={fieldOptions}
|
fieldOptions={fieldOptions}
|
||||||
firstField={firstField}
|
firstField={firstField}
|
||||||
onChange={set}
|
onChange={set}
|
||||||
|
|
@ -122,6 +175,7 @@ export function NodeInspector({
|
||||||
node={node}
|
node={node}
|
||||||
keyPrefix={key}
|
keyPrefix={key}
|
||||||
schema={schema}
|
schema={schema}
|
||||||
|
sources={sources}
|
||||||
fieldOptions={fieldOptions}
|
fieldOptions={fieldOptions}
|
||||||
firstField={firstField}
|
firstField={firstField}
|
||||||
onChange={set}
|
onChange={set}
|
||||||
|
|
@ -131,11 +185,13 @@ export function NodeInspector({
|
||||||
{node.kind === "request_reviewer" ? (
|
{node.kind === "request_reviewer" ? (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>asks for a review</legend>
|
<legend>asks for a review</legend>
|
||||||
<TextField
|
<ValueField
|
||||||
id={`${key}-reviewer`}
|
id={`${key}-reviewer`}
|
||||||
label="reviewer (a forge login)"
|
label="reviewer (a forge login)"
|
||||||
value={node.reviewer}
|
value={node.reviewer}
|
||||||
onInput={(reviewer) => set({ ...node, reviewer })}
|
spec={kindSpec(schema, "request_reviewer", "reviewer")}
|
||||||
|
sources={sources}
|
||||||
|
onChange={(reviewer) => set({ ...node, reviewer })}
|
||||||
/>
|
/>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -198,12 +254,16 @@ export function NodeInspector({
|
||||||
function MatchFields({
|
function MatchFields({
|
||||||
node,
|
node,
|
||||||
keyPrefix,
|
keyPrefix,
|
||||||
|
schema,
|
||||||
|
sources,
|
||||||
fieldOptions,
|
fieldOptions,
|
||||||
firstField,
|
firstField,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
node: Extract<FlowNode, { kind: "match" }>;
|
node: Extract<FlowNode, { kind: "match" }>;
|
||||||
keyPrefix: string;
|
keyPrefix: string;
|
||||||
|
schema: FlowSchema;
|
||||||
|
sources: Sources;
|
||||||
fieldOptions: { value: string; label: string }[];
|
fieldOptions: { value: string; label: string }[];
|
||||||
firstField: string;
|
firstField: string;
|
||||||
onChange: (next: FlowNode) => void;
|
onChange: (next: FlowNode) => void;
|
||||||
|
|
@ -224,11 +284,13 @@ function MatchFields({
|
||||||
setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x)))
|
setWhen(node.when.map((x, j) => (j === i ? { ...x, field } : x)))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<ValueField
|
||||||
id={`${keyPrefix}-c${i}-equals`}
|
id={`${keyPrefix}-c${i}-equals`}
|
||||||
label="equals"
|
label="equals"
|
||||||
value={c.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)))
|
setWhen(node.when.map((x, j) => (j === i ? { ...x, equals } : x)))
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -252,6 +314,7 @@ function TodoFields({
|
||||||
node,
|
node,
|
||||||
keyPrefix,
|
keyPrefix,
|
||||||
schema,
|
schema,
|
||||||
|
sources,
|
||||||
fieldOptions,
|
fieldOptions,
|
||||||
firstField,
|
firstField,
|
||||||
onChange,
|
onChange,
|
||||||
|
|
@ -259,6 +322,7 @@ function TodoFields({
|
||||||
node: TodoNode;
|
node: TodoNode;
|
||||||
keyPrefix: string;
|
keyPrefix: string;
|
||||||
schema: FlowSchema;
|
schema: FlowSchema;
|
||||||
|
sources: Sources;
|
||||||
fieldOptions: { value: string; label: string }[];
|
fieldOptions: { value: string; label: string }[];
|
||||||
firstField: string;
|
firstField: string;
|
||||||
onChange: (next: FlowNode) => void;
|
onChange: (next: FlowNode) => void;
|
||||||
|
|
@ -304,11 +368,13 @@ function TodoFields({
|
||||||
onChange={(field) => onChange({ ...node, to: { field } })}
|
onChange={(field) => onChange({ ...node, to: { field } })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<ValueField
|
||||||
id={`${keyPrefix}-to-agent`}
|
id={`${keyPrefix}-to-agent`}
|
||||||
label="agent"
|
label="agent"
|
||||||
value={node.to.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
|
<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: [] }), []);
|
||||||
|
});
|
||||||
|
|
@ -358,6 +358,48 @@ pub struct Described {
|
||||||
pub description: String,
|
pub description: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `values_from` marker: the names of the agents, as `GET /api/agents` lists
|
||||||
|
/// them.
|
||||||
|
pub const VALUES_FROM_AGENTS: &str = "agents";
|
||||||
|
|
||||||
|
/// `values_from` marker: the repositories, as `GET /api/repos` lists them.
|
||||||
|
pub const VALUES_FROM_REPOS: &str = "repos";
|
||||||
|
|
||||||
|
/// The `kind` field's values: what the router puts there, which is not the
|
||||||
|
/// words of the field's description.
|
||||||
|
const KINDS: &[(&str, &str)] = &[("issue", "An issue"), ("PR", "A pull request")];
|
||||||
|
|
||||||
|
/// Some of what the forge sends as `action`. Others exist, so it is a list of
|
||||||
|
/// suggestions only.
|
||||||
|
const ACTIONS: &[(&str, &str)] = &[
|
||||||
|
("opened", "Created"),
|
||||||
|
("closed", "Closed, or a pull request merged"),
|
||||||
|
("reopened", "Opened again after being closed"),
|
||||||
|
("edited", "Title or text changed"),
|
||||||
|
("assigned", "An assignee added"),
|
||||||
|
("unassigned", "An assignee removed"),
|
||||||
|
("synchronized", "New commits pushed to a pull request"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// One event field a condition or recipient can name, and what it can hold.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
|
pub struct EventField {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
/// Values the field is known to hold; empty when none are listed.
|
||||||
|
pub values: Vec<Described>,
|
||||||
|
/// Where an editor asks for the values only known live: one of
|
||||||
|
/// [`VALUES_FROM_AGENTS`], [`VALUES_FROM_REPOS`]. Absent when there is
|
||||||
|
/// none.
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub values_from: Option<String>,
|
||||||
|
/// Whether `values` or the source is every value the field can hold,
|
||||||
|
/// which an editor can show as a dropdown. When false they are
|
||||||
|
/// suggestions and the editor still takes any other text.
|
||||||
|
pub closed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// One field of a node kind.
|
/// One field of a node kind.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
|
|
@ -369,6 +411,13 @@ pub struct KindField {
|
||||||
/// The accepted values when the field takes one of a fixed few; empty
|
/// The accepted values when the field takes one of a fixed few; empty
|
||||||
/// otherwise.
|
/// otherwise.
|
||||||
pub values: Vec<Described>,
|
pub values: Vec<Described>,
|
||||||
|
/// Where an editor asks for the values only known live, as
|
||||||
|
/// [`EventField::values_from`].
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub values_from: Option<String>,
|
||||||
|
/// Whether the values are every one the field accepts, as
|
||||||
|
/// [`EventField::closed`].
|
||||||
|
pub closed: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One kind of node and the fields it takes besides `id` and `kind`.
|
/// One kind of node and the fields it takes besides `id` and `kind`.
|
||||||
|
|
@ -395,7 +444,7 @@ pub struct Limits {
|
||||||
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
#[cfg_attr(feature = "schema", derive(utoipa::ToSchema))]
|
||||||
pub struct Schema {
|
pub struct Schema {
|
||||||
pub source_events: Vec<Described>,
|
pub source_events: Vec<Described>,
|
||||||
pub fields: Vec<Described>,
|
pub fields: Vec<EventField>,
|
||||||
pub node_kinds: Vec<KindSchema>,
|
pub node_kinds: Vec<KindSchema>,
|
||||||
pub limits: Limits,
|
pub limits: Limits,
|
||||||
}
|
}
|
||||||
|
|
@ -415,17 +464,56 @@ fn kind_field(name: &str, description: &str, required: bool) -> KindField {
|
||||||
description: description.to_owned(),
|
description: description.to_owned(),
|
||||||
required,
|
required,
|
||||||
values: Vec::new(),
|
values: Vec::new(),
|
||||||
|
values_from: None,
|
||||||
|
closed: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// [`FIELDS`] with what each is known to hold.
|
||||||
|
fn event_fields() -> Vec<EventField> {
|
||||||
|
FIELDS
|
||||||
|
.iter()
|
||||||
|
.map(|(name, description)| {
|
||||||
|
let (values, values_from, closed) = match *name {
|
||||||
|
"event" => (described(SOURCE_EVENTS), None, true),
|
||||||
|
"kind" => (described(KINDS), None, true),
|
||||||
|
"action" => (described(ACTIONS), None, false),
|
||||||
|
"repo" => (Vec::new(), Some(VALUES_FROM_REPOS), false),
|
||||||
|
"assignees" | "assignees_added" | "assignees_removed" | "todo_holders" => {
|
||||||
|
(Vec::new(), Some(VALUES_FROM_AGENTS), false)
|
||||||
|
}
|
||||||
|
_ => (Vec::new(), None, false),
|
||||||
|
};
|
||||||
|
EventField {
|
||||||
|
name: (*name).to_owned(),
|
||||||
|
description: (*description).to_owned(),
|
||||||
|
values,
|
||||||
|
values_from: values_from.map(str::to_owned),
|
||||||
|
closed,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The schema of this build's flows.
|
/// The schema of this build's flows.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn schema() -> Schema {
|
pub fn schema() -> Schema {
|
||||||
let mut op = kind_field("op", "What the todo does to the recipient's list", true);
|
let mut op = kind_field("op", "What the todo does to the recipient's list", true);
|
||||||
op.values = described(OPS);
|
op.values = described(OPS);
|
||||||
|
op.closed = true;
|
||||||
|
let mut to = kind_field(
|
||||||
|
"to",
|
||||||
|
"The recipients: {field: <event field>} or {agent: <agent name>}",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
to.values_from = Some(VALUES_FROM_AGENTS.to_owned());
|
||||||
|
to.closed = true;
|
||||||
|
let mut reviewer = kind_field("reviewer", "The forge login to request a review from", true);
|
||||||
|
// A login need not belong to an agent: the classifier's does not.
|
||||||
|
reviewer.values_from = Some(VALUES_FROM_AGENTS.to_owned());
|
||||||
Schema {
|
Schema {
|
||||||
source_events: described(SOURCE_EVENTS),
|
source_events: described(SOURCE_EVENTS),
|
||||||
fields: described(FIELDS),
|
fields: event_fields(),
|
||||||
node_kinds: vec![
|
node_kinds: vec![
|
||||||
KindSchema {
|
KindSchema {
|
||||||
kind: "source".to_owned(),
|
kind: "source".to_owned(),
|
||||||
|
|
@ -449,11 +537,7 @@ pub fn schema() -> Schema {
|
||||||
kind: "todo".to_owned(),
|
kind: "todo".to_owned(),
|
||||||
description: "A leaf: emits one todo op per recipient".to_owned(),
|
description: "A leaf: emits one todo op per recipient".to_owned(),
|
||||||
fields: vec![
|
fields: vec![
|
||||||
kind_field(
|
to,
|
||||||
"to",
|
|
||||||
"The recipients: {field: <event field>} or {agent: <agent name>}",
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
op,
|
op,
|
||||||
kind_field("key", "The todo key, with {field} placeholders", true),
|
kind_field("key", "The todo key, with {field} placeholders", true),
|
||||||
kind_field("prefix", "For a clear: key is a prefix", false),
|
kind_field("prefix", "For a clear: key is a prefix", false),
|
||||||
|
|
@ -467,11 +551,7 @@ pub fn schema() -> Schema {
|
||||||
KindSchema {
|
KindSchema {
|
||||||
kind: "request_reviewer".to_owned(),
|
kind: "request_reviewer".to_owned(),
|
||||||
description: "A leaf: asks the forge for a review of the pull request the event concerns, unless the reviewer's review of its head already stands; does nothing for an issue".to_owned(),
|
description: "A leaf: asks the forge for a review of the pull request the event concerns, unless the reviewer's review of its head already stands; does nothing for an issue".to_owned(),
|
||||||
fields: vec![kind_field(
|
fields: vec![reviewer],
|
||||||
"reviewer",
|
|
||||||
"The forge login to request a review from",
|
|
||||||
true,
|
|
||||||
)],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
limits: Limits {
|
limits: Limits {
|
||||||
|
|
@ -906,6 +986,48 @@ mod tests {
|
||||||
assert_eq!(listed.len(), 2);
|
assert_eq!(listed.len(), 2);
|
||||||
assert!(op_names("nope").is_none(), "control");
|
assert!(op_names("nope").is_none(), "control");
|
||||||
|
|
||||||
|
// What an editor offers for a field is what the router puts there.
|
||||||
|
let field = |name: &str| s.fields.iter().find(|f| f.name == name).expect(name);
|
||||||
|
let names =
|
||||||
|
|f: &EventField| -> Vec<String> { f.values.iter().map(|v| v.name.clone()).collect() };
|
||||||
|
assert_eq!(names(field("kind")), ["issue", "PR"]);
|
||||||
|
assert!(field("kind").closed);
|
||||||
|
let events: Vec<String> = s.source_events.iter().map(|e| e.name.clone()).collect();
|
||||||
|
assert_eq!(names(field("event")), events);
|
||||||
|
assert!(field("event").closed);
|
||||||
|
assert!(!field("action").closed, "the forge may send more actions");
|
||||||
|
assert!(names(field("action")).contains(&"closed".to_owned()));
|
||||||
|
assert_eq!(
|
||||||
|
field("repo").values_from.as_deref(),
|
||||||
|
Some(VALUES_FROM_REPOS)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
field("assignees").values_from.as_deref(),
|
||||||
|
Some(VALUES_FROM_AGENTS)
|
||||||
|
);
|
||||||
|
assert!(field("title").values.is_empty() && field("title").values_from.is_none());
|
||||||
|
// A marker is one an editor knows to resolve.
|
||||||
|
for f in &s.fields {
|
||||||
|
if let Some(from) = &f.values_from {
|
||||||
|
assert!([VALUES_FROM_AGENTS, VALUES_FROM_REPOS].contains(&from.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let kind_field_of = |kind: &str, name: &str| {
|
||||||
|
s.node_kinds
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.kind == kind)
|
||||||
|
.and_then(|k| k.fields.iter().find(|f| f.name == name))
|
||||||
|
.expect(name)
|
||||||
|
};
|
||||||
|
assert!(kind_field_of("todo", "op").closed);
|
||||||
|
assert_eq!(
|
||||||
|
kind_field_of("todo", "to").values_from.as_deref(),
|
||||||
|
Some(VALUES_FROM_AGENTS)
|
||||||
|
);
|
||||||
|
let reviewer = kind_field_of("request_reviewer", "reviewer");
|
||||||
|
assert_eq!(reviewer.values_from.as_deref(), Some(VALUES_FROM_AGENTS));
|
||||||
|
assert!(!reviewer.closed, "any forge login is allowed");
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows),
|
(s.limits.max_nodes, s.limits.max_depth, s.limits.max_flows),
|
||||||
(MAX_NODES, MAX_DEPTH, MAX_FLOWS)
|
(MAX_NODES, MAX_DEPTH, MAX_FLOWS)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue