Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <H, B, C>(props: TableProps<H, B, C>): JSX.Element => {
const MuiTable = <B, C>(props: TableProps<B, C>): 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 (
<Paper className={props.className} variant="outlined">
<MuiTableToolbar {...props.toolbar} />

<TableContainer>
<Table size="small">
{props.header && <MuiTableHeader {...props.header} />}
<TableContainer
style={
isScrollContained
? {
maxHeight:
typeof props.maxHeight === 'number'
? `${props.maxHeight}px`
: props.maxHeight,
}
: undefined
}
sx={isScrollContained ? { overflow: 'auto' } : undefined}
>
<Table
className={
// MUI applies border-collapse: collapse by default, which ignores
// borderSpacing. gridSx relies on borderSpacing: 0 to flush cell
// gaps, so override to border-separate with !important via
// Tailwind's !border-separate. Only applied when gridSx is active.
hasGroupedHeaders || hasPinnedColumns
? '!border-separate'
: undefined
}
size="small"
stickyHeader={stickyHeader}
sx={sx}
>
{props.header && <MuiTableHeader rows={props.header.rows} />}

<MuiTableBody {...props.body} />
</Table>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { CellRender } from '../adapters/Body';

import MuiTableRow from './MuiTableRow';

const MuiTableBody = <B, C>(props: BodyProps<B, C>): JSX.Element => (
type MuiTableBodyProps<B, C> = BodyProps<B, C>;

const MuiTableBody = <B, C>(props: MuiTableBodyProps<B, C>): JSX.Element => (
<TableBody>
{props.rows.map((row, index) => {
const rowProps = props.forEachRow(row, index);
Expand Down
163 changes: 124 additions & 39 deletions client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx
Original file line number Diff line number Diff line change
@@ -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 = <H,>(props: HeaderProps<H>): JSX.Element => (
<TableHead>
<TableRow>
{props.headers.map((header, index) => {
const headerProps = props.forEach(header, index);

return (
<TableCell
key={headerProps.id}
className={`whitespace-nowrap ${headerProps.className ?? ''}`}
>
{isRowSelector(headerProps.render) ? (
<MuiTableRowSelector {...headerProps.render} />
) : (
<>
{headerProps.sorting && (
<TableSortLabel
active={headerProps.sorting.sorted}
direction={headerProps.sorting.direction}
onClick={headerProps.sorting.onClickSort}
>
{headerProps.render}
</TableSortLabel>
)}

{!headerProps.sorting && headerProps.render}
</>
)}

{headerProps.filtering && (
<MuiFilterMenu {...headerProps.filtering} />
)}
</TableCell>
);
})}
</TableRow>
</TableHead>
);
type MuiTableHeaderProps = HeaderProps;
type HeaderCell = MuiTableHeaderProps['rows'][number]['cells'][number];
type HeaderLeaf = NonNullable<HeaderCell['leaf']>;

const renderLeafContent = (leaf: HeaderLeaf): JSX.Element => {
if (isRowSelector(leaf.render)) {
return (
<>
<MuiTableRowSelector {...leaf.render} />
{leaf.filtering && <MuiFilterMenu {...leaf.filtering} />}
</>
);
}

return (
<>
{leaf.sorting ? (
<TableSortLabel
active={leaf.sorting.sorted}
direction={leaf.sorting.direction}
onClick={leaf.sorting.onClickSort}
>
{leaf.render}
</TableSortLabel>
) : (
leaf.render
)}
{leaf.filtering && <MuiFilterMenu {...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 (
<TableHead>
{rows.map((row, rowIndex) => (
<TableRow
key={row.rowKey}
ref={rowRefs[rowIndex]}
sx={
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 (
<TableCell
key={cell.key}
className={[
'whitespace-nowrap',
cell.className ?? '',
isPinnedWithRowSpan ? 'grid-pin-rowspan' : '',
]
.filter(Boolean)
.join(' ')}
colSpan={cell.colSpan > 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}
</TableCell>
);
})}
</TableRow>
))}
</TableHead>
);
};

export default MuiTableHeader;
62 changes: 52 additions & 10 deletions client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,66 @@ 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<C> extends RowRender {
getCells: () => C[];
forEachCell: (cell: C, index: number) => CellRender;
}

const MuiTableRow = <C,>(props: MuiTableRowProps<C>): JSX.Element => (
<TableRow className={props.className}>
{props
.getCells()
.map((cell, cellIndex) => props.forEachCell(cell, cellIndex))
.filter((cellProps) => !cellProps.shouldNotRender)
.map((cellProps) => {
const MuiTableRow = <C,>(props: MuiTableRowProps<C>): 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 (
<TableRow className={props.className}>
{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 (
<TableCell
key={cellProps.id}
className={cellProps.className}
colSpan={cellProps.colSpan}
data-table-pin={cellProps.pin ?? undefined}
data-table-pin-offset-px={pinOffsetPx}
sx={pinSx}
>
{isRowSelector(cellProps.render) ? (
<MuiTableRowSelector {...cellProps.render} />
Expand All @@ -33,8 +74,9 @@ const MuiTableRow = <C,>(props: MuiTableRowProps<C>): JSX.Element => (
</TableCell>
);
})}
</TableRow>
);
</TableRow>
);
};

export default memo(MuiTableRow, (prevProps, nextProps) => {
if (!prevProps.getEqualityData || !nextProps.getEqualityData) return false;
Expand All @@ -46,4 +88,4 @@ export default memo(MuiTableRow, (prevProps, nextProps) => {
return false;

return equal(prevEqualityData, nextEqualityData);
});
}) as typeof MuiTableRow;
Loading