feat: add schema approval surface
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SchemaApprovalSurface } from "./SchemaApprovalSurface.js";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("SchemaApprovalSurface", () => {
|
||||
it("renders explicit schema fields and outcome actions", () => {
|
||||
const onSubmit = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
render(
|
||||
<SchemaApprovalSurface
|
||||
title="Issue review resume"
|
||||
schema={{
|
||||
type: "object",
|
||||
required: ["selected_issue_ids"],
|
||||
properties: {
|
||||
selected_issue_ids: { type: "array", description: "Issue ids to create" },
|
||||
comment: { type: "string" },
|
||||
},
|
||||
}}
|
||||
payload={{ selected_issue_ids: ["risk-1"], comment: "Create the selected issue." }}
|
||||
outcomes={["submitted", "cancelled"]}
|
||||
runId="run_recorded_lda_report"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
const surface = screen.getByRole("group", { name: /issue review resume/i });
|
||||
expect(within(surface).getByText("selected issue ids")).toBeInTheDocument();
|
||||
expect(within(surface).getByText("required")).toBeInTheDocument();
|
||||
expect(within(surface).getByText("[\"risk-1\"]")).toBeInTheDocument();
|
||||
expect(within(surface).getByText("run_recorded_lda_report")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(surface).getByRole("button", { name: /submit/i }));
|
||||
fireEvent.click(within(surface).getByRole("button", { name: /cancel/i }));
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders payload preview for loose object schemas", () => {
|
||||
render(
|
||||
<SchemaApprovalSurface
|
||||
title="Issue review resume"
|
||||
schema={{ type: "object" }}
|
||||
payload={{ selected_issue_ids: ["risk-1"], comment: "Create the selected issue." }}
|
||||
outcomes={["submitted", "cancelled"]}
|
||||
runId="run_recorded_lda_report"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("No additional resume fields are declared by this schema.")).toBeInTheDocument();
|
||||
expect(screen.getByText("selected_issue_ids")).toBeInTheDocument();
|
||||
expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows submitted and cancelled states without active actions", () => {
|
||||
const { rerender } = render(
|
||||
<SchemaApprovalSurface
|
||||
title="Issue review resume"
|
||||
schema={{ type: "object" }}
|
||||
payload={{}}
|
||||
outcomes={["submitted", "cancelled"]}
|
||||
runId={null}
|
||||
state="submitted"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Outcome: submitted")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SchemaApprovalSurface
|
||||
title="Issue review resume"
|
||||
schema={{ type: "object" }}
|
||||
payload={{}}
|
||||
outcomes={["submitted", "cancelled"]}
|
||||
runId={null}
|
||||
state="cancelled"
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Outcome: cancelled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { buildSchemaApprovalModel } from "./schema-approval-model.js";
|
||||
|
||||
export type SchemaApprovalSurfaceProps = {
|
||||
readonly title: string;
|
||||
readonly schema: unknown;
|
||||
readonly payload: unknown;
|
||||
readonly outcomes: ReadonlyArray<string>;
|
||||
readonly runId: string | null;
|
||||
readonly state?: "ready" | "submitted" | "cancelled";
|
||||
readonly onSubmit?: (() => void) | undefined;
|
||||
readonly onCancel?: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export const SchemaApprovalSurface = ({
|
||||
title,
|
||||
schema,
|
||||
payload,
|
||||
outcomes,
|
||||
runId,
|
||||
state = "ready",
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: SchemaApprovalSurfaceProps) => {
|
||||
const model = buildSchemaApprovalModel({ schema, payload, outcomes });
|
||||
const isResolved = state !== "ready";
|
||||
|
||||
return (
|
||||
<section className="schema-approval-surface" role="group" aria-label={title} data-state={state}>
|
||||
<header className="schema-approval-surface__header">
|
||||
<span>Schema-backed decision</span>
|
||||
<strong>{title}</strong>
|
||||
<code>{runId ?? "run unavailable"}</code>
|
||||
</header>
|
||||
|
||||
<div className="schema-approval-surface__body">
|
||||
{model.hasExplicitFields ? (
|
||||
<dl className="schema-approval-surface__fields">
|
||||
{model.fields.map((field) => (
|
||||
<div key={field.name} className="schema-approval-surface__field" data-kind={field.kind}>
|
||||
<dt>
|
||||
<span>{field.label}</span>
|
||||
{field.required ? <small>required</small> : <small>optional</small>}
|
||||
</dt>
|
||||
<dd>
|
||||
<code>{field.valuePreview ?? "not provided"}</code>
|
||||
{field.description ? <p>{field.description}</p> : null}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
) : (
|
||||
<div className="schema-approval-surface__loose">
|
||||
<p>No additional resume fields are declared by this schema.</p>
|
||||
<dl>
|
||||
{model.payloadPreview.map((entry) => (
|
||||
<div key={entry.key}>
|
||||
<dt>{entry.key}</dt>
|
||||
<dd><code>{entry.value}</code></dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="schema-approval-surface__actions">
|
||||
{isResolved ? (
|
||||
<strong>Outcome: {state}</strong>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" onClick={onSubmit} disabled={!onSubmit}>
|
||||
Submit
|
||||
</button>
|
||||
<button type="button" onClick={onCancel} disabled={!onCancel}>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<span>{model.outcomes.join(" / ")}</span>
|
||||
</footer>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user