an archetype flow can be customized for one agent
This commit is contained in:
parent
f2e735a98f
commit
5443c1c9ba
5 changed files with 153 additions and 34 deletions
|
|
@ -370,21 +370,25 @@ function savedIds(view: FlowsView): string[] {
|
|||
// The draft the editor opens for a flow not saved yet: an empty one, or a copy
|
||||
// of the built-in, custom or archetype flow `from` (an archetype flow by its key). 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 {
|
||||
export function seedFlow(
|
||||
view: FlowsView,
|
||||
from: string | null,
|
||||
agent: string | null = null,
|
||||
): Flow | null {
|
||||
const taken = savedIds(view);
|
||||
if (from === null) return { ...newFlow("", taken), id: NEW_FLOW_ID };
|
||||
const archetype = (view.archetype_flows ?? []).find((a) => a.key === from);
|
||||
// An archetype's "members" has no meaning in the operator's document: the
|
||||
// copy sends to the one member there is, or to nobody until one is named.
|
||||
// An archetype's "members" has no meaning in the operator's document. A copy
|
||||
// for one `agent` sends to it; a copy for all sends to the one member there
|
||||
// is, or to nobody until one is named, which a save refuses.
|
||||
const who =
|
||||
agent ?? (archetype?.members.length === 1 ? archetype.members[0] : "");
|
||||
const source = archetype
|
||||
? withMembersAs(
|
||||
archetype.flow,
|
||||
archetype.members.length === 1 ? archetype.members[0] : "",
|
||||
)
|
||||
? withMembersAs(archetype.flow, who)
|
||||
: [...view.builtins, ...view.flows].find((f) => f.id === from);
|
||||
if (!source) return null;
|
||||
return {
|
||||
...duplicateFlow(source, `${source.name} (copy)`, taken),
|
||||
...duplicateFlow(source, `${source.name} (${agent ?? "copy"})`, taken),
|
||||
id: NEW_FLOW_ID,
|
||||
};
|
||||
}
|
||||
|
|
@ -474,3 +478,16 @@ export function withMembersAs(flow: Flow, who: string): Flow {
|
|||
),
|
||||
};
|
||||
}
|
||||
|
||||
// The disabled list a save of a draft goes out with: when the draft is the
|
||||
// archetype flow `from` customized for `agent`, with that agent's switch-off
|
||||
// added, so the agent gets the copy and not both. Otherwise `disabled` as is.
|
||||
export function disabledOnSave(
|
||||
disabled: string[],
|
||||
from: string | null,
|
||||
agent: string | null,
|
||||
): string[] {
|
||||
if (from === null || agent === null) return disabled;
|
||||
const key = `${from}@${agent}`;
|
||||
return disabled.includes(key) ? disabled : [...disabled, key].sort();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import {
|
|||
saveOutcome,
|
||||
previewRequest,
|
||||
saveRequest,
|
||||
disabledOnSave,
|
||||
seedFlow,
|
||||
parentOf,
|
||||
withFlow,
|
||||
|
|
@ -70,7 +71,10 @@ export function FlowEditorPage({
|
|||
isNew?: boolean;
|
||||
}) {
|
||||
const flowId = decodeURIComponent(params.flowId);
|
||||
const from = new URLSearchParams(useSearch()).get("from");
|
||||
const search = new URLSearchParams(useSearch());
|
||||
const from = search.get("from");
|
||||
// With `from` an archetype flow: the one agent this copy is for.
|
||||
const agent = search.get("agent");
|
||||
const [, navigate] = useLocation();
|
||||
const [view, setView] = useState<FlowsView | null>(null);
|
||||
const [draft, setDraft] = useState<Flow | null>(null);
|
||||
|
|
@ -108,7 +112,7 @@ export function FlowEditorPage({
|
|||
setSaveError(null);
|
||||
if (!keepDraft) {
|
||||
const flow = isNew
|
||||
? seedFlow(next, from)
|
||||
? seedFlow(next, from, agent)
|
||||
: next.flows.find((f) => f.id === flowId);
|
||||
setSeed(isNew && flow ? structuredClone(flow) : null);
|
||||
setDraft(flow ? structuredClone(flow) : null);
|
||||
|
|
@ -120,7 +124,7 @@ export function FlowEditorPage({
|
|||
);
|
||||
}
|
||||
},
|
||||
[flowId, isNew, from],
|
||||
[flowId, isNew, from, agent],
|
||||
);
|
||||
useEffect(() => {
|
||||
void load(false);
|
||||
|
|
@ -150,16 +154,16 @@ export function FlowEditorPage({
|
|||
// tab, following a link or pressing back.
|
||||
const leaving = useLeaveGuard(dirty);
|
||||
|
||||
// Send `flows` as the document; true when the controller took it.
|
||||
async function put(flows: Flow[]): Promise<boolean> {
|
||||
// Send `flows` and `disabled` as the document; true when the controller took it.
|
||||
async function put(
|
||||
flows: Flow[],
|
||||
disabled: string[] = view?.disabled_builtins ?? [],
|
||||
): Promise<boolean> {
|
||||
if (!view) return false;
|
||||
setBusy(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const r = await fetch(
|
||||
FLOWS_URL,
|
||||
saveRequest(view, flows, view.disabled_builtins),
|
||||
);
|
||||
const r = await fetch(FLOWS_URL, saveRequest(view, flows, disabled));
|
||||
const outcome = saveOutcome(r.status);
|
||||
if (outcome === "saved") {
|
||||
const next = (await r.json()) as FlowsView;
|
||||
|
|
@ -220,7 +224,12 @@ export function FlowEditorPage({
|
|||
async function save() {
|
||||
if (!view || !draft) return;
|
||||
const flow = isNew ? savedAs(view, draft) : draft;
|
||||
if (!(await put(withFlow(view.flows, flow))) || !isNew) return;
|
||||
// The first save of a copy made for one agent also switches the
|
||||
// archetype's flow off for it, in the same write.
|
||||
const disabled = isNew
|
||||
? disabledOnSave(view.disabled_builtins, from, agent)
|
||||
: view.disabled_builtins;
|
||||
if (!(await put(withFlow(view.flows, flow), disabled)) || !isNew) return;
|
||||
leaving.release();
|
||||
navigate(`/notifications/${encodeURIComponent(flow.id)}`, {
|
||||
replace: true,
|
||||
|
|
|
|||
|
|
@ -289,3 +289,9 @@
|
|||
gap: 0.4em 1em;
|
||||
margin-top: 0.4em;
|
||||
}
|
||||
|
||||
.notify-flow-agent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,11 +100,14 @@ export function NotificationRouter() {
|
|||
}
|
||||
|
||||
// Open the editor on a draft: empty, or a copy of the flow `from`.
|
||||
const draftFrom = (from?: string) =>
|
||||
// `agent`: a copy of the archetype flow `from` for that one agent.
|
||||
const draftFrom = (from?: string, agent?: string) =>
|
||||
navigate(
|
||||
from === undefined
|
||||
? NEW_FLOW_PATH
|
||||
: `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}`,
|
||||
: `${NEW_FLOW_PATH}?from=${encodeURIComponent(from)}${
|
||||
agent === undefined ? "" : `&agent=${encodeURIComponent(agent)}`
|
||||
}`,
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
@ -191,20 +194,24 @@ export function NotificationRouter() {
|
|||
<div class="notify-flow-agents">
|
||||
<span>agents:</span>
|
||||
{af.members.map((agent) => (
|
||||
<CheckboxField
|
||||
key={agent}
|
||||
id={`archetype-${af.key}-${agent}`}
|
||||
label={agent}
|
||||
checked={
|
||||
!view.disabled_builtins.includes(
|
||||
memberKey(af.key, agent),
|
||||
)
|
||||
}
|
||||
disabled={pending}
|
||||
onChange={(checked) =>
|
||||
setBuiltin(memberKey(af.key, agent), checked)
|
||||
}
|
||||
/>
|
||||
<span key={agent} class="notify-flow-agent">
|
||||
<CheckboxField
|
||||
id={`archetype-${af.key}-${agent}`}
|
||||
label={agent}
|
||||
checked={
|
||||
!view.disabled_builtins.includes(
|
||||
memberKey(af.key, agent),
|
||||
)
|
||||
}
|
||||
disabled={pending}
|
||||
onChange={(checked) =>
|
||||
setBuiltin(memberKey(af.key, agent), checked)
|
||||
}
|
||||
/>
|
||||
<Button onClick={() => draftFrom(af.key, agent)}>
|
||||
customize {agent}
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<FlowGraph
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
blankNode,
|
||||
DEFAULT_LIMITS,
|
||||
depthOf,
|
||||
disabledOnSave,
|
||||
duplicateFlow,
|
||||
heightBelow,
|
||||
isDirty,
|
||||
|
|
@ -428,6 +429,55 @@ test("seedFlow copies an archetype flow by its key, sending to its one member",
|
|||
assert.ok(recipients(members).every((to) => to === "members"));
|
||||
});
|
||||
|
||||
test("seedFlow customizes an archetype flow for one agent, and the save adds that agent's switch-off", () => {
|
||||
const members = {
|
||||
...flow(),
|
||||
id: "new-issues",
|
||||
name: "New issues",
|
||||
nodes: flow().nodes.map((n) =>
|
||||
n.kind === "todo" ? { ...n, to: "members" } : n,
|
||||
),
|
||||
};
|
||||
const key = "archetype:triage/new-issues";
|
||||
const view = {
|
||||
revision: 1,
|
||||
disabled_builtins: ["closed"],
|
||||
builtins: [],
|
||||
flows: [],
|
||||
archetype_flows: [
|
||||
{ archetype: "triage", key, flow: members, members: ["alice", "bob"] },
|
||||
],
|
||||
};
|
||||
const todos = (f) => f.nodes.filter((n) => n.kind === "todo");
|
||||
const mine = seedFlow(view, key, "bob");
|
||||
assert.equal(mine.id, NEW_FLOW_ID);
|
||||
assert.equal(mine.name, "New issues (bob)");
|
||||
assert.ok(todos(mine).length > 0, "control: it has a todo");
|
||||
assert.ok(todos(mine).every((n) => n.to.agent === "bob"));
|
||||
// Without an agent, two members leave the recipient empty for the save to refuse.
|
||||
assert.ok(todos(seedFlow(view, key)).every((n) => n.to.agent === ""));
|
||||
// The save adds the one agent's key, sorted in, once; plain drafts add none.
|
||||
assert.deepEqual(disabledOnSave(["closed"], key, "bob"), [
|
||||
"archetype:triage/new-issues@bob",
|
||||
"closed",
|
||||
]);
|
||||
assert.deepEqual(
|
||||
disabledOnSave(["archetype:triage/new-issues@bob"], key, "bob"),
|
||||
["archetype:triage/new-issues@bob"],
|
||||
);
|
||||
assert.deepEqual(disabledOnSave(["closed"], key, null), ["closed"]);
|
||||
assert.deepEqual(disabledOnSave(["closed"], null, "bob"), ["closed"]);
|
||||
const sent = JSON.parse(
|
||||
saveRequest(
|
||||
view,
|
||||
[savedAs(view, mine)],
|
||||
disabledOnSave(view.disabled_builtins, key, "bob"),
|
||||
).body,
|
||||
);
|
||||
assert.deepEqual(sent.disabled_builtins, [key + "@bob", "closed"]);
|
||||
assert.equal(sent.flows[0].id, "new-issues-bob");
|
||||
});
|
||||
|
||||
test("savedAs takes the id from the name, clear of every saved id", () => {
|
||||
const view = {
|
||||
revision: 1,
|
||||
|
|
|
|||
Loading…
Reference in a new issue