fix: preserve colliding local authoring paths
This commit is contained in:
@@ -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) => ({
|
||||
...current,
|
||||
target: selection === "catalog"
|
||||
? localPathFromPickerValue(target, targetOptions, "step_input")
|
||||
: target,
|
||||
}))}
|
||||
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: 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) => ({
|
||||
...current,
|
||||
sourcePath: selection === "catalog"
|
||||
? localPathFromPickerValue(source, sourceOptions, "step_output")
|
||||
: source,
|
||||
}))}
|
||||
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: 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 = (
|
||||
|
||||
Reference in New Issue
Block a user