diff --git a/packages/core/src/components/data-table/body.tsx b/packages/core/src/components/data-table/body.tsx new file mode 100644 index 00000000..40951bb0 --- /dev/null +++ b/packages/core/src/components/data-table/body.tsx @@ -0,0 +1,115 @@ +import { useContext, useMemo } from "react"; +import { Table } from "@/components/table"; +import { DataTableContext, PinMeasureContext } from "./context"; +import { buildColumnKeys, computePinLayout } from "./column-layout"; +import { DataTableLoaderRows, DataTableStatusRow } from "./status-rows"; +import { DataTableRows } from "./rows"; +import { useDataTableT } from "./i18n"; + +// Fallback row count when no pageSize is configured (static / uncontrolled tables) +const DEFAULT_ROWS = 5; + +/** @internal */ +function DataTableBody({ className }: { className?: string }) { + const ctx = useContext(DataTableContext); + if (!ctx) { + throw new Error(" must be used within "); + } + const { + columns: allColumns, + visibleColumns: columns, + pinnedColumns, + rows, + loading, + error, + onClickRow, + rowActions, + isRowSelected, + toggleRowSelection, + pageSize, + rowExpansion, + isRowExpanded, + toggleRowExpansion, + } = ctx; + const t = useDataTableT(); + const widths = useContext(PinMeasureContext); + const hasRowActions = !!(rowActions && rowActions.length > 0); + const hasSelection = !!toggleRowSelection; + const hasExpand = !!rowExpansion; + const totalColSpan = + (columns?.length ?? 1) + (hasRowActions ? 1 : 0) + (hasSelection ? 1 : 0) + (hasExpand ? 1 : 0); + const rowCount = pageSize > 0 ? pageSize : DEFAULT_ROWS; + const columnKeys = useMemo(() => buildColumnKeys(allColumns), [allColumns]); + const pinLayout = useMemo( + () => + computePinLayout(columns, pinnedColumns, { + hasSelection, + hasExpand, + hasRowActions, + widths, + columnKeys, + }), + [columns, pinnedColumns, hasSelection, hasExpand, hasRowActions, widths, columnKeys], + ); + const tableBodyProps = { + "data-slot": "data-table-body", + className, + }; + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + + + {t("errorPrefix")} {error.message} + + + + ); + } + + if (!rows || rows.length === 0) { + return ( + + + {t("noData")} + + + ); + } + + return ( + + + + ); +} +DataTableBody.displayName = "DataTable.Body"; + +export { DataTableBody }; diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index ade1595e..c6b91dcf 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -3,7 +3,7 @@ import { parseDate } from "@internationalized/date"; import { Link } from "react-router"; import { BadgeList, toValueArray } from "@/components/badge-list"; import type { SelectOption } from "@/types/collection"; -import { optionLabel, resolveBadgeOptions } from "./cell-edit"; +import { optionLabel, resolveBadgeOptions } from "./cell-values"; import { normalizeTemporalFilterValue } from "@/lib/temporal-filter-values"; import type { BadgeCellOptions, diff --git a/packages/core/src/components/data-table/cell-rendering.test.tsx b/packages/core/src/components/data-table/cell-rendering.test.tsx new file mode 100644 index 00000000..5217ba45 --- /dev/null +++ b/packages/core/src/components/data-table/cell-rendering.test.tsx @@ -0,0 +1,620 @@ +import { afterEach, describe, it, expect, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import type { ReactNode } from "react"; +import { resetLocalTimeZone } from "@internationalized/date"; +import { AppShellConfigContext, buildConfigurations } from "@/contexts/appshell-context"; +import { DataTable } from "./data-table"; +import { useDataTable } from "./use-data-table"; +import type { Column } from "./types"; +import { wrapper, createTimeZoneWrapper, stubBrowserTimeZone } from "./test-utils"; + +afterEach(() => { + cleanup(); + vi.unstubAllEnvs(); + resetLocalTimeZone(); + vi.restoreAllMocks(); +}); + +describe("DataTable", () => { + it("renders editable and read-only dates in the same AppShell timezone", () => { + stubBrowserTimeZone(); + type Row = { id: string; value: string }; + function Harness() { + const table = useDataTable({ + columns: [ + { + id: "value", + label: "Date", + type: "date", + typeOptions: { locale: "en-US" }, + edit: { canEdit: (row) => row.id === "1", onValueCommitted: vi.fn() }, + }, + ], + data: { + rows: [ + { id: "1", value: "2026-10-09T00:00:00Z" }, + { id: "2", value: "2026-10-09T00:00:00Z" }, + { id: "3", value: "2026-10-09" }, + ], + }, + }); + return ( + + + + ); + } + render(, { wrapper: createTimeZoneWrapper("America/New_York") }); + expect(screen.getAllByText("Oct 8, 2026")).toHaveLength(2); + expect(screen.getByRole("button", { name: "Date, Oct 8, 2026" })).toBeDefined(); + expect(screen.getByText("Oct 9, 2026")).toBeDefined(); + }); + + it("reuses date formatters on row updates and refreshes them for column or timezone changes", () => { + const DateTimeFormat = Intl.DateTimeFormat; + const formatterSpy = vi + .spyOn(Intl, "DateTimeFormat") + .mockImplementation(function (locales, options) { + return new DateTimeFormat(locales, options); + }); + type Row = { id: string; value: string }; + const columns: Column[] = [ + { id: "value", type: "date", typeOptions: { locale: "en-US" } }, + { + id: "custom", + type: "date", + typeOptions: { locale: "not a locale" }, + render: () => "Custom renderer", + }, + ]; + const rows = [ + { id: "1", value: "2026-10-09" }, + { id: "2", value: "2026-10-09T00:00:00Z" }, + ]; + function Harness({ + rows: currentRows, + columns: currentColumns, + timeZone, + }: { + rows: Row[]; + columns: Column[]; + timeZone: string; + }) { + const table = useDataTable({ columns: currentColumns, data: { rows: currentRows } }); + return ( + + + + + + ); + } + const formatterCount = () => + formatterSpy.mock.calls.filter( + ([, options]) => options?.month === "short" || options?.month === "long", + ).length; + const { rerender } = render( + , + ); + expect(screen.getByText("Oct 8, 2026")).toBeDefined(); + const initialCount = formatterCount(); + expect(initialCount).toBeGreaterThan(0); + + const nextRows = [...rows, { id: "3", value: "2026-10-10" }]; + rerender(); + expect(screen.getByText("Oct 10, 2026")).toBeDefined(); + expect(formatterCount()).toBe(initialCount); + + rerender(); + expect(screen.queryByText("Oct 8, 2026")).toBeNull(); + const zonedCount = formatterCount(); + expect(zonedCount).toBeGreaterThan(initialCount); + + const nextColumns: Column[] = [ + { id: "value", type: "date", typeOptions: { locale: "de-DE", dateFormat: "long" } }, + ]; + rerender(); + expect(screen.getAllByText("9. Oktober 2026")).toHaveLength(2); + expect(formatterCount()).toBeGreaterThan(zonedCount); + }); + + describe("column type renderers", () => { + type TypedRow = { + id: string; + name: string | null; + total: number | null; + currency: string; + placedAt: string | null; + status: string; + detailUrl: string | null; + }; + + const typedRows: TypedRow[] = [ + { + id: "1", + name: "Order 1", + total: 1234.5, + currency: "USD", + placedAt: "2026-04-09T12:00:00Z", + status: "shipped", + detailUrl: "/orders/1", + }, + { + id: "2", + name: null, + total: null, + currency: "JPY", + placedAt: null, + status: "unknown", + detailUrl: null, + }, + ]; + + function renderTypedTable(columns: Column[]) { + function Harness() { + const table = useDataTable({ + columns, + data: { rows: typedRows }, + }); + return ( + + + + ); + } + return render(, { wrapper }); + } + + it("renders text cells with — placeholder for null values", () => { + const { container } = renderTypedTable([ + { label: "Name", type: "text", accessor: (r) => r.name }, + ]); + expect(container.textContent).toContain("Order 1"); + expect(container.textContent).toContain("—"); + }); + + it("renders money cells with currency formatting", () => { + const { container } = renderTypedTable([ + { + label: "Total", + type: "money", + accessor: (r) => r.total, + typeOptions: { currency: "USD", locale: "en-US" }, + }, + ]); + expect(container.textContent).toContain("$1,234.50"); + }); + + it("reads currency from row via function accessor", () => { + const { container } = renderTypedTable([ + { + label: "Total", + type: "money", + accessor: (r) => r.total ?? 0, + typeOptions: { currency: (r) => r.currency, locale: "en-US" }, + }, + ]); + // First row: USD 1234.5 → "$1,234.50". Second row: JPY 0 → "¥0". + expect(container.textContent).toContain("$1,234.50"); + expect(container.textContent).toContain("¥0"); + }); + + it("renders date cells with short format by default", () => { + const { container } = renderTypedTable([ + { + label: "Placed", + type: "date", + accessor: (r) => r.placedAt, + typeOptions: { locale: "en-US" }, + }, + ]); + expect(container.textContent).toMatch(/Apr 9, 2026/); + }); + + it("renders badge cells via variant/label maps", () => { + const { container } = renderTypedTable([ + { + label: "Status", + type: "badge", + accessor: (r) => r.status, + typeOptions: { + badgeVariantMap: { shipped: "success" }, + badgeLabelMap: { shipped: "Shipped" }, + }, + }, + ]); + expect(container.textContent).toContain("Shipped"); + // Unknown values still render but with default variant + raw label + expect(container.textContent).toContain("unknown"); + }); + + it("renders multiple badges from array accessor", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: ["urgent", "fragile"], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + typeOptions: { + badgeVariantMap: { urgent: "error", fragile: "warning" }, + }, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("urgent"); + expect(container.textContent).toContain("fragile"); + }); + + it("renders maxVisible badges with +N overflow in DataTable", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: ["a", "b", "c", "d"], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + typeOptions: { maxVisible: 2 }, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("a"); + expect(container.textContent).toContain("b"); + expect(container.textContent).toContain("+2"); + expect(container.textContent).not.toContain("c"); + expect(container.textContent).not.toContain("d"); + }); + + it("renders placeholder for empty array in badge column", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: [] as string[], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("—"); + }); + + it("renders placeholder for array of all-null values in badge column", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: [null, null] as (string | null)[], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags as unknown as string[], + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("—"); + }); + + it("renders placeholder for array of empty strings in badge column", () => { + const rows = [ + { + id: "1", + name: "Item", + tags: ["", ""], + status: "shipped", + amount: 100, + date: "2026-01-01", + detailUrl: "/items/1", + }, + ]; + function Harness() { + const table = useDataTable<(typeof rows)[number]>({ + columns: [ + { + label: "Tags", + type: "badge", + accessor: (r) => r.tags, + }, + ], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("—"); + }); + + it("renders link cells with anchor when href is provided", () => { + function RouterWrapper({ children }: { children: ReactNode }) { + return {wrapper({ children })}; + } + function Harness() { + const table = useDataTable({ + columns: [ + { + label: "Name", + type: "link", + accessor: (r) => r.name, + typeOptions: { href: (r) => r.detailUrl }, + }, + ], + data: { rows: typedRows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper: RouterWrapper }); + const anchor = container.querySelector("a[href='/orders/1']"); + expect(anchor).not.toBeNull(); + expect(anchor?.textContent).toBe("Order 1"); + }); + + it("falls back to row[id] when accessor is omitted", () => { + const { container } = renderTypedTable([{ id: "name", type: "text" }]); + expect(container.textContent).toContain("Order 1"); + }); + + it("renders boolean as ✓/✗ when no type is set", () => { + type BoolRow = { id: string; active: boolean }; + const rows: BoolRow[] = [ + { id: "1", active: true }, + { id: "2", active: false }, + ]; + function Harness() { + const table = useDataTable({ + columns: [{ id: "active", label: "Active", accessor: (r) => r.active }], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain("✓"); + expect(container.textContent).toContain("✗"); + }); + + it("renders Date as locale string when no type is set", () => { + type DateRow = { id: string; createdAt: Date }; + const date = new Date("2026-03-15T00:00:00Z"); + const rows: DateRow[] = [{ id: "1", createdAt: date }]; + function Harness() { + const table = useDataTable({ + columns: [{ id: "createdAt", label: "Created", accessor: (r) => r.createdAt }], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain(date.toLocaleDateString()); + }); + + it("renders object as JSON when no type is set", () => { + type ObjRow = { id: string; meta: Record }; + const rows: ObjRow[] = [{ id: "1", meta: { foo: "bar" } }]; + function Harness() { + const table = useDataTable({ + columns: [{ id: "meta", label: "Meta", accessor: (r) => r.meta }], + data: { rows }, + }); + return ( + + + + ); + } + const { container } = render(, { wrapper }); + expect(container.textContent).toContain('{"foo":"bar"}'); + }); + + it("explicit render overrides type renderer", () => { + const { container } = renderTypedTable([ + { + label: "Status", + type: "badge", + accessor: (r) => r.status, + render: (r) => {r.status.toUpperCase()}, + }, + ]); + expect(container.querySelector("[data-testid='custom']")).not.toBeNull(); + expect(container.textContent).toContain("SHIPPED"); + }); + + // Compile-time tests — these assertions verify the discriminated-union + // shape rejects wrong-type options. Runtime behavior is incidental. + it("[types] rejects wrong-type options on each branch", () => { + const moneyCol: Column = { + type: "money", + accessor: (r) => r.total, + // @ts-expect-error — badgeVariantMap is not a money option + typeOptions: { badgeVariantMap: { shipped: "success" } }, + }; + const badgeCol: Column = { + type: "badge", + accessor: (r) => r.status, + // @ts-expect-error — currency is not a badge option + typeOptions: { currency: "USD" }, + }; + // @ts-expect-error — text columns reject typeOptions entirely + const textCol: Column = { + type: "text", + accessor: (r) => r.name, + typeOptions: { locale: "en-US" }, + }; + // Reference the variables so the binding isn't elided. + expect([moneyCol, badgeCol, textCol]).toHaveLength(3); + }); + + it("[types] requires href on link columns", () => { + // @ts-expect-error — link branch requires typeOptions.href + const linkColMissingHref: Column = { + type: "link", + accessor: (r) => r.name, + }; + const linkColOk: Column = { + type: "link", + accessor: (r) => r.name, + typeOptions: { href: (r) => r.detailUrl }, + }; + expect([linkColMissingHref, linkColOk]).toHaveLength(2); + }); + + it("[types] rejects array and object accessor return values on typed branches", () => { + // Each typed branch narrows accessor's return type to values its + // renderer can display. Arrays and plain objects are compile errors — + // the built-in renderers never produced anything useful for them. Pass + // `render` (and drop `type`) when the cell value isn't a primitive. + + // @ts-expect-error — text accessor cannot return an array + const textArr: Column = { + type: "text", + accessor: () => [1, 2], + }; + // @ts-expect-error — text accessor cannot return a plain object + const textObj: Column = { + type: "text", + accessor: () => ({ a: 1 }), + }; + // @ts-expect-error — number accessor cannot return an object + const numberObj: Column = { + type: "number", + accessor: () => ({ value: 1 }), + }; + // @ts-expect-error — money accessor cannot return an array + const moneyArr: Column = { + type: "money", + accessor: () => [100], + }; + // @ts-expect-error — date accessor cannot return an array + const dateArr: Column = { + type: "date", + accessor: () => [2026, 5, 13], + }; + // badge accessor CAN return an array (multi-badge support) + const badgeArr: Column = { + type: "badge", + accessor: () => ["a", "b"], + }; + // @ts-expect-error — link accessor cannot return a plain object + const linkObj: Column = { + type: "link", + accessor: () => ({ label: "x" }), + typeOptions: { href: () => "/x" }, + }; + + // Date is allowed on the date branch (and only there). + const dateOk: Column = { + type: "date", + accessor: () => new Date(), + }; + // The untyped branch keeps `unknown` — callers escape the built-in + // renderer entirely by providing `render`, so any return is fine. + const untypedAny: Column = { + accessor: () => ({ shape: "anything" }), + render: () => null, + }; + + expect([ + textArr, + textObj, + numberObj, + moneyArr, + dateArr, + badgeArr, + linkObj, + dateOk, + untypedAny, + ]).toHaveLength(9); + }); + }); +}); diff --git a/packages/core/src/components/data-table/cell-edit.test.ts b/packages/core/src/components/data-table/cell-values.test.ts similarity index 99% rename from packages/core/src/components/data-table/cell-edit.test.ts rename to packages/core/src/components/data-table/cell-values.test.ts index aede0122..e6bf9eaa 100644 --- a/packages/core/src/components/data-table/cell-edit.test.ts +++ b/packages/core/src/components/data-table/cell-values.test.ts @@ -13,7 +13,7 @@ import { toNumberEditText, toNumberValue, toTextValue, -} from "./cell-edit"; +} from "./cell-values"; describe("typed text that isn't a number", () => { it("is reported as not a number, not silently accepted", () => { diff --git a/packages/core/src/components/data-table/cell-edit.ts b/packages/core/src/components/data-table/cell-values.ts similarity index 100% rename from packages/core/src/components/data-table/cell-edit.ts rename to packages/core/src/components/data-table/cell-values.ts diff --git a/packages/core/src/components/data-table/column-footer.tsx b/packages/core/src/components/data-table/column-footer.tsx new file mode 100644 index 00000000..8f61fd2c --- /dev/null +++ b/packages/core/src/components/data-table/column-footer.tsx @@ -0,0 +1,140 @@ +import { useContext, useMemo, type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { Table } from "@/components/table"; +import { DataTableContext, PinMeasureContext } from "./context"; +import { + SELECTION_WIDTH, + SELECTION_KEY, + EXPAND_WIDTH, + EXPAND_KEY, + ACTIONS_WIDTH, + ACTIONS_KEY, + buildColumnKeys, + computePinLayout, + pinCellProps, + resolveAlign, +} from "./column-layout"; + +function DataTableColumnFooter() { + const ctx = useContext(DataTableContext); + if (!ctx) { + throw new Error(" must be used within "); + } + const { + columns: allColumns, + visibleColumns: columns, + pinnedColumns, + rows, + loading, + error, + toggleRowSelection, + rowExpansion, + rowActions, + } = ctx; + const widths = useContext(PinMeasureContext); + const hasSelection = !!toggleRowSelection; + const hasExpand = !!rowExpansion; + const hasRowActions = !!rowActions?.length; + const columnKeys = useMemo(() => buildColumnKeys(allColumns), [allColumns]); + const { ordered, keys, placements, selection, expand, actions } = useMemo( + () => + computePinLayout(columns, pinnedColumns, { + hasSelection, + hasExpand, + hasRowActions, + widths, + columnKeys, + }), + [columns, pinnedColumns, hasSelection, hasExpand, hasRowActions, widths, columnKeys], + ); + + if (error || !ordered.some((col) => col.footer !== undefined)) return null; + + return ( + + + {hasSelection && ( + + )} + {hasExpand && ( + + )} + {ordered.map((col) => { + const key = keys.get(col) as string; + const footerContext = { rows }; + const customFooterProps = loading ? undefined : col.footerProps?.(footerContext); + let footerContent: ReactNode = null; + if (!loading) { + footerContent = + typeof col.footer === "function" ? col.footer(footerContext) : col.footer; + } + const { style, className } = pinCellProps( + placements.get(col), + { + style: { + ...customFooterProps?.style, + ...(col.width ? { width: col.width } : {}), + }, + className: cn(resolveAlign(col) === "right" && "astw:text-right"), + }, + "footer", + ); + return ( + + {col.footer !== undefined && + (loading ? ( +