Skip to content

Commit ec5ec1c

Browse files
committed
refactor(gradebook): GradebookTable consumes extended Table
Removes ~230 lines of bespoke MUI/sx/useLayoutEffect machinery from GradebookTable in favor of the grouped-header / pin API added in lib/components/table. Intentional behavior change: Total column is now sticky-right during horizontal scroll (was previously rowSpan-only without sticky).
1 parent adb06b2 commit ec5ec1c

1 file changed

Lines changed: 63 additions & 234 deletions

File tree

client/app/bundles/course/gradebook/components/GradebookTable.tsx

Lines changed: 63 additions & 234 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
1-
import { FC, useLayoutEffect, useMemo, useRef, useState } from 'react';
1+
import { FC, useMemo } from 'react';
22
import { defineMessages } from 'react-intl';
3-
import {
4-
Paper,
5-
Table,
6-
TableBody,
7-
TableCell,
8-
TableContainer,
9-
TableHead,
10-
TableRow,
11-
Tooltip,
12-
} from '@mui/material';
13-
import { black, grey, white } from 'theme/colors';
3+
import { Tooltip } from '@mui/material';
144

5+
import { ColumnTemplate } from 'lib/components/table';
6+
import Table from 'lib/components/table/Table';
157
import useTranslation from 'lib/hooks/useTranslation';
168

179
import { AssessmentData, StudentRow, TabData } from '../types';
@@ -34,14 +26,6 @@ interface Props {
3426
showRawScore: boolean;
3527
}
3628

