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 {
|
.demo-outcome-panel small {
|
||||||
color: var(--text-secondary);
|
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