diff --git a/frontend/src/components/ui/card.test.tsx b/frontend/src/components/ui/card.test.tsx new file mode 100644 index 00000000..1fd88723 --- /dev/null +++ b/frontend/src/components/ui/card.test.tsx @@ -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 ( + + Header + Content + Footer + + ) +} + +test("uses concrete Tailwind 3 spacing for default and small cards", () => { + const { container } = render( + <> + + + + ) + + 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(") + } +}) diff --git a/frontend/src/components/ui/card.tsx b/frontend/src/components/ui/card.tsx index 53ea324e..1bd1a93e 100644 --- a/frontend/src/components/ui/card.tsx +++ b/frontend/src/components/ui/card.tsx @@ -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">) {
) { return (
) @@ -84,7 +84,7 @@ function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
=", + 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(); + 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(); + + 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(); @@ -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(); + + 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(); + + 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(); 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(); + const minimalPlan: CtePlanV2 = { + schema_version: 2, + ctes: [{ name: "minimal", index: 1 }], + }; + + render(); + 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(); + + 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]" + ); + } }); diff --git a/frontend/src/viewers/CtePlanViewer.tsx b/frontend/src/viewers/CtePlanViewer.tsx index 9a6731e4..92739a8d 100644 --- a/frontend/src/viewers/CtePlanViewer.tsx +++ b/frontend/src/viewers/CtePlanViewer.tsx @@ -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 {children}; } -function FilterCard({ filter, index }: { filter: CtePlanFilter; index: number }) { +function FilterRow({ filter, index }: { filter: CtePlanFilter; index: number }) { return (
-
+

Column

- + {filter.column}
-
+

Operator

- + {filter.op}

Value

- + {filter.value}
{(filter.description || filter.rationale) && ( -
+
{filter.description && ( <> Description -

{filter.description}

+

+ {filter.description} +

)} {filter.rationale && ( <> Rationale -

{filter.rationale}

+

+ {filter.rationale} +

)}
@@ -61,26 +66,28 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) { const dependsOn = cte.depends_on ?? []; return ( - - + +
CTE {cte.index}/{total} - +

{cte.name} - +

{cte.purpose && ( -

{cte.purpose}

+

+ {cte.purpose} +

)}
- -
-
+ +
+

Depends on

{dependsOn.length ? ( -
+
{dependsOn.map((dependency) => ( {dependency} ))} @@ -91,9 +98,9 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
{cte.keys && cte.keys.length > 0 && ( -
+

Keys

-
+
{cte.keys.map((key) => ( {key} ))} @@ -103,19 +110,19 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) {
{cte.tables && cte.tables.length > 0 && ( -
+

Tables

-
+
{cte.tables.map((table) => (
- + {table.name} {table.description && ( -

+

{table.description}

)} @@ -126,20 +133,20 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) { )} {cte.filters && cte.filters.length > 0 && ( -
+

Filters

-
+
{cte.filters.map((f, i) => ( - + ))}
)} {cte.output_columns && cte.output_columns.length > 0 && ( -
+

Output columns

-
+
{cte.output_columns.map((column) => ( {column} ))} @@ -148,9 +155,11 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) { )} {cte.rationale && ( -
+

Rationale

-

{cte.rationale}

+

+ {cte.rationale} +

)} @@ -160,17 +169,38 @@ function CteCard({ cte, total }: { cte: CtePlanCte; total: number }) { export function CtePlanViewer({ plan }: { plan: CtePlanV2 }) { return ( -
- {plan.question &&

{plan.question}

} - {plan.strategy &&

{plan.strategy}

} +
+
+ {plan.question && ( +
+

Question

+

+ {plan.question} +

+
+ )} + {plan.strategy && ( +
+

Strategy

+

+ {plan.strategy} +

+
+ )} -
- {plan.ctes.map((c, i) => ( - - {i > 0 && } - {c.name} - - ))} +
+

Execution order

+
+ {plan.ctes.map((c, i) => ( + + {i > 0 && } + + {c.name} + + + ))} +
+