diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx index c4e2957ad96..3572069d043 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx @@ -2,19 +2,59 @@ import { Paper, Table, TableContainer } from '@mui/material'; import TableProps from '../adapters/Table'; +import { gridSx } from './gridSxStyles'; import MuiTableBody from './MuiTableBody'; import MuiTableHeader from './MuiTableHeader'; import MuiTablePagination from './MuiTablePagination'; import MuiTableToolbar from './MuiTableToolbar'; -const MuiTable = (props: TableProps): JSX.Element => { +const MuiTable = (props: TableProps): JSX.Element => { + // A flat table produces exactly 1 header row (the leaf row). More than 1 + // means at least one group row exists above it. + const hasGroupedHeaders = (props.header?.rows.length ?? 0) > 1; + // Pinned cells only appear in row 0 but checking all rows is harmless. + const hasPinnedColumns = + props.header?.rows.some((r) => r.cells.some((c) => c.pin)) ?? false; + const isScrollContained = props.maxHeight !== undefined; + const stickyHeader = hasGroupedHeaders || isScrollContained; + + const sx = + hasGroupedHeaders || hasPinnedColumns + ? gridSx({ hasGroupedHeaders, hasPinnedColumns }) + : undefined; + return ( - - - {props.header && } + +
+ {props.header && }
diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableBody.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableBody.tsx index 60314d8d6a3..4ccabea3991 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableBody.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableBody.tsx @@ -5,7 +5,9 @@ import { CellRender } from '../adapters/Body'; import MuiTableRow from './MuiTableRow'; -const MuiTableBody = (props: BodyProps): JSX.Element => ( +type MuiTableBodyProps = BodyProps; + +const MuiTableBody = (props: MuiTableBodyProps): JSX.Element => ( {props.rows.map((row, index) => { const rowProps = props.forEachRow(row, index); diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx index 49a4fe1f954..312d2c04583 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx @@ -1,47 +1,132 @@ import { TableCell, TableHead, TableRow, TableSortLabel } from '@mui/material'; -import { HeaderProps, isRowSelector } from '../adapters'; +import { isRowSelector } from '../adapters'; +import type HeaderProps from '../adapters/Header'; +import { computePinOffsets, pinCellSx } from './gridSxStyles'; import MuiFilterMenu from './MuiFilterMenu'; import MuiTableRowSelector from './MuiTableRowSelector'; +import { useStickyHeaderOffsets } from './useStickyHeaderOffsets'; -const MuiTableHeader = (props: HeaderProps): JSX.Element => ( - - - {props.headers.map((header, index) => { - const headerProps = props.forEach(header, index); - - return ( - - {isRowSelector(headerProps.render) ? ( - - ) : ( - <> - {headerProps.sorting && ( - - {headerProps.render} - - )} - - {!headerProps.sorting && headerProps.render} - - )} - - {headerProps.filtering && ( - - )} - - ); - })} - - -); +type MuiTableHeaderProps = HeaderProps; +type HeaderCell = MuiTableHeaderProps['rows'][number]['cells'][number]; +type HeaderLeaf = NonNullable; + +const renderLeafContent = (leaf: HeaderLeaf): JSX.Element => { + if (isRowSelector(leaf.render)) { + return ( + <> + + {leaf.filtering && } + + ); + } + + return ( + <> + {leaf.sorting ? ( + + {leaf.render} + + ) : ( + leaf.render + )} + {leaf.filtering && } + + ); +}; + +const MuiTableHeader = (props: MuiTableHeaderProps): JSX.Element => { + const { rows } = props; + const { rowRefs, rowTops } = useStickyHeaderOffsets(rows.length); + + // Pinned header cells are emitted only in the first row; grouped pins use rowSpan. + const leftPins = rows[0]?.cells.filter((c) => c.pin === 'left') ?? []; + const rightPins = rows[0]?.cells.filter((c) => c.pin === 'right') ?? []; + + const leftOffsets = computePinOffsets( + leftPins.map((c) => c.widthPx ?? 0), + 'left', + ); + const rightOffsets = computePinOffsets( + rightPins.map((c) => c.widthPx ?? 0), + 'right', + ); + + const leftOffsetMap = new Map( + leftPins.map((c, i) => [c.key, leftOffsets[i]]), + ); + const rightOffsetMap = new Map( + rightPins.map((c, i) => [c.key, rightOffsets[i]]), + ); + + const getPinOffset = (cell: HeaderCell): number | undefined => { + if (!cell.pin) return undefined; + if (cell.pin === 'left') return leftOffsetMap.get(cell.key); + return rightOffsetMap.get(cell.key); + }; + + const isGrouped = rows.length > 1; + + return ( + + {rows.map((row, rowIndex) => ( + 0 + ? { + '& .MuiTableCell-stickyHeader': { top: rowTops[rowIndex] }, + } + : undefined + } + > + {row.cells.map((cell) => { + const isLeafCell = cell.leaf !== undefined; + const offset = getPinOffset(cell); + const pinConfig = + cell.pin != null && offset != null && cell.widthPx != null + ? { side: cell.pin, offsetPx: offset, widthPx: cell.widthPx } + : undefined; + const isPinnedWithRowSpan = + pinConfig != null && isGrouped && cell.rowSpan > 1; + + return ( + 1 ? cell.colSpan : undefined} + data-table-cell-kind={isLeafCell ? 'leaf' : 'group'} + data-table-pin={cell.pin ?? undefined} + data-table-pin-offset-px={pinConfig?.offsetPx} + rowSpan={cell.rowSpan > 1 ? cell.rowSpan : undefined} + sx={ + pinConfig + ? pinCellSx({ ...pinConfig, isHeader: true }) + : undefined + } + > + {cell.leaf !== undefined + ? renderLeafContent(cell.leaf) + : cell.render} + + ); + })} + + ))} + + ); +}; export default MuiTableHeader; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx index 48c776a92b8..58eed565695 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx @@ -5,6 +5,7 @@ import equal from 'fast-deep-equal'; import { isRowSelector } from '../adapters'; import { CellRender, RowRender } from '../adapters/Body'; +import { computePinOffsets, pinCellSx } from './gridSxStyles'; import MuiTableRowSelector from './MuiTableRowSelector'; interface MuiTableRowProps extends RowRender { @@ -12,18 +13,58 @@ interface MuiTableRowProps extends RowRender { forEachCell: (cell: C, index: number) => CellRender; } -const MuiTableRow = (props: MuiTableRowProps): JSX.Element => ( - - {props - .getCells() - .map((cell, cellIndex) => props.forEachCell(cell, cellIndex)) - .filter((cellProps) => !cellProps.shouldNotRender) - .map((cellProps) => { +const MuiTableRow = (props: MuiTableRowProps): JSX.Element => { + const allCells = props + .getCells() + .map((cell, i) => props.forEachCell(cell, i)); + const visible = allCells.filter((c) => !c.shouldNotRender); + + const leftPinWidths = visible + .filter((c) => c.pin === 'left') + .map((c) => c.widthPx ?? 0); + const rightPinWidths = visible + .filter((c) => c.pin === 'right') + .map((c) => c.widthPx ?? 0); + const leftOffsets = computePinOffsets(leftPinWidths, 'left'); + const rightOffsets = computePinOffsets(rightPinWidths, 'right'); + + let leftPinCount = 0; + let rightPinCount = 0; + + return ( + + {visible.map((cellProps) => { + let pinSx; + let pinOffsetPx: number | undefined; + + if (cellProps.pin === 'left') { + pinOffsetPx = leftOffsets[leftPinCount]; + pinSx = pinCellSx({ + side: 'left', + offsetPx: pinOffsetPx, + widthPx: cellProps.widthPx!, + isHeader: false, + }); + leftPinCount += 1; + } else if (cellProps.pin === 'right') { + pinOffsetPx = rightOffsets[rightPinCount]; + pinSx = pinCellSx({ + side: 'right', + offsetPx: pinOffsetPx, + widthPx: cellProps.widthPx!, + isHeader: false, + }); + rightPinCount += 1; + } + return ( {isRowSelector(cellProps.render) ? ( @@ -33,8 +74,9 @@ const MuiTableRow = (props: MuiTableRowProps): JSX.Element => ( ); })} - -); + + ); +}; export default memo(MuiTableRow, (prevProps, nextProps) => { if (!prevProps.getEqualityData || !nextProps.getEqualityData) return false; @@ -46,4 +88,4 @@ export default memo(MuiTableRow, (prevProps, nextProps) => { return false; return equal(prevEqualityData, nextEqualityData); -}); +}) as typeof MuiTableRow; diff --git a/client/app/lib/components/table/MuiTableAdapter/gridSxStyles.ts b/client/app/lib/components/table/MuiTableAdapter/gridSxStyles.ts new file mode 100644 index 00000000000..e1680bf7b33 --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/gridSxStyles.ts @@ -0,0 +1,104 @@ +import { SxProps, Theme } from '@mui/material'; +import { TABLE_BORDER, TABLE_BORDER_STRONG, white } from 'theme/colors'; + +interface GridSxOpts { + hasGroupedHeaders: boolean; + hasPinnedColumns: boolean; +} + +export const gridSx = (opts: GridSxOpts): SxProps => ({ + // borderSpacing: 0 collapses default cell gaps so borders are flush. + // borderCollapse: collapse is intentionally avoided — it merges adjacent + // borders into one, which breaks sticky columns (they need independent + // borders to stay visible as they overlap scrolled content). + borderSpacing: 0, + + '& .MuiTableCell-root': { + borderBottom: `1px solid ${TABLE_BORDER}`, + // borderLeft not borderRight: in sticky contexts borderRight on cell N + // is painted over by cell N+1's white background. borderLeft is owned + // by the cell's own stacking layer and always visible. + borderLeft: `1px solid ${TABLE_BORDER}`, + // Solid background is required for sticky cells to cover scrolled + // content beneath them. + backgroundColor: white, + }, + + // MUI sets background-color: inherit on sticky header cells in some theme + // configurations, making them transparent so scrolled body content shows + // through. Explicitly override to white. + '& .MuiTableCell-stickyHeader': { backgroundColor: white }, + + ...(opts.hasGroupedHeaders && { + // Multi-row sticky headers are composited row-by-row. Put separators on + // the lower row's borderTop, not the upper row's borderBottom, so the + // separator stays visible when sticky. + '& .MuiTableHead-root .MuiTableRow-root:not(:last-child) .MuiTableCell-root': + { + borderBottom: 'none', + }, + '& .MuiTableHead-root .MuiTableRow-root:not(:first-child) .MuiTableCell-root': + { + borderTop: `1px solid ${TABLE_BORDER}`, + }, + '& .MuiTableHead-root .MuiTableRow-root:last-child .MuiTableCell-root': { + borderBottom: `2px solid ${TABLE_BORDER_STRONG}`, + }, + }), + + ...(opts.hasGroupedHeaders && + opts.hasPinnedColumns && { + // Row-spanning pinned headers live in the first row but visually reach + // the bottom of the header, so restore the header/body separator. + '& .MuiTableHead-root .MuiTableRow-root .MuiTableCell-root.grid-pin-rowspan': + { + borderBottom: `2px solid ${TABLE_BORDER_STRONG}`, + }, + }), + + ...(opts.hasPinnedColumns && { + // Reassert the final border when sticky columns cause MUI to drop it. + '& .MuiTableBody-root .MuiTableRow-root:last-child .MuiTableCell-root': { + borderBottom: `1px solid ${TABLE_BORDER}`, + borderLeft: `1px solid ${TABLE_BORDER}`, + }, + }), +}); + +export const pinCellSx = (opts: { + side: 'left' | 'right'; + offsetPx: number; + widthPx: number; + isHeader: boolean; +}): SxProps => ({ + position: 'sticky', + [opts.side]: opts.offsetPx, + width: opts.widthPx, + minWidth: opts.widthPx, + maxWidth: opts.widthPx, + backgroundColor: white, + zIndex: opts.isHeader ? 40 : 20, +}); + +export const computePinOffsets = ( + widths: number[], + side: 'left' | 'right', +): number[] => { + const out = new Array(widths.length); + + if (side === 'left') { + let acc = 0; + for (let i = 0; i < widths.length; i += 1) { + out[i] = acc; + acc += widths[i]; + } + return out; + } + + let acc = 0; + for (let i = widths.length - 1; i >= 0; i -= 1) { + out[i] = acc; + acc += widths[i]; + } + return out; +}; diff --git a/client/app/lib/components/table/MuiTableAdapter/useStickyHeaderOffsets.ts b/client/app/lib/components/table/MuiTableAdapter/useStickyHeaderOffsets.ts new file mode 100644 index 00000000000..0bba70a57f8 --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/useStickyHeaderOffsets.ts @@ -0,0 +1,40 @@ +import { createRef, RefObject, useLayoutEffect, useRef, useState } from 'react'; + +export const useStickyHeaderOffsets = ( + rowCount: number, +): { + rowRefs: RefObject[]; + rowTops: number[]; +} => { + const rowRefs = useRef[]>([]); + if (rowRefs.current.length !== rowCount) { + rowRefs.current = Array.from( + { length: rowCount }, + (_, i) => rowRefs.current[i] ?? createRef(), + ); + } + + const [rowTops, setRowTops] = useState(() => + Array(rowCount).fill(0), + ); + + useLayoutEffect(() => { + const compute = (): void => { + const heights = rowRefs.current.map( + (ref) => ref.current?.offsetHeight ?? 0, + ); + const tops: number[] = []; + let acc = 0; + for (let i = 0; i < rowCount; i += 1) { + tops.push(acc); + acc += heights[i]; + } + setRowTops(tops); + }; + compute(); + window.addEventListener('resize', compute); + return () => window.removeEventListener('resize', compute); + }, [rowCount]); + + return { rowRefs: rowRefs.current, rowTops }; +}; diff --git a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts index 5fdf50fd16a..702585bad03 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts @@ -4,6 +4,8 @@ import { RowSelector } from '../adapters'; import { buildColumns, BuiltColumns, ColumnTemplate, Data } from '../builder'; export const ROW_SELECTOR_ID = 'rowSelector'; +export const INDEX_COL_WIDTH_PX = 48; +export const ROW_SELECTOR_WIDTH_PX = 48; const buildTanStackColumns = ( columns: ColumnTemplate[], diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index 16806f3445e..b9df5ac5398 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Cell, ColumnFiltersState, @@ -7,25 +7,25 @@ import { getFilteredRowModel, getPaginationRowModel, getSortedRowModel, - Header, Row, useReactTable, } from '@tanstack/react-table'; import isEmpty from 'lodash-es/isEmpty'; import { RowEqualityData, TableProps } from '../adapters'; -import { TableTemplate } from '../builder'; +import type { CellRender } from '../adapters/Body'; +import { buildHeaderRows, TableTemplate } from '../builder'; import { downloadCsv } from '../utils'; -import buildTanStackColumns from './columnsBuilder'; +import buildTanStackColumns, { + INDEX_COL_WIDTH_PX, + ROW_SELECTOR_ID, + ROW_SELECTOR_WIDTH_PX, +} from './columnsBuilder'; import generateCsv from './csvGenerator'; import { customCellRender, customHeaderRender } from './customFlexRender'; -type TanStackTableProps = TableProps< - Header, - Row, - Cell ->; +type TanStackTableProps = TableProps, Cell>; const useTanStackTableBuilder = ( props: TableTemplate, @@ -115,59 +115,124 @@ const useTanStackTableBuilder = ( downloadCsv(csvData, props.csvDownload?.filename); }; + const tsHeaders = table.getHeaderGroups()[0]?.headers; + const tsOffset = + (props.indexing?.indices ? 1 : 0) + (props.indexing?.rowSelectable ? 1 : 0); + + const activeColumns = props.columns.filter((c) => !c.unless); + + // Indexing columns (row selector, row indices) are prepended to TanStack's + // column array at indices 0..tsOffset-1. Prepending them here as synthetic + // left-pinned ColumnTemplates lets buildHeaderRows include them in the header + // and account for their widths in computePinOffsets, so user-defined pinned + // columns receive correct sticky offsets. + const allColumnsForHeader = useMemo(() => { + const indexingCols: typeof activeColumns = []; + if (props.indexing?.indices) + indexingCols.push({ + id: 'index', + title: '', + cell: () => null, + pin: 'left', + widthPx: INDEX_COL_WIDTH_PX, + }); + if (props.indexing?.rowSelectable) + indexingCols.push({ + id: ROW_SELECTOR_ID, + title: '', + cell: () => null, + pin: 'left', + widthPx: ROW_SELECTOR_WIDTH_PX, + }); + return [...indexingCols, ...activeColumns]; + }, [props.indexing?.indices, props.indexing?.rowSelectable, activeColumns]); + + // Indexing widths in TanStack column order (same order as prepended above). + const indexingWidths = useMemo(() => { + const widths: number[] = []; + if (props.indexing?.indices) widths.push(INDEX_COL_WIDTH_PX); + if (props.indexing?.rowSelectable) widths.push(ROW_SELECTOR_WIDTH_PX); + return widths; + }, [props.indexing?.indices, props.indexing?.rowSelectable]); + + const headerRows = useMemo( + () => + buildHeaderRows(allColumnsForHeader, (_column, originalIndex) => { + // originalIndex maps directly to tsHeaders because allColumnsForHeader + // already absorbs the tsOffset by prepending the indexing columns. + const tsHeader = tsHeaders[originalIndex]; + return { + id: tsHeader.id, + render: customHeaderRender(tsHeader), + className: getRealColumn(originalIndex)?.className, + sorting: tsHeader.column.getCanSort() + ? { + sorted: Boolean(tsHeader.column.getIsSorted()), + direction: tsHeader.column.getIsSorted() || undefined, + onClickSort: tsHeader.column.getToggleSortingHandler(), + } + : undefined, + filtering: tsHeader.column.getCanFilter() + ? { + filters: tsHeader.column.getFilterValue() as unknown[], + uniqueFilterValues: Array.from( + tsHeader.column.getFacetedUniqueValues().keys(), + ).sort(), + getFilterLabel: + getRealColumn(originalIndex)?.filterProps?.getLabel, + onAddFilter: (value): void => { + resetPagination(); + tsHeader.column.setFilterValue((currentFilters?: unknown[]) => + currentFilters?.filter((filter) => filter !== value), + ); + }, + onClearFilters: (): void => { + resetPagination(); + tsHeader.column.setFilterValue(undefined); + }, + onRemoveFilter: (value): void => { + resetPagination(); + tsHeader.column.setFilterValue( + (currentFilters?: unknown[]) => + currentFilters ? [...currentFilters, value] : [value], + ); + }, + tooltipLabel: props.filter?.tooltipLabel, + clearFiltersLabel: props.filter?.clearFilterTooltipLabel, + } + : undefined, + }; + }), + // tsHeaders changes on sort/filter state; allColumnsForHeader is stable per render cycle + [allColumnsForHeader, tsHeaders], + ); + return { header: { - headers: table.getHeaderGroups()[0]?.headers, - forEach: (header, index) => ({ - id: header.id, - render: customHeaderRender(header), - className: getRealColumn(index)?.className, - sorting: header.column.getCanSort() - ? { - sorted: Boolean(header.column.getIsSorted()), - direction: header.column.getIsSorted() || undefined, - onClickSort: header.column.getToggleSortingHandler(), - } - : undefined, - filtering: header.column.getCanFilter() - ? { - filters: header.column.getFilterValue() as unknown[], - uniqueFilterValues: Array.from( - header.column.getFacetedUniqueValues().keys(), - ).sort(), - getFilterLabel: getRealColumn(index)?.filterProps?.getLabel, - onAddFilter: (value): void => { - resetPagination(); - header.column.setFilterValue((currentFilters?: unknown[]) => - currentFilters?.filter((filter) => filter !== value), - ); - }, - onClearFilters: (): void => { - resetPagination(); - header.column.setFilterValue(undefined); - }, - onRemoveFilter: (value): void => { - resetPagination(); - header.column.setFilterValue((currentFilters?: unknown[]) => - currentFilters ? [...currentFilters, value] : [value], - ); - }, - tooltipLabel: props.filter?.tooltipLabel, - clearFiltersLabel: props.filter?.clearFilterTooltipLabel, - } - : undefined, - }), + rows: headerRows, }, body: { rows: table.getRowModel().rows, getCells: (row) => row.getVisibleCells(), - forEachCell: (cell, row, index) => ({ - id: cell.id, - render: customCellRender(cell), - className: getRealColumn(index)?.className, - colSpan: getRealColumn(index)?.colSpan?.(row.original), - shouldNotRender: getRealColumn(index)?.cellUnless?.(row.original), - }), + forEachCell: (cell, row, index): CellRender => { + if (index < tsOffset) { + return { + id: cell.id, + render: customCellRender(cell), + pin: 'left', + widthPx: indexingWidths[index], + }; + } + return { + id: cell.id, + render: customCellRender(cell), + className: getRealColumn(index)?.className, + colSpan: getRealColumn(index)?.colSpan?.(row.original), + shouldNotRender: getRealColumn(index)?.cellUnless?.(row.original), + pin: getRealColumn(index)?.pin, + widthPx: getRealColumn(index)?.widthPx, + }; + }, forEachRow: (row) => ({ id: row.id, className: props.getRowClassName?.(row.original), @@ -213,6 +278,7 @@ const useTanStackTableBuilder = ( searchPlaceholder: props.search?.searchPlaceholder, buttons: props.toolbar?.buttons, }, + maxHeight: props.maxHeight, }; }; diff --git a/client/app/lib/components/table/__tests__/Table.test.tsx b/client/app/lib/components/table/__tests__/Table.test.tsx new file mode 100644 index 00000000000..a18d76039d9 --- /dev/null +++ b/client/app/lib/components/table/__tests__/Table.test.tsx @@ -0,0 +1,447 @@ +import { IntlProvider } from 'react-intl'; +import type { RenderResult } from '@testing-library/react'; +import { render } from '@testing-library/react'; + +import type { TableTemplate } from '../builder'; +import Table from '../Table'; + +const LEFT_PIN_TD = 'td[data-table-pin="left"]'; +const PIN_OFFSET_ATTR = 'data-table-pin-offset-px'; + +const Wrapper = ({ children }: { children: React.ReactNode }): JSX.Element => ( + {children} +); + +const renderTable = (ui: JSX.Element): RenderResult => + render(ui, { wrapper: Wrapper }); + +interface Row { + id: string; + name: string; + score: number; +} + +const rows: Row[] = [ + { id: '1', name: 'Alice', score: 90 }, + { id: '2', name: 'Bob', score: 80 }, +]; + +const flatColumns: TableTemplate['columns'] = [ + { + id: 'name', + of: 'name', + title: 'Name', + cell: (r) => r.name, + sortable: true, + }, + { id: 'score', of: 'score', title: 'Score', cell: (r) => r.score }, +]; + +describe(' — flat consumer regression', () => { + it('renders one thead tr with no data-table-pin attributes', () => { + const { container } = renderTable( +
r.id} />, + ); + + const headerRows = container.querySelectorAll('thead tr'); + expect(headerRows).toHaveLength(1); + + const allCells = container.querySelectorAll('th, td'); + allCells.forEach((cell) => { + expect(cell.getAttribute('data-table-pin')).toBeNull(); + }); + }); + + it('renders a sort handle on sortable columns', () => { + const { container } = renderTable( +
r.id} />, + ); + expect(container.querySelector('.MuiTableSortLabel-root')).toBeTruthy(); + }); + + it('container has no maxHeight style when maxHeight not provided', () => { + const { container } = renderTable( +
r.id} />, + ); + const tableContainer = container.querySelector( + '.MuiTableContainer-root', + ) as HTMLElement | null; + expect(tableContainer?.style.maxHeight ?? '').toBe(''); + }); +}); + +describe('
— grouped + pinned + scroll-contained', () => { + const groupedColumns: TableTemplate['columns'] = [ + { + id: 'pinnedName', + title: 'Student', + cell: (r) => r.name, + pin: 'left', + widthPx: 120, + }, + { + id: 'score', + title: 'Score', + cell: (r) => r.score, + groupPath: [ + { id: 'outer', title: 'Outer Group' }, + { id: 'inner', title: 'Inner Group' }, + ], + }, + { + id: 'pinnedScore', + title: 'Total', + cell: (r) => r.score, + pin: 'right', + widthPx: 80, + }, + ]; + + it('renders 3 thead tr elements for depth-2 groupPath', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + const headerRows = container.querySelectorAll('thead tr'); + expect(headerRows).toHaveLength(3); + }); + + it('pinned th has correct data-table-pin attributes', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + // Pinned columns use rowSpan=3 and appear only in the first row DOM element + const leftPins = container.querySelectorAll('th[data-table-pin="left"]'); + const rightPins = container.querySelectorAll('th[data-table-pin="right"]'); + expect(leftPins).toHaveLength(1); + expect(rightPins).toHaveLength(1); + }); + + it('pinned cells have data-table-cell-kind="leaf", group cells have "group"', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + // Row 0: pinnedName(leaf), outerGroup(group), pinnedScore(leaf) + // Row 1: innerGroup(group) + // Row 2: score(leaf) + const leafCells = container.querySelectorAll( + '[data-table-cell-kind="leaf"]', + ); + const groupCells = container.querySelectorAll( + '[data-table-cell-kind="group"]', + ); + expect(leafCells).toHaveLength(3); + expect(groupCells).toHaveLength(2); + }); + + it('container has maxHeight style when maxHeight is provided', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + const tableContainer = container.querySelector( + '.MuiTableContainer-root', + ) as HTMLElement | null; + expect(tableContainer?.style.maxHeight).toBe('400px'); + }); + + it('rowSpan HTML attribute is set correctly on row-spanning pinned header cells', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + const headerRows = container.querySelectorAll('thead tr'); + + // Row 0: leftPin (rowSpan=3), outer group (colSpan=1,rowSpan=1), rightPin (rowSpan=3) + const row0Cells = headerRows[0].querySelectorAll('th'); + const leftPinCell = Array.from(row0Cells).find( + (c) => c.getAttribute('data-table-pin') === 'left', + ); + expect(leftPinCell?.getAttribute('rowspan')).toBe('3'); + + const outerGroupCell = Array.from(row0Cells).find( + (c) => c.getAttribute('data-table-cell-kind') === 'group', + ); + expect(outerGroupCell).toBeTruthy(); + }); + + it('table element has !border-separate class when pinned columns are present', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + expect( + container.querySelector('table')?.classList.contains('!border-separate'), + ).toBe(true); + }); +}); + +describe('
— flat with one pin, no maxHeight', () => { + const flatWithPin: TableTemplate['columns'] = [ + { + id: 'pinnedName', + title: 'Name', + cell: (r) => r.name, + pin: 'left', + widthPx: 100, + }, + { id: 'score', title: 'Score', cell: (r) => r.score }, + ]; + + it('renders one thead tr', () => { + const { container } = renderTable( +
r.id} />, + ); + expect(container.querySelectorAll('thead tr')).toHaveLength(1); + }); + + it('container has no maxHeight style', () => { + const { container } = renderTable( +
r.id} />, + ); + const tableContainer = container.querySelector( + '.MuiTableContainer-root', + ) as HTMLElement | null; + expect(tableContainer?.style.maxHeight ?? '').toBe(''); + }); + + it('body td for pinned column has data-table-pin="left"', () => { + const { container } = renderTable( +
r.id} />, + ); + const pinnedBodyCells = container.querySelectorAll(LEFT_PIN_TD); + expect(pinnedBodyCells).toHaveLength(rows.length); + }); +}); + +describe('
— pin + indexing — row selector', () => { + const pinnedWithSelector: TableTemplate['columns'] = [ + { + id: 'pinnedName', + title: 'Name', + cell: (r) => r.name, + pin: 'left', + widthPx: 120, + }, + { id: 'score', title: 'Score', cell: (r) => r.score }, + ]; + + it('row selector body cells have data-table-pin="left"', () => { + const { container } = renderTable( +
r.id} + indexing={{ rowSelectable: true }} + />, + ); + const leftPinBodyCells = container.querySelectorAll(LEFT_PIN_TD); + // One row-selector td + one pinnedName td per data row + expect(leftPinBodyCells).toHaveLength(rows.length * 2); + const checkboxCells = Array.from(leftPinBodyCells).filter((td) => + td.querySelector('input[type="checkbox"]'), + ); + expect(checkboxCells).toHaveLength(rows.length); + }); + + it('row selector header cell has data-table-pin="left" and renders select-all checkbox', () => { + const { container } = renderTable( +
r.id} + indexing={{ rowSelectable: true }} + />, + ); + const leftPinHeaders = container.querySelectorAll( + 'th[data-table-pin="left"]', + ); + // rowSelector header + pinnedName header + expect(leftPinHeaders).toHaveLength(2); + const hasSelectAll = Array.from(leftPinHeaders).some((th) => + th.querySelector('input[type="checkbox"]'), + ); + expect(hasSelectAll).toBe(true); + }); + + it('pinned header and body cells have correct data-table-pin-offset-px values', () => { + const { container } = renderTable( +
r.id} + indexing={{ rowSelectable: true }} + />, + ); + // Left pins in order: rowSelector (widthPx=48, offset=0), pinnedName (widthPx=120, offset=48) + const leftPinHeaders = Array.from( + container.querySelectorAll('th[data-table-pin="left"]'), + ); + expect(leftPinHeaders[0]?.getAttribute(PIN_OFFSET_ATTR)).toBe('0'); + expect(leftPinHeaders[1]?.getAttribute(PIN_OFFSET_ATTR)).toBe('48'); + + const leftPinBodyCells = Array.from( + container.querySelectorAll(LEFT_PIN_TD), + ); + const selectorCells = leftPinBodyCells.filter((td) => + td.querySelector('input[type="checkbox"]'), + ); + const nameCells = leftPinBodyCells.filter( + (td) => !td.querySelector('input[type="checkbox"]'), + ); + selectorCells.forEach((td) => + expect(td.getAttribute(PIN_OFFSET_ATTR)).toBe('0'), + ); + nameCells.forEach((td) => + expect(td.getAttribute(PIN_OFFSET_ATTR)).toBe('48'), + ); + }); + + it('does not warn about unsupported pin + row-selector combination', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + try { + renderTable( +
r.id} + indexing={{ rowSelectable: true }} + />, + ); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('unsupported'), + ); + } finally { + warnSpy.mockRestore(); + } + }); +}); + +describe('
— pin + indexing (indices)', () => { + const pinnedWithIndices: TableTemplate['columns'] = [ + { + id: 'pinnedName', + title: 'Name', + cell: (r) => r.name, + pin: 'left', + widthPx: 120, + }, + { id: 'score', title: 'Score', cell: (r) => r.score }, + ]; + + it('index body cells have data-table-pin="left"', () => { + const { container } = renderTable( +
r.id} + indexing={{ indices: true }} + />, + ); + const leftPinBodyCells = container.querySelectorAll(LEFT_PIN_TD); + // One index td + one pinnedName td per data row + expect(leftPinBodyCells).toHaveLength(rows.length * 2); + }); + + it('pinned header and body cells have correct data-table-pin-offset-px values', () => { + const { container } = renderTable( +
r.id} + indexing={{ indices: true }} + />, + ); + // Left pins in order: index (widthPx=48, offset=0), pinnedName (widthPx=120, offset=48) + const leftPinHeaders = Array.from( + container.querySelectorAll('th[data-table-pin="left"]'), + ); + expect(leftPinHeaders[0]?.getAttribute(PIN_OFFSET_ATTR)).toBe('0'); + expect(leftPinHeaders[1]?.getAttribute(PIN_OFFSET_ATTR)).toBe('48'); + + const leftPinBodyCells = Array.from( + container.querySelectorAll(LEFT_PIN_TD), + ); + // First cell per row (index column): offset 0; second (pinnedName): offset 48 + for (let i = 0; i < leftPinBodyCells.length; i += 2) { + expect(leftPinBodyCells[i]?.getAttribute(PIN_OFFSET_ATTR)).toBe('0'); + expect(leftPinBodyCells[i + 1]?.getAttribute(PIN_OFFSET_ATTR)).toBe('48'); + } + }); +}); + +describe('
— sort UI on leaf cells only', () => { + const depthOneWithSort: TableTemplate['columns'] = [ + { + id: 'pinnedName', + of: 'name', + title: 'Student', + cell: (r) => r.name, + pin: 'left', + widthPx: 120, + sortable: true, + }, + { + id: 'score', + of: 'score', + title: 'Score', + cell: (r) => r.score, + groupPath: [{ id: 'g', title: 'Scores' }], + sortable: true, + }, + ]; + + it('sort handle appears on pinned (leaf) cell and leaf-row cell, not on group cell', () => { + const { container } = renderTable( +
r.id} />, + ); + const headerRows = container.querySelectorAll('thead tr'); + expect(headerRows).toHaveLength(2); + + // Row 0 contains the pinned leaf cell (has sort) and the group cell (no sort) + const row0Cells = headerRows[0].querySelectorAll('th'); + const pinnedCell = Array.from(row0Cells).find( + (c) => c.getAttribute('data-table-pin') === 'left', + ); + expect(pinnedCell?.querySelector('.MuiTableSortLabel-root')).toBeTruthy(); + + const groupCell = Array.from(row0Cells).find( + (c) => c.getAttribute('data-table-cell-kind') === 'group', + ); + expect(groupCell?.querySelector('.MuiTableSortLabel-root')).toBeNull(); + + // Row 1 (leaf row) — sort handle on the score column + const row1Cells = headerRows[1].querySelectorAll('th'); + expect( + Array.from(row1Cells).some((c) => + c.querySelector('.MuiTableSortLabel-root'), + ), + ).toBe(true); + }); +}); diff --git a/client/app/lib/components/table/__tests__/gridSxStyles.test.ts b/client/app/lib/components/table/__tests__/gridSxStyles.test.ts new file mode 100644 index 00000000000..1765ca00999 --- /dev/null +++ b/client/app/lib/components/table/__tests__/gridSxStyles.test.ts @@ -0,0 +1,31 @@ +import { computePinOffsets } from '../MuiTableAdapter/gridSxStyles'; + +describe('computePinOffsets', () => { + it('left: cumulative from left', () => { + expect(computePinOffsets([10, 20, 30], 'left')).toEqual([0, 10, 30]); + }); + + it('left: single element', () => { + expect(computePinOffsets([50], 'left')).toEqual([0]); + }); + + it('left: empty', () => { + expect(computePinOffsets([], 'left')).toEqual([]); + }); + + it('right: cumulative from right, rightmost gets 0', () => { + expect(computePinOffsets([10, 20, 30], 'right')).toEqual([50, 30, 0]); + }); + + it('right: single element', () => { + expect(computePinOffsets([50], 'right')).toEqual([0]); + }); + + it('right: empty', () => { + expect(computePinOffsets([], 'right')).toEqual([]); + }); + + it('right: two elements', () => { + expect(computePinOffsets([40, 60], 'right')).toEqual([60, 0]); + }); +}); diff --git a/client/app/lib/components/table/adapters/Body.ts b/client/app/lib/components/table/adapters/Body.ts index 4230762eb4f..ed4adf03fae 100644 --- a/client/app/lib/components/table/adapters/Body.ts +++ b/client/app/lib/components/table/adapters/Body.ts @@ -19,6 +19,8 @@ export interface CellRender { className?: string; colSpan?: number; shouldNotRender?: boolean; + pin?: 'left' | 'right'; + widthPx?: number; } interface BodyProps { diff --git a/client/app/lib/components/table/adapters/Header.ts b/client/app/lib/components/table/adapters/Header.ts index 75617fb0461..c76607662b5 100644 --- a/client/app/lib/components/table/adapters/Header.ts +++ b/client/app/lib/components/table/adapters/Header.ts @@ -1,5 +1,7 @@ import { ReactNode } from 'react'; +import { HeaderRow } from '../builder/buildHeaderRows'; + import FilterProps from './Filter'; import RowSelector from './RowSelector'; import SortProps from './Sort'; @@ -12,9 +14,8 @@ interface HeaderRender { filtering?: FilterProps; } -interface HeaderProps { - headers: H[]; - forEach: (header: H, index: number) => HeaderRender; +interface HeaderProps { + rows: HeaderRow[]; } export default HeaderProps; diff --git a/client/app/lib/components/table/adapters/Table.ts b/client/app/lib/components/table/adapters/Table.ts index 12fb6ef0de0..fb3cf64eef2 100644 --- a/client/app/lib/components/table/adapters/Table.ts +++ b/client/app/lib/components/table/adapters/Table.ts @@ -4,13 +4,14 @@ import HeaderProps from './Header'; import PaginationProps from './Pagination'; import ToolbarProps from './Toolbar'; -interface TableProps { +interface TableProps { body: BodyProps; className?: string; pagination?: PaginationProps; - header?: HeaderProps; + header?: HeaderProps; toolbar?: ToolbarProps; handles: HandlersProps; + maxHeight?: number | string; } export default TableProps; diff --git a/client/app/lib/components/table/builder/ColumnTemplate.ts b/client/app/lib/components/table/builder/ColumnTemplate.ts index eda11a70c9d..ee315b329d3 100644 --- a/client/app/lib/components/table/builder/ColumnTemplate.ts +++ b/client/app/lib/components/table/builder/ColumnTemplate.ts @@ -3,6 +3,23 @@ import { StringOrTemplateHeader } from '@tanstack/react-table'; export type Data = object; +/** + * Describes one level of a column's header group hierarchy. + * Columns sharing the same `id` at the same depth and in a contiguous run + * are merged into a single spanning group cell. + */ +export interface GroupSegment { + id: string | number; + title: ReactNode; + /** Plain-text label for non-DOM contexts (CSV export, aria labels). */ + label?: string; + /** + * Per-group cell styling is not yet supported — add `className?: string` + * here, then `className?: string` to `GroupHeaderCell`, then thread + * `seg.className` through `buildGroupCells` in `buildHeaderRows.ts`. + */ +} + interface FilteringProps { beforeFilter?: (value: string) => unknown; shouldInclude?: (datum: D, filterValue) => boolean; @@ -19,7 +36,7 @@ interface SortingProps { undefinedPriority?: false | 'first' | 'last'; } -interface ColumnTemplate { +interface ColumnTemplateBase { title: StringOrTemplateHeader; cell: (datum: D) => ReactNode; of?: keyof D; @@ -38,4 +55,55 @@ interface ColumnTemplate { cellUnless?: (datum: D) => boolean; } +/** + * Pins the column to the left or right edge. `widthPx` is required for + * sticky offset math. `groupPath` is forbidden — pinned columns span all + * header rows automatically. + */ +interface PinnedColumn { + pin: 'left' | 'right'; + widthPx: number; + groupPath?: never; +} + +/** + * Non-pinned column. `widthPx` is optional. `groupPath` defines multi-level + * header rows — all non-pinned columns must share the same groupPath depth. + */ +interface UnpinnedColumn { + pin?: never; + widthPx?: number; + /** + * Multi-level header path. Each entry describes one header row above the + * leaf row. Columns sharing the same `id` at the same depth in a contiguous + * run are merged into a single spanning group cell. + * + * All non-pinned columns must have the same `groupPath` depth. + * + * Example — two category rows above the leaf row: + * ``` + * | Mission | Exams | + * | PE | Exercises| Exercises | + * | A | B | C | D | + * ``` + * ```ts + * { id: 'A', groupPath: [{ id: 'mission', title: 'Mission' }, { id: 'pe', title: 'PE' }] } + * { id: 'B', groupPath: [{ id: 'mission', title: 'Mission' }, { id: 'exercises', title: 'Exercises' }] } + * { id: 'C', groupPath: [{ id: 'exams', title: 'Exams' }, { id: 'exercises', title: 'Exercises' }] } + * { id: 'D', groupPath: [{ id: 'exams', title: 'Exams' }, { id: 'exercises', title: 'Exercises' }] } + * ``` + * A+B share `mission` at depth 0 → merged "Mission" (colSpan 2). + * C+D share `exams` at depth 0 → merged "Exams" (colSpan 2). + * C+D share `exercises` under the same parent `exams` → merged "Exercises" + * (colSpan 2). + * B+C both use `exercises` at depth 1 and are adjacent, but their parents + * differ (`mission` vs `exams`) → two separate "Exercises" cells, not merged. + * Merging requires the same id AND the same parent, not adjacency alone. + */ + groupPath?: GroupSegment[]; +} + +type ColumnTemplate = ColumnTemplateBase & + (PinnedColumn | UnpinnedColumn); + export default ColumnTemplate; diff --git a/client/app/lib/components/table/builder/TableTemplate.ts b/client/app/lib/components/table/builder/TableTemplate.ts index c6de07ae6d9..587ede36ff7 100644 --- a/client/app/lib/components/table/builder/TableTemplate.ts +++ b/client/app/lib/components/table/builder/TableTemplate.ts @@ -23,6 +23,12 @@ interface TableTemplate { filter?: FilterTemplate; toolbar?: ToolbarTemplate; sort?: SortTemplate; + /** + * Constrains the table container height and enables a scroll container. + * A sticky header is automatically enabled when this is set or when grouped + * headers are active. + */ + maxHeight?: number | string; } export default TableTemplate; diff --git a/client/app/lib/components/table/builder/__tests__/buildHeaderRows.test.ts b/client/app/lib/components/table/builder/__tests__/buildHeaderRows.test.ts new file mode 100644 index 00000000000..331fac8ec99 --- /dev/null +++ b/client/app/lib/components/table/builder/__tests__/buildHeaderRows.test.ts @@ -0,0 +1,367 @@ +import { buildHeaderRows } from '../buildHeaderRows'; +import type ColumnTemplate from '../ColumnTemplate'; + +interface D { + id: string; + name: string; +} + +const col = ( + id: string, + overrides: Partial> = {}, +): ColumnTemplate => + ({ id, title: id, cell: () => null, ...overrides }) as ColumnTemplate; + +const buildLeaf = (_column: ColumnTemplate, index: number): string => + `leaf-${index}`; + +describe('buildHeaderRows', () => { + it('returns [] for empty columns', () => { + expect(buildHeaderRows([], buildLeaf)).toEqual([]); + }); + + it('flat columns, no pin, no groupPath → single isLeaf row in declaration order', () => { + const cols = [col('a'), col('b'), col('c')]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(1); + expect(rows[0].isLeaf).toBe(true); + expect(rows[0].cells).toHaveLength(3); + rows[0].cells.forEach((cell, i) => { + expect(cell.colSpan).toBe(1); + expect(cell.rowSpan).toBe(1); + expect(cell.leaf).toBe(`leaf-${i}`); + expect(cell.render).toBeUndefined(); + }); + }); + + it('flat with one left pin → pin cell first', () => { + const cols = [col('mid'), col('pinned', { pin: 'left', widthPx: 100 })]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(1); + expect(rows[0].isLeaf).toBe(true); + const [first, second] = rows[0].cells; + expect(first.pin).toBe('left'); + expect(second.pin).toBeUndefined(); + }); + + it('flat with pins on both sides → leftPin first, rightPin last', () => { + const cols = [ + col('mid'), + col('right', { pin: 'right', widthPx: 80 }), + col('left', { pin: 'left', widthPx: 80 }), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(1); + const [first, second, third] = rows[0].cells; + expect(first.pin).toBe('left'); + expect(second.pin).toBeUndefined(); + expect(third.pin).toBe('right'); + }); + + it('depth-1 groupPath with two contiguous groups → 2 rows', () => { + const cols = [ + col('a', { groupPath: [{ id: 'g1', title: 'G1' }] }), + col('b', { groupPath: [{ id: 'g1', title: 'G1' }] }), + col('c', { groupPath: [{ id: 'g2', title: 'G2' }] }), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(2); + + const [groupRow, leafRow] = rows; + expect(groupRow.isLeaf).toBe(false); + expect(groupRow.cells).toHaveLength(2); + expect(groupRow.cells[0].colSpan).toBe(2); + expect(groupRow.cells[0].render).toBe('G1'); + expect(groupRow.cells[0].leaf).toBeUndefined(); + expect(groupRow.cells[1].colSpan).toBe(1); + expect(groupRow.cells[1].render).toBe('G2'); + + expect(leafRow.isLeaf).toBe(true); + expect(leafRow.cells).toHaveLength(3); + leafRow.cells.forEach((cell) => expect(cell.leaf).toBeDefined()); + }); + + it('depth-2 groupPath → 3 rows', () => { + const cols = [ + col('a', { + groupPath: [ + { id: 'outer', title: 'Outer' }, + { id: 'inner1', title: 'Inner1' }, + ], + }), + col('b', { + groupPath: [ + { id: 'outer', title: 'Outer' }, + { id: 'inner2', title: 'Inner2' }, + ], + }), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(3); + expect(rows[0].isLeaf).toBe(false); + expect(rows[1].isLeaf).toBe(false); + expect(rows[2].isLeaf).toBe(true); + // outer row: one group spanning 2 + expect(rows[0].cells).toHaveLength(1); + expect(rows[0].cells[0].colSpan).toBe(2); + // inner row: two groups spanning 1 each + expect(rows[1].cells).toHaveLength(2); + expect(rows[1].cells[0].colSpan).toBe(1); + expect(rows[1].cells[1].colSpan).toBe(1); + // leaf row + expect(rows[2].cells).toHaveLength(2); + }); + + it('depth-1 with left + right pin → 2 rows, pin cells rowSpan 2 on row 0', () => { + const cols = [ + col('mid', { groupPath: [{ id: 'g', title: 'G' }] }), + col('lpn', { pin: 'left', widthPx: 60 }), + col('rpn', { pin: 'right', widthPx: 60 }), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(2); + + const groupRow = rows[0]; + expect(groupRow.cells[0].pin).toBe('left'); + expect(groupRow.cells[0].rowSpan).toBe(2); + expect(groupRow.cells[0].leaf).toBeDefined(); + expect(groupRow.cells[0].render).toBeUndefined(); + + expect(groupRow.cells[2].pin).toBe('right'); + expect(groupRow.cells[2].rowSpan).toBe(2); + expect(groupRow.cells[2].leaf).toBeDefined(); + expect(groupRow.cells[2].render).toBeUndefined(); + + // Middle group cell + expect(groupRow.cells[1].render).toBe('G'); + expect(groupRow.cells[1].leaf).toBeUndefined(); + + // Leaf row has only middle columns + const leafRow = rows[1]; + expect(leafRow.cells).toHaveLength(1); + expect(leafRow.cells[0].pin).toBeUndefined(); + }); + + it('depth-2 with two-column left pin → 3 rows, both pins rowSpan 3 on row 0', () => { + const cols = [ + col('mid', { + groupPath: [ + { id: 'o', title: 'O' }, + { id: 'i', title: 'I' }, + ], + }), + col('lp1', { pin: 'left', widthPx: 50 }), + col('lp2', { pin: 'left', widthPx: 70 }), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(3); + const row0 = rows[0]; + const pinCells = row0.cells.filter((c) => c.pin === 'left'); + expect(pinCells).toHaveLength(2); + pinCells.forEach((c) => { + expect(c.rowSpan).toBe(3); + expect(c.colSpan).toBe(1); + }); + // declaration order within the left side is preserved + expect(pinCells[0].key).toBe('lp1'); + expect(pinCells[1].key).toBe('lp2'); + }); + + it('depth-1 with two-column right pin → 2 rows, both right pins present', () => { + const cols = [ + col('mid', { groupPath: [{ id: 'g', title: 'G' }] }), + col('rp1', { pin: 'right', widthPx: 50 }), + col('rp2', { pin: 'right', widthPx: 80 }), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + expect(rows).toHaveLength(2); + + const rightPins = rows[0].cells.filter((c) => c.pin === 'right'); + expect(rightPins).toHaveLength(2); + }); + + it('per-cell invariant: exactly one of render/leaf is set across depth-2 + pinned fixture', () => { + const cols = [ + col('mid1', { + groupPath: [ + { id: 'o', title: 'Outer' }, + { id: 'i1', title: 'Inner1' }, + ], + }), + col('mid2', { + groupPath: [ + { id: 'o', title: 'Outer' }, + { id: 'i2', title: 'Inner2' }, + ], + }), + col('lp', { pin: 'left', widthPx: 100 }), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + + rows.forEach((row) => { + row.cells.forEach((cell) => { + const hasRender = cell.render !== undefined; + const hasLeaf = cell.leaf !== undefined; + expect(hasRender !== hasLeaf).toBe(true); + }); + }); + }); + + it('dev warning: duplicate column ids warns in development', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const orig = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + buildHeaderRows([col('a'), col('b'), col('a')], buildLeaf); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('duplicate column id "a"'), + ); + } finally { + process.env.NODE_ENV = orig; + warnSpy.mockRestore(); + } + }); + + it('dev error: inconsistent groupPath depths throws in development', () => { + const orig = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + expect(() => + buildHeaderRows( + [ + col('a', { groupPath: [{ id: 'g', title: 'G' }] }), + col('b'), // depth 0 vs depth 1 + ], + buildLeaf, + ), + ).toThrow(); + } finally { + process.env.NODE_ENV = orig; + } + }); + + it('dev no-warning: different tabs under the same category produce separate leaf columns', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const orig = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + const rows = buildHeaderRows( + [ + col('a', { + groupPath: [ + { id: 'cat1', title: 'Mission' }, + { id: 'tab1', title: 'Exercises' }, + ], + }), + col('b', { + groupPath: [ + { id: 'cat1', title: 'Mission' }, + { id: 'tab2', title: 'PE' }, + ], + }), + col('c', { + groupPath: [ + { id: 'cat1', title: 'Mission' }, + { id: 'tab3', title: 'Homework' }, + ], + }), + ], + buildLeaf, + ); + expect(warnSpy).not.toHaveBeenCalled(); + // category row: one spanning cell for Mission + expect(rows[0].cells).toHaveLength(1); + expect(rows[0].cells[0].colSpan).toBe(3); + // tab row: three separate cells, one per tab + expect(rows[1].cells).toHaveLength(3); + rows[1].cells.forEach((cell) => expect(cell.colSpan).toBe(1)); + // leaf row: three columns + expect(rows[2].cells).toHaveLength(3); + } finally { + process.env.NODE_ENV = orig; + warnSpy.mockRestore(); + } + }); + + it('dev no-warning: same tab id under different categories is valid and does not warn', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const orig = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + buildHeaderRows( + [ + col('a', { + groupPath: [ + { id: 'cat1', title: 'Mission' }, + { id: 'exercises', title: 'Exercises' }, + ], + }), + col('b', { + groupPath: [ + { id: 'cat1', title: 'Mission' }, + { id: 'pe', title: 'PE' }, + ], + }), + col('c', { + groupPath: [ + { id: 'cat2', title: 'Exams' }, + { id: 'exercises', title: 'Exercises' }, // same id, different parent — valid + ], + }), + ], + buildLeaf, + ); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = orig; + warnSpy.mockRestore(); + } + }); + + it('dev warning: non-contiguous same-id runs warns in development', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const orig = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + buildHeaderRows( + [ + col('a', { groupPath: [{ id: 'x', title: 'X' }] }), + col('b', { groupPath: [{ id: 'x', title: 'X' }] }), + col('c', { groupPath: [{ id: 'y', title: 'Y' }] }), + col('d', { groupPath: [{ id: 'x', title: 'X' }] }), // non-contiguous + ], + buildLeaf, + ); + expect(warnSpy).toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = orig; + warnSpy.mockRestore(); + } + }); + + it('pin render-order invariant: declared mid/pin mix is reordered to leftPin, mid, rightPin', () => { + const cols = [ + col('mid1'), + col('lp', { pin: 'left', widthPx: 80 }), + col('mid2'), + col('rp', { pin: 'right', widthPx: 80 }), + col('mid3'), + ]; + const rows = buildHeaderRows(cols, buildLeaf); + + expect(rows).toHaveLength(1); + const [c0, c1, c2, c3, c4] = rows[0].cells; + expect(c0.key).toBe('lp'); + expect(c1.key).toBe('mid1'); + expect(c2.key).toBe('mid2'); + expect(c3.key).toBe('mid3'); + expect(c4.key).toBe('rp'); + }); +}); diff --git a/client/app/lib/components/table/builder/buildHeaderRows.ts b/client/app/lib/components/table/builder/buildHeaderRows.ts new file mode 100644 index 00000000000..3af38855ea4 --- /dev/null +++ b/client/app/lib/components/table/builder/buildHeaderRows.ts @@ -0,0 +1,378 @@ +import { ReactNode } from 'react'; + +import type ColumnTemplate from './ColumnTemplate'; +import type { Data, GroupSegment } from './ColumnTemplate'; + +interface BaseHeaderCell { + key: string; + colSpan: number; + rowSpan: number; +} + +/** + * A header cell that labels a span of columns (e.g. "Revenue" spanning three + * sub-columns). Always sits in a group row, never in the leaf row. + * + * `leaf?: never` enforces that group cells are purely labels — they have no + * data column associated with them and no `buildLeafRender` payload. A renderer + * can never accidentally treat a group cell as if it had data. The side effect + * is that it makes `HeaderCell` a proper discriminated union: with `leaf?: never` + * the only valid value is `undefined`, so a truthy `cell.leaf` check narrows + * `HeaderCell` to `LeafHeaderCell`. + * + * Per-group styling (`className`) is not supported because group cells are + * synthesised from `GroupSegment` entries, which carry no `className`. To add + * it: (1) add `className?: string` to `GroupSegment` in `ColumnTemplate.ts`, + * (2) add `className?: string` here, (3) thread `seg.className` through + * `buildGroupCells` when constructing each cell. + */ +export interface GroupHeaderCell extends BaseHeaderCell { + render?: ReactNode; + leaf?: never; + widthPx?: never; + pin?: never; + className?: never; +} + +/** + * A header cell that directly represents a data column (the leaf row). Always + * sits in the bottom header row. + * + * `render?: never` enforces that leaf cells must express their content through + * `leaf` (the caller-supplied payload via `buildLeafRender`), never through a + * raw `ReactNode`. This closes off accidentally bypassing the `buildLeafRender` + * mechanism by shoving a `ReactNode` directly into the cell. The side effect is + * that a truthy `cell.render` check narrows `HeaderCell` to `GroupHeaderCell`. + * + * Pinned columns are also `LeafHeaderCell`s — they represent data columns fixed + * to an edge. Because `ColumnTemplate` forbids `groupPath` on pinned columns, + * they have no group hierarchy above them. Instead they are placed in row 0 + * with `rowSpan = depth + 1` so they visually span all header rows. + * + * `widthPx` is present here but absent on `GroupHeaderCell` because only leaf + * cells ever need an explicit pixel width — group cells derive their width from + * the sum of their children via `colSpan`, so declaring one would be redundant + * and potentially contradictory. + * + * `Leaf = unknown` rather than `ReactNode` because the leaf value is + * caller-defined via `buildLeafRender` — in practice it is a TanStack + * `Header` object (when used with `useTanStackTableBuilder`) or a `ReactNode` + * (for simpler renderers). TypeScript infers the concrete type from the + * `buildLeafRender` signature, so the default is rarely needed directly. + */ +export interface LeafHeaderCell extends BaseHeaderCell { + render?: never; + leaf: Leaf; + widthPx?: number; + pin?: 'left' | 'right'; + className?: string; +} + +/** A cell in a header row — either a group label or a leaf column header. */ +export type HeaderCell = GroupHeaderCell | LeafHeaderCell; + +/** + * One row in the rendered table header. + * + * `isLeaf` is true only for the bottom row, which maps 1-to-1 with data + * columns. Renderers use this to know where to attach sort/filter controls. + * All rows above it are group rows (`isLeaf: false`). + */ +export interface HeaderRow { + cells: HeaderCell[]; + isLeaf: boolean; + rowKey: string; +} + +/** + * Caller-supplied function that maps a `ColumnTemplate` to whatever leaf + * type the renderer needs (e.g. a `HeaderRender` with sort/filter callbacks). + * This is the seam between the generic builder and a specific renderer. + */ +export type BuildLeafRender = ( + column: ColumnTemplate, + index: number, +) => Leaf; + +const getDepth = (path?: GroupSegment[]): number => path?.length ?? 0; + +/** + * Builds the group cells for one header row at depth `r`. + * + * Scans `middle` left-to-right and collapses contiguous runs of columns that + * share the same `groupPath[r].id` into a single `GroupHeaderCell` whose + * `colSpan` equals the run length. Columns with distinct ids each produce + * their own cell with `colSpan: 1`. + * + * To add per-group styling, thread `seg.className` into the pushed cell once + * `GroupSegment.className` and `GroupHeaderCell.className` are defined. + * + * Pinned columns are never passed here — `ColumnTemplate` forbids `groupPath` + * on pinned columns (`PinnedColumn.groupPath?: never`), and they are handled + * separately as full-height `LeafHeaderCell`s that span all rows. + */ +const buildGroupCells = ( + middle: { col: ColumnTemplate; index: number }[], + r: number, +): GroupHeaderCell[] => { + const cells: GroupHeaderCell[] = []; + let runStart = 0; + while (runStart < middle.length) { + const seg = middle[runStart].col.groupPath?.[r]; + let runEnd = runStart + 1; + while ( + runEnd < middle.length && + middle[runEnd].col.groupPath?.[r]?.id === seg?.id + ) { + runEnd += 1; + } + cells.push({ + key: `r${r}:${runStart}`, + render: seg?.title, + colSpan: runEnd - runStart, + rowSpan: 1, + }); + runStart = runEnd; + } + return cells; +}; + +/** + * Validates constraints that cannot be expressed in TypeScript's type system. + * + * TypeScript checks each `ColumnTemplate` value in isolation at construction + * time. The two constraints below are *cross-element* — they require comparing + * values across every column in the array simultaneously, which TypeScript has + * no mechanism to express. + * + * (0) Duplicate column ids: duplicate `id` values produce duplicate React keys, + * corrupting reconciliation — rows may not update correctly, sort state may + * attach to the wrong column, selections may bleed. Easy to introduce by + * copy-pasting a column definition; hard to debug without an explicit + * warning. TypeScript cannot express uniqueness constraints on optional + * fields across an array. + * + * (1) Consistent groupPath depth: every non-pinned column must declare the + * same number of group rows (`groupPath.length`). TypeScript cannot assert + * "all elements of this array must have the same `.groupPath.length`". + * If depths differ, `buildHeaderRows` uses the maximum — shorter columns + * would produce `undefined` segments, silently generating blank group cells + * instead of failing visibly. + * + * (2) Same-id adjacency within a parent: columns sharing the same + * `groupPath[r].id` under the same parent group must be adjacent. If they + * are not, the run-length scan produces two separate group cells with + * identical labels rather than one merged spanning cell — a broken header. + * Sharing the same id under *different* parents is valid and expected (e.g. + * an "Exercises" tab under both "Mission" and "Exams" correctly produces + * two separate cells). TypeScript cannot express ordering constraints across + * array elements. + * + * Only runs when `NODE_ENV !== 'production'`. Behaviour differs by environment: + * in development, depth errors throw; in test, they only `console.error` so a + * single bad column does not abort the whole test suite. Non-adjacent same-id + * columns within the same parent group are `console.warn` in both environments + * (non-adjacent same-id columns under different parents are valid and do not + * warn). + */ +const validateInvariants = ( + columns: ColumnTemplate[], + middle: { col: ColumnTemplate; index: number }[], + depth: number, +): void => { + if (process.env.NODE_ENV === 'production') return; + + const seenIds = new Set(); + columns.forEach((col) => { + if (col.id) { + if (seenIds.has(col.id)) { + console.warn( + `lib/components/table: duplicate column id "${col.id}" — this produces duplicate React keys and corrupts reconciliation.`, + ); + } + seenIds.add(col.id); + } + }); + + middle.forEach(({ col }) => { + const colDepth = getDepth(col.groupPath); + if (colDepth !== depth) { + const msg = `lib/components/table: inconsistent groupPath depths — expected ${depth}, got ${colDepth} for column "${col.id ?? '(unnamed)'}"`; + console.error(msg); + if (process.env.NODE_ENV === 'development') throw new Error(msg); + } + }); + + if (depth > 0) { + for (let r = 0; r < depth; r += 1) { + // Reset tracking at parent-group boundaries (r > 0 only) so that the + // same id under different parents is not flagged — e.g. "Exercises" under + // both "Mission" and "Exams" is valid. At r = 0 there is no parent, so + // the check is global: non-adjacent same-id top-level groups are always + // broken. + let seenGroupIds = new Set(); + let lastId: string | number | undefined; + let lastParentId: string | number | undefined; + middle.forEach(({ col }) => { + if (r > 0) { + const parentId = col.groupPath![r - 1]?.id; + if (parentId !== lastParentId) { + seenGroupIds = new Set(); + lastId = undefined; + lastParentId = parentId; + } + } + const id = col.groupPath![r]?.id; + if (id !== lastId) { + if (seenGroupIds.has(id)) { + console.warn( + `lib/components/table: non-contiguous group segments at depth ${r} — id "${String(id)}" appears in multiple non-adjacent runs within the same parent group.`, + ); + } + seenGroupIds.add(id); + lastId = id; + } + }); + } + } +}; + +/** + * Converts a flat array of `ColumnTemplate`s into a row-major grid of header + * cells, ready for a renderer to turn into `` rows. + * + * ## Layout model + * + * Columns are partitioned into three groups based on their `pin` value: + * - `leftPin` — columns with `pin: 'left'`, in declaration order + * - `middle` — unpinned columns, in declaration order + * - `rightPin` — columns with `pin: 'right'`, in declaration order + * + * The final visual order is always: left-pinned | middle | right-pinned. + * + * ## Flat headers (no groupPath) + * + * When no column has a `groupPath`, a single leaf row is returned containing + * one cell per column in visual order. Every cell has `colSpan: 1, rowSpan: 1`. + * + * ## Grouped headers (groupPath set) + * + * `groupPath` has `depth` entries (depth ≥ 1). The output has `depth + 1` + * rows: `depth` group rows followed by one leaf row. + * + * **Group rows (rows 0 … depth-1):** + * At each depth `r`, adjacent middle columns that share the same + * `groupPath[r].id` are merged into one `GroupHeaderCell` with + * `colSpan = run length`. Columns with distinct ids each get their own cell. + * + * **Pinned columns in grouped headers:** + * Pinned columns appear only in row 0 as `LeafHeaderCell`s with + * `rowSpan = depth + 1` — they visually span all header rows including the + * leaf row. They are NOT repeated in the leaf row. Left pins are prepended to + * row 0; right pins are appended to row 0. + * + * **Leaf row (last row):** + * One `LeafHeaderCell` per middle column. Pinned columns are absent here + * because their rowSpan cells already cover this row. + * + * @param columns Ordered array of column definitions. + * @param buildLeafRender Renderer-supplied function to build each leaf cell's + * payload (e.g. attaching sort/filter callbacks). + */ +export const buildHeaderRows = ( + columns: ColumnTemplate[], + buildLeafRender: BuildLeafRender, +): HeaderRow[] => { + if (columns.length === 0) return []; + + const leftPin: { col: ColumnTemplate; index: number }[] = []; + const middle: { col: ColumnTemplate; index: number }[] = []; + const rightPin: { col: ColumnTemplate; index: number }[] = []; + columns.forEach((col, index) => { + if (col.pin === 'left') leftPin.push({ col, index }); + else if (col.pin === 'right') rightPin.push({ col, index }); + else middle.push({ col, index }); + }); + + // All non-pinned columns must share the same depth; take the max so the + // algorithm has a defined number of group rows even if depths are inconsistent + // (validateInvariants will have reported the error in dev/test). + const depth = middle.reduce( + (max, { col }) => Math.max(max, getDepth(col.groupPath)), + 0, + ); + + validateInvariants(columns, middle, depth); + + // Fast path: no group rows — single leaf row in visual order. + if (depth === 0) { + return [ + { + isLeaf: true, + rowKey: 'leaf', + cells: [...leftPin, ...middle, ...rightPin].map(({ col, index }) => ({ + key: col.id ?? `col-${index}`, + colSpan: 1, + rowSpan: 1, + widthPx: col.widthPx, + pin: col.pin, + className: col.className, + leaf: buildLeafRender(col, index), + })), + }, + ]; + } + + // Pinned columns span every header row (group rows + the leaf row), so their + // rowSpan equals the total number of rows. They are placed only in row 0. + const buildPinCell = ( + col: ColumnTemplate, + index: number, + ): HeaderCell => ({ + key: col.id ?? `pin-${index}`, + colSpan: 1, + rowSpan: depth + 1, + widthPx: col.widthPx, + pin: col.pin, + className: col.className, + leaf: buildLeafRender(col, index), + }); + + const rows: HeaderRow[] = []; + + for (let r = 0; r < depth; r += 1) { + const cells: HeaderCell[] = []; + + // Pinned cells are added once, in the first group row only. + if (r === 0) { + leftPin.forEach(({ col, index }) => cells.push(buildPinCell(col, index))); + } + + cells.push(...buildGroupCells(middle, r)); + + if (r === 0) { + rightPin.forEach(({ col, index }) => + cells.push(buildPinCell(col, index)), + ); + } + + rows.push({ cells, isLeaf: false, rowKey: `group-${r}` }); + } + + // Leaf row: one cell per middle column. Pinned columns are absent — their + // rowSpan cells from row 0 already cover this row visually. + rows.push({ + isLeaf: true, + rowKey: 'leaf', + cells: middle.map(({ col, index }) => ({ + key: col.id ?? `leaf-${index}`, + colSpan: 1, + rowSpan: 1, + widthPx: col.widthPx, + className: col.className, + leaf: buildLeafRender(col, index), + })), + }); + + return rows; +}; diff --git a/client/app/lib/components/table/builder/index.ts b/client/app/lib/components/table/builder/index.ts index 869466251d4..ef2befa46fe 100644 --- a/client/app/lib/components/table/builder/index.ts +++ b/client/app/lib/components/table/builder/index.ts @@ -1,4 +1,16 @@ export type { BuiltColumns } from './buildColumns'; export { buildColumns } from './buildColumns'; -export type { default as ColumnTemplate, Data } from './ColumnTemplate'; +export type { + BuildLeafRender, + GroupHeaderCell, + HeaderCell, + HeaderRow, + LeafHeaderCell, +} from './buildHeaderRows'; +export { buildHeaderRows } from './buildHeaderRows'; +export type { + default as ColumnTemplate, + Data, + GroupSegment, +} from './ColumnTemplate'; export type { default as TableTemplate } from './TableTemplate'; diff --git a/client/app/theme/colors.js b/client/app/theme/colors.js index 4a21bc8ba96..4afde9ff08d 100644 --- a/client/app/theme/colors.js +++ b/client/app/theme/colors.js @@ -43,3 +43,6 @@ export const BLUE_CHART_BACKGROUND = 'rgba(54, 162, 235, 0.2)'; export const BLUE_CHART_BORDER = 'rgba(54, 162, 235, 1)'; export const INVISIBLE_CHART_COLOR = 'rgba(255, 255, 255, 0)'; + +export const TABLE_BORDER = grey[200]; +export const TABLE_BORDER_STRONG = grey[400];