fix: route multi-outcome capability nodes

This commit is contained in:
lda
2026-08-09 16:18:05 +07:00 Verified
parent 98861a7fc2
commit 906b676993
3 changed files with 57 additions and 0 deletions
@@ -50,4 +50,27 @@ describe("CapabilityNodeForm", () => {
expect(dirtyStates.at(-1)).toBe(true);
expect(stepId).toHaveValue("enrich");
});
it("submits an explicit target for every declared capability outcome", async () => {
const user = userEvent.setup();
const submissions: unknown[] = [];
render(
<CapabilityNodeForm
capabilityName="everything.default.echo"
inputSchema={{ type: "object", properties: {} }}
onSubmit={(value) => { submissions.push(value); }}
routeOutcomes={["ok", "error"]}
/>,
);
await user.type(screen.getByRole("textbox", { name: "Step id" }), "echo");
expect(screen.getByRole("textbox", { name: "Route target for ok" })).toHaveValue("__end__");
await user.clear(screen.getByRole("textbox", { name: "Route target for error" }));
await user.type(screen.getByRole("textbox", { name: "Route target for error" }), "recover");
await user.click(screen.getByRole("button", { name: "Add node" }));
expect(submissions[0]).toMatchObject({
routes: { ok: "__end__", error: "recover" },
});
});
});
@@ -34,6 +34,7 @@ export type CapabilityNodeFormProps = {
readonly onDirtyChange?: (dirty: boolean) => void;
readonly submitLabel?: string;
readonly stepIdReadOnly?: boolean;
readonly routeOutcomes?: ReadonlyArray<string>;
readonly hidden?: boolean;
};
@@ -50,12 +51,14 @@ export const CapabilityNodeForm = ({
onDirtyChange,
submitLabel = "Add node",
stepIdReadOnly = false,
routeOutcomes = [],
hidden = false,
}: CapabilityNodeFormProps) => {
const stepIdRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLInputElement>(null);
const retryRef = useRef<HTMLInputElement>(null);
const timeoutSecondsRef = useRef<HTMLInputElement>(null);
const routeTargetRefs = useRef(new Map<string, HTMLInputElement>());
const dirtyRef = useRef(false);
const initialSchemaResult = useMemo(
() => serializeSchemaValues(
@@ -86,6 +89,16 @@ export const CapabilityNodeForm = ({
...result.bindings,
...result.literalBindings,
],
...(routeOutcomes.length > 0
? {
routes: Object.fromEntries(
routeOutcomes.map((outcome) => [
outcome,
routeTargetRefs.current.get(outcome)?.value.trim() || "__end__",
]),
),
}
: {}),
});
const notifyValueChange = (result: SchemaSerializationResult): void => {
@@ -183,6 +196,26 @@ export const CapabilityNodeForm = ({
/>
{metadataMessage("timeout_seconds") && <p role="alert">{metadataMessage("timeout_seconds")}</p>}
</label>
{routeOutcomes.length > 0 && (
<fieldset className="authoring-form__routes">
<legend>Outcome routes</legend>
<p>Choose where each declared outcome continues. New routes default to the workflow end.</p>
{routeOutcomes.map((outcome) => (
<label key={outcome}>
{outcome}
<input
aria-label={`Route target for ${outcome}`}
defaultValue={initialValue?.routes?.[outcome] ?? "__end__"}
onChange={notifyMetadataChange}
ref={(element) => {
if (element === null) routeTargetRefs.current.delete(outcome);
else routeTargetRefs.current.set(outcome, element);
}}
/>
</label>
))}
</fieldset>
)}
</>
}
submitLabel={submitLabel}
@@ -291,6 +291,7 @@ export const ContextInspector = ({
onDirtyChange={controller.markDirty}
onSubmit={controller.addCapability}
onValueChange={(value) => controller.rememberCapabilityForm("add", value)}
routeOutcomes={capabilityDetail.outcomes}
/>
)}
</>