fix: improve cte review presentation
This commit is contained in:
@@ -169,30 +169,30 @@ Expected: PASS.
|
|||||||
- `SqlViewer({ blocks })` shows the layout toggle only when `blocks.length > 1`.
|
- `SqlViewer({ blocks })` shows the layout toggle only when `blocks.length > 1`.
|
||||||
- CTE plan data shape remains unchanged.
|
- CTE plan data shape remains unchanged.
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing semantic/layout tests**
|
- [x] **Step 1: Write failing semantic/layout tests**
|
||||||
|
|
||||||
Assert long filter data is rendered in distinct elements labeled Column, Operator, and Value;
|
Assert long filter data is rendered in distinct elements labeled Column, Operator, and Value;
|
||||||
assert card sections expose stable headings. Assert a one-block SQL viewer has no Horizontal or
|
assert card sections expose stable headings. Assert a one-block SQL viewer has no Horizontal or
|
||||||
Vertical controls while a two-block viewer retains both.
|
Vertical controls while a two-block viewer retains both.
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests and verify RED**
|
- [x] **Step 2: Run tests and verify RED**
|
||||||
|
|
||||||
Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx`
|
Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx`
|
||||||
|
|
||||||
Expected: structured filter labels are absent and the one-block toggle is present.
|
Expected: structured filter labels are absent and the one-block toggle is present.
|
||||||
|
|
||||||
- [ ] **Step 3: Implement responsive CTE cards**
|
- [x] **Step 3: Implement responsive CTE cards**
|
||||||
|
|
||||||
Use a bordered header grid, prose blocks with `leading-relaxed`, metadata rows with fixed labels,
|
Use a bordered header grid, prose blocks with `leading-relaxed`, metadata rows with fixed labels,
|
||||||
`break-words`/`font-mono` for physical identifiers, and a responsive filter grid such as
|
`break-words`/`font-mono` for physical identifiers, and a responsive filter grid such as
|
||||||
`grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]`. Keep output columns wrapping.
|
`grid-cols-1 sm:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]`. Keep output columns wrapping.
|
||||||
|
|
||||||
- [ ] **Step 4: Hide the single-block toggle**
|
- [x] **Step 4: Hide the single-block toggle**
|
||||||
|
|
||||||
Wrap the layout control in `{blocks.length > 1 && (...)}` without changing multi-block state or
|
Wrap the layout control in `{blocks.length > 1 && (...)}` without changing multi-block state or
|
||||||
rendering.
|
rendering.
|
||||||
|
|
||||||
- [ ] **Step 5: Run targeted tests and typecheck**
|
- [x] **Step 5: Run targeted tests and typecheck**
|
||||||
|
|
||||||
Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx && npx tsc -b`
|
Run: `cd frontend && npx vitest run src/viewers/CtePlanViewer.test.tsx src/viewers/SqlViewer.test.tsx src/viewers/CteResultViewer.test.tsx && npx tsc -b`
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen, within } from "@testing-library/react";
|
||||||
import { CtePlanViewer } from "./CtePlanViewer";
|
import { CtePlanViewer } from "./CtePlanViewer";
|
||||||
import type { CtePlanV2 } from "./artifactV2";
|
import type { CtePlanV2 } from "./artifactV2";
|
||||||
|
|
||||||
@@ -56,6 +56,18 @@ test("renders one card per CTE with index badge, purpose, and filter value", ()
|
|||||||
expect(screen.getByText(/TRUE/)).toBeInTheDocument();
|
expect(screen.getByText(/TRUE/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders each filter as aligned column, operator, and value fields", () => {
|
||||||
|
render(<CtePlanViewer plan={plan} />);
|
||||||
|
|
||||||
|
const filter = screen.getByRole("group", { name: "Filter 1" });
|
||||||
|
expect(within(filter).getByText("Column")).toBeInTheDocument();
|
||||||
|
expect(within(filter).getByText("Operator")).toBeInTheDocument();
|
||||||
|
expect(within(filter).getByText("Value")).toBeInTheDocument();
|
||||||
|
expect(within(filter).getByText("pazienti.stato")).toBeInTheDocument();
|
||||||
|
expect(within(filter).getByText("IS")).toBeInTheDocument();
|
||||||
|
expect(within(filter).getByText("TRUE")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
|
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
|
||||||
render(<CtePlanViewer plan={plan} />);
|
render(<CtePlanViewer plan={plan} />);
|
||||||
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
|
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
|
||||||
|
|||||||
@@ -4,19 +4,55 @@ import { Badge } from "../components/ui/badge";
|
|||||||
import type { CtePlanCte, CtePlanFilter, CtePlanV2 } from "./artifactV2";
|
import type { CtePlanCte, CtePlanFilter, CtePlanV2 } from "./artifactV2";
|
||||||
|
|
||||||
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
const CHIP = "rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground/90";
|
||||||
|
const FIELD_LABEL = "text-[0.65rem] font-semibold uppercase tracking-[0.08em] text-muted-foreground";
|
||||||
|
|
||||||
function Chip({ children }: { children: ReactNode }) {
|
function Chip({ children }: { children: ReactNode }) {
|
||||||
return <span className={CHIP}>{children}</span>;
|
return <span className={CHIP}>{children}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FilterChip({ filter }: { filter: CtePlanFilter }) {
|
function FilterCard({ filter, index }: { filter: CtePlanFilter; index: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-0.5">
|
<div
|
||||||
<span className={CHIP}>
|
role="group"
|
||||||
{filter.column} {filter.op} {filter.value}
|
aria-label={`Filter ${index}`}
|
||||||
</span>
|
className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3"
|
||||||
{filter.description && <p className="text-xs text-muted-foreground">{filter.description}</p>}
|
>
|
||||||
{filter.rationale && <p className="text-xs text-muted-foreground">{filter.rationale}</p>}
|
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_minmax(5rem,auto)_minmax(0,1fr)]">
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className={FIELD_LABEL}>Column</p>
|
||||||
|
<code className="block [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground">
|
||||||
|
{filter.column}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-1 sm:text-center">
|
||||||
|
<p className={FIELD_LABEL}>Operator</p>
|
||||||
|
<code className="block [overflow-wrap:anywhere] font-mono text-xs font-semibold leading-relaxed text-primary">
|
||||||
|
{filter.op}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 space-y-1">
|
||||||
|
<p className={FIELD_LABEL}>Value</p>
|
||||||
|
<code className="block whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground">
|
||||||
|
{filter.value}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{(filter.description || filter.rationale) && (
|
||||||
|
<div className="grid gap-x-4 gap-y-2 border-t border-border/50 pt-3 sm:grid-cols-[5.5rem_minmax(0,1fr)]">
|
||||||
|
{filter.description && (
|
||||||
|
<>
|
||||||
|
<span className={FIELD_LABEL}>Description</span>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">{filter.description}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{filter.rationale && (
|
||||||
|
<>
|
||||||
|
<span className={FIELD_LABEL}>Rationale</span>
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">{filter.rationale}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -25,84 +61,97 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
|||||||
const dependsOn = cte.depends_on ?? [];
|
const dependsOn = cte.depends_on ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader className="gap-3 border-b border-border/60 bg-muted/15 pb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-2.5">
|
||||||
<Badge variant="outline" className="font-mono">
|
<Badge variant="outline" className="font-mono">
|
||||||
CTE {cte.index}/{total}
|
CTE {cte.index}/{total}
|
||||||
</Badge>
|
</Badge>
|
||||||
<CardTitle className="font-mono text-sm">{cte.name}</CardTitle>
|
<CardTitle className="min-w-0 [overflow-wrap:anywhere] font-mono text-sm leading-relaxed">
|
||||||
|
{cte.name}
|
||||||
|
</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
{cte.purpose && <p className="text-sm text-muted-foreground">{cte.purpose}</p>}
|
{cte.purpose && (
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">{cte.purpose}</p>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-3">
|
<CardContent className="flex flex-col gap-5 pt-1">
|
||||||
<div>
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
<h4 className="thot-label mb-1">Depends on</h4>
|
<section>
|
||||||
{dependsOn.length ? (
|
<h4 className="thot-label mb-2">Depends on</h4>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
{dependsOn.length ? (
|
||||||
{dependsOn.map((d) => (
|
<div className="flex flex-wrap gap-1.5">
|
||||||
<Chip key={d}>{d}</Chip>
|
{dependsOn.map((dependency) => (
|
||||||
))}
|
<Chip key={dependency}>{dependency}</Chip>
|
||||||
</div>
|
))}
|
||||||
) : (
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">no dependencies</p>
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">no dependencies</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{cte.keys && cte.keys.length > 0 && (
|
||||||
|
<section>
|
||||||
|
<h4 className="thot-label mb-2">Keys</h4>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{cte.keys.map((key) => (
|
||||||
|
<Chip key={key}>{key}</Chip>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cte.tables && cte.tables.length > 0 && (
|
{cte.tables && cte.tables.length > 0 && (
|
||||||
<div>
|
<section>
|
||||||
<h4 className="thot-label mb-1">Tables</h4>
|
<h4 className="thot-label mb-2">Tables</h4>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="divide-y divide-border/50 rounded-lg border border-border/60">
|
||||||
{cte.tables.map((t) => (
|
{cte.tables.map((table) => (
|
||||||
<div key={t.name}>
|
<div
|
||||||
<span className="font-mono text-sm">{t.name}</span>
|
key={table.name}
|
||||||
{t.description && (
|
className="grid min-w-0 gap-1 px-3 py-2.5 sm:grid-cols-[minmax(10rem,0.8fr)_minmax(0,1.2fr)] sm:gap-4"
|
||||||
<p className="text-xs text-muted-foreground">{t.description}</p>
|
>
|
||||||
|
<code className="[overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground">
|
||||||
|
{table.name}
|
||||||
|
</code>
|
||||||
|
{table.description && (
|
||||||
|
<p className="text-xs leading-relaxed text-muted-foreground">
|
||||||
|
{table.description}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
|
||||||
|
|
||||||
{cte.keys && cte.keys.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h4 className="thot-label mb-1">Keys</h4>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{cte.keys.map((k) => (
|
|
||||||
<Chip key={k}>{k}</Chip>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cte.filters && cte.filters.length > 0 && (
|
{cte.filters && cte.filters.length > 0 && (
|
||||||
<div>
|
<section>
|
||||||
<h4 className="thot-label mb-1">Filters</h4>
|
<h4 className="thot-label mb-2">Filters</h4>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
{cte.filters.map((f, i) => (
|
{cte.filters.map((f, i) => (
|
||||||
<FilterChip key={i} filter={f} />
|
<FilterCard key={`${f.column}-${i}`} filter={f} index={i + 1} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cte.output_columns && cte.output_columns.length > 0 && (
|
{cte.output_columns && cte.output_columns.length > 0 && (
|
||||||
<div>
|
<section>
|
||||||
<h4 className="thot-label mb-1">Output columns</h4>
|
<h4 className="thot-label mb-2">Output columns</h4>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{cte.output_columns.map((c) => (
|
{cte.output_columns.map((column) => (
|
||||||
<Chip key={c}>{c}</Chip>
|
<Chip key={column}>{column}</Chip>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{cte.rationale && (
|
{cte.rationale && (
|
||||||
<div>
|
<section className="rounded-lg border-l-2 border-primary/40 bg-primary/5 px-3.5 py-3">
|
||||||
<h4 className="thot-label mb-1">Rationale</h4>
|
<h4 className="thot-label mb-1.5">Rationale</h4>
|
||||||
<p className="text-sm text-foreground/90">{cte.rationale}</p>
|
<p className="text-sm leading-relaxed text-foreground/90">{cte.rationale}</p>
|
||||||
</div>
|
</section>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -112,8 +161,8 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
|
|||||||
export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
|
export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{plan.question && <p className="text-sm text-muted-foreground">{plan.question}</p>}
|
{plan.question && <p className="text-sm leading-relaxed text-muted-foreground">{plan.question}</p>}
|
||||||
{plan.strategy && <p className="thot-prose">{plan.strategy}</p>}
|
{plan.strategy && <p className="thot-prose leading-relaxed">{plan.strategy}</p>}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 font-mono text-xs text-muted-foreground">
|
<div className="flex flex-wrap items-center gap-1.5 font-mono text-xs text-muted-foreground">
|
||||||
{plan.ctes.map((c, i) => (
|
{plan.ctes.map((c, i) => (
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ test("passes sql to SqlViewer with mapped test status", async () => {
|
|||||||
render(<CteResultViewer result={okResult} />);
|
render(<CteResultViewer result={okResult} />);
|
||||||
await screen.findByTestId("hl");
|
await screen.findByTestId("hl");
|
||||||
expect(screen.getByText(/passed/i)).toBeInTheDocument();
|
expect(screen.getByText(/passed/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /horizontal|vertical/i })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("error status maps to failed test status and destructive badge", async () => {
|
test("error status maps to failed test status and destructive badge", async () => {
|
||||||
|
|||||||
@@ -84,3 +84,11 @@ test("(c) layout toggle flips its label between Orizzontale and Verticale", asyn
|
|||||||
screen.queryByRole("button", { name: /horizontal/i })
|
screen.queryByRole("button", { name: /horizontal/i })
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a single SQL block hides the multi-block layout toggle", async () => {
|
||||||
|
render(<SqlViewer blocks={[blocks[0]]} />);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("hl")).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /horizontal/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /vertical/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|||||||
@@ -89,16 +89,18 @@ export function SqlViewer({ blocks }: { blocks: SqlBlock[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sql-viewer">
|
<div className="sql-viewer">
|
||||||
<div className="flex gap-2 mb-2">
|
{blocks.length > 1 && (
|
||||||
<button
|
<div className="flex gap-2 mb-2">
|
||||||
className="text-xs px-2 py-1 rounded-md border border-border/70 shadow-xs transition-colors hover:bg-accent"
|
<button
|
||||||
onClick={() =>
|
className="text-xs px-2 py-1 rounded-md border border-border/70 shadow-xs transition-colors hover:bg-accent"
|
||||||
setLayout((l) => (l === "vertical" ? "horizontal" : "vertical"))
|
onClick={() =>
|
||||||
}
|
setLayout((l) => (l === "vertical" ? "horizontal" : "vertical"))
|
||||||
>
|
}
|
||||||
{layout === "vertical" ? "Horizontal" : "Vertical"}
|
>
|
||||||
</button>
|
{layout === "vertical" ? "Horizontal" : "Vertical"}
|
||||||
</div>
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
|||||||
Reference in New Issue
Block a user