fix: make join review read only
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
export interface WidgetOption {
|
||||
id: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
rationale?: string;
|
||||
meta?: Record<string, unknown>;
|
||||
selected?: boolean;
|
||||
recommended?: boolean;
|
||||
@@ -32,7 +34,7 @@ export interface WidgetDescriptor {
|
||||
phase?: string;
|
||||
title?: string;
|
||||
intro?: string;
|
||||
widget: "info" | "select" | "multiselect" | "freetext" | "artifact-gate" | "artifact" | string;
|
||||
widget: "info" | "select" | "multiselect" | "join-review" | "freetext" | "artifact-gate" | "artifact" | string;
|
||||
options?: WidgetOption[];
|
||||
reserved?: string[];
|
||||
allow_empty?: boolean;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { WidgetDescriptor } from "../api/types";
|
||||
import { JoinReviewWidget } from "./JoinReviewWidget";
|
||||
|
||||
const descriptor = {
|
||||
id: "u-joins",
|
||||
widget: "join-review",
|
||||
title: "F4 — Proposed joins",
|
||||
confirm_label: "Continue",
|
||||
reserved: ["other"],
|
||||
options: [
|
||||
{
|
||||
id: "procedure-event",
|
||||
label: "Procedure to event",
|
||||
detail: "JOIN procedure p ON p.patient_id = e.patient_id",
|
||||
rationale: "Patient grain",
|
||||
},
|
||||
{
|
||||
id: "event-time",
|
||||
label: "Event to time",
|
||||
detail: "JOIN dim_time d ON d.day_key = e.time_key",
|
||||
rationale: "DWH time key",
|
||||
},
|
||||
],
|
||||
} satisfies WidgetDescriptor;
|
||||
|
||||
test("renders joins as read-only information and Continue accepts the complete set", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<JoinReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
|
||||
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
|
||||
expect(screen.getByText("Procedure to event")).toBeInTheDocument();
|
||||
expect(screen.getByText(descriptor.options[0].detail)).toBeInTheDocument();
|
||||
expect(screen.getByText("Patient grain")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Continue" }));
|
||||
|
||||
expect(onRespond).toHaveBeenCalledWith({
|
||||
id: "u-joins",
|
||||
kind: "join-review",
|
||||
choices: ["procedure-event", "event-time"],
|
||||
});
|
||||
});
|
||||
|
||||
test("Other specify sends textual feedback instead of a join selection", async () => {
|
||||
const onRespond = vi.fn();
|
||||
render(<JoinReviewWidget descriptor={descriptor} onRespond={onRespond} />);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Other — specify" }));
|
||||
await userEvent.type(screen.getByPlaceholderText("Describe your alternative…"), "Use the episode key");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Send" }));
|
||||
|
||||
expect(onRespond).toHaveBeenCalledWith({
|
||||
id: "u-joins",
|
||||
control: "freetext",
|
||||
text: "Use the episode key",
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Link2 } from "lucide-react";
|
||||
import type { WidgetProps } from "./types";
|
||||
import { ReservedControls } from "./ReservedControls";
|
||||
|
||||
export function JoinReviewWidget({ descriptor, onRespond }: WidgetProps) {
|
||||
const options = descriptor.options ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4 rounded-xl border border-border/70 bg-card p-4 shadow-sm">
|
||||
<div className="space-y-1.5">
|
||||
{descriptor.title && (
|
||||
<p className="text-[0.95rem] font-semibold leading-snug text-foreground">
|
||||
{descriptor.title}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
These joins are required by the selected tables and are shown for review.
|
||||
To request a correction, use Other — specify.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
{options.map((option, index) => (
|
||||
<article
|
||||
key={option.id}
|
||||
className="rounded-lg border border-border/70 bg-background px-3.5 py-3"
|
||||
>
|
||||
<div className="flex items-start gap-2.5">
|
||||
<span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||
<Link2 className="size-3.5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-2.5">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
||||
{index + 1}
|
||||
</span>
|
||||
<h3 className="text-sm font-semibold leading-snug text-foreground">
|
||||
{option.label}
|
||||
</h3>
|
||||
</div>
|
||||
{option.detail && (
|
||||
<code className="block whitespace-pre-wrap break-words rounded-md bg-muted/70 px-3 py-2 font-mono text-xs leading-relaxed text-foreground">
|
||||
{option.detail}
|
||||
</code>
|
||||
)}
|
||||
{option.rationale && (
|
||||
<div className="grid gap-1 sm:grid-cols-[5rem_minmax(0,1fr)] sm:gap-3">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Rationale
|
||||
</span>
|
||||
<p className="text-sm leading-relaxed text-muted-foreground">
|
||||
{option.rationale}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm font-semibold text-primary-foreground shadow-xs transition-colors hover:bg-[oklch(var(--primary-hover))]"
|
||||
onClick={() =>
|
||||
onRespond({
|
||||
id: descriptor.id,
|
||||
kind: "join-review",
|
||||
choices: options.map((option) => option.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
{descriptor.confirm_label ?? "Continue"}
|
||||
</button>
|
||||
|
||||
<ReservedControls
|
||||
reserved={descriptor.reserved}
|
||||
onControl={(control, text) =>
|
||||
onRespond({
|
||||
id: descriptor.id,
|
||||
control,
|
||||
...(text !== undefined ? { text } : {}),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { MultiselectWidget } from "./MultiselectWidget";
|
||||
import { ArtifactGateWidget } from "./ArtifactGateWidget";
|
||||
import { ArtifactWidget } from "./ArtifactWidget";
|
||||
import { SchemaLinkingGateWidget } from "./SchemaLinkingGateWidget";
|
||||
import { JoinReviewWidget } from "./JoinReviewWidget";
|
||||
register("select", SelectWidget);
|
||||
register("info", InfoWidget);
|
||||
register("freetext", FreetextWidget);
|
||||
@@ -13,4 +14,5 @@ register("multiselect", MultiselectWidget);
|
||||
register("artifact-gate", ArtifactGateWidget);
|
||||
register("artifact", ArtifactWidget);
|
||||
register("schema-linking", SchemaLinkingGateWidget);
|
||||
register("join-review", JoinReviewWidget);
|
||||
export { resolve } from "./registry";
|
||||
|
||||
@@ -19,3 +19,9 @@ test("index registers the schema-linking widget", async () => {
|
||||
const Comp = resolve("schema-linking");
|
||||
expect(Comp.name).toBe("SchemaLinkingGateWidget");
|
||||
});
|
||||
|
||||
test("index registers the read-only join review widget", async () => {
|
||||
await import("./index");
|
||||
const Comp = resolve("join-review");
|
||||
expect(Comp.name).toBe("JoinReviewWidget");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user