fix: preserve colliding local authoring paths

This commit is contained in:
lda
2026-08-14 19:38:48 +07:00 Verified
parent 50a7fe3207
commit 22bcba3596
7 changed files with 113 additions and 23 deletions
@@ -16,6 +16,7 @@ export type AuthoringPathPickerProps = {
readonly allowCustom?: boolean;
readonly describedBy?: string | undefined;
readonly invalid?: boolean;
readonly selection?: AuthoringPathSelection;
};
type OptionGroup = {
@@ -50,6 +51,7 @@ export const AuthoringPathPicker = ({
allowCustom = false,
describedBy,
invalid = false,
selection = "catalog",
}: AuthoringPathPickerProps) => {
const id = safeId(useId());
const searchId = `${id}-search`;
@@ -57,7 +59,9 @@ export const AuthoringPathPicker = ({
const [search, setSearch] = useState("");
const [customValue, setCustomValue] = useState(value);
const [advancedOpen, setAdvancedOpen] = useState(
() => allowCustom && value.trim() !== "" && !options.some((option) => option.path === value),
() => allowCustom && value.trim() !== "" && (
selection === "custom" || !options.some((option) => option.path === value)
),
);
const requestedUses = normalizedUses(uses);
const normalizedSearch = search.trim().toLocaleLowerCase();
@@ -102,11 +106,12 @@ export const AuthoringPathPicker = ({
return (
<button
aria-describedby={description === "" ? undefined : reasonId}
aria-pressed={option.path === value}
aria-pressed={selection === "catalog" && option.path === value}
className="authoring-path-picker__option"
disabled={!compatible}
key={option.path}
onClick={() => {
if (option.path !== value) setAdvancedOpen(false);
setCustomValue(option.path);
onChange(option.path, "catalog");
}}
@@ -106,6 +106,27 @@ describe("StepInputBindingsForm", () => {
expect(submissions).toEqual([[{ path: "input.title", target: "step_input.text" }]]);
});
it("preserves a colliding local target when its matching catalog option is clicked", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<StepInputBinding>[] = [];
render(
<StepInputBindingsForm
inputSchema={schema}
sourceOptions={[authoringOption("input.title", "Title", "workflow_input", ["step_input"])]}
targetOptions={[authoringOption("step_input.text", "Text target", "step_input", ["step_input"])]}
initialBindings={[{ path: "input.title", target: "step_input.text" }]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
const matchingOption = screen.getByRole("button", { name: /Text target/ });
expect(matchingOption).toHaveAttribute("aria-pressed", "false");
await user.click(matchingOption);
await user.click(screen.getByRole("button", { name: "Save inputs" }));
expect(submissions).toEqual([[{ path: "input.title", target: "step_input.text" }]]);
});
it("formats local and graph path objects at whole and nested paths", () => {
expect(displayLocalInputPath({ root: "local", parts: [] })).toBe(".");
expect(displayLocalInputPath({ root: "local", parts: ["payload", "item"] })).toBe("payload.item");
@@ -470,6 +470,7 @@ const StepInputBindingsFormContent = ({
const expressionModeId = `${row.id}-expression-mode`;
const canConstruct = field?.kind === "array" || field?.kind === "object";
const hasIssues = issues.length > 0;
const targetPickerValue = pickerValueForLocalPath(row.target, targetOptions, "step_input");
const literalSources: FieldSources = field === null
? {}
: { [formatTOMLPath(field.path)]: { mode: "literal", value: row.value } };
@@ -481,15 +482,24 @@ const StepInputBindingsFormContent = ({
describedBy={hasIssues ? errorId : undefined}
invalid={hasIssues}
label={`Target for row ${rowNumber}`}
onChange={(target, selection) => editRow(row.id, (current) => ({
onChange={(target, selection) => editRow(row.id, (current) => {
const currentPickerValue = pickerValueForLocalPath(current.target, targetOptions, "step_input");
const preserveCustom = selection === "catalog" &&
currentPickerValue.provenance === "custom" &&
currentPickerValue.value === target;
return {
...current,
target: selection === "catalog"
target: preserveCustom
? current.target
: selection === "catalog"
? localPathFromPickerValue(target, targetOptions, "step_input")
: target,
}))}
};
})}
options={targetOptions}
selection={targetPickerValue.provenance}
uses="step_input"
value={pickerValueForLocalPath(row.target, targetOptions, "step_input")}
value={targetPickerValue.value}
/>
<fieldset aria-label={`Source mode for input row ${rowNumber}`} className="schema-form__source">
<legend>Value source</legend>
@@ -145,6 +145,28 @@ describe("StepOutputBindingsForm", () => {
expect(submissions).toEqual([[{ source: "step_output.text", target: "state.existing" }]]);
});
it("preserves a colliding local source when its matching catalog option is clicked", async () => {
const user = userEvent.setup();
const submissions: ReadonlyArray<OutputBinding>[] = [];
render(
<StepOutputBindingsForm
outputSchema={outputSchema}
stateSchema={stateSchema}
sourceOptions={[authoringOption("step_output.text", "Text output", "step_output", ["step_output_source"])]}
targetOptions={[authoringOption("state.existing", "Existing state", "workflow_state", ["state_target"])]}
initialBindings={[{ source: "step_output.text", target: "state.existing" }]}
onSubmit={(value) => { submissions.push(value); }}
/>,
);
const matchingOption = screen.getByRole("button", { name: /Text output/ });
expect(matchingOption).toHaveAttribute("aria-pressed", "false");
await user.click(matchingOption);
await user.click(screen.getByRole("button", { name: "Save outputs" }));
expect(submissions).toEqual([[{ source: "step_output.text", target: "state.existing" }]]);
});
it("offers capability output sources and existing state targets", () => {
render(
<StepOutputBindingsForm
@@ -191,6 +191,7 @@ const OutputRowEditor = ({
const issues = rowIssueMessages(row, rowDiagnostics, localIssues);
const errorId = `${row.id}-errors`;
const preview = inferredStateSchemaPreview(outputSchema, row.sourcePath, row.target);
const sourcePickerValue = pickerValueForLocalPath(row.sourcePath, sourceOptions, "step_output");
const hasIssues = issues.length > 0;
return (
<fieldset aria-label={`Output row ${rowNumber}`} className="schema-form__group">
@@ -201,15 +202,24 @@ const OutputRowEditor = ({
describedBy={hasIssues ? errorId : undefined}
invalid={hasIssues}
label={`Source path for output row ${rowNumber}`}
onChange={(source, selection) => onEdit(row.id, (current) => ({
onChange={(source, selection) => onEdit(row.id, (current) => {
const currentPickerValue = pickerValueForLocalPath(current.sourcePath, sourceOptions, "step_output");
const preserveCustom = selection === "catalog" &&
currentPickerValue.provenance === "custom" &&
currentPickerValue.value === source;
return {
...current,
sourcePath: selection === "catalog"
sourcePath: preserveCustom
? current.sourcePath
: selection === "catalog"
? localPathFromPickerValue(source, sourceOptions, "step_output")
: source,
}))}
};
})}
options={sourceOptions}
selection={sourcePickerValue.provenance}
uses="step_output_source"
value={pickerValueForLocalPath(row.sourcePath, sourceOptions, "step_output")}
value={sourcePickerValue.value}
/>
<AuthoringPathPicker
allowCustom
@@ -366,9 +366,19 @@ describe("selected-step schema helpers", () => {
expect(authoringOptionsForUse(inventoryOptions, "step_output_source")).toEqual([
inventoryOptions[1],
]);
expect(pickerValueForLocalPath("text", inventoryOptions, "step_output")).toBe("step_output.text");
expect(pickerValueForLocalPath("text", inventoryOptions, "step_output")).toEqual({
provenance: "catalog",
value: "step_output.text",
});
expect(pickerValueForLocalPath("step_output.text", inventoryOptions, "step_output")).toEqual({
provenance: "custom",
value: "step_output.text",
});
expect(localPathFromPickerValue("step_output.text", inventoryOptions, "step_output")).toBe("text");
expect(localPathFromPickerValue("custom.value", inventoryOptions, "step_output")).toBe("custom.value");
expect(pickerValueForLocalPath("custom.value", inventoryOptions, "step_output")).toEqual({
provenance: "custom",
value: "custom.value",
});
const wholeOutput: AuthoringPathOption = {
availability: "available",
@@ -379,7 +389,10 @@ describe("selected-step schema helpers", () => {
schema: { type: "object" },
uses: ["step_output_source"],
};
expect(pickerValueForLocalPath(".", [wholeOutput], "step_output")).toBe("step_output");
expect(pickerValueForLocalPath(".", [wholeOutput], "step_output")).toEqual({
provenance: "catalog",
value: "step_output",
});
expect(localPathFromPickerValue("step_output", [wholeOutput], "step_output")).toBe(".");
});
@@ -411,6 +411,11 @@ export const authoringOptionsForUse = (
use: AuthoringPathUse,
): ReadonlyArray<AuthoringPathOption> => options.filter((option) => option.uses.includes(use));
export type PickerPathValue = {
readonly provenance: "catalog" | "custom";
readonly value: string;
};
/**
* Keeps picker values canonical while binding editors continue submitting
* local paths for step inputs and step outputs.
@@ -419,11 +424,15 @@ export const pickerValueForLocalPath = (
localPath: string,
options: ReadonlyArray<AuthoringPathOption>,
root: "step_input" | "step_output",
): string => {
): PickerPathValue => {
if (options.some((option) => option.path === localPath)) {
return { provenance: "custom", value: localPath };
}
const canonicalPath = localPath === "." ? root : `${root}.${localPath}`;
return options.find((option) => option.path === localPath)?.path ??
options.find((option) => option.path === canonicalPath)?.path ??
localPath;
const canonicalOption = options.find((option) => option.path === canonicalPath);
return canonicalOption === undefined
? { provenance: "custom", value: localPath }
: { provenance: "catalog", value: canonicalOption.path };
};
export const localPathFromPickerValue = (