fix(frontend): restore CTE card padding and hierarchy

This commit is contained in:
User
2026-07-14 23:24:02 +02:00
parent d2d8029ff2
commit 964bff9612
4 changed files with 237 additions and 52 deletions
+48
View File
@@ -0,0 +1,48 @@
import { render, within } from "@testing-library/react"
import { Card, CardContent, CardFooter, CardHeader } from "./card"
function CardFixture({ size = "default" }: { size?: "default" | "sm" }) {
return (
<Card data-testid={`${size}-card`} size={size}>
<CardHeader>Header</CardHeader>
<CardContent>Content</CardContent>
<CardFooter>Footer</CardFooter>
</Card>
)
}
test("uses concrete Tailwind 3 spacing for default and small cards", () => {
const { container } = render(
<>
<CardFixture />
<CardFixture size="sm" />
</>
)
const defaultCard = within(container).getByTestId("default-card")
const smallCard = within(container).getByTestId("sm-card")
expect(defaultCard).toHaveClass("gap-4", "py-4")
expect(smallCard).toHaveClass("data-[size=sm]:gap-3", "data-[size=sm]:py-3")
for (const card of [defaultCard, smallCard]) {
expect(card.querySelector('[data-slot="card-header"]')).toHaveClass(
"px-4",
"group-data-[size=sm]/card:px-3"
)
expect(card.querySelector('[data-slot="card-content"]')).toHaveClass(
"px-4",
"group-data-[size=sm]/card:px-3"
)
expect(card.querySelector('[data-slot="card-footer"]')).toHaveClass(
"p-4",
"group-data-[size=sm]/card:p-3"
)
}
for (const element of container.querySelectorAll("[class]")) {
expect(element.className).not.toContain("(--card-spacing)")
expect(element.className).not.toContain("--spacing(")
}
})
+4 -4
View File
@@ -12,7 +12,7 @@ function Card({
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-(--card-spacing) overflow-hidden rounded-xl border border-border/70 bg-card py-(--card-spacing) text-sm text-card-foreground shadow-sm [--card-spacing:--spacing(4)] has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
"group/card flex flex-col gap-4 overflow-hidden rounded-xl border border-border/70 bg-card py-4 text-sm text-card-foreground shadow-sm data-[size=sm]:gap-3 data-[size=sm]:py-3 [&:has([data-slot=card-footer])]:pb-0 has-[>img:first-child]:pt-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
@@ -25,7 +25,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-(--card-spacing) has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-(--card-spacing)",
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 [&:has([data-slot=card-action])]:grid-cols-[1fr_auto] [&:has([data-slot=card-description])]:grid-rows-[auto_auto] [&.border-b]:pb-4 group-data-[size=sm]/card:[&.border-b]:pb-3",
className
)}
{...props}
@@ -73,7 +73,7 @@ function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-(--card-spacing)", className)}
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
@@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-(--card-spacing)",
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props}
+110 -3
View File
@@ -23,6 +23,11 @@ const plan: CtePlanV2 = {
description: "Flag attivo",
rationale: "Solo pazienti in follow-up",
},
{
column: "pazienti.data_ultimo_follow_up",
op: ">=",
value: "CURRENT_DATE - INTERVAL '5 years'",
},
],
output_columns: ["paziente_id", "data_nascita"],
},
@@ -40,12 +45,31 @@ const plan: CtePlanV2 = {
test("renders strategy, question, and a chain strip with CTE names in order", () => {
render(<CtePlanViewer plan={plan} />);
expect(screen.getByText("Question")).toBeInTheDocument();
expect(screen.getByText("Strategy")).toBeInTheDocument();
expect(screen.getByText("Execution order")).toBeInTheDocument();
expect(screen.getByText(plan.question!)).toBeInTheDocument();
expect(screen.getByText(plan.strategy!)).toBeInTheDocument();
expect(screen.getAllByText("pazienti_idonei").length).toBeGreaterThan(0);
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
});
test("uses semantic CTE headings and explicit responsive card padding", () => {
const { container } = render(<CtePlanViewer plan={plan} />);
expect(screen.getByRole("heading", { level: 3, name: "pazienti_idonei" })).toBeInTheDocument();
for (const header of container.querySelectorAll('[data-slot="card-header"]')) {
expect(header).toHaveClass("p-4", "sm:p-5");
}
for (const content of container.querySelectorAll('[data-slot="card-content"]')) {
expect(content).toHaveClass("p-4", "sm:p-5");
}
for (const card of container.querySelectorAll('[data-slot="card"]')) {
expect(card).toHaveClass("shadow-none");
}
});
test("renders one card per CTE with index badge, purpose, and filter value", () => {
render(<CtePlanViewer plan={plan} />);
@@ -68,6 +92,28 @@ test("renders each filter as aligned column, operator, and value fields", () =>
expect(within(filter).getByText("TRUE")).toBeInTheDocument();
});
test("groups filters in one bordered divided list with accessible padded rows", () => {
render(<CtePlanViewer plan={plan} />);
const filters = screen.getAllByRole("group", { name: /Filter \d/ });
expect(filters).toHaveLength(2);
expect(filters[0].parentElement).toBe(filters[1].parentElement);
expect(filters[0].parentElement).toHaveClass("border", "divide-y");
for (const filter of filters) {
expect(filter).toHaveClass("p-3", "sm:p-4");
}
});
test("renders rationale as a plain top-divided section", () => {
render(<CtePlanViewer plan={plan} />);
const rationale = screen
.getByText("Base della catena: riduce il volume prima dei join")
.closest("section");
expect(rationale).toHaveClass("border-t", "pt-4");
expect(rationale).not.toHaveClass("border-l-2", "bg-primary/5", "rounded-lg");
});
test("shows 'no dependencies' for a CTE with an empty depends_on", () => {
render(<CtePlanViewer plan={plan} />);
expect(screen.getByText(/no dependencies/i)).toBeInTheDocument();
@@ -81,8 +127,69 @@ test("renders depends_on chips for a CTE that has dependencies", () => {
});
test("omits sections for missing optional fields without rendering 'undefined'", () => {
render(<CtePlanViewer plan={plan} />);
const minimalPlan: CtePlanV2 = {
schema_version: 2,
ctes: [{ name: "minimal", index: 1 }],
};
render(<CtePlanViewer plan={minimalPlan} />);
expect(screen.queryByText(/undefined/i)).not.toBeInTheDocument();
// impianti_recenti has no rationale/keys/filters — no crash, no stray text
expect(screen.getAllByText("impianti_recenti").length).toBeGreaterThan(0);
expect(screen.queryByText("Question")).not.toBeInTheDocument();
expect(screen.queryByText("Strategy")).not.toBeInTheDocument();
expect(screen.queryByText("Purpose")).not.toBeInTheDocument();
expect(screen.queryByText("Tables")).not.toBeInTheDocument();
expect(screen.queryByText("Filters")).not.toBeInTheDocument();
expect(screen.queryByText("Keys")).not.toBeInTheDocument();
expect(screen.queryByText("Output columns")).not.toBeInTheDocument();
expect(screen.queryByText("Rationale")).not.toBeInTheDocument();
});
test("allows long CTE, table, filter, and chip values to wrap safely", () => {
const longName = "cte_" + "extremely_long_unbroken_name_".repeat(6);
const longTable = "schema." + "extremely_long_table_name_".repeat(6);
const longColumn = "schema.table." + "extremely_long_filter_column_".repeat(5);
const longOperator = "EXTREMELY_LONG_OPERATOR_WITHOUT_BREAKS";
const longValue = "value_" + "extremely_long_unbroken_value_".repeat(6);
const longChip = "column_" + "extremely_long_unbroken_chip_".repeat(6);
const longPlan: CtePlanV2 = {
schema_version: 2,
ctes: [
{
name: longName,
index: 1,
depends_on: [longChip],
tables: [{ name: longTable }],
keys: [longChip],
filters: [{ column: longColumn, op: longOperator, value: longValue }],
output_columns: [longChip],
},
],
};
render(<CtePlanViewer plan={longPlan} />);
expect(screen.getByRole("heading", { level: 3, name: longName })).toHaveClass(
"min-w-0",
"[overflow-wrap:anywhere]"
);
expect(screen.getByText(longTable)).toHaveClass(
"min-w-0",
"whitespace-pre-wrap",
"[overflow-wrap:anywhere]"
);
expect(screen.getByText(longColumn)).toHaveClass("min-w-0", "[overflow-wrap:anywhere]");
expect(screen.getByText(longOperator)).toHaveClass("min-w-0", "[overflow-wrap:anywhere]");
expect(screen.getByText(longValue)).toHaveClass(
"min-w-0",
"whitespace-pre-wrap",
"[overflow-wrap:anywhere]"
);
for (const chip of screen.getAllByText(longChip)) {
expect(chip).toHaveClass(
"min-w-0",
"whitespace-pre-wrap",
"[overflow-wrap:anywhere]"
);
}
});
+71 -41
View File
@@ -1,54 +1,59 @@
import type { ReactNode } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { Card, CardContent, CardHeader } from "../components/ui/card";
import { Badge } from "../components/ui/badge";
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 =
"min-w-0 max-w-full whitespace-pre-wrap [overflow-wrap:anywhere] 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 }) {
return <span className={CHIP}>{children}</span>;
}
function FilterCard({ filter, index }: { filter: CtePlanFilter; index: number }) {
function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number }) {
return (
<div
role="group"
aria-label={`Filter ${index}`}
className="space-y-3 rounded-lg border border-border/60 bg-muted/20 p-3"
className="min-w-0 space-y-3 p-3 sm:p-4"
>
<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="grid min-w-0 grid-cols-1 gap-3 md: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">
<code className="block min-w-0 whitespace-pre-wrap [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">
<div className="min-w-0 space-y-1 md:text-center">
<p className={FIELD_LABEL}>Operator</p>
<code className="block [overflow-wrap:anywhere] font-mono text-xs font-semibold leading-relaxed text-primary">
<code className="block min-w-0 whitespace-pre-wrap [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">
<code className="block min-w-0 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)]">
<div className="grid min-w-0 gap-x-4 gap-y-2 border-t border-border/50 pt-3 md: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>
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] 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>
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-relaxed text-muted-foreground">
{filter.rationale}
</p>
</>
)}
</div>
@@ -61,26 +66,28 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
const dependsOn = cte.depends_on ?? [];
return (
<Card className="overflow-hidden">
<CardHeader className="gap-3 border-b border-border/60 bg-muted/15 pb-4">
<Card className="gap-0 rounded-lg border py-0 shadow-none">
<CardHeader className="gap-3 rounded-t-lg border-b border-border/60 bg-muted/15 p-4 sm:p-5">
<div className="grid min-w-0 grid-cols-[auto_minmax(0,1fr)] items-center gap-2.5">
<Badge variant="outline" className="font-mono">
CTE {cte.index}/{total}
</Badge>
<CardTitle className="min-w-0 [overflow-wrap:anywhere] font-mono text-sm leading-relaxed">
<h3 className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] font-mono text-sm font-medium leading-relaxed">
{cte.name}
</CardTitle>
</h3>
</div>
{cte.purpose && (
<p className="text-sm leading-relaxed text-muted-foreground">{cte.purpose}</p>
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-relaxed text-muted-foreground">
{cte.purpose}
</p>
)}
</CardHeader>
<CardContent className="flex flex-col gap-5 pt-1">
<div className="grid gap-4 sm:grid-cols-2">
<section>
<CardContent className="flex min-w-0 flex-col gap-5 p-4 sm:p-5">
<div className="grid min-w-0 gap-4 md:grid-cols-2">
<section className="min-w-0">
<h4 className="thot-label mb-2">Depends on</h4>
{dependsOn.length ? (
<div className="flex flex-wrap gap-1.5">
<div className="flex min-w-0 flex-wrap gap-1.5">
{dependsOn.map((dependency) => (
<Chip key={dependency}>{dependency}</Chip>
))}
@@ -91,9 +98,9 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
</section>
{cte.keys && cte.keys.length > 0 && (
<section>
<section className="min-w-0">
<h4 className="thot-label mb-2">Keys</h4>
<div className="flex flex-wrap gap-1.5">
<div className="flex min-w-0 flex-wrap gap-1.5">
{cte.keys.map((key) => (
<Chip key={key}>{key}</Chip>
))}
@@ -103,19 +110,19 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
</div>
{cte.tables && cte.tables.length > 0 && (
<section>
<section className="min-w-0">
<h4 className="thot-label mb-2">Tables</h4>
<div className="divide-y divide-border/50 rounded-lg border border-border/60">
<div className="min-w-0 divide-y divide-border/50 rounded-lg border border-border/60">
{cte.tables.map((table) => (
<div
key={table.name}
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"
className="grid min-w-0 gap-1 p-3 sm:p-4 md:grid-cols-[minmax(10rem,0.8fr)_minmax(0,1.2fr)] md:gap-4"
>
<code className="[overflow-wrap:anywhere] font-mono text-xs leading-relaxed text-foreground">
<code className="min-w-0 whitespace-pre-wrap [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">
<p className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-xs leading-relaxed text-muted-foreground">
{table.description}
</p>
)}
@@ -126,20 +133,20 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
)}
{cte.filters && cte.filters.length > 0 && (
<section>
<section className="min-w-0">
<h4 className="thot-label mb-2">Filters</h4>
<div className="flex flex-col gap-3">
<div className="min-w-0 divide-y divide-border/50 rounded-lg border border-border/60">
{cte.filters.map((f, i) => (
<FilterCard key={`${f.column}-${i}`} filter={f} index={i + 1} />
<FilterRow key={`${f.column}-${i}`} filter={f} index={i + 1} />
))}
</div>
</section>
)}
{cte.output_columns && cte.output_columns.length > 0 && (
<section>
<section className="min-w-0">
<h4 className="thot-label mb-2">Output columns</h4>
<div className="flex flex-wrap gap-1.5">
<div className="flex min-w-0 flex-wrap gap-1.5">
{cte.output_columns.map((column) => (
<Chip key={column}>{column}</Chip>
))}
@@ -148,9 +155,11 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
)}
{cte.rationale && (
<section className="rounded-lg border-l-2 border-primary/40 bg-primary/5 px-3.5 py-3">
<section className="min-w-0 border-t border-border/60 pt-4">
<h4 className="thot-label mb-1.5">Rationale</h4>
<p className="text-sm leading-relaxed text-foreground/90">{cte.rationale}</p>
<p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-relaxed text-foreground/90">
{cte.rationale}
</p>
</section>
)}
</CardContent>
@@ -160,18 +169,39 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) {
return (
<div className="flex flex-col gap-4">
{plan.question && <p className="text-sm leading-relaxed text-muted-foreground">{plan.question}</p>}
{plan.strategy && <p className="thot-prose leading-relaxed">{plan.strategy}</p>}
<div className="flex min-w-0 flex-col gap-6">
<div className="flex min-w-0 flex-col gap-3">
{plan.question && (
<div className="min-w-0 space-y-1.5">
<p className="thot-label">Question</p>
<p className="whitespace-pre-wrap [overflow-wrap:anywhere] text-sm leading-relaxed text-muted-foreground">
{plan.question}
</p>
</div>
)}
{plan.strategy && (
<div className="min-w-0 space-y-1.5">
<p className="thot-label">Strategy</p>
<p className="thot-prose whitespace-pre-wrap [overflow-wrap:anywhere] leading-relaxed">
{plan.strategy}
</p>
</div>
)}
<div className="flex flex-wrap items-center gap-1.5 font-mono text-xs text-muted-foreground">
<div className="min-w-0 space-y-1.5">
<p className="thot-label">Execution order</p>
<div className="flex min-w-0 flex-wrap items-center gap-1.5 font-mono text-xs text-muted-foreground">
{plan.ctes.map((c, i) => (
<span key={c.name} className="flex items-center gap-1.5">
<span key={c.name} className="flex min-w-0 max-w-full items-center gap-1.5">
{i > 0 && <span aria-hidden="true">→</span>}
<span className="text-foreground/90">{c.name}</span>
<span className="min-w-0 whitespace-pre-wrap [overflow-wrap:anywhere] text-foreground/90">
{c.name}
</span>
</span>
))}
</div>
</div>
</div>
<div className="flex flex-col gap-3">
{plan.ctes.map((cte) => (