fix: harden authoring schema and contract projections
This commit is contained in:
@@ -95,4 +95,32 @@ describe("WorkflowContractInspector", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Save outcomes" }));
|
||||
expect(controller.setContract).toHaveBeenCalledWith({ outcomes: ["ok"] });
|
||||
});
|
||||
|
||||
it("marks output reordering as dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
controller.markDirty.mockClear();
|
||||
const outputDraft = {
|
||||
...draft,
|
||||
draft: {
|
||||
...draft.draft,
|
||||
output: [
|
||||
{ path: "state.report", target: "report" },
|
||||
{ path: "state.report", target: "report_copy" },
|
||||
],
|
||||
},
|
||||
} satisfies DraftWorkspace;
|
||||
render(<WorkflowContractInspector contract="output" controller={controller} draft={outputDraft} inventory={inventory} />);
|
||||
|
||||
await user.click(screen.getAllByRole("button", { name: "Move up" })[1]!);
|
||||
expect(controller.markDirty).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks outcome removal as dirty", async () => {
|
||||
const user = userEvent.setup();
|
||||
controller.markDirty.mockClear();
|
||||
render(<WorkflowContractInspector contract="outcomes" controller={controller} draft={draft} inventory={inventory} />);
|
||||
|
||||
await user.click(screen.getAllByRole("button", { name: "Remove" })[0]!);
|
||||
expect(controller.markDirty).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,7 +127,20 @@ const WorkflowOutputBindingsForm = ({ controller, draft, inventory }: WorkflowCo
|
||||
) : <label>Literal JSON<textarea value={row.value} onChange={(event) => update(row.id, { value: event.target.value })} /></label>}
|
||||
<label>Output target<select value={row.target} onChange={(event) => update(row.id, { target: event.target.value })}><option value="">Choose output field</option>{targets.map((target) => <option key={target.path} value={target.path.replace(/^output\./, "")}>{target.label}</option>)}</select></label>
|
||||
<div className="workflow-output-bindings__actions">
|
||||
<button disabled={index === 0} onClick={() => setRows((current) => { const copy = [...current]; [copy[index - 1], copy[index]] = [copy[index]!, copy[index - 1]!]; return copy; })} type="button">Move up</button>
|
||||
<button
|
||||
disabled={index === 0}
|
||||
onClick={() => {
|
||||
setRows((current) => {
|
||||
const copy = [...current];
|
||||
[copy[index - 1], copy[index]] = [copy[index]!, copy[index - 1]!];
|
||||
return copy;
|
||||
});
|
||||
controller.markDirty();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Move up
|
||||
</button>
|
||||
<button onClick={() => { setRows((current) => current.filter((item) => item.id !== row.id)); controller.markDirty(); }} type="button">Remove binding</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -143,7 +156,7 @@ const OutcomesForm = ({ controller, draft }: WorkflowContractInspectorProps) =>
|
||||
const [rows, setRows] = useState<ReadonlyArray<string>>(() => Array.isArray(raw) ? raw.map(String) : []);
|
||||
return (
|
||||
<form className="workflow-outcomes-form" onSubmit={(event) => { event.preventDefault(); void controller.setContract({ outcomes: normalizeOutcomes(rows) }); }}>
|
||||
{rows.map((outcome, index) => <div key={index}><label>Outcome {index + 1}<input value={outcome} onChange={(event) => { setRows((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item)); controller.markDirty(); }} /></label><button onClick={() => setRows((current) => current.filter((_, itemIndex) => itemIndex !== index))} type="button">Remove</button></div>)}
|
||||
{rows.map((outcome, index) => <div key={index}><label>Outcome {index + 1}<input value={outcome} onChange={(event) => { setRows((current) => current.map((item, itemIndex) => itemIndex === index ? event.target.value : item)); controller.markDirty(); }} /></label><button onClick={() => { setRows((current) => current.filter((_, itemIndex) => itemIndex !== index)); controller.markDirty(); }} type="button">Remove</button></div>)}
|
||||
<button onClick={() => { setRows((current) => [...current, ""]); controller.markDirty(); }} type="button">Add outcome</button>
|
||||
<button type="submit">Save outcomes</button>
|
||||
</form>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkflowSchemaFieldsForm } from "./WorkflowSchemaFieldsForm.js";
|
||||
@@ -79,4 +79,68 @@ describe("WorkflowSchemaFieldsForm", () => {
|
||||
await user.click(screen.getByRole("button", { name: "Save output schema" }));
|
||||
expect(onSubmit).toHaveBeenLastCalledWith(expect.objectContaining({ properties: {} }));
|
||||
});
|
||||
|
||||
it("updates dotted property names independently from nested properties", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<WorkflowSchemaFieldsForm
|
||||
contract="input"
|
||||
onSubmit={onSubmit}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: {
|
||||
"a.b": { type: "string", description: "Flat property" },
|
||||
a: {
|
||||
type: "object",
|
||||
properties: { b: { type: "string", description: "Nested property" } },
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nested = screen.getByRole("group", { name: "Schema field b" });
|
||||
const description = within(nested).getByRole("textbox", { name: "Description" });
|
||||
await user.clear(description);
|
||||
await user.type(description, "Updated nested property");
|
||||
await user.click(screen.getByRole("button", { name: "Save input schema" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({
|
||||
"a.b": expect.objectContaining({ description: "Flat property" }),
|
||||
a: expect.objectContaining({
|
||||
properties: { b: expect.objectContaining({ description: "Updated nested property" }) },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects blank and duplicate field names before serializing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<WorkflowSchemaFieldsForm
|
||||
contract="output"
|
||||
onSubmit={onSubmit}
|
||||
schema={{
|
||||
type: "object",
|
||||
properties: { first: { type: "string" }, second: { type: "string" } },
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const names = screen.getAllByRole("textbox", { name: "Field name" });
|
||||
await user.clear(names[1]!);
|
||||
await user.click(screen.getByRole("button", { name: "Save output schema" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("must not be blank");
|
||||
|
||||
await user.type(names[1]!, "first");
|
||||
await user.click(screen.getByRole("button", { name: "Save output schema" }));
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("must be unique");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { JsonObject } from "../domain/draft-workspace-models.js";
|
||||
import {
|
||||
projectWorkflowSchema,
|
||||
serializeWorkflowSchema,
|
||||
validateWorkflowSchemaRows,
|
||||
type WorkflowContractKind,
|
||||
type WorkflowSchemaFieldRow,
|
||||
type WorkflowSchemaFieldType,
|
||||
@@ -210,8 +211,12 @@ export const WorkflowSchemaFieldsForm = ({
|
||||
}: WorkflowSchemaFieldsFormProps) => {
|
||||
const [projection] = useState(() => projectWorkflowSchema(schema));
|
||||
const [rows, setRows] = useState(projection.rows);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const nextId = useRef(0);
|
||||
const markDirty = (): void => onDirtyChange?.(true);
|
||||
const markDirty = (): void => {
|
||||
setValidationError(null);
|
||||
onDirtyChange?.(true);
|
||||
};
|
||||
const createId = (): string => `new-field-${nextId.current++}`;
|
||||
const update = (
|
||||
id: string,
|
||||
@@ -226,6 +231,11 @@ export const WorkflowSchemaFieldsForm = ({
|
||||
};
|
||||
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
const issues = validateWorkflowSchemaRows(rows);
|
||||
if (issues.length > 0) {
|
||||
setValidationError(issues.join(" "));
|
||||
return;
|
||||
}
|
||||
void Promise.resolve(
|
||||
onSubmit(serializeWorkflowSchema(projection, rows, { state: contract === "state" })),
|
||||
).catch(() => undefined);
|
||||
@@ -233,6 +243,7 @@ export const WorkflowSchemaFieldsForm = ({
|
||||
|
||||
return (
|
||||
<form className="workflow-schema-fields-form" noValidate onSubmit={submit}>
|
||||
{validationError !== null && <p role="alert">{validationError}</p>}
|
||||
{projection.rootUnsupportedReason !== null ? (
|
||||
<section aria-label="Unsupported root schema" className="workflow-schema-field--unsupported">
|
||||
<p>{projection.rootUnsupportedReason}</p>
|
||||
|
||||
@@ -45,12 +45,21 @@ export type WorkflowContractPatch = {
|
||||
readonly outcomes?: ReadonlyArray<string>;
|
||||
};
|
||||
|
||||
type WorkflowSchemaPathSegment =
|
||||
| { readonly kind: "property"; readonly name: string }
|
||||
| { readonly kind: "array-item" };
|
||||
|
||||
const MAX_DEPTH = 64;
|
||||
const COMPOSITION_KEYS = ["oneOf", "anyOf", "allOf", "not", "if", "then", "else"] as const;
|
||||
|
||||
const isRecord = (value: unknown): value is JsonObject =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const schemaFieldId = (path: ReadonlyArray<WorkflowSchemaPathSegment>): string =>
|
||||
// JSON-encoded tagged segments keep a property named "a.b" distinct from
|
||||
// a nested property path ["a", "b"].
|
||||
JSON.stringify(path);
|
||||
|
||||
export const copyJson = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(copyJson);
|
||||
if (!isRecord(value)) return value;
|
||||
@@ -97,7 +106,7 @@ const projectField = (
|
||||
name: string,
|
||||
schema: unknown,
|
||||
required: boolean,
|
||||
path: string,
|
||||
path: ReadonlyArray<WorkflowSchemaPathSegment>,
|
||||
depth: number,
|
||||
): WorkflowSchemaFieldRow => {
|
||||
const raw = isRecord(schema) ? copyObject(schema) : {};
|
||||
@@ -117,16 +126,16 @@ const projectField = (
|
||||
childName,
|
||||
childSchema,
|
||||
requiredNames.has(childName),
|
||||
`${path}.${childName}`,
|
||||
[...path, { kind: "property", name: childName }],
|
||||
depth + 1,
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const item = reason === null && type === "array" && raw.items !== undefined
|
||||
? projectField("item", raw.items, true, `${path}.items`, depth + 1)
|
||||
? projectField("item", raw.items, true, [...path, { kind: "array-item" }], depth + 1)
|
||||
: null;
|
||||
return {
|
||||
id: path,
|
||||
id: schemaFieldId(path),
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
@@ -154,13 +163,37 @@ export const projectWorkflowSchema = (schema: unknown): WorkflowSchemaProjection
|
||||
schema: root,
|
||||
rows: rootReason === null
|
||||
? Object.entries(properties).map(([name, field]) =>
|
||||
projectField(name, field, requiredNames.has(name), name, 1),
|
||||
projectField(
|
||||
name,
|
||||
field,
|
||||
requiredNames.has(name),
|
||||
[{ kind: "property", name }],
|
||||
1,
|
||||
),
|
||||
)
|
||||
: [],
|
||||
rootUnsupportedReason: rootReason,
|
||||
};
|
||||
};
|
||||
|
||||
export const validateWorkflowSchemaRows = (
|
||||
rows: ReadonlyArray<WorkflowSchemaFieldRow>,
|
||||
): ReadonlyArray<string> => {
|
||||
const issues = new Set<string>();
|
||||
const visit = (scopeRows: ReadonlyArray<WorkflowSchemaFieldRow>): void => {
|
||||
const names = new Set<string>();
|
||||
for (const row of scopeRows) {
|
||||
if (row.name.trim() === "") issues.add("Field names must not be blank.");
|
||||
else if (names.has(row.name)) issues.add("Field names must be unique within each object.");
|
||||
names.add(row.name);
|
||||
visit(row.children);
|
||||
if (row.item !== null) visit([row.item]);
|
||||
}
|
||||
};
|
||||
visit(rows);
|
||||
return [...issues];
|
||||
};
|
||||
|
||||
const serializeField = (row: WorkflowSchemaFieldRow, state: boolean): JsonObject => {
|
||||
if (row.unsupportedReason !== null) return copyObject(row.raw);
|
||||
const next = copyObject(row.raw);
|
||||
@@ -200,6 +233,8 @@ export const serializeWorkflowSchema = (
|
||||
options: { readonly state?: boolean } = {},
|
||||
): JsonObject => {
|
||||
if (projection.rootUnsupportedReason !== null) return copyObject(projection.schema);
|
||||
const issues = validateWorkflowSchemaRows(rows);
|
||||
if (issues.length > 0) throw new Error(issues.join(" "));
|
||||
const next = copyObject(projection.schema);
|
||||
next.type = "object";
|
||||
next.properties = Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user