fix: preserve colliding local authoring paths
This commit is contained in:
@@ -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 = (
|
||||||
|
|||||||
Reference in New Issue
Block a user