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>
|
||||
);
|
||||
};
|
||||
@@ -925,3 +925,97 @@
|
||||
.demo-outcome-panel small {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* --- schema approval surface --- */
|
||||
|
||||
.schema-approval-surface {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
padding: 0.85rem;
|
||||
border: 1px solid color-mix(in oklch, var(--accent-amber) 48%, var(--stage-line));
|
||||
border-radius: 0.85rem;
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in oklch, var(--accent-amber) 16%, transparent), transparent 48%),
|
||||
var(--stage-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.schema-approval-surface__header,
|
||||
.schema-approval-surface__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.schema-approval-surface__header span,
|
||||
.schema-approval-surface__actions span,
|
||||
.schema-approval-surface__field small {
|
||||
color: var(--text-muted);
|
||||
font: 700 0.68rem/1 var(--font-mono);
|
||||
}
|
||||
|
||||
.schema-approval-surface__header strong {
|
||||
font: 700 1rem/1.1 var(--font-display);
|
||||
}
|
||||
|
||||
.schema-approval-surface__header code,
|
||||
.schema-approval-surface__field code,
|
||||
.schema-approval-surface__loose code {
|
||||
font: 600 0.72rem/1.35 var(--font-mono);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.schema-approval-surface__fields,
|
||||
.schema-approval-surface__loose dl {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.schema-approval-surface__field,
|
||||
.schema-approval-surface__loose dl > div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 0.55fr) minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
align-items: start;
|
||||
padding: 0.55rem;
|
||||
border: 1px solid color-mix(in oklch, var(--stage-line) 70%, transparent);
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(in oklch, var(--stage-inset) 82%, transparent);
|
||||
}
|
||||
|
||||
.schema-approval-surface__field dt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.schema-approval-surface__field dd,
|
||||
.schema-approval-surface__loose dd {
|
||||
display: grid;
|
||||
gap: 0.3rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.schema-approval-surface__field p,
|
||||
.schema-approval-surface__loose p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.schema-approval-surface__actions button {
|
||||
border: 1px solid var(--stage-line);
|
||||
border-radius: 0.55rem;
|
||||
padding: 0.48rem 0.7rem;
|
||||
background: color-mix(in oklch, var(--stage-surface) 78%, var(--accent-cyan));
|
||||
color: var(--text-primary);
|
||||
font: 700 0.78rem/1 var(--font-mono);
|
||||
}
|
||||
|
||||
.schema-approval-surface__actions button:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user