37-
interface HeaderGroup {
38-
id: number;
39-
title: string;
40-
colSpan: number;
41-
}
42-
43-
const ASSESSMENT_COL_WIDTH = 'w-[160px] min-w-[160px] max-w-[160px]';
44-
4529
const RawScore: FC<{ grade: number; maxGrade: number }> = ({
4630
grade,
4731
maxGrade,
@@ -68,232 +52,77 @@ const formatGrade = (
6852
return `${((grade / maxGrade) * 100).toFixed(2)}%`;
6953
};
7054

71-
const buildHeaderGroups = (
55+
const buildColumns = (
7256
assessments: AssessmentData[],
7357
tabMap: Map<number, TabData>,
74-
getId: (tab: TabData) => number,
75-
getTitle: (tab: TabData) => string,
76-
): HeaderGroup[] => {
77-
const seen = assessments.reduce((map, assessment) => {
78-
const tab = tabMap.get(assessment.tabId);
79-
if (!tab) return map;
80-
const id = getId(tab);
81-
const existing = map.get(id);
82-
if (existing) {
83-
existing.colSpan += 1;
84-
} else {
85-
map.set(id, { id, title: getTitle(tab), colSpan: 1 });
86-
}
87-
return map;
88-
}, new Map<number, HeaderGroup>());
89-
return Array.from(seen.values());
90-
};
91-
92-
const stickyNameCell = {
93-
position: 'sticky',
94-
left: 0,
95-
zIndex: 20,
96-
backgroundColor: white,
97-
minWidth: '12rem',
98-
boxShadow: `inset -1px 0 0 ${grey[400]}`,
99-
};
100-
101-
const stickyNameHeaderCell = {
102-
...stickyNameCell,
103-
zIndex: 40,
104-
color: black,
105-
};
106-
107-
const headerCell = {
108-
backgroundColor: white,
109-
fontWeight: 600,
110-
textAlign: 'center',
111-
color: black,
112-
zIndex: 30,
113-
};
114-
115-
const rowSpanHeaderCellSx = {
116-
backgroundColor: white,
117-
// rowSpan=3 cells sit in row 1, so the last-child selector in Table sx won't
118-
// match them. !important overrides MUI's default cell border for these cells.
119-
borderBottom: `2px solid ${grey[600]} !important`,
58+
t: ReturnType<typeof useTranslation>['t'],
59+
showRawScore: boolean,
60+
): ColumnTemplate<StudentRow>[] => {
61+
const numberAlign = showRawScore ? 'text-left' : 'text-right';
62+
63+
const leaves: ColumnTemplate<StudentRow>[] = assessments
64+
.map((a) => {
65+
const tab = tabMap.get(a.tabId);
66+
if (!tab) return null;
67+
return {
68+
id: `assessment-${a.id}`,
69+
title: (
70+
<Tooltip placement="top" title={a.title}>
71+
<span className="block truncate">{a.title}</span>
72+
</Tooltip>
73+
),
74+
groupPath: [
75+
{ id: tab.categoryId, title: tab.categoryTitle, label: tab.categoryTitle },
76+
{ id: tab.id, title: tab.title, label: tab.title },
77+
],
78+
widthPx: 160,
79+
className: `${numberAlign} tabular-nums`,
80+
cell: (row) =>
81+
formatGrade(row.grades[String(a.id)] ?? 0, a.maxGrade, showRawScore),
82+
} satisfies ColumnTemplate<StudentRow>;
83+
})
84+
.filter((c): c is ColumnTemplate<StudentRow> => c !== null);
85+
86+
return [
87+
{
88+
id: 'name',
89+
title: t(translations.studentName),
90+
pin: 'left',
91+
widthPx: 192,
92+
cell: (row) => row.name,
93+
},
94+
...leaves,
95+
{
96+
id: 'total',
97+
title: t(translations.total),
98+
pin: 'right',
99+
widthPx: 96,
100+
className: `${numberAlign} tabular-nums`,
101+
cell: (row) =>
102+
formatGrade(row.totalGrade, row.totalMaxGrade, showRawScore),
103+
},
104+
];
120105
};
121106

122-
const GradebookTable: FC<Props> = ({
123-
tabs,
124-
assessments,
125-
students,
126-
showRawScore,
127-
}) => {
107+
const GradebookTable: FC<Props> = ({ tabs, assessments, students, showRawScore }) => {
128108
const { t } = useTranslation();
129-
130-
// MUI stickyHeader sets top:0 on every thead cell, stacking all three rows at
131-
// the same y-position. Measure row heights and override `top` on rows 2 and 3.
132-
const row1Ref = useRef<HTMLTableRowElement>(null);
133-
const row2Ref = useRef<HTMLTableRowElement>(null);
134-
const [row2Top, setRow2Top] = useState(0);
135-
const [row3Top, setRow3Top] = useState(0);
136-
137109
const tabMap = useMemo(
138110
() => new Map(tabs.map((tab) => [tab.id, tab])),
139111
[tabs],
140112
);
141-
142-
const categoryGroups = useMemo(
143-
() =>
144-
buildHeaderGroups(
145-
assessments,
146-
tabMap,
147-
(tab) => tab.categoryId,
148-
(tab) => tab.categoryTitle,
149-
),
150-
[assessments, tabMap],
151-
);
152-
153-
const tabGroups = useMemo(
154-
() =>
155-
buildHeaderGroups(
156-
assessments,
157-
tabMap,
158-
(tab) => tab.id,
159-
(tab) => tab.title,
160-
),
161-
[assessments, tabMap],
113+
const columns = useMemo(
114+
() => buildColumns(assessments, tabMap, t, showRawScore),
115+
[assessments, tabMap, t, showRawScore],
162116
);
163117

164-
useLayoutEffect(() => {
165-
const h1 = row1Ref.current?.offsetHeight ?? 0;
166-
const h2 = row2Ref.current?.offsetHeight ?? 0;
167-
setRow2Top(h1);
168-
setRow3Top(h1 + h2);
169-
}, [categoryGroups, tabGroups]);
170-
171-
const gradeAlign = showRawScore ? 'left' : 'right';
172-
173118
return (
174-
<TableContainer
175-
className="max-h-[70vh] w-full"
176-
component={Paper}
177-
variant="outlined"
178-
>
179-
<Table
180-
className="!border-separate"
181-
stickyHeader
182-
sx={{
183-
borderSpacing: 0,
184-
// Sticky cells at the same z-index paint in DOM order, so a later
185-
// cell's white background covers the border of the cell before it.
186-
// Fix: let the COVERING cell own the border line.
187-
// Horizontal: use borderLeft (not borderRight) — cell N+1 owns the vertical sep.
188-
// Vertical: use borderTop on non-first header rows (not borderBottom on row above).
189-
'& .MuiTableCell-root': {
190-
borderBottom: `1px solid ${grey[400]}`,
191-
borderLeft: `1px solid ${grey[400]}`,
192-
backgroundColor: white,
193-
},
194-
'& .MuiTableCell-stickyHeader': {
195-
backgroundColor: white,
196-
},
197-
'& .assessment-cell': {
198-
boxShadow: `inset 1px 0 0 ${grey[400]}`,
199-
},
200-
// Non-last header rows: drop borderBottom (covered by the next row's bg).
201-
'& .MuiTableHead-root .MuiTableRow-root:not(:last-child) .MuiTableCell-root':
202-
{
203-
borderBottom: 'none',
204-
},
205-
// Non-first header rows: add borderTop so the line is owned by the covering row.
206-
'& .MuiTableHead-root .MuiTableRow-root:not(:first-child) .MuiTableCell-root':
207-
{
208-
borderTop: `1px solid ${grey[400]}`,
209-
},
210-
// Last header row: thick bottom border separating header from body.
211-
'& .MuiTableHead-root .MuiTableRow-root:last-child .MuiTableCell-root':
212-
{
213-
borderBottom: `2px solid ${grey[600]}`,
214-
},
215-
}}
216-
>
217-
<TableHead>
218-
<TableRow ref={row1Ref}>
219-
<TableCell
220-
rowSpan={3}
221-
sx={{ ...stickyNameHeaderCell, ...rowSpanHeaderCellSx }}
222-
>
223-
{t(translations.studentName)}
224-
</TableCell>
225-
{categoryGroups.map((cat) => (
226-
<TableCell key={cat.id} colSpan={cat.colSpan} sx={headerCell}>
227-
{cat.title}
228-
</TableCell>
229-
))}
230-
<TableCell
231-
rowSpan={3}
232-
sx={{ ...headerCell, ...rowSpanHeaderCellSx }}
233-
>
234-
{t(translations.total)}
235-
</TableCell>
236-
</TableRow>
237-
238-
<TableRow
239-
ref={row2Ref}
240-
sx={{ '& .MuiTableCell-stickyHeader': { top: row2Top } }}
241-
>
242-
{tabGroups.map((tab) => (
243-
<TableCell
244-
key={tab.id}
245-
align="center"
246-
colSpan={tab.colSpan}
247-
sx={headerCell}
248-
>
249-
{tab.title}
250-
</TableCell>
251-
))}
252-
</TableRow>
253-
254-
<TableRow sx={{ '& .MuiTableCell-stickyHeader': { top: row3Top } }}>
255-
{assessments.map((assessment) => (
256-
<TableCell
257-
key={assessment.id}
258-
className={`${ASSESSMENT_COL_WIDTH} assessment-cell z-30 text-black`}
259-
>
260-
<Tooltip placement="top" title={assessment.title}>
261-
<span className="block truncate">{assessment.title}</span>
262-
</Tooltip>
263-
</TableCell>
264-
))}
265-
</TableRow>
266-
</TableHead>
267-
268-
<TableBody>
269-
{students.map((student) => (
270-
<TableRow key={student.id}>
271-
<TableCell sx={stickyNameCell}>{student.name}</TableCell>
272-
{assessments.map((assessment) => (
273-
<TableCell
274-
key={assessment.id}
275-
align={gradeAlign}
276-
className={`${ASSESSMENT_COL_WIDTH} tabular-nums`}
277-
>
278-
{formatGrade(
279-
student.grades[String(assessment.id)] ?? 0,
280-
assessment.maxGrade,
281-
showRawScore,
282-
)}
283-
</TableCell>
284-
))}
285-
<TableCell align={gradeAlign} className="tabular-nums">
286-
{formatGrade(
287-
student.totalGrade,
288-
student.totalMaxGrade,
289-
showRawScore,
290-
)}
291-
</TableCell>
292-
</TableRow>
293-
))}
294-
</TableBody>
295-
</Table>
296-
</TableContainer>
119+
<Table
120+
columns={columns}
121+
data={students}
122+
getRowId={(s) => s.id.toString()}
123+
getRowEqualityData={(s) => s}
124+
maxHeight="70vh"
125+
/>
297126
);
298127
};
299128

0 commit comments

Comments
 (0)