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 allowCustom?: boolean;
readonly describedBy?: string | undefined; readonly describedBy?: string | undefined;
readonly invalid?: boolean; readonly invalid?: boolean;
readonly selection?: AuthoringPathSelection;
}; };
type OptionGroup = { type OptionGroup = {
@@ -50,6 +51,7 @@ export const AuthoringPathPicker = ({
allowCustom = false, allowCustom = false,
describedBy, describedBy,
invalid = false, invalid = false,
selection = "catalog",
}: AuthoringPathPickerProps) => { }: AuthoringPathPickerProps) => {
const id = safeId(useId()); const id = safeId(useId());
const searchId = `${id}-search`; const searchId = `${id}-search`;
@@ -57,7 +59,9 @@ export const AuthoringPathPicker = ({
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [customValue, setCustomValue] = useState(value); const [customValue, setCustomValue] = useState(value);
const [advancedOpen, setAdvancedOpen] = useState( 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 requestedUses = normalizedUses(uses);
const normalizedSearch = search.trim().toLocaleLowerCase(); const normalizedSearch = search.trim().toLocaleLowerCase();
@@ -102,11 +106,12 @@ export const AuthoringPathPicker = ({
return ( return (
<button <button
aria-describedby={description === "" ? undefined : reasonId} aria-describedby={description === "" ? undefined : reasonId}
aria-pressed={option.path === value} aria-pressed={selection === "catalog" && option.path === value}
className="authoring-path-picker__option" className="authoring-path-picker__option"
disabled={!compatible} disabled={!compatible}
key={option.path} key={option.path}
onClick={() => { onClick={() => {
if (option.path !== value) setAdvancedOpen(false);
setCustomValue(option.path); setCustomValue(option.path);
onChange(option.path, "catalog"); onChange(option.path, "catalog");
}} }}
@@ -106,6 +106,27 @@ describe("StepInputBindingsForm", () => {
expect(submissions).toEqual([[{ path: "input.title", target: "step_input.text" }]]); 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", () => { it("formats local and graph path objects at whole and nested paths", () => {
expect(displayLocalInputPath({ root: "local", parts: [] })).toBe("."); expect(displayLocalInputPath({ root: "local", parts: [] })).toBe(".");
expect(displayLocalInputPath({ root: "local", parts: ["payload", "item"] })).toBe("payload.item"); expect(displayLocalInputPath({ root: "local", parts: ["payload", "item"] })).toBe("payload.item");
@@ -470,6 +470,7 @@ const StepInputBindingsFormContent = ({
const expressionModeId = `${row.id}-expression-mode`; const expressionModeId = `${row.id}-expression-mode`;
const canConstruct = field?.kind === "array" || field?.kind === "object"; const canConstruct = field?.kind === "array" || field?.kind === "object";
const hasIssues = issues.length > 0; const hasIssues = issues.length > 0;
const targetPickerValue = pickerValueForLocalPath(row.target, targetOptions, "step_input");
const literalSources: FieldSources = field === null const literalSources: FieldSources = field === null
? {} ? {}
: { [formatTOMLPath(field.path)]: { mode: "literal", value: row.value } }; : { [formatTOMLPath(field.path)]: { mode: "literal", value: row.value } };
@@ -481,15 +482,24 @@ const StepInputBindingsFormContent = ({
describedBy={hasIssues ? errorId : undefined} describedBy={hasIssues ? errorId : undefined}
invalid={hasIssues} invalid={hasIssues}
label={`Target for row ${rowNumber}`} 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, ...current,
target: selection === "catalog" target: preserveCustom
? current.target
: selection === "catalog"
? localPathFromPickerValue(target, targetOptions, "step_input") ? localPathFromPickerValue(target, targetOptions, "step_input")
: target, : target,
}))} };
})}
options={targetOptions} options={targetOptions}
selection={targetPickerValue.provenance}
uses="step_input" 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"> <fieldset aria-label={`Source mode for input row ${rowNumber}`} className="schema-form__source">
<legend>Value source</legend> <legend>Value source</legend>
@@ -145,6 +145,28 @@ describe("StepOutputBindingsForm", () => {
expect(submissions).toEqual([[{ source: "step_output.text", target: "state.existing" }]]); 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", () => { it("offers capability output sources and existing state targets", () => {
render( render(
<StepOutputBindingsForm <StepOutputBindingsForm
@@ -191,6 +191,7 @@ const OutputRowEditor = ({
const issues = rowIssueMessages(row, rowDiagnostics, localIssues); const issues = rowIssueMessages(row, rowDiagnostics, localIssues);
const errorId = `${row.id}-errors`; const errorId = `${row.id}-errors`;
const preview = inferredStateSchemaPreview(outputSchema, row.sourcePath, row.target); const preview = inferredStateSchemaPreview(outputSchema, row.sourcePath, row.target);
const sourcePickerValue = pickerValueForLocalPath(row.sourcePath, sourceOptions, "step_output");
const hasIssues = issues.length > 0; const hasIssues = issues.length > 0;
return ( return (
<fieldset aria-label={`Output row ${rowNumber}`} className="schema-form__group"> <fieldset aria-label={`Output row ${rowNumber}`} className="schema-form__group">
@@ -201,15 +202,24 @@ const OutputRowEditor = ({
describedBy={hasIssues ? errorId : undefined} describedBy={hasIssues ? errorId : undefined}
invalid={hasIssues} invalid={hasIssues}
label={`Source path for output row ${rowNumber}`} 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, ...current,
sourcePath: selection === "catalog" sourcePath: preserveCustom
? current.sourcePath
: selection === "catalog"
? localPathFromPickerValue(source, sourceOptions, "step_output") ? localPathFromPickerValue(source, sourceOptions, "step_output")
: source, : source,
}))} };
})}
options={sourceOptions} options={sourceOptions}
selection={sourcePickerValue.provenance}
uses="step_output_source" uses="step_output_source"
value={pickerValueForLocalPath(row.sourcePath, sourceOptions, "step_output")} value={sourcePickerValue.value}
/> />
<AuthoringPathPicker <AuthoringPathPicker
allowCustom allowCustom
@@ -366,9 +366,19 @@ describe("selected-step schema helpers", () => {
expect(authoringOptionsForUse(inventoryOptions, "step_output_source")).toEqual([ expect(authoringOptionsForUse(inventoryOptions, "step_output_source")).toEqual([
inventoryOptions[1], 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("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 = { const wholeOutput: AuthoringPathOption = {
availability: "available", availability: "available",
@@ -379,7 +389,10 @@ describe("selected-step schema helpers", () => {
schema: { type: "object" }, schema: { type: "object" },
uses: ["step_output_source"], 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("."); expect(localPathFromPickerValue("step_output", [wholeOutput], "step_output")).toBe(".");
}); });
@@ -411,6 +411,11 @@ export const authoringOptionsForUse = (
use: AuthoringPathUse, use: AuthoringPathUse,
): ReadonlyArray<AuthoringPathOption> => options.filter((option) => option.uses.includes(use)); ): 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 * Keeps picker values canonical while binding editors continue submitting
* local paths for step inputs and step outputs. * local paths for step inputs and step outputs.
@@ -419,11 +424,15 @@ export const pickerValueForLocalPath = (
localPath: string, localPath: string,
options: ReadonlyArray<AuthoringPathOption>, options: ReadonlyArray<AuthoringPathOption>,
root: "step_input" | "step_output", 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}`; const canonicalPath = localPath === "." ? root : `${root}.${localPath}`;
return options.find((option) => option.path === localPath)?.path ?? const canonicalOption = options.find((option) => option.path === canonicalPath);
options.find((option) => option.path === canonicalPath)?.path ?? return canonicalOption === undefined
localPath; ? { provenance: "custom", value: localPath }
: { provenance: "catalog", value: canonicalOption.path };
}; };
export const localPathFromPickerValue = ( export const localPathFromPickerValue = (