Skip to content

Commit 09a480d

Browse files
committed
feat(table): add buildHeaderRows pure function + tests
1 parent df49860 commit 09a480d

19 files changed

Lines changed: 1733 additions & 118 deletions

client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,59 @@ import { Paper, Table, TableContainer } from '@mui/material';
22

33
import TableProps from '../adapters/Table';
44

5+
import { gridSx } from './gridSxStyles';
56
import MuiTableBody from './MuiTableBody';
67
import MuiTableHeader from './MuiTableHeader';
78
import MuiTablePagination from './MuiTablePagination';
89
import MuiTableToolbar from './MuiTableToolbar';
910

10-
const MuiTable = <H, B, C>(props: TableProps<H, B, C>): JSX.Element => {
11+
const MuiTable = <B, C>(props: TableProps<B, C>): JSX.Element => {
12+
// A flat table produces exactly 1 header row (the leaf row). More than 1
13+
// means at least one group row exists above it.
14+
const hasGroupedHeaders = (props.header?.rows.length ?? 0) > 1;
15+
// Pinned cells only appear in row 0 but checking all rows is harmless.
16+
const hasPinnedColumns =
17+
props.header?.rows.some((r) => r.cells.some((c) => c.pin)) ?? false;
18+
const isScrollContained = props.maxHeight !== undefined;
19+
const stickyHeader = hasGroupedHeaders || isScrollContained;
20+
21+
const sx =
22+
hasGroupedHeaders || hasPinnedColumns
23+
? gridSx({ hasGroupedHeaders, hasPinnedColumns })
24+
: undefined;
25+
1126
return (
1227
<Paper className={props.className} variant="outlined">
1328
<MuiTableToolbar {...props.toolbar} />
1429

15-
<TableContainer>
16-
<Table size="small">
17-
{props.header && <MuiTableHeader {...props.header} />}
30+
<TableContainer
31+
style={
32+
isScrollContained
33+
? {
34+
maxHeight:
35+
typeof props.maxHeight === 'number'
36+
? `${props.maxHeight}px`
37+
: props.maxHeight,
38+
}
39+
: undefined
40+
}
41+
sx={isScrollContained ? { overflow: 'auto' } : undefined}
42+
>
43+
<Table
44+
className={
45+
// MUI applies border-collapse: collapse by default, which ignores
46+
// borderSpacing. gridSx relies on borderSpacing: 0 to flush cell
47+
// gaps, so override to border-separate with !important via
48+
// Tailwind's !border-separate. Only applied when gridSx is active.
49+
hasGroupedHeaders || hasPinnedColumns
50+
? '!border-separate'
51+
: undefined
52+
}
53+
size="small"
54+
stickyHeader={stickyHeader}
55+
sx={sx}
56+
>
57+
{props.header && <MuiTableHeader rows={props.header.rows} />}
1858

1959
<MuiTableBody {...props.body} />
2060
</Table>

client/app/lib/components/table/MuiTableAdapter/MuiTableBody.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { CellRender } from '../adapters/Body';
55

66
import MuiTableRow from './MuiTableRow';
77

8-
const MuiTableBody = <B, C>(props: BodyProps<B, C>): JSX.Element => (
8+
type MuiTableBodyProps<B, C> = BodyProps<B, C>;
9+
10+
const MuiTableBody = <B, C>(props: MuiTableBodyProps<B, C>): JSX.Element => (
911
<TableBody>
1012
{props.rows.map((row, index) => {
1113
const rowProps = props.forEachRow(row, index);
Lines changed: 123 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,131 @@
11
import { TableCell, TableHead, TableRow, TableSortLabel } from '@mui/material';
22

3-
import { HeaderProps, isRowSelector } from '../adapters';
3+
import { isRowSelector } from '../adapters';
4+
import type HeaderProps from '../adapters/Header';
45

6+
import { computePinOffsets, pinCellSx } from './gridSxStyles';
57
import MuiFilterMenu from './MuiFilterMenu';
68
import MuiTableRowSelector from './MuiTableRowSelector';
9+
import { useStickyHeaderOffsets } from './useStickyHeaderOffsets';
710

8-
const MuiTableHeader = <H,>(props: HeaderProps<H>): JSX.Element => (
9-
<TableHead>
10-
<TableRow>
11-
{props.headers.map((header, index) => {
12-
const headerProps = props.forEach(header, index);
13-
14-
return (
15-
<TableCell
16-
key={headerProps.id}
17-
className={`whitespace-nowrap ${headerProps.className ?? ''}`}
18-
>
19-
{isRowSelector(headerProps.render) ? (
20-
<MuiTableRowSelector {...headerProps.render} />
21-
) : (
22-
<>
23-
{headerProps.sorting && (
24-
<TableSortLabel
25-
active={headerProps.sorting.sorted}
26-
direction={headerProps.sorting.direction}
27-
onClick={headerProps.sorting.onClickSort}
28-
>
29-
{headerProps.render}
30-
</TableSortLabel>
31-
)}
32-
33-
{!headerProps.sorting && headerProps.render}
34-
</>
35-
)}
36-
37-
{headerProps.filtering && (
38-
<MuiFilterMenu {...headerProps.filtering} />
39-
)}
40-
</TableCell>
41-
);
42-
})}
43-
</TableRow>
44-
</TableHead>
45-
);
11+
type MuiTableHeaderProps = HeaderProps;
12+
type HeaderCell = MuiTableHeaderProps['rows'][number]['cells'][number];
13+
type HeaderLeaf = NonNullable<HeaderCell['leaf']>;
14+
15+
const renderLeafContent = (leaf: HeaderLeaf): JSX.Element => {
16+
if (isRowSelector(leaf.render)) {
17+
return (
18+
<>
19+
<MuiTableRowSelector {...leaf.render} />
20+
{leaf.filtering && <MuiFilterMenu {...leaf.filtering} />}
21+
</>
22+
);
23+
}
24+
25+
return (
26+
<>
27+
{leaf.sorting ? (
28+
<TableSortLabel
29+
active={leaf.sorting.sorted}
30+
direction={leaf.sorting.direction}
31+
onClick={leaf.sorting.onClickSort}
32+
>
33+
{leaf.render}
34+
</TableSortLabel>
35+
) : (
36+
leaf.render
37+
)}
38+
{leaf.filtering && <MuiFilterMenu {...leaf.filtering} />}
39+
</>
40+
);
41+
};
42+
43+
const MuiTableHeader = (props: MuiTableHeaderProps): JSX.Element => {
44+
const { rows } = props;
45+
const { rowRefs, rowTops } = useStickyHeaderOffsets(rows.length);
46+
47+
// Pinned header cells are emitted only in the first row; grouped pins use rowSpan.
48+
const leftPins = rows[0]?.cells.filter((c) => c.pin === 'left') ?? [];
49+
const rightPins = rows[0]?.cells.filter((c) => c.pin === 'right') ?? [];
50+
51+
const leftOffsets = computePinOffsets(
52+
leftPins.map((c) => c.widthPx ?? 0),
53+
'left',
54+
);
55+
const rightOffsets = computePinOffsets(
56+
rightPins.map((c) => c.widthPx ?? 0),
57+
'right',
58+
);
59+
60+
const leftOffsetMap = new Map(
61+
leftPins.map((c, i) => [c.key, leftOffsets[i]]),
62+
);
63+
const rightOffsetMap = new Map(
64+
rightPins.map((c, i) => [c.key, rightOffsets[i]]),
65+
);
66+
67+
const getPinOffset = (cell: HeaderCell): number | undefined => {
68+
if (!cell.pin) return undefined;
69+
if (cell.pin === 'left') return leftOffsetMap.get(cell.key);
70+
return rightOffsetMap.get(cell.key);
71+
};
72+
73+
const isGrouped = rows.length > 1;
74+
75+
return (
76+
<TableHead>
77+
{rows.map((row, rowIndex) => (
78+
<TableRow
79+
key={row.rowKey}
80+
ref={rowRefs[rowIndex]}
81+
sx={
82+
rowIndex > 0
83+
? {
84+
'& .MuiTableCell-stickyHeader': { top: rowTops[rowIndex] },
85+
}
86+
: undefined
87+
}
88+
>
89+
{row.cells.map((cell) => {
90+
const isLeafCell = cell.leaf !== undefined;
91+
const offset = getPinOffset(cell);
92+
const pinConfig =
93+
cell.pin != null && offset != null && cell.widthPx != null
94+
? { side: cell.pin, offsetPx: offset, widthPx: cell.widthPx }
95+
: undefined;
96+
const isPinnedWithRowSpan =
97+
pinConfig != null && isGrouped && cell.rowSpan > 1;
98+
99+
return (
100+
<TableCell
101+
key={cell.key}
102+
className={[
103+
'whitespace-nowrap',
104+
cell.className ?? '',
105+
isPinnedWithRowSpan ? 'grid-pin-rowspan' : '',
106+
]
107+
.filter(Boolean)
108+
.join(' ')}
109+
colSpan={cell.colSpan > 1 ? cell.colSpan : undefined}
110+
data-table-cell-kind={isLeafCell ? 'leaf' : 'group'}
111+
data-table-pin={cell.pin ?? undefined}
112+
rowSpan={cell.rowSpan > 1 ? cell.rowSpan : undefined}
113+
sx={
114+
pinConfig
115+
? pinCellSx({ ...pinConfig, isHeader: true })
116+
: undefined
117+
}
118+
>
119+
{cell.leaf !== undefined
120+
? renderLeafContent(cell.leaf)
121+
: cell.render}
122+
</TableCell>
123+
);
124+
})}
125+
</TableRow>
126+
))}
127+
</TableHead>
128+
);
129+
};
46130

47131
export default MuiTableHeader;

client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx

Lines changed: 48 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,25 +5,62 @@ import equal from 'fast-deep-equal';
55
import { isRowSelector } from '../adapters';
66
import { CellRender, RowRender } from '../adapters/Body';
77

8+
import { computePinOffsets, pinCellSx } from './gridSxStyles';
89
import MuiTableRowSelector from './MuiTableRowSelector';
910

1011
interface MuiTableRowProps<C> extends RowRender {
1112
getCells: () => C[];
1213
forEachCell: (cell: C, index: number) => CellRender;
1314
}
1415

15-
const MuiTableRow = <C,>(props: MuiTableRowProps<C>): JSX.Element => (
16-
<TableRow className={props.className}>
17-
{props
18-
.getCells()
19-
.map((cell, cellIndex) => props.forEachCell(cell, cellIndex))
20-
.filter((cellProps) => !cellProps.shouldNotRender)
21-
.map((cellProps) => {
16+
const MuiTableRow = <C,>(props: MuiTableRowProps<C>): JSX.Element => {
17+
const allCells = props
18+
.getCells()
19+
.map((cell, i) => props.forEachCell(cell, i));
20+
const visible = allCells.filter((c) => !c.shouldNotRender);
21+
22+
const leftPinWidths = visible
23+
.filter((c) => c.pin === 'left')
24+
.map((c) => c.widthPx ?? 0);
25+
const rightPinWidths = visible
26+
.filter((c) => c.pin === 'right')
27+
.map((c) => c.widthPx ?? 0);
28+
const leftOffsets = computePinOffsets(leftPinWidths, 'left');
29+
const rightOffsets = computePinOffsets(rightPinWidths, 'right');
30+
31+
let leftPinCount = 0;
32+
let rightPinCount = 0;
33+
34+
return (
35+
<TableRow className={props.className}>
36+
{visible.map((cellProps) => {
37+
let pinSx;
38+
39+
if (cellProps.pin === 'left') {
40+
pinSx = pinCellSx({
41+
side: 'left',
42+
offsetPx: leftOffsets[leftPinCount],
43+
widthPx: cellProps.widthPx!,
44+
isHeader: false,
45+
});
46+
leftPinCount += 1;
47+
} else if (cellProps.pin === 'right') {
48+
pinSx = pinCellSx({
49+
side: 'right',
50+
offsetPx: rightOffsets[rightPinCount],
51+
widthPx: cellProps.widthPx!,
52+
isHeader: false,
53+
});
54+
rightPinCount += 1;
55+
}
56+
2257
return (
2358
<TableCell
2459
key={cellProps.id}
2560
className={cellProps.className}
2661
colSpan={cellProps.colSpan}
62+
data-table-pin={cellProps.pin ?? undefined}
63+
sx={pinSx}
2764
>
2865
{isRowSelector(cellProps.render) ? (
2966
<MuiTableRowSelector {...cellProps.render} />
@@ -33,8 +70,9 @@ const MuiTableRow = <C,>(props: MuiTableRowProps<C>): JSX.Element => (
3370
</TableCell>
3471
);
3572
})}
36-
</TableRow>
37-
);
73+
</TableRow>
74+
);
75+
};
3876

3977
export default memo(MuiTableRow, (prevProps, nextProps) => {
4078
if (!prevProps.getEqualityData || !nextProps.getEqualityData) return false;
@@ -46,4 +84,4 @@ export default memo(MuiTableRow, (prevProps, nextProps) => {
4684
return false;
4785

4886
return equal(prevEqualityData, nextEqualityData);
49-
});
87+
}) as typeof MuiTableRow;

0 commit comments

Comments
 (0)