diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..3d746de87be --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true +class Course::GradebookComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Gradebook' + end + + def sidebar_items + return [] unless can?(:read_gradebook, current_course) + + [ + { + key: :gradebook, + icon: :gradebook, + weight: 9, + path: course_gradebook_path(current_course) + } + ] + end +end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb new file mode 100644 index 00000000000..4448323c67f --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +class Course::GradebookController < Course::ComponentController + before_action :authorize_read_gradebook! + + def index + @published_assessments = fetch_published_assessments + assessment_ids = @published_assessments.pluck(:id) + @students = current_course.course_users.students.without_phantom_users.includes(:user) + student_ids = @students.pluck(:user_id) + @assessment_max_grades = Course::Assessment.max_grades(assessment_ids) + @student_assessment_grades = Course::Assessment::Submission.grade_summary( + student_ids: student_ids, + assessment_ids: assessment_ids + ) + end + + private + + def authorize_read_gradebook! + authorize! :read_gradebook, current_course + end + + def component + current_component_host[:course_gradebook_component] + end + + def fetch_published_assessments + current_course.assessments. + published. + includes(tab: :category). + joins(tab: :category). + reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id') + end +end diff --git a/app/models/components/course/gradebook_ability_component.rb b/app/models/components/course/gradebook_ability_component.rb new file mode 100644 index 00000000000..e924d721a18 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + allow_staff_read_gradebook if course_user&.staff? + super + end + + private + + def allow_staff_read_gradebook + can :read_gradebook, Course, id: course.id + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 3128ed42528..aec93f9de08 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -160,6 +160,22 @@ def self.use_relative_model_naming? true end + # Returns a hash of assessment_id => max_grade (sum of question maximum_grades). + def self.max_grades(assessment_ids) + return {} if assessment_ids.empty? + + rows = find_by_sql( + sanitize_sql_array([<<-SQL.squish, assessment_ids]) + SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade + FROM course_question_assessments cqa + JOIN course_assessment_questions caq ON caq.id = cqa.question_id + WHERE cqa.assessment_id IN (?) + GROUP BY cqa.assessment_id + SQL + ) + rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] } + end + def to_partial_path 'course/assessment/assessments/assessment' end diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index c4919d6ec14..b0edd494d6a 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -323,6 +323,26 @@ def self.on_dependent_status_change(answer) answer.submission.last_graded_time = Time.now end + # Returns a hash of [creator_id, assessment_id] => grade for the given students and assessments. + # Only graded/published submissions are counted; submitted-but-ungraded contribute 0. + def self.grade_summary(student_ids:, assessment_ids:) + return {} if student_ids.empty? || assessment_ids.empty? + + rows = find_by_sql( + sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) + SELECT cas.creator_id, cas.assessment_id, COALESCE(SUM(caa.grade), 0) AS grade + FROM course_assessment_submissions cas + JOIN course_assessment_answers caa ON caa.submission_id = cas.id + WHERE cas.creator_id IN (?) + AND cas.assessment_id IN (?) + AND cas.workflow_state IN ('graded', 'published') + AND caa.current_answer = TRUE + GROUP BY cas.creator_id, cas.assessment_id + SQL + ) + rows.to_h { |row| [[row.creator_id, row.assessment_id], row.grade.to_f] } + end + private # Queues the submission for auto grading, after the submission has changed to the submitted state. diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder new file mode 100644 index 00000000000..0b84958c8c1 --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,36 @@ +# frozen_string_literal: true +tabs = @published_assessments.map(&:tab).uniq(&:id) + +json.tabs tabs do |tab| + json.id tab.id + json.title tab.title + json.categoryId tab.category.id + json.categoryTitle tab.category.title +end + +json.assessments @published_assessments do |assessment| + json.id assessment.id + json.title assessment.title + json.tabId assessment.tab_id + json.maxGrade @assessment_max_grades[assessment.id] || 0.0 +end + +json.students @students do |course_user| + user_id = course_user.user_id + total_grade = 0.0 + total_max_grade = 0.0 + grades = {} + + @published_assessments.each do |assessment| + grade = @student_assessment_grades[[user_id, assessment.id]] || 0.0 + grades[assessment.id] = grade + total_grade += grade + total_max_grade += @assessment_max_grades[assessment.id] || 0.0 + end + + json.id course_user.id + json.name course_user.name + json.grades grades + json.totalGrade total_grade + json.totalMaxGrade total_max_grade +end diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts new file mode 100644 index 00000000000..6c3018f14aa --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,14 @@ +import { AxiosResponse } from 'axios'; +import { GradebookData } from 'types/course/gradebook'; + +import BaseCourseAPI from './Base'; + +export default class GradebookAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/gradebook`; + } + + index(): Promise> { + return this.client.get(this.#urlPrefix); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 8f5df6176fe..355a5878c53 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -12,6 +12,7 @@ import DuplicationAPI from './Duplication'; import EnrolRequestsAPI from './EnrolRequests'; import ExperiencePointsRecordAPI from './ExperiencePointsRecord'; import ForumAPI from './Forum'; +import GradebookAPI from './Gradebook'; import GroupsAPI from './Groups'; import LeaderboardAPI from './Leaderboard'; import LearningMapAPI from './LearningMap'; @@ -48,6 +49,7 @@ const CourseAPI = { experiencePointsRecord: new ExperiencePointsRecordAPI(), folders: new FoldersAPI(), forum: ForumAPI, + gradebook: new GradebookAPI(), groups: new GroupsAPI(), leaderboard: new LeaderboardAPI(), learningMap: new LearningMapAPI(), diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx new file mode 100644 index 00000000000..2b425d8dc1b --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from 'test-utils'; + +import GradebookIndex from '../pages/GradebookIndex'; +import { GradebookState } from '../types'; + +jest.mock('../operations', () => ({ + __esModule: true, + default: () => (): Promise => Promise.resolve(), +})); + +const gradebookState: GradebookState = { + tabs: [ + { id: 1, title: 'Assignments', categoryId: 10, categoryTitle: 'Missions' }, + ], + assessments: [{ id: 10, title: 'Assignment 1', tabId: 1, maxGrade: 100 }], + students: [ + { + id: 1, + name: 'Alice Smith', + grades: { '10': 80 }, + totalGrade: 80, + totalMaxGrade: 100, + }, + { + id: 2, + name: 'Bob Jones', + grades: { '10': 60 }, + totalGrade: 60, + totalMaxGrade: 100, + }, + ], +}; + +describe('', () => { + it('renders all students initially', async () => { + render(, { state: { gradebook: gradebookState } }); + expect(await screen.findByText('Alice Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Jones')).toBeInTheDocument(); + }); + + it('filters students by name on search input', async () => { + render(, { state: { gradebook: gradebookState } }); + + const input = await screen.findByPlaceholderText('Search by student name'); + fireEvent.change(input, { target: { value: 'alice' } }); + + expect(screen.getByText('Alice Smith')).toBeInTheDocument(); + expect(screen.queryByText('Bob Jones')).not.toBeInTheDocument(); + }); + + it('renders a show percentage toggle', async () => { + render(, { state: { gradebook: gradebookState } }); + expect(await screen.findByLabelText('Show percentage')).toBeInTheDocument(); + }); + + it('shows raw scores by default', async () => { + render(, { state: { gradebook: gradebookState } }); + + // Raw score is the default — Alice's grade shows as "80 / 100" + expect(await screen.findAllByText('80 / 100')).not.toHaveLength(0); + expect(screen.queryByText('80.00%')).not.toBeInTheDocument(); + }); + + it('switches from raw score to percentage when the toggle is clicked', async () => { + render(, { state: { gradebook: gradebookState } }); + + // Wait for default raw score rendering + await screen.findAllByText('80 / 100'); + + // Click the "Show percentage" toggle + fireEvent.click(screen.getByLabelText('Show percentage')); + + // Percentage replaces the raw score + expect(await screen.findAllByText('80.00%')).not.toHaveLength(0); + expect(screen.queryByText('80 / 100')).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx new file mode 100644 index 00000000000..61ef652c35c --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,181 @@ +import { render, screen } from 'test-utils'; + +import GradebookTable from '../components/GradebookTable'; +import { AssessmentData, StudentRow, TabData } from '../types'; + +const tabs: TabData[] = [ + { id: 1, title: 'Assignments', categoryId: 10, categoryTitle: 'Missions' }, + { id: 2, title: 'Quizzes', categoryId: 10, categoryTitle: 'Missions' }, +]; + +const assessments: AssessmentData[] = [ + { id: 10, title: 'Assignment 1', tabId: 1, maxGrade: 100 }, + { id: 20, title: 'Quiz 1', tabId: 2, maxGrade: 20 }, + { id: 30, title: 'Quiz 2', tabId: 2, maxGrade: 0 }, +]; + +const students: StudentRow[] = [ + { + id: 1, + name: 'Alice Smith', + grades: { '10': 80, '20': 15, '30': 0 }, + totalGrade: 95, + totalMaxGrade: 120, + }, + { + id: 2, + name: 'Bob Jones', + grades: { '10': 0, '20': 0, '30': 0 }, + totalGrade: 0, + totalMaxGrade: 100, + }, +]; + +describe('', () => { + it('renders a column header for each assessment and a Total column', async () => { + render( + , + ); + expect(await screen.findByText('Assignment 1')).toBeInTheDocument(); + expect(screen.getByText('Quiz 1')).toBeInTheDocument(); + expect(screen.getByText('Total')).toBeInTheDocument(); + }); + + it('renders student names', async () => { + render( + , + ); + expect(await screen.findByText('Alice Smith')).toBeInTheDocument(); + expect(screen.getByText('Bob Jones')).toBeInTheDocument(); + }); + + it('renders a category header row spanning its tabs', async () => { + render( + , + ); + expect(await screen.findByText('Missions')).toBeInTheDocument(); + }); + + it('renders a tab header row with each tab title', async () => { + render( + , + ); + const headers = await screen.findAllByRole('columnheader'); + const headerTexts = headers.map((h) => h.textContent); + expect(headerTexts).toContain('Assignments'); + expect(headerTexts).toContain('Quizzes'); + }); + + it('truncates a long assessment title in the column header', async () => { + const longTitle = + 'This Is An Extremely Long Assessment Title That Should Be Visually Truncated'; + render( + , + ); + expect(await screen.findByText(longTitle)).toHaveClass('truncate'); + }); + + describe('when showPercentage is true', () => { + it('renders percentage for an assessment grade', async () => { + render( + , + ); + // Alice: 80/100 = 80.00% + expect(await screen.findByText('80.00%')).toBeInTheDocument(); + }); + + it('renders percentage for the total', async () => { + render( + , + ); + // Alice total: 95/120 = 79.17% + expect(await screen.findByText('79.17%')).toBeInTheDocument(); + }); + + it('renders – when maxGrade is 0', async () => { + render( + , + ); + expect((await screen.findAllByText('–')).length).toBeGreaterThan(0); + }); + }); + + describe('when showPercentage is false', () => { + it('renders raw score for an assessment grade with sr-only accessible text', async () => { + render( + , + ); + // Alice: 80/100 — accessible text is in an sr-only span + expect((await screen.findAllByText('80 / 100')).length).toBeGreaterThan(0); + }); + + it('renders raw score for the total', async () => { + render( + , + ); + expect((await screen.findAllByText('95 / 120')).length).toBeGreaterThan(0); + }); + + it('still renders – when maxGrade is 0', async () => { + render( + , + ); + expect((await screen.findAllByText('–')).length).toBeGreaterThan(0); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx new file mode 100644 index 00000000000..56133e09dee --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -0,0 +1,129 @@ +import { FC, useMemo } from 'react'; +import { defineMessages } from 'react-intl'; +import { Tooltip } from '@mui/material'; + +import { ColumnTemplate } from 'lib/components/table'; +import Table from 'lib/components/table/Table'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { AssessmentData, StudentRow, TabData } from '../types'; + +const translations = defineMessages({ + studentName: { + id: 'course.gradebook.GradebookTable.studentName', + defaultMessage: 'Student Name', + }, + total: { + id: 'course.gradebook.GradebookTable.total', + defaultMessage: 'Total', + }, +}); + +interface Props { + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentRow[]; + showPercentage: boolean; +} + +const RawScore: FC<{ grade: number; maxGrade: number }> = ({ + grade, + maxGrade, +}) => ( + <> + + {grade} / {maxGrade} + +
+ {grade} + / + {maxGrade} +
+ +); + +const formatGrade = ( + grade: number, + maxGrade: number, + showPercentage: boolean, +): string | JSX.Element => { + if (maxGrade === 0) return '–'; + if (!showPercentage) return ; + return `${((grade / maxGrade) * 100).toFixed(2)}%`; +}; + +const buildColumns = ( + assessments: AssessmentData[], + tabMap: Map, + t: ReturnType['t'], + showPercentage: boolean, +): ColumnTemplate[] => { + const numberAlign = showPercentage ? 'text-right' : 'text-left'; + + const leaves: ColumnTemplate[] = assessments + .map((a) => { + const tab = tabMap.get(a.tabId); + if (!tab) return null; + return { + id: `assessment-${a.id}`, + title: ( + + {a.title} + + ), + groupPath: [ + { id: tab.categoryId, title: tab.categoryTitle, label: tab.categoryTitle }, + { id: tab.id, title: tab.title, label: tab.title }, + ], + widthPx: 160, + className: `${numberAlign} tabular-nums`, + cell: (row) => + formatGrade(row.grades[String(a.id)] ?? 0, a.maxGrade, showPercentage), + } satisfies ColumnTemplate; + }) + .filter((c): c is ColumnTemplate => c !== null); + + return [ + { + id: 'name', + title: t(translations.studentName), + pin: 'left', + widthPx: 192, + cell: (row) => row.name, + }, + ...leaves, + { + id: 'total', + title: t(translations.total), + pin: 'right', + widthPx: 96, + className: `${numberAlign} tabular-nums`, + cell: (row) => + formatGrade(row.totalGrade, row.totalMaxGrade, showPercentage), + }, + ]; +}; + +const GradebookTable: FC = ({ tabs, assessments, students, showPercentage }) => { + const { t } = useTranslation(); + const tabMap = useMemo( + () => new Map(tabs.map((tab) => [tab.id, tab])), + [tabs], + ); + const columns = useMemo( + () => buildColumns(assessments, tabMap, t, showPercentage), + [assessments, tabMap, t, showPercentage], + ); + + return ( + s.id.toString()} + getRowEqualityData={(s) => ({ ...s, showPercentage })} + maxHeight="70vh" + /> + ); +}; + +export default GradebookTable; diff --git a/client/app/bundles/course/gradebook/handles.ts b/client/app/bundles/course/gradebook/handles.ts new file mode 100644 index 00000000000..7b204f48ed0 --- /dev/null +++ b/client/app/bundles/course/gradebook/handles.ts @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl'; + +import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +const translations = defineMessages({ + header: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, +}); + +export const gradebookHandle: DataHandle = (match) => { + const courseId = match.params.courseId; + + return { + getData: async (): Promise => ({ + activePath: `/courses/${courseId}/gradebook`, + content: { title: translations.header }, + }), + }; +}; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts new file mode 100644 index 00000000000..fd9835eb9ac --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,13 @@ +import { Operation } from 'store'; + +import CourseAPI from 'api/course'; + +import { actions } from './store'; + +const fetchGradebook = (): Operation => async (dispatch) => + CourseAPI.gradebook.index().then((response) => { + const { tabs, assessments, students } = response.data; + dispatch(actions.saveGradebook(tabs, assessments, students)); + }); + +export default fetchGradebook; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx new file mode 100644 index 00000000000..dabad30769c --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,94 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { FormControlLabel, Switch, TextField } from '@mui/material'; + +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import GradebookTable from '../../components/GradebookTable'; +import fetchGradebook from '../../operations'; +import { getAssessments, getStudents, getTabs } from '../../selectors'; + +const translations = defineMessages({ + gradebook: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, + fetchFailure: { + id: 'course.gradebook.GradebookIndex.fetchFailure', + defaultMessage: 'Failed to retrieve Gradebook.', + }, + searchPlaceholder: { + id: 'course.gradebook.GradebookIndex.searchPlaceholder', + defaultMessage: 'Search by student name', + }, + showPercentage: { + id: 'course.gradebook.GradebookIndex.showPercentage', + defaultMessage: 'Show percentage', + }, +}); + +const GradebookIndex: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [isLoading, setIsLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [showPercentage, setShowPercentage] = useState(false); + + const tabs = useAppSelector(getTabs); + const assessments = useAppSelector(getAssessments); + const students = useAppSelector(getStudents); + + useEffect(() => { + dispatch(fetchGradebook()) + .finally(() => setIsLoading(false)) + .catch(() => toast.error(t(translations.fetchFailure))); + }, [dispatch]); + + const filteredStudents = students.filter((s) => + s.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + return ( + + {isLoading ? ( + + ) : ( + <> +
+ setSearchQuery(e.target.value)} + placeholder={t(translations.searchPlaceholder)} + size="medium" + value={searchQuery} + variant="outlined" + /> + setShowPercentage(e.target.checked)} + /> + } + label={t(translations.showPercentage)} + /> +
+
+ +
+ + )} +
+ ); +}; + +export default GradebookIndex; diff --git a/client/app/bundles/course/gradebook/selectors.ts b/client/app/bundles/course/gradebook/selectors.ts new file mode 100644 index 00000000000..2d3ca5efdae --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,9 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ +import { AppState } from 'store'; + +const getLocalState = (state: AppState) => state.gradebook; + +export const getTabs = (state: AppState) => getLocalState(state).tabs; +export const getAssessments = (state: AppState) => + getLocalState(state).assessments; +export const getStudents = (state: AppState) => getLocalState(state).students; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..f0f40a6ab4b --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,50 @@ +import { produce } from 'immer'; +import type { + AssessmentData, + StudentRow, + TabData, +} from 'types/course/gradebook'; + +import { + GradebookActionType, + GradebookState, + SAVE_GRADEBOOK, + SaveGradebookAction, +} from './types'; + +const initialState: GradebookState = { + tabs: [], + assessments: [], + students: [], +}; + +const reducer = produce( + (draft: GradebookState, action: GradebookActionType) => { + switch (action.type) { + case SAVE_GRADEBOOK: { + draft.tabs = action.tabs; + draft.assessments = action.assessments; + draft.students = action.students; + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: ( + tabs: TabData[], + assessments: AssessmentData[], + students: StudentRow[], + ): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + tabs, + assessments, + students, + }), +}; + +export default reducer; diff --git a/client/app/bundles/course/gradebook/types.ts b/client/app/bundles/course/gradebook/types.ts new file mode 100644 index 00000000000..11968e3568a --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,29 @@ +import type { + AssessmentData, + StudentRow, + TabData, +} from 'types/course/gradebook'; + +export type { + AssessmentData, + GradebookData, + StudentRow, + TabData, +} from 'types/course/gradebook'; + +export const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; + +export interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentRow[]; +} + +export type GradebookActionType = SaveGradebookAction; + +export interface GradebookState { + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentRow[]; +} diff --git a/client/app/bundles/course/material/folders/components/tables/WorkbinTable.tsx b/client/app/bundles/course/material/folders/components/tables/WorkbinTable.tsx index aab99edbc1c..01cbd89e73b 100644 --- a/client/app/bundles/course/material/folders/components/tables/WorkbinTable.tsx +++ b/client/app/bundles/course/material/folders/components/tables/WorkbinTable.tsx @@ -61,7 +61,6 @@ const WorkbinTable: FC = (props) => { subfolders, materials, isCurrentCourseStudent, - canManageKnowledgeBase, isConcrete, } = props; diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx index c4e2957ad96..1e4859b784f 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx @@ -2,21 +2,58 @@ 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 => { + const hasGroupedHeaders = (props.header?.rows.length ?? 0) > 1; + 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..617429799c3 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableBody.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableBody.tsx @@ -5,7 +5,11 @@ import { CellRender } from '../adapters/Body'; import MuiTableRow from './MuiTableRow'; -const MuiTableBody = (props: BodyProps): JSX.Element => ( +interface MuiTableBodyProps extends BodyProps { + hasPinnedColumns?: boolean; +} + +const MuiTableBody = (props: MuiTableBodyProps): JSX.Element => ( {props.rows.map((row, index) => { const rowProps = props.forEachRow(row, index); @@ -19,6 +23,7 @@ const MuiTableBody = (props: BodyProps): JSX.Element => ( } getCells={(): C[] => props.getCells(row)} getEqualityData={rowProps.getEqualityData} + hasPinnedColumns={props.hasPinnedColumns} id={rowProps.id} /> ); diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx index 49a4fe1f954..29eb9e718ad 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableHeader.tsx @@ -1,47 +1,129 @@ import { TableCell, TableHead, TableRow, TableSortLabel } from '@mui/material'; -import { HeaderProps, isRowSelector } from '../adapters'; +import { isRowSelector } from '../adapters'; +import { HeaderRender } from '../adapters/Header'; +import { HeaderCell, HeaderRow } from '../builder/buildHeaderRows'; +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 && ( - - )} - - ); - })} - - -); +interface MuiTableHeaderProps { + rows: HeaderRow[]; +} + +const renderLeafContent = (leaf: HeaderRender): JSX.Element => { + const content = isRowSelector(leaf.render) ? ( + + ) : ( + leaf.render + ); + + return ( + <> + {leaf.sorting ? ( + + {content} + + ) : ( + content + )} + {leaf.filtering && } + + ); +}; + +const MuiTableHeader = (props: MuiTableHeaderProps): JSX.Element => { + const { rows } = props; + const { rowRefs, rowTops } = useStickyHeaderOffsets(rows.length); + + 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) => ( + c.key).join(',')} + ref={rowRefs[rowIndex]} + sx={ + rowIndex > 0 + ? { + '& .MuiTableCell-stickyHeader': { top: rowTops[rowIndex] }, + } + : undefined + } + > + {row.cells.map((cell) => { + const offset = getPinOffset(cell); + const isPinned = cell.pin != null && offset != null; + const isPinnedWithRowSpan = + isPinned && isGrouped && cell.rowSpan > 1; + + return ( + 1 ? cell.colSpan : undefined} + data-table-cell-kind={cell.leaf ? 'leaf' : 'group'} + data-table-pin={cell.pin ?? undefined} + data-table-pin-offset-px={isPinned ? offset : undefined} + rowSpan={cell.rowSpan > 1 ? cell.rowSpan : undefined} + sx={ + isPinned + ? pinCellSx({ + side: cell.pin!, + offsetPx: offset!, + widthPx: cell.widthPx!, + isHeader: true, + }) + : undefined + } + > + {cell.leaf ? 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..1af4f461729 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableRow.tsx @@ -5,25 +5,69 @@ 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 { getCells: () => C[]; forEachCell: (cell: C, index: number) => CellRender; + hasPinnedColumns?: boolean; } -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 pinOffsetAttr: number | undefined; + + if (cellProps.pin === 'left') { + const offset = leftOffsets[leftPinCount]; + pinOffsetAttr = offset; + pinSx = pinCellSx({ + side: 'left', + offsetPx: offset, + widthPx: cellProps.widthPx!, + isHeader: false, + }); + leftPinCount += 1; + } else if (cellProps.pin === 'right') { + const offset = rightOffsets[rightPinCount]; + pinOffsetAttr = offset; + pinSx = pinCellSx({ + side: 'right', + offsetPx: offset, + widthPx: cellProps.widthPx!, + isHeader: false, + }); + rightPinCount += 1; + } + return ( {isRowSelector(cellProps.render) ? ( @@ -33,8 +77,9 @@ const MuiTableRow = (props: MuiTableRowProps): JSX.Element => ( ); })} - -); + + ); +}; export default memo(MuiTableRow, (prevProps, nextProps) => { if (!prevProps.getEqualityData || !nextProps.getEqualityData) return false; @@ -46,4 +91,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..a80faee1c8d --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/gridSxStyles.ts @@ -0,0 +1,78 @@ +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, + + '& .MuiTableCell-root': { + borderBottom: `1px solid ${TABLE_BORDER}`, + borderLeft: `1px solid ${TABLE_BORDER}`, + backgroundColor: white, + }, + + '& .MuiTableCell-stickyHeader': { backgroundColor: white }, + + ...(opts.hasGroupedHeaders && { + '& .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 && { + '& .grid-pin-rowspan': { + borderBottom: `2px solid ${TABLE_BORDER_STRONG} !important`, + }, + }), + + ...(opts.hasPinnedColumns && { + '& .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[] => { + if (side === 'left') { + return widths.reduce<{ out: number[]; acc: number }>( + ({ out, acc }, w) => ({ out: [...out, acc], acc: acc + w }), + { out: [], acc: 0 }, + ).out; + } + return widths.reduceRight<{ out: number[]; acc: number }>( + ({ out, acc }, w) => ({ out: [acc, ...out], acc: acc + w }), + { out: [], acc: 0 }, + ).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..8df0f12cdc3 --- /dev/null +++ b/client/app/lib/components/table/MuiTableAdapter/useStickyHeaderOffsets.ts @@ -0,0 +1,40 @@ +import { 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] ?? { current: null }, + ); + } + + 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/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index 16806f3445e..a6779ea6a88 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -7,25 +7,21 @@ 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 { HeaderRender } from '../adapters/Header'; +import { buildHeaderRows, ColumnTemplate, TableTemplate } from '../builder'; import { downloadCsv } from '../utils'; import buildTanStackColumns 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,48 +111,76 @@ const useTanStackTableBuilder = ( downloadCsv(csvData, props.csvDownload?.filename); }; + if (process.env.NODE_ENV !== 'production') { + const hasPin = props.columns.some((c) => c.pin); + if (hasPin && (props.indexing?.rowSelectable || props.indexing?.indices)) { + console.warn( + 'lib/components/table: combining `pin` with `indexing` is not supported in v1.', + ); + } + } + + 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); + + const leafForEach = ( + _column: ColumnTemplate, + originalIndex: number, + ): HeaderRender => { + const tsHeader = tsHeaders[originalIndex + tsOffset]; + return { + id: tsHeader.id, + render: customHeaderRender(tsHeader), + className: getRealColumn(originalIndex + tsOffset)?.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 + tsOffset)?.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, + }; + }; + + const headerRows = buildHeaderRows( + activeColumns, + leafForEach, + ); + 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, @@ -167,6 +191,8 @@ const useTanStackTableBuilder = ( 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, @@ -213,6 +239,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..99d5fe87f24 --- /dev/null +++ b/client/app/lib/components/table/__tests__/Table.test.tsx @@ -0,0 +1,291 @@ +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 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} + />, + ); + const leftPins = container.querySelectorAll('th[data-table-pin="left"]'); + const rightPins = container.querySelectorAll('th[data-table-pin="right"]'); + expect(leftPins.length).toBeGreaterThan(0); + expect(rightPins.length).toBeGreaterThan(0); + }); + + it('left pin has offset 0, right pin has offset 0', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + const leftPin = container.querySelector( + 'th[data-table-pin="left"]', + ) as HTMLElement; + const rightPin = container.querySelector( + 'th[data-table-pin="right"]', + ) as HTMLElement; + expect(leftPin.getAttribute('data-table-pin-offset-px')).toBe('0'); + expect(rightPin.getAttribute('data-table-pin-offset-px')).toBe('0'); + }); + + it('pinned cells have data-table-cell-kind="leaf", group cells have "group"', () => { + const { container } = renderTable( +
r.id} + maxHeight={400} + />, + ); + const leafCells = container.querySelectorAll( + '[data-table-cell-kind="leaf"]', + ); + const groupCells = container.querySelectorAll( + '[data-table-cell-kind="group"]', + ); + expect(leafCells.length).toBeGreaterThan(0); + expect(groupCells.length).toBeGreaterThan(0); + }); + + 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('colSpan/rowSpan HTML attributes are set correctly on 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(); + }); +}); + +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( + 'td[data-table-pin="left"]', + ); + expect(pinnedBodyCells.length).toBeGreaterThan(0); + }); +}); + +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..dceb6fa9233 100644 --- a/client/app/lib/components/table/adapters/Header.ts +++ b/client/app/lib/components/table/adapters/Header.ts @@ -1,10 +1,12 @@ import { ReactNode } from 'react'; +import { HeaderRow } from '../builder/buildHeaderRows'; + import FilterProps from './Filter'; import RowSelector from './RowSelector'; import SortProps from './Sort'; -interface HeaderRender { +export interface HeaderRender { id: string; render: ReactNode | RowSelector; className?: string; @@ -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/adapters/index.ts b/client/app/lib/components/table/adapters/index.ts index e8d65f16056..085767680cf 100644 --- a/client/app/lib/components/table/adapters/index.ts +++ b/client/app/lib/components/table/adapters/index.ts @@ -1,6 +1,6 @@ export type { default as BodyProps, RowEqualityData } from './Body'; export type { default as FilterProps } from './Filter'; -export type { default as HeaderProps } from './Header'; +export type { default as HeaderProps, HeaderRender } from './Header'; export type { default as PaginationProps } from './Pagination'; export type { default as RowSelector } from './RowSelector'; export { isRowSelector } from './RowSelector'; diff --git a/client/app/lib/components/table/builder/ColumnTemplate.ts b/client/app/lib/components/table/builder/ColumnTemplate.ts index eda11a70c9d..1abc5b2c75f 100644 --- a/client/app/lib/components/table/builder/ColumnTemplate.ts +++ b/client/app/lib/components/table/builder/ColumnTemplate.ts @@ -3,6 +3,18 @@ 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; +} + interface FilteringProps { beforeFilter?: (value: string) => unknown; shouldInclude?: (datum: D, filterValue) => boolean; @@ -36,6 +48,31 @@ interface ColumnTemplate { className?: string; colSpan?: (datum: D) => number; cellUnless?: (datum: D) => boolean; + + /** + * 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. Pinned + * columns must not set `groupPath` — they span all header rows. + */ + groupPath?: GroupSegment[]; + + /** + * Pins the column to the left or right edge of a horizontally scrollable + * table. Pinned columns are rendered before (left) or after (right) all + * non-pinned columns regardless of declaration order. + * + * `widthPx` is required when `pin` is set. + */ + pin?: 'left' | 'right'; + + /** + * Fixed pixel width for this column. Required when `pin` is set so the + * sticky positioning math can compute cumulative offsets correctly. + */ + widthPx?: number; } 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..22c69677181 --- /dev/null +++ b/client/app/lib/components/table/builder/__tests__/buildHeaderRows.test.ts @@ -0,0 +1,290 @@ +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, +}); + +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 error: pin without widthPx throws in development', () => { + const orig = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + try { + expect(() => + buildHeaderRows([col('p', { pin: 'left' })], buildLeaf), + ).toThrow(); + } finally { + process.env.NODE_ENV = orig; + } + }); + + 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 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..a620a2203de --- /dev/null +++ b/client/app/lib/components/table/builder/buildHeaderRows.ts @@ -0,0 +1,180 @@ +import { ReactNode } from 'react'; + +import type ColumnTemplate from './ColumnTemplate'; +import type { Data, GroupSegment } from './ColumnTemplate'; + +export interface HeaderCell { + key: string; + /** Set only when `leaf` is undefined — group cell. */ + render?: ReactNode; + colSpan: number; + rowSpan: number; + widthPx?: number; + pin?: 'left' | 'right'; + className?: string; + /** Present for leaf cells (data columns + pinned columns). Mutually exclusive with `render`. */ + leaf?: Leaf; +} + +export interface HeaderRow { + cells: HeaderCell[]; + isLeaf: boolean; +} + +export type BuildLeafRender = ( + column: ColumnTemplate, + index: number, +) => Leaf; + +const getDepth = (path?: GroupSegment[]): number => path?.length ?? 0; + +const validateInvariants = ( + leftPin: { col: ColumnTemplate; index: number }[], + middle: { col: ColumnTemplate; index: number }[], + rightPin: { col: ColumnTemplate; index: number }[], + depth: number, +): void => { + if (process.env.NODE_ENV === 'production') return; + + const allPinned = [...leftPin, ...rightPin]; + allPinned.forEach(({ col }) => { + if (col.widthPx == null) { + const msg = `lib/components/table: pinned column "${col.id ?? '(unnamed)'}" is missing widthPx — pinned columns must have a fixed pixel width.`; + console.error(msg); + if (process.env.NODE_ENV === 'development') throw new Error(msg); + } + }); + + 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) { + const seenIds = new Set(); + let lastId: string | number | undefined; + middle.forEach(({ col }) => { + const seg = col.groupPath![r]; + const id = seg?.id; + if (id !== lastId) { + if (seenIds.has(id)) { + console.warn( + `lib/components/table: non-contiguous group segments at depth ${r} — id "${String(id)}" appears in multiple non-adjacent runs.`, + ); + } + seenIds.add(id); + lastId = id; + } + }); + } + } +}; + +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 }); + }); + + const depth = middle.reduce( + (max, { col }) => Math.max(max, getDepth(col.groupPath)), + 0, + ); + + validateInvariants(leftPin, middle, rightPin, depth); + + if (depth === 0) { + return [ + { + isLeaf: true, + 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), + })), + }, + ]; + } + + 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[] = []; + + if (r === 0) { + leftPin.forEach(({ col, index }) => cells.push(buildPinCell(col, index))); + } + + 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; + } + + if (r === 0) { + rightPin.forEach(({ col, index }) => + cells.push(buildPinCell(col, index)), + ); + } + + rows.push({ cells, isLeaf: false }); + } + + rows.push({ + isLeaf: true, + 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..cb2984c3fc8 100644 --- a/client/app/lib/components/table/builder/index.ts +++ b/client/app/lib/components/table/builder/index.ts @@ -1,4 +1,10 @@ export type { BuiltColumns } from './buildColumns'; export { buildColumns } from './buildColumns'; -export type { default as ColumnTemplate, Data } from './ColumnTemplate'; +export type { BuildLeafRender, HeaderCell, HeaderRow } 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/routers/course/index.tsx b/client/app/routers/course/index.tsx index 047262ac54e..59a6b8fbe8f 100644 --- a/client/app/routers/course/index.tsx +++ b/client/app/routers/course/index.tsx @@ -190,6 +190,26 @@ const courseRouter: Translated = (t) => ({ }; }, }, + { + path: 'gradebook', + lazy: async (): Promise> => { + const [gradebookHandle, GradebookIndex] = await Promise.all([ + import( + /* webpackChunkName: 'gradebookHandle' */ + 'course/gradebook/handles' + ).then((module) => module.gradebookHandle), + import( + /* webpackChunkName: 'GradebookIndex' */ + 'course/gradebook/pages/GradebookIndex' + ).then((module) => module.default), + ]); + + return { + Component: GradebookIndex, + handle: gradebookHandle, + }; + }, + }, { path: 'learning_map', lazy: async (): Promise> => ({ diff --git a/client/app/store.ts b/client/app/store.ts index 456ac7acbc0..3117dabc973 100644 --- a/client/app/store.ts +++ b/client/app/store.ts @@ -28,6 +28,7 @@ import enrolRequestsReducer from './bundles/course/enrol-requests/store'; import disbursementReducer from './bundles/course/experience-points/disbursement/store'; import experiencePointsReducer from './bundles/course/experience-points/store'; import forumsReducer from './bundles/course/forum/store'; +import gradebookReducer from './bundles/course/gradebook/store'; import groupsReducer from './bundles/course/group/store'; import leaderboardReducer from './bundles/course/leaderboard/store'; import learningMapReducer from './bundles/course/learning-map/store'; @@ -64,6 +65,7 @@ const rootReducer = combineReducers({ enrolRequests: enrolRequestsReducer, folders: foldersReducer, forums: forumsReducer, + gradebook: gradebookReducer, groups: groupsReducer, invitations: invitationsReducer, leaderboard: leaderboardReducer, 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]; diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts new file mode 100644 index 00000000000..8cea27ad9fd --- /dev/null +++ b/client/app/types/course/gradebook.ts @@ -0,0 +1,27 @@ +export interface TabData { + id: number; + title: string; + categoryId: number; + categoryTitle: string; +} + +export interface AssessmentData { + id: number; + title: string; + tabId: number; + maxGrade: number; +} + +export interface StudentRow { + id: number; + name: string; + grades: Record; // assessmentId (as string) → grade + totalGrade: number; + totalMaxGrade: number; +} + +export interface GradebookData { + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentRow[]; +} diff --git a/client/locales/en.json b/client/locales/en.json index 66413747666..d8c9f7a016e 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -4110,6 +4110,9 @@ "course.componentTitles.course_forums_component": { "defaultMessage": "Forums" }, + "course.componentTitles.course_gradebook_component": { + "defaultMessage": "Gradebook" + }, "course.componentTitles.course_groups_component": { "defaultMessage": "Groups" }, @@ -5523,6 +5526,24 @@ "course.group.NameDescriptionForm.name": { "defaultMessage": "Name" }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "Failed to retrieve Gradebook." + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "Gradebook" + }, + "course.gradebook.GradebookIndex.searchPlaceholder": { + "defaultMessage": "Search by student name" + }, + "course.gradebook.GradebookIndex.showPercentage": { + "defaultMessage": "Show percentage" + }, + "course.gradebook.GradebookTable.studentName": { + "defaultMessage": "Student Name" + }, + "course.gradebook.GradebookTable.total": { + "defaultMessage": "Total" + }, "course.group.NameDescriptionForm.nameLength": { "defaultMessage": "The name is too long!" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 93d955f80be..5f90a5787f5 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -4085,6 +4085,9 @@ "course.componentTitles.course_forums_component": { "defaultMessage": "论坛" }, + "course.componentTitles.course_gradebook_component": { + "defaultMessage": "成绩册" + }, "course.componentTitles.course_groups_component": { "defaultMessage": "组" }, @@ -5498,6 +5501,24 @@ "course.group.NameDescriptionForm.name": { "defaultMessage": "姓名" }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "无法加载成绩册。" + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "成绩册" + }, + "course.gradebook.GradebookIndex.searchPlaceholder": { + "defaultMessage": "按学生姓名搜索" + }, + "course.gradebook.GradebookIndex.showRawScore": { + "defaultMessage": "显示原始分数" + }, + "course.gradebook.GradebookTable.studentName": { + "defaultMessage": "学生姓名" + }, + "course.gradebook.GradebookTable.total": { + "defaultMessage": "总分" + }, "course.group.NameDescriptionForm.nameLength": { "defaultMessage": "名字太长了!" }, diff --git a/config/routes.rb b/config/routes.rb index 921ce2fb71d..3f45ad22bc6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -496,6 +496,10 @@ get 'groups', as: :group end + resource :gradebook, only: [] do + get '/' => 'gradebook#index' + end + scope module: :discussion do resources :topics, path: 'comments', only: [:index] do get 'pending', on: :collection diff --git a/lib/tasks/coursemology/seed_gradebook.rake b/lib/tasks/coursemology/seed_gradebook.rake new file mode 100644 index 00000000000..910728085a4 --- /dev/null +++ b/lib/tasks/coursemology/seed_gradebook.rake @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +# Usage: bundle exec rails coursemology:seed_gradebook +# Creates a demo course with 3 categories, multiple assessments, and 20 students +# with varied grades for demonstrating the gradebook. + +namespace :coursemology do + task seed_gradebook: 'db:seed' do + require 'factory_bot_rails' + + ActsAsTenant.with_tenant(Instance.default) do + admin = User::Email.find_by_email('test@example.org').user + User.stamper = admin + + course = Course.create!( + title: 'Gradebook Demo Course', + published: true, + enrollable: false, + creator: admin, + updater: admin + ) + + puts "Created course: #{course.title} (id=#{course.id})" + + # ── Categories, tabs, assessments ────────────────────────────────────── + + structure = [ + { + title: 'Missions', + tabs: [ + { + title: 'Assignments', + assessments: [ + { title: 'Mission 1 — Variables & Control Flow', max: 20 }, + { title: 'Mission 2 — Functions & Recursion', max: 20 }, + { title: 'Mission 3 — Data Structures', max: 25 }, + { title: 'Mission 4 — Sorting Algorithms', max: 25 } + ] + }, + { + title: 'Optional Missions', + assessments: [ + { title: 'Optional Mission A — Regex', max: 10 }, + { title: 'Optional Mission B — Concurrency', max: 10 } + ] + } + ] + }, + { + title: 'Tutorials', + tabs: [ + { + title: 'Problem Sets', + assessments: [ + { title: 'Problem Set 1', max: 10 }, + { title: 'Problem Set 2', max: 10 }, + { title: 'Problem Set 3', max: 10 }, + { title: 'Problem Set 4', max: 10 }, + { title: 'Problem Set 5', max: 10 } + ] + }, + { + title: 'Recitation Quizzes', + assessments: [ + { title: 'Recitation Quiz 1', max: 5 }, + { title: 'Recitation Quiz 2', max: 5 }, + { title: 'Recitation Quiz 3', max: 5 } + ] + } + ] + }, + { + title: 'Projects', + tabs: [ + + title: 'Milestones', + assessments: [ + { title: 'Project Milestone 1 — Proposal', max: 15 }, + { title: 'Project Milestone 2 — Prototype', max: 20 }, + { title: 'Project Milestone 3 — Final Report', max: 30 } + ] + + ] + } + ] + + all_assessments = [] + start_at = 1.month.ago + + structure.each_with_index do |cat_def, cat_i| + category = course.assessment_categories.create!( + title: cat_def[:title], + weight: cat_i + 1, + creator: admin, + updater: admin + ) + + cat_def[:tabs].each_with_index do |tab_def, tab_i| + tab = category.tabs.create!( + title: tab_def[:title], + weight: tab_i + 1, + creator: admin, + updater: admin + ) + + tab_def[:assessments].each_with_index do |a_def, a_i| + assessment = Course::Assessment.new( + course: course, + tab: tab, + title: a_def[:title], + description: '', + base_exp: 0, + autograded: false, + start_at: start_at + (((cat_i * 10) + (tab_i * 4) + a_i) * 3).days, + creator: admin, + updater: admin + ) + assessment.lesson_plan_item.published = true + + # Build one MCQ question with the desired max grade. + question = FactoryBot.build( + :course_assessment_question_multiple_response, + :multiple_choice, + maximum_grade: a_def[:max] + ) + assessment.question_assessments.build( + question: question.acting_as, + weight: a_i + 1 + ) + assessment.save! + all_assessments << { record: assessment, max: a_def[:max] } + print '.' + end + end + end + puts "\nCreated #{all_assessments.size} assessments across 3 categories." + + # ── Students ─────────────────────────────────────────────────────────── + + student_profiles = [ + # [name, grade_tier] tier: :high | :mid | :low | :sparse + ['Alice Tan', :high], + ['Bob Lim', :high], + ['Carol Chen', :high], + ['David Ng', :high], + ['Eve Zhang', :high], + ['Frank Liu', :mid], + ['Grace Wang', :mid], + ['Henry Kim', :mid], + ['Irene Pham', :mid], + ['James Ho', :mid], + ['Karen Soh', :mid], + ['Leo Bui', :mid], + ['Mia Yeo', :mid], + ['Nathan Koh', :low], + ['Olivia Tan', :low], + ['Paul Wu', :low], + ['Quinn Lee', :low], + ['Rachel Goh', :sparse], + ['Samuel Ong', :sparse], + ['Tina Chan', :sparse] + ] + + rng = Random.new(42) # fixed seed for reproducible grades + + student_profiles.each do |name, tier| + user = User.new(name: name, email: "#{name.downcase.gsub(' ', '.')}@gradebook.demo", + password: 'Coursemology!') + user.skip_confirmation! + user.save! + + course_user = CourseUser.create!( + course: course, user: user, role: :student, + name: name, creator: admin, updater: admin + ) + + tier_params = { + high: [0.95, (0.78..1.00)], + mid: [0.85, (0.50..0.80)], + low: [0.70, (0.20..0.55)], + sparse: [0.40, (0.30..0.70)] + } + submission_probability, grade_fraction_range = tier_params[tier] + + all_assessments.each do |a| + next if rng.rand > submission_probability + + fraction = rng.rand(grade_fraction_range) + earned = (fraction * a[:max]).round.clamp(0, a[:max]) + + submission = Course::Assessment::Submission.new( + assessment: a[:record], + creator: user, + updater: user + ) + submission.experience_points_record.course_user = course_user + submission.experience_points_record.creator = user + submission.experience_points_record.updater = user + answers = a[:record].questions.attempt(submission) + answers.each { |ans| ans.current_answer = true } + submission.answers = answers + submission.save! + + submission.finalise! + submission.save! + + submission.answers.reload.each do |answer| + answer.grade = earned + answer.grader = admin + answer.graded_at = Time.zone.now + answer.save! + end + + submission.mark! + submission.save! + submission.publish! + submission.save! + end + + print '.' + end + + puts "\nCreated #{student_profiles.size} students with submissions." + puts "\nDone! Log in as test@example.org and visit:" + puts " http://localhost:3000/courses/#{course.id}/gradebook" + end + end +end diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb new file mode 100644 index 00000000000..c8f0a3b5b2d --- /dev/null +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::GradebookController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + + describe '#index' do + render_views + subject { get :index, as: :json, params: { course_id: course } } + + context 'when the gradebook component is disabled' do + let(:ta) { create(:course_teaching_assistant, course: course) } + before do + controller_sign_in(controller, ta.user) + allow(controller).to receive_message_chain('current_component_host.[]').and_return(nil) + end + + it 'raises an component not found error' do + expect { subject }.to raise_error(ComponentNotFoundError) + end + end + + context 'when a student visits the page' do + let(:student) { create(:course_student, course: course) } + before { controller_sign_in(controller, student.user) } + + it { expect { subject }.to raise_error(CanCan::AccessDenied) } + end + + context 'when a teaching assistant visits the page' do + let(:ta) { create(:course_teaching_assistant, course: course) } + before { controller_sign_in(controller, ta.user) } + + it { expect(subject).to be_successful } + + it 'returns tabs, assessments, and students keys' do + subject + data = JSON.parse(response.body) + expect(data).to have_key('tabs') + expect(data).to have_key('assessments') + expect(data).to have_key('students') + end + end + + context 'when a manager visits the page' do + let(:manager) { create(:course_manager, course: course) } + before { controller_sign_in(controller, manager.user) } + + it { expect(subject).to be_successful } + end + + context 'when an observer visits the page' do + let(:observer) { create(:course_observer, course: course) } + before { controller_sign_in(controller, observer.user) } + + it { expect(subject).to be_successful } + end + + context 'with a published assessment and a graded submission' do + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:tab) { course.assessment_categories.first.tabs.first } + let!(:assessment) do + create(:course_assessment_assessment, :published_with_mcq_question, + course: course, tab: tab) + end + let!(:student) { create(:course_student, course: course) } + let!(:submission) do + create(:course_assessment_submission, :graded, + assessment: assessment, creator: student.user) + end + + before do + submission.answers.update_all(grade: 5.0, current_answer: true) + controller_sign_in(controller, ta.user) + end + + it 'includes the assessment in the response' do + subject + data = JSON.parse(response.body) + expect(data['assessments'].map { |a| a['id'] }).to include(assessment.id) + end + + it 'includes the tab in the response' do + subject + data = JSON.parse(response.body) + expect(data['tabs'].map { |t| t['id'] }).to include(tab.id) + end + + it 'returns the correct grade for the student and assessment' do + subject + data = JSON.parse(response.body) + student_data = data['students'].find { |s| s['id'] == student.id } + expect(student_data['grades'][assessment.id.to_s].to_f).to eq(5.0) + end + + it 'returns a positive maxGrade for the assessment' do + subject + data = JSON.parse(response.body) + assessment_data = data['assessments'].find { |a| a['id'] == assessment.id } + expect(assessment_data['maxGrade'].to_f).to be > 0 + end + + it 'returns correct totalGrade and totalMaxGrade' do + subject + data = JSON.parse(response.body) + student_data = data['students'].find { |s| s['id'] == student.id } + expect(student_data['totalGrade'].to_f).to eq(5.0) + expect(student_data['totalMaxGrade'].to_f).to be > 0 + end + end + end + end +end