diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..fe2ccfe09e2 --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,23 @@ +# 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: self.class.key, + icon: :gradebook, + title: I18n.t('course.gradebook.component.sidebar_title'), + type: :normal, + 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..71cfa4fc145 --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +class Course::GradebookController < Course::ComponentController + before_action :authorize_read_gradebook! + + def index + respond_to do |format| + format.json do + @published_assessments = fetch_published_assessments + @categories, @tabs = fetch_categories_and_tabs + @students = fetch_students + assessment_ids = @published_assessments.pluck(:id) + @assessment_max_grades = Course::Assessment.max_grades(assessment_ids) + @submissions = Course::Assessment::Submission.grade_summary( + student_ids: @students.map(&:user_id), + assessment_ids: assessment_ids + ) + end + end + end + + private + + def authorize_read_gradebook! + authorize! :read_gradebook, current_course + end + + def component + current_component_host[:course_gradebook_component] + end + + def fetch_categories_and_tabs + tabs = @published_assessments.map(&:tab).uniq(&:id) + [tabs.map(&:category).uniq(&:id), tabs] + end + + def fetch_students + current_course.levels.to_a + current_course.course_users.students.without_phantom_users. + calculated(:experience_points).includes(:user).to_a + 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..d5b9862f299 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + can :read_gradebook, Course, id: course.id if course_user&.staff? + super + 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..ec9a2d0de48 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -323,6 +323,27 @@ def self.on_dependent_status_change(answer) answer.submission.last_graded_time = Time.now end + # Returns an array of submission rows for the given students and assessments. + # Each row has: student_id (creator_id), assessment_id, grade (float). + # Only graded/published submissions are included. + def self.grade_summary(student_ids:, assessment_ids:) + return [] if student_ids.empty? || assessment_ids.empty? + + find_by_sql( + sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) + SELECT cas.creator_id AS student_id, cas.assessment_id, + SUM(caa.grade) 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 + ) + 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..8c67f0a0703 --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,34 @@ +# frozen_string_literal: true +json.categories @categories do |cat| + json.id cat.id + json.title cat.title +end + +json.tabs @tabs do |tab| + json.id tab.id + json.title tab.title + json.categoryId tab.category_id +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 +end + +json.students @students do |course_user| + json.id course_user.user_id + json.name course_user.name + json.email course_user.user.email + json.level course_user.level_number + json.totalXp course_user.experience_points +end + +json.submissions @submissions do |sub| + json.studentId sub.student_id + json.assessmentId sub.assessment_id + json.grade sub.grade&.to_f +end + +json.gamificationEnabled current_course.gamified? diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts new file mode 100644 index 00000000000..e00c94a64c3 --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,15 @@ +import { GradebookData } from 'types/course/gradebook'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from './Base'; + +export default class GradebookAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/gradebook`; + } + + index(): APIResponse { + 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/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx index 12f47b012e3..4799c28d3b3 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -12,6 +11,7 @@ import { DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx index f2beac495af..8f44a3496ae 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/MaterialsListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { @@ -10,6 +9,7 @@ import { DuplicationMaterialData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx index 55610147699..3425d9bf1cb 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/VideosListing.tsx @@ -2,7 +2,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -11,6 +10,7 @@ import { DuplicationVideoTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx index 03a1b32fb3a..84a7e486043 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AchievementsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -11,6 +10,7 @@ import { actions } from 'course/duplication/store'; import { DuplicationAchievementData } from 'course/duplication/types'; import { getAchievementBadgeUrl } from 'course/helper/achievements'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import Thumbnail from 'lib/components/core/Thumbnail'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx index 3b4817e9590..0f0d78708b5 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/AssessmentsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { @@ -17,6 +16,7 @@ import { DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx index 9d6c22a01ad..fd1d09852d8 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/MaterialsSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { actions } from 'course/duplication/store'; @@ -12,6 +11,7 @@ import { DuplicationMaterialData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx index 1c1a6a77068..fbd3cdebd03 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/SurveysSelector.tsx @@ -3,13 +3,13 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { actions } from 'course/duplication/store'; import { DuplicationSurveyData } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx index 25ab8fa9505..fb010970d96 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/ItemsSelector/VideosSelector.tsx @@ -3,7 +3,6 @@ import { defineMessages } from 'react-intl'; import { ListSubheader, Typography } from '@mui/material'; import BulkSelectors from 'course/duplication/components/BulkSelectors'; -import IndentedCheckbox from 'course/duplication/components/IndentedCheckbox'; import TypeBadge from 'course/duplication/components/TypeBadge'; import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import { selectDuplicationStore } from 'course/duplication/selectors'; @@ -13,6 +12,7 @@ import { DuplicationVideoTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx new file mode 100644 index 00000000000..261abaa3cdf --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx @@ -0,0 +1,265 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { buildAssessmentColumnId } from '../components/buildAssessmentColumnIds'; +import GradebookColumnTree from '../components/GradebookColumnTree'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { id: 101, title: 'Quiz 2', tabId: 10, maxGrade: 10 }, +]; + +const asnId100 = buildAssessmentColumnId(100); +const asnId101 = buildAssessmentColumnId(101); +const allIds = ['name', 'email', 'level', asnId100, asnId101]; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +describe('GradebookColumnTree', () => { + it('renders Student info and Grades branch labels', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Student info')).toBeInTheDocument(); + expect(screen.getByText('Grades')).toBeInTheDocument(); + }); + + it('renders Gamification branch when gamificationEnabled', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Gamification')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^level$/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^total xp$/i }), + ).toBeInTheDocument(); + }); + + it('hides Gamification branch when gamificationEnabled is false', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.queryByText('Gamification')).not.toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /^level$/i }), + ).not.toBeInTheDocument(); + }); + + it('name checkbox is disabled and always checked', () => { + const visibility: Record = { + name: false, + email: true, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const nameCheckbox = screen.getByRole('checkbox', { name: /^name/i }); + expect(nameCheckbox).toBeDisabled(); + expect(nameCheckbox).toBeChecked(); + }); + + it('non-name student info checkboxes are enabled and reflect visibility state', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const emailCheckbox = screen.getByRole('checkbox', { name: /^email$/i }); + expect(emailCheckbox).not.toBeDisabled(); + expect(emailCheckbox).not.toBeChecked(); + }); + + it('clicking a student info checkbox calls setVisible with its column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /^email$/i })); + expect(setVisible).toHaveBeenCalledWith('email', expect.any(Boolean)); + }); + + it('renders Category, Tab, and assessment checkboxes', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Cat A')).toBeInTheDocument(); + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 1/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 2/i }), + ).toBeInTheDocument(); + }); + + it('clicking an assessment checkbox calls setVisible with the single column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /quiz 1/i })); + expect(setVisible).toHaveBeenCalledWith(asnId100, expect.any(Boolean)); + }); + + it('renders "Always included" chip next to the Name row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Always included')).toBeInTheDocument(); + }); + + it('does not render "Always included" chip next to email row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getAllByText('Always included')).toHaveLength(1); + }); + + it('Student info branch is indeterminate when some but not all student cols are visible', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect( + screen.getByRole('checkbox', { name: /student info/i }), + ).toHaveAttribute('data-indeterminate', 'true'); + }); +}); 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..e0fc76ae2ce --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,146 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import fetchGradebook from '../operations'; +import GradebookIndex from '../pages/GradebookIndex'; + +jest.mock('../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; id: number } => ({ + courseTitle: 'Test Course', + id: 1, + }), +})); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn(), success: jest.fn() }, +})); + +jest.mock('../operations', () => ({ + __esModule: true, + default: jest.fn(() => (): Promise => Promise.resolve()), +})); + +const mockFetchGradebook = fetchGradebook as jest.Mock; + +const emptyState = { + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + }, +}; + +const noStudentsState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [], + submissions: [], + gamificationEnabled: false, + }, +}; + +const populatedState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + ], + submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: false, + }, +}; + +const populatedStateWithGamification = { + gradebook: { + ...populatedState.gradebook, + gamificationEnabled: true, + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); +}); + +describe('GradebookIndex', () => { + it('shows loading indicator initially', () => { + render(, { state: emptyState }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows the gradebook table after data loads', async () => { + render(, { state: populatedState }); + expect( + await screen.findByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + + it('shows the page title', async () => { + render(, { state: populatedState }); + expect(await screen.findByText('Gradebook')).toBeInTheDocument(); + }); + + it('shows empty students message when there are no students', async () => { + render(, { state: noStudentsState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows empty students message when both assessments and students are absent', async () => { + render(, { state: emptyState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows error toast when fetch fails', async () => { + mockFetchGradebook.mockReturnValueOnce( + (): Promise => Promise.reject(new Error('Network error')), + ); + render(, { state: emptyState }); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + }); + + it('shows grade-only hint in column picker when gamification is disabled and no data cols selected', async () => { + render(, { state: populatedState }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + expect( + await screen.findByText( + 'No grade columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); + + it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => { + render(, { state: populatedStateWithGamification }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + fireEvent.click( + await screen.findByRole('checkbox', { name: /gamification/i }), + ); + expect( + await screen.findByText( + 'No grade or gamification columns selected - export will include student info only.', + ), + ).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..46ca8388a67 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,426 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; + +import GradebookTable from '../components/GradebookTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, +]; +const students: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + level: 5, + totalXp: 300, + }, +]; +const submissions: SubmissionData[] = [ + { studentId: 1, assessmentId: 100, grade: 8 }, +]; + +const makeStudents = (n: number): StudentData[] => + Array.from({ length: n }, (_, i) => ({ + id: i + 1, + name: `Student ${i + 1}`, + email: `student${i + 1}@example.com`, + level: 1, + totalXp: 0, + })); + +// User id used in all renders so localStorage is keyed as `${USER_ID}:gradebook_columns_1` +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:gradebook_columns_1`; + +// Preloaded state that gives a non-zero userId so useTanStackTableBuilder +// activates the effectiveStorageKey and reads/writes localStorage. +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +interface RenderOptions { + gamificationEnabled?: boolean; +} + +const renderTable = ({ + gamificationEnabled = true, +}: RenderOptions = {}): void => { + render( + , + { state: userState }, + ); +}; + +const renderTableWithAssessmentVisible = ( + options: RenderOptions = {}, +): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + 'asn-100': true, + }), + ); + renderTable(options); +}; + +describe('GradebookTable', () => { + beforeEach(() => localStorage.clear()); + + it('renders both student names', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(await screen.findByText('Bob')).toBeInTheDocument(); + }); + + it('renders two header rows (column titles and max marks)', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + const { container } = render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + expect(container.querySelectorAll('thead tr')).toHaveLength(2); + }); + + it('shows Select Columns button and Export button', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /select columns/i }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); + + describe('export button label reflects selection', () => { + it('shows "Export all rows" when no rows are selected', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(); + }); + + it('shows tooltip "all rows will be exported" when no rows are selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const exportBtn = await screen.findByRole('button', { + name: /export all rows/i, + }); + await user.hover(exportBtn); + expect( + await screen.findByText(/all rows will be exported/i), + ).toBeInTheDocument(); + }); + + it('hides the tooltip when a row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + const exportBtn = await screen.findByRole('button', { + name: /export 1 row/i, + }); + await user.hover(exportBtn); + expect( + screen.queryByText(/all rows will be exported/i), + ).not.toBeInTheDocument(); + }); + + it('shows "Export 1 row" when one row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + }); + + it('shows "Export all rows" when all rows are selected via the corner checkbox', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[0]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(), + ); + expect( + screen.queryByRole('button', { name: /export \d+ row/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('shows the Max Marks header row', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Max Marks')).toBeInTheDocument(); + }); + + it('renders row selection checkboxes', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(2); + }); + + describe('row selection', () => { + it('keeps search input visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('keeps Export button visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect( + screen.getByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + }); + + it('does not show assessment columns in the table by default', async () => { + renderTable(); + await screen.findByText('Alice'); + expect(screen.queryByText('Quiz 1')).not.toBeInTheDocument(); + }); + + it('shows gamification columns by default when gamification is enabled', async () => { + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Level')).toBeInTheDocument(); + expect(screen.getByText('Total XP')).toBeInTheDocument(); + }); + + describe('gamification columns', () => { + it('shows level and totalXp in the column picker when gamification is enabled', async () => { + const user = userEvent.setup(); + renderTable({ gamificationEnabled: true }); + const selectColumnsBtn = await screen.findByRole('button', { + name: /select columns/i, + }); + await user.click(selectColumnsBtn); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Level')).toBeInTheDocument(); + expect(within(dialog).getByText('Total XP')).toBeInTheDocument(); + }); + }); + + describe('locked name column', () => { + it('name is always visible even when localStorage sets it to false', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: false, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + await waitFor(() => + expect(screen.getByText('Alice')).toBeInTheDocument(), + ); + }); + }); + + describe('gamification disabled', () => { + it('level and totalXp absent from table headers when gamification is disabled', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + level: true, + totalXp: true, + 'asn-100': true, + }), + ); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + expect(screen.queryByText('Total XP')).not.toBeInTheDocument(); + }); + }); + + it('shows the table when gamification columns are visible and assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('export button is always enabled regardless of which columns are selected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByRole('button', { name: /export/i })).not.toBeDisabled(); + }); + + it('shows the table (not an empty state) when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows the table when all optional columns are deselected with gamification', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ 'asn-100': false, level: false, totalXp: false }), + ); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows pagination when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByText(/rows per page/i)).toBeInTheDocument(); + }); + + it('shows the table with assessment columns when restored from localStorage', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + expect(await screen.findByText('Quiz 1')).toBeInTheDocument(); + }); + + describe('search', () => { + it('filters by name', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'Alice'); + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('filters by email', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'bob@example.com'); + await waitFor(() => + expect(screen.queryByText('Alice')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + }); + + describe('cross-page selection', () => { + it('export label reflects selection count across pages', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + render( + , + { state: userState }, + ); + + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + + await user.click( + screen.getByRole('button', { name: /go to next page/i }), + ); + await waitFor(() => + expect(screen.getByText('Student 11')).toBeInTheDocument(), + ); + expect(screen.queryByText('Student 1')).not.toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx new file mode 100644 index 00000000000..84dd01108cb --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -0,0 +1,235 @@ +import { useMemo } from 'react'; +import { defineMessages } from 'react-intl'; +import { Chip } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import { + ColumnPickerRenderContext, + ColumnPickerTreeGroup, +} from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { + GAMIFICATION_COL_IDS, + type GamificationColId, + STUDENT_INFO_COL_IDS, + type StudentInfoColId, +} from '../constants'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; + +const translations = defineMessages({ + studentInfo: { + id: 'course.gradebook.GradebookColumnTree.studentInfo', + defaultMessage: 'Student info', + }, + name: { + id: 'course.gradebook.GradebookColumnTree.name', + defaultMessage: 'Name', + }, + email: { + id: 'course.gradebook.GradebookColumnTree.email', + defaultMessage: 'Email', + }, + level: { + id: 'course.gradebook.GradebookColumnTree.level', + defaultMessage: 'Level', + }, + totalXp: { + id: 'course.gradebook.GradebookColumnTree.totalXp', + defaultMessage: 'Total XP', + }, + gamification: { + id: 'course.gradebook.GradebookColumnTree.gamification', + defaultMessage: 'Gamification', + }, + grades: { + id: 'course.gradebook.GradebookColumnTree.grades', + defaultMessage: 'Grades', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +interface GradebookColumnTreeProps extends ColumnPickerRenderContext { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + gamificationEnabled: boolean; +} + +const STUDENT_ALL_IDS = [...STUDENT_INFO_COL_IDS]; +const GAMIFICATION_ALL_IDS = [...GAMIFICATION_COL_IDS]; + +const GradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, + categories, + tabs, + assessments, + gamificationEnabled, +}: GradebookColumnTreeProps): JSX.Element => { + const { t } = useTranslation(); + const context: ColumnPickerRenderContext = { + isVisible, + setVisible, + setManyVisible, + }; + + const asnIds = useMemo( + () => assessments.map((a) => buildAssessmentColumnId(a.id)), + [assessments], + ); + + const tabAsnIds = useMemo(() => { + const map = new Map(); + assessments.forEach((a) => { + const existing = map.get(a.tabId) ?? []; + map.set(a.tabId, [...existing, buildAssessmentColumnId(a.id)]); + }); + return map; + }, [assessments]); + + const catTabs = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, tab]); + }); + return map; + }, [tabs]); + + const asnById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + + const catAsnIds = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, ...tabIds]); + }); + return map; + }, [tabs, tabAsnIds]); + + return ( +
+ + {STUDENT_INFO_COL_IDS.map((id: StudentInfoColId) => + id === 'name' ? ( + + {t(translations[id])} + + + } + /> + ) : ( + setVisible(id, e.target.checked)} + /> + ), + )} + + + {gamificationEnabled && ( + + {GAMIFICATION_COL_IDS.map((id: GamificationColId) => ( + setVisible(id, e.target.checked)} + /> + ))} + + )} + + + {categories.map((cat) => { + const catIds = catAsnIds.get(cat.id) ?? []; + const thisCatTabs = catTabs.get(cat.id) ?? []; + return ( + + {thisCatTabs.map((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + return ( + + {tabIds.map((id) => { + const asnId = parseAssessmentColumnId(id); + const asn = + asnId !== null ? asnById.get(asnId) : undefined; + if (!asn) return null; + return ( + setVisible(id, e.target.checked)} + /> + ); + })} + + ); + })} + + ); + })} + +
+ ); +}; + +export default GradebookColumnTree; 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..43e160ab23e --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -0,0 +1,636 @@ +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { defineMessages } from 'react-intl'; +import { + Checkbox, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, +} from '@mui/material'; +import { flexRender } from '@tanstack/react-table'; + +import type { + ColumnPickerRenderContext, + ColumnTemplate, +} from 'lib/components/table/builder'; +import MuiTablePagination from 'lib/components/table/MuiTableAdapter/MuiTablePagination'; +import MuiTableToolbar from 'lib/components/table/MuiTableAdapter/MuiTableToolbar'; +import useTanStackTableBuilder from 'lib/components/table/TanStackTableBuilder'; +import { + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + DEFAULT_TABLE_ROWS_PER_PAGE, +} from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { GAMIFICATION_COL_IDS } from '../constants'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; +import GradebookColumnTree from './GradebookColumnTree'; + +const COL_WIDTHS = { + name: 160, + email: 220, + level: 70, + totalXp: 100, + assessment: 150, +} as const; + +const CHECKBOX_WIDTH = 56; + +const getColWidth = (id: string): number => + COL_WIDTHS[id as keyof typeof COL_WIDTHS] ?? COL_WIDTHS.assessment; + +const isLeftAligned = (id: string): boolean => id === 'name' || id === 'email'; + +const translations = defineMessages({ + searchStudents: { + id: 'course.gradebook.GradebookIndex.searchStudents', + defaultMessage: 'Search by name or email', + }, + exportButton: { + id: 'course.gradebook.GradebookIndex.exportButton', + defaultMessage: 'Export all rows', + }, + exportRows: { + id: 'course.gradebook.GradebookIndex.exportRows', + defaultMessage: 'Export {count, plural, one {# row} other {# rows}}', + }, + exportAllTooltip: { + id: 'course.gradebook.GradebookIndex.exportAllTooltip', + defaultMessage: 'No rows selected - all rows will be exported.', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + name: { + id: 'course.gradebook.GradebookColumnTree.name', + defaultMessage: 'Name', + }, + email: { + id: 'course.gradebook.GradebookColumnTree.email', + defaultMessage: 'Email', + }, + level: { + id: 'course.gradebook.GradebookColumnTree.level', + defaultMessage: 'Level', + }, + totalXp: { + id: 'course.gradebook.GradebookColumnTree.totalXp', + defaultMessage: 'Total XP', + }, + maxMarks: { + id: 'course.gradebook.GradebookTable.maxMarks', + defaultMessage: 'Max Marks', + }, + noDataColumnsHint: { + id: 'course.gradebook.GradebookTable.noDataColumnsHint', + defaultMessage: + 'No grade columns selected - export will include student info only.', + }, + noDataColumnsHintWithGamification: { + id: 'course.gradebook.GradebookTable.noDataColumnsHintWithGamification', + defaultMessage: + 'No grade or gamification columns selected - export will include student info only.', + }, +}); + +const HeaderLabel = forwardRef< + HTMLSpanElement, + { text: string; onSingleLine: (fits: boolean) => void } +>(({ text, onSingleLine }, forwardedRef): JSX.Element => { + const innerRef = useRef(null); + const [display, setDisplay] = useState(text); + + useLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + + const lh = parseFloat(getComputedStyle(el).lineHeight) || 20; + const oneLineH = lh + 1; + const twoLineH = lh * 2 + 1; + + el.textContent = text; + + if (el.scrollHeight <= oneLineH) { + onSingleLine(true); + setDisplay(text); + return; + } + + onSingleLine(false); + + if (el.scrollHeight <= twoLineH) { + setDisplay(text); + return; + } + + let lo = 1; + let hi = text.length; + let best = `${text[0]}…`; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const candidate = `${text.slice(0, mid)}…`; + el.textContent = candidate; + if (el.scrollHeight <= twoLineH) { + best = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // Ensure DOM reflects `best` before React reconciles — the loop's last + // el.textContent assignment may be a too-long candidate, not `best`. + el.textContent = best; + setDisplay(best); + }, [text, onSingleLine]); + + return ( + { + innerRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }} + style={{ display: 'block' }} + > + {display} + + ); +}); +HeaderLabel.displayName = 'HeaderLabel'; + +interface GradebookRow { + studentId: number; + name: string; + email: string; + level: number; + totalXp: number; + grades: Partial>; +} + +interface GradebookTableProps { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + courseTitle: string; + courseId: number; + gamificationEnabled: boolean; +} + +const GradebookTable = ({ + categories, + tabs, + assessments, + students, + submissions, + courseTitle, + courseId, + gamificationEnabled, +}: GradebookTableProps): JSX.Element => { + const { t } = useTranslation(); + + const submissionsByStudent = useMemo(() => { + const map = new Map(); + submissions.forEach((s) => { + const existing = map.get(s.studentId); + if (existing) { + existing.push(s); + } else { + map.set(s.studentId, [s]); + } + }); + return map; + }, [submissions]); + + const rows = useMemo( + () => + students.map((student) => { + const subs = submissionsByStudent.get(student.id) ?? []; + const grades: Partial> = {}; + assessments.forEach((a) => { + const sub = subs.find((s) => s.assessmentId === a.id); + if (sub != null) grades[a.id] = sub.grade; + }); + return { + studentId: student.id, + name: student.name, + email: student.email, + level: student.level, + totalXp: student.totalXp, + grades, + }; + }), + [students, assessments, submissionsByStudent], + ); + + const columns = useMemo[]>(() => { + const cols: ColumnTemplate[] = [ + { + id: 'name', + title: t(translations.name), + of: 'name', + cell: (row) => row.name, + csvDownloadable: true, + searchable: true, + searchProps: { getValue: (row) => row.name }, + }, + { + id: 'email', + title: t(translations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + }, + ]; + + if (gamificationEnabled) { + cols.push({ + id: 'level', + title: t(translations.level), + of: 'level', + cell: (row) => row.level, + csvDownloadable: true, + }); + cols.push({ + id: 'totalXp', + title: t(translations.totalXp), + of: 'totalXp', + cell: (row) => row.totalXp, + csvDownloadable: true, + }); + } + + assessments.forEach((asn) => { + const colId = buildAssessmentColumnId(asn.id); + cols.push({ + id: colId, + title: asn.title, + accessorFn: (row) => row.grades[asn.id], + cell: (row) => { + const grade = row.grades[asn.id]; + if (grade === undefined) return '—'; + if (grade === null) return ''; + return grade; + }, + csvDownloadable: true, + defaultVisible: false, + }); + }); + return cols; + }, [assessments, gamificationEnabled, t]); + + const assessmentMaxGrades = useMemo( + () => new Map(assessments.map((a) => [a.id, a.maxGrade])), + [assessments], + ); + + const dataColumnIds = useMemo( + () => [ + ...assessments.map((a) => buildAssessmentColumnId(a.id)), + ...GAMIFICATION_COL_IDS, + ], + [assessments], + ); + + const columnPicker = useMemo( + () => ({ + render: (context: ColumnPickerRenderContext) => ( + + ), + locked: ['name'], + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + getExtraHeaderRows: (colIds): string[][] => { + const hasAssessments = colIds.some( + (id) => parseAssessmentColumnId(id) !== null, + ); + if (!hasAssessments) return []; + return [ + colIds.map((id) => { + if (id === 'name') return t(translations.maxMarks); + const asnId = parseAssessmentColumnId(id); + if (asnId !== null) + return String(assessmentMaxGrades.get(asnId) ?? ''); + return ''; + }), + ]; + }, + storageKey: `gradebook_columns_${courseId}`, + dataColumnIds, + noDataColumnsHint: gamificationEnabled + ? t(translations.noDataColumnsHintWithGamification) + : t(translations.noDataColumnsHint), + }), + [ + assessments, + categories, + gamificationEnabled, + tabs, + t, + assessmentMaxGrades, + courseId, + dataColumnIds, + ], + ); + + const { toolbar, body, pagination } = useTanStackTableBuilder({ + data: rows, + columns, + getRowId: (row) => row.studentId.toString(), + getRowEqualityData: (row) => row, + indexing: { rowSelectable: true }, + pagination: { + rowsPerPage: [ + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + 25, + 50, + DEFAULT_TABLE_ROWS_PER_PAGE, + ], + showAllRows: true, + }, + search: { searchPlaceholder: t(translations.searchStudents) }, + toolbar: { show: true, keepNative: true }, + csvDownload: { + filename: `${courseTitle}_gradebook`, + showDownloadButton: false, + }, + columnPicker, + }); + + const visibility = toolbar?.getColumnVisibility?.() ?? {}; + const isColVisible = (id: string): boolean => visibility[id] ?? true; + const visibleCols = columns.filter((c) => + isColVisible(c.id ?? (c.of as string)), + ); + + const selectedCount = body.selectedCount ?? 0; + + const directExportLabel = useMemo((): string => { + const isPartialSelection = selectedCount > 0 && selectedCount < rows.length; + if (isPartialSelection) + return t(translations.exportRows, { count: selectedCount }); + return t(translations.exportButton); + }, [selectedCount, rows.length, t]); + + const toolbarWithLabel = toolbar?.columnPicker + ? { + ...toolbar, + columnPicker: { + ...toolbar.columnPicker, + directExportLabel, + directExportTooltip: + selectedCount === 0 ? t(translations.exportAllTooltip) : undefined, + }, + } + : toolbar; + + const totalWidth = useMemo( + () => + CHECKBOX_WIDTH + + visibleCols.reduce((sum, c) => { + const id = c.id ?? (c.of as string); + return sum + getColWidth(id); + }, 0), + [visibleCols], + ); + + const allRowsSelected = body.allFilteredSelected ?? false; + const someRowsSelected = body.someFilteredSelected ?? false; + const toggleAllRows = (): void => body.toggleAllFiltered?.(); + + const hasVisibleAssessments = useMemo( + () => + visibleCols.some( + (c) => parseAssessmentColumnId(c.id ?? (c.of as string)) !== null, + ), + [visibleCols], + ); + + const row1Ref = useRef(null); + const [row2Top, setRow2Top] = useState(0); + useLayoutEffect(() => { + setRow2Top(row1Ref.current?.offsetHeight ?? 0); + }, [visibleCols]); + + const headerFitsRef = useRef>({}); + const [headerFits, setHeaderFits] = useState>({}); + const onSingleLine = useCallback((id: string, fits: boolean): void => { + if (headerFitsRef.current[id] !== fits) { + headerFitsRef.current[id] = fits; + setHeaderFits((prev) => ({ ...prev, [id]: fits })); + } + }, []); + const singleLineCallbacks = useMemo( + () => + new Map( + visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return [id, (f: boolean): void => onSingleLine(id, f)]; + }), + ), + [visibleCols, onSingleLine], + ); + + return ( +
+ +
+ + + ({ + tableLayout: 'fixed', + borderCollapse: 'separate', + borderSpacing: 0, + + '& th, & td': { + boxSizing: 'border-box', + border: 0, + + // Draws the cell grid without relying on collapsed borders. + borderBottom: `0.5px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return ; + })} + + + + + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const label = typeof c.title === 'string' ? c.title : id; + const isLeft = isLeftAligned(id); + const fits = headerFits[id] ?? false; + return ( + + + + + + ); + })} + + {hasVisibleAssessments && ( + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const asnId = parseAssessmentColumnId(id); + let cellContent: string | number = ''; + if (id === 'name') cellContent = t(translations.maxMarks); + else if (asnId !== null) + cellContent = assessmentMaxGrades.get(asnId) ?? ''; + return ( + + {cellContent} + + ); + })} + + )} + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + return ( + + + + + {row + .getVisibleCells() + .filter((cell) => cell.column.id !== 'rowSelector') + .map((cell) => { + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
+
+ {pagination && } +
+
+
+ ); +}; + +export default GradebookTable; diff --git a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts new file mode 100644 index 00000000000..d12a4bd26a7 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts @@ -0,0 +1,7 @@ +export const buildAssessmentColumnId = (asnId: number): string => + `asn-${asnId}`; + +export const parseAssessmentColumnId = (colId: string): number | null => { + const match = colId.match(/^asn-(\d+)$/); + return match ? Number(match[1]) : null; +}; diff --git a/client/app/bundles/course/gradebook/constants.ts b/client/app/bundles/course/gradebook/constants.ts new file mode 100644 index 00000000000..87a49f50a7c --- /dev/null +++ b/client/app/bundles/course/gradebook/constants.ts @@ -0,0 +1,5 @@ +export const STUDENT_INFO_COL_IDS = ['name', 'email'] as const; +export type StudentInfoColId = (typeof STUDENT_INFO_COL_IDS)[number]; + +export const GAMIFICATION_COL_IDS = ['level', 'totalXp'] as const; +export type GamificationColId = (typeof GAMIFICATION_COL_IDS)[number]; diff --git a/client/app/bundles/course/gradebook/handles.ts b/client/app/bundles/course/gradebook/handles.ts new file mode 100644 index 00000000000..0022bfbd02c --- /dev/null +++ b/client/app/bundles/course/gradebook/handles.ts @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl'; + +import type { 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..35790580ed0 --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,12 @@ +import type { Operation } from 'store'; + +import CourseAPI from 'api/course'; + +import { actions } from './store'; + +const fetchGradebook = (): Operation => async (dispatch) => { + const response = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(response.data)); +}; + +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..cc140af58fd --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,102 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { PeopleAlt } from '@mui/icons-material'; +import { Typography } 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 { useCourseContext } from '../../../container/CourseLoader'; +import GradebookTable from '../../components/GradebookTable'; +import fetchGradebook from '../../operations'; +import { + getAssessments, + getCategories, + getGamificationEnabled, + getStudents, + getSubmissions, + 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.', + }, + noStudents: { + id: 'course.gradebook.GradebookIndex.noStudents', + defaultMessage: 'No students enrolled yet', + }, + noStudentsHint: { + id: 'course.gradebook.GradebookIndex.noStudentsHint', + defaultMessage: 'Grades will appear here once students join the course.', + }, +}); + +const GradebookIndex: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseTitle } = useCourseContext(); + const { courseId: courseIdParam } = useParams(); + const courseId = parseInt(courseIdParam!, 10); + const [isLoading, setIsLoading] = useState(true); + + const assessments = useAppSelector(getAssessments); + const categories = useAppSelector(getCategories); + const tabs = useAppSelector(getTabs); + const students = useAppSelector(getStudents); + const submissions = useAppSelector(getSubmissions); + const gamificationEnabled = useAppSelector(getGamificationEnabled); + + useEffect(() => { + dispatch(fetchGradebook()) + .finally(() => setIsLoading(false)) + .catch(() => toast.error(t(translations.fetchFailure))); + }, [dispatch]); + + let content: JSX.Element; + if (isLoading) { + content = ; + } else if (students.length === 0) { + content = ( +
+ + + {t(translations.noStudents)} + + + {t(translations.noStudentsHint)} + +
+ ); + } else { + content = ( + + ); + } + + return ( + + {content} + + ); +}; + +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..fbe62e2611a --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,24 @@ +import type { AppState } from 'store'; + +type GradebookState = AppState['gradebook']; + +function getLocalState(state: AppState): GradebookState { + return state.gradebook; +} + +export const getCategories = (state: AppState): GradebookState['categories'] => + getLocalState(state).categories; +export const getTabs = (state: AppState): GradebookState['tabs'] => + getLocalState(state).tabs; +export const getAssessments = ( + state: AppState, +): GradebookState['assessments'] => getLocalState(state).assessments; +export const getStudents = (state: AppState): GradebookState['students'] => + getLocalState(state).students; +export const getSubmissions = ( + state: AppState, +): GradebookState['submissions'] => getLocalState(state).submissions; +export const getGamificationEnabled = ( + state: AppState, +): GradebookState['gamificationEnabled'] => + getLocalState(state).gamificationEnabled; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..00e3291032b --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,63 @@ +import { produce } from 'immer'; +import type { GradebookData } from 'types/course/gradebook'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from './types'; + +const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; + +interface GradebookState { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, +}; + +const reducer = produce( + (draft: GradebookState, action: SaveGradebookAction) => { + switch (action.type) { + case SAVE_GRADEBOOK: { + draft.categories = action.payload.categories; + draft.tabs = action.payload.tabs; + draft.assessments = action.payload.assessments; + draft.students = action.payload.students; + draft.submissions = action.payload.submissions; + draft.gamificationEnabled = action.payload.gamificationEnabled; + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: (data: GradebookData): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + payload: data, + }), +}; + +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..f94aa7bf9c5 --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,8 @@ +export type { + AssessmentData, + CategoryData, + GradebookData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index b92b165a744..c52ce359071 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -75,6 +75,10 @@ const translations = defineMessages({ id: 'course.componentTitles.course_forums_component', defaultMessage: 'Forums', }, + course_gradebook_component: { + id: 'course.componentTitles.course_gradebook_component', + defaultMessage: 'Gradebook', + }, course_groups_component: { id: 'course.componentTitles.course_groups_component', defaultMessage: 'Groups', diff --git a/client/app/bundles/course/duplication/components/IndentedCheckbox.tsx b/client/app/lib/components/core/IndentedCheckbox.tsx similarity index 100% rename from client/app/bundles/course/duplication/components/IndentedCheckbox.tsx rename to client/app/lib/components/core/IndentedCheckbox.tsx diff --git a/client/app/lib/components/core/dialogs/Prompt.tsx b/client/app/lib/components/core/dialogs/Prompt.tsx index 5330d10c7c4..32f4c7f251a 100644 --- a/client/app/lib/components/core/dialogs/Prompt.tsx +++ b/client/app/lib/components/core/dialogs/Prompt.tsx @@ -15,6 +15,7 @@ interface BasePromptProps { open?: boolean; title?: string | ReactNode; children?: string | ReactNode; + footer?: ReactNode; onClose?: () => void; onClosed?: () => void; disabled?: boolean; @@ -84,6 +85,8 @@ const Prompt = (props: PromptProps): JSX.Element => { )} + {props.footer} + {!props.cancel ? ( + )} + + {renderNative && props.columnPicker && props.onDirectExport && ( + + + + + + )} + + {renderNative && props.onDownloadCsv && ( + + + + + + )} + + {props.columnPicker && props.commitColumnVisibility && ( + setPickerOpen(false)} + open={pickerOpen} + /> + )} ); }; diff --git a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts index 5fdf50fd16a..8ebb3127667 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts @@ -45,8 +45,12 @@ const buildTanStackColumns = ( (column) => ({ id: column.id, - accessorKey: column.of, - accessorFn: column.searchProps?.getValue, + ...(column.accessorFn !== undefined + ? { accessorFn: column.accessorFn } + : { + accessorKey: column.of, + accessorFn: column.searchProps?.getValue, + }), header: column.title, cell: ({ row: { original: datum } }) => column.cell(datum), enableSorting: Boolean(column.sortable), diff --git a/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts b/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts index 965a2c684e4..14e8f4189d4 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/csvGenerator.ts @@ -1,34 +1,54 @@ import { ReactNode } from 'react'; -import { Row } from '@tanstack/react-table'; +import { Column, Table } from '@tanstack/react-table'; import { unparse } from 'papaparse'; import { ColumnTemplate, Data } from '../builder'; interface CsvGenerator { - headers: string[]; - rows: () => Row[]; - getRealColumn: (index: number) => ColumnTemplate | undefined; + table: Table; + getRealColumn: (id: string) => ColumnTemplate | undefined; + getExtraHeaderRows?: (columnIds: string[]) => string[][]; + onlySelected?: boolean; } +const extractHeader = ( + col: Column, + realColumn: ColumnTemplate | undefined, +): string => { + const title = realColumn?.title; + if (typeof title === 'string') return title; + return realColumn?.id ?? col.id; +}; + const generateCsv = ( options: CsvGenerator, ): Promise => new Promise((resolve) => { - const rows = [options.headers]; - - options.rows().forEach((row) => { - const rowData = row - .getAllCells() - .reduce((cells, cell, index) => { - const realColumn = options.getRealColumn(index); - const csvDownloadable = realColumn?.csvDownloadable; - if (!csvDownloadable) return cells; - - const value = cell.getValue() as ReactNode; - cells.push(realColumn.csvValue?.(value) ?? value?.toString() ?? ''); - return cells; - }, []); - + // Keep ONLY columns where the consumer explicitly set csvDownloadable === true. + // Columns with `csvDownloadable: undefined` or `false` are excluded (matches the + // original behaviour where `csvDownloadable ?? false` gated headers). + const leafColumns = options.table.getVisibleLeafColumns(); + const exportColumns = leafColumns.filter( + (col) => options.getRealColumn(col.id)?.csvDownloadable === true, + ); + + const headers = exportColumns.map((col) => + extractHeader(col, options.getRealColumn(col.id)), + ); + + const colIds = exportColumns.map((col) => col.id); + const extraHeaderRows = options.getExtraHeaderRows?.(colIds) ?? []; + const rows: string[][] = [headers, ...extraHeaderRows]; + + const exportRows = options.onlySelected + ? options.table.getSelectedRowModel().rows + : options.table.getCoreRowModel().rows; + exportRows.forEach((row) => { + const rowData = exportColumns.map((col) => { + const realColumn = options.getRealColumn(col.id); + const value = row.getValue(col.id) as ReactNode; + return realColumn?.csvValue?.(value) ?? value?.toString() ?? ''; + }); rows.push(rowData); }); diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index 16806f3445e..0c5fbed2376 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Cell, ColumnFiltersState, @@ -9,12 +9,17 @@ import { getSortedRowModel, Header, Row, + Updater, useReactTable, + VisibilityState, } from '@tanstack/react-table'; import isEmpty from 'lodash-es/isEmpty'; +import { getUserEntity } from 'bundles/users/selectors'; +import { useAppSelector } from 'lib/hooks/store'; + import { RowEqualityData, TableProps } from '../adapters'; -import { TableTemplate } from '../builder'; +import { ColumnTemplate, TableTemplate } from '../builder'; import { downloadCsv } from '../utils'; import buildTanStackColumns from './columnsBuilder'; @@ -30,6 +35,14 @@ type TanStackTableProps = TableProps< const useTanStackTableBuilder = ( props: TableTemplate, ): TanStackTableProps => { + const currentUserId = useAppSelector(getUserEntity).id; + // Namespace the caller's key by userId so two users on the same device + // don't share visibility preferences. Guard against userId=0 (not yet loaded). + const effectiveStorageKey = + props.columnPicker?.storageKey && currentUserId > 0 + ? `${currentUserId}:${props.columnPicker.storageKey}` + : undefined; + const [columns, getRealColumn] = buildTanStackColumns( props.columns, props.indexing?.rowSelectable, @@ -47,6 +60,91 @@ const useTanStackTableBuilder = ( pageIndex: props.pagination?.initialPageIndex ?? 0, }); + const initialVisibility = useMemo(() => { + let stored: VisibilityState | null = null; + if (effectiveStorageKey) { + try { + const raw = localStorage.getItem(effectiveStorageKey); + stored = raw ? (JSON.parse(raw) as VisibilityState) : null; + } catch { + stored = null; + } + } + return Object.fromEntries( + props.columns.map((c) => { + const id = c.id ?? (c.of as string); + const storedValue = stored?.[id]; + return [ + id, + storedValue !== undefined ? storedValue : c.defaultVisible ?? true, + ]; + }), + ); + }, []); + const [columnVisibility, setColumnVisibility] = + useState(initialVisibility); + + // Ref-based so enforceLocked is stable and never a changing useEffect dep. + const lockedRef = useRef(props.columnPicker?.locked); + lockedRef.current = props.columnPicker?.locked; + + const enforceLocked = useCallback( + (next: VisibilityState): VisibilityState => { + const locked = lockedRef.current; + if (!locked || locked.length === 0) return next; + const enforced = { ...next }; + locked.forEach((id) => { + enforced[id] = true; + }); + return enforced; + }, + [], + ); + + const safeSetVisibility = (updater: Updater): void => { + setColumnVisibility((prev) => { + const next = typeof updater === 'function' ? updater(prev) : updater; + return enforceLocked(next); + }); + }; + + useEffect(() => { + if (!effectiveStorageKey) return; + try { + localStorage.setItem( + effectiveStorageKey, + JSON.stringify(columnVisibility), + ); + } catch { + // setItem throws QuotaExceededError (storage full) or SecurityError (private + // browsing on some browsers). Persistence is best-effort; the current session + // is unaffected if it fails. + } + }, [columnVisibility, effectiveStorageKey]); + + // Reconcile when columns change (e.g. async-loaded gradebook assessments). + useEffect(() => { + setColumnVisibility((prev) => { + const currentIds = props.columns.map((c) => c.id ?? (c.of as string)); + const colMap = new Map( + props.columns.map((c) => [c.id ?? (c.of as string), c]), + ); + const next: VisibilityState = {}; + currentIds.forEach((id) => { + next[id] = Object.hasOwn(prev, id) + ? prev[id] + : colMap.get(id)?.defaultVisible ?? true; + }); + const enforced = enforceLocked(next); + // Return prev reference when nothing changed — prevents infinite re-render + // loop when columns/locked arrays are new references on every render. + const changed = + Object.keys(enforced).length !== Object.keys(prev).length || + Object.keys(enforced).some((k) => enforced[k] !== prev[k]); + return changed ? enforced : prev; + }); + }, [props.columns, enforceLocked]); + const resetPagination = (): void => setPagination((current) => ({ ...current, pageIndex: 0 })); @@ -83,7 +181,9 @@ const useTanStackTableBuilder = ( columnFilters, globalFilter: searchKeyword.trim(), pagination, + columnVisibility, }, + onColumnVisibilityChange: safeSetVisibility, initialState: { sorting: props.sort?.initially && [ { @@ -94,24 +194,40 @@ const useTanStackTableBuilder = ( }, }); - const generateAndDownloadCsv = async (): Promise => { - const headers = table.options.columns.reduce( - (acc, column, index) => { - const header = column.header || column.id; - if (header && (getRealColumn(index)?.csvDownloadable ?? false)) { - acc.push(header as string); - } - return acc; - }, - [], - ); + const getRealColumnById = (id: string): ColumnTemplate | undefined => { + // Use the position within getAllLeafColumns() as the index into getRealColumn. + // We cannot search table.options.columns by c.id (undefined for accessorKey-based columns), + // and we cannot use col.columnDef reference equality because TanStack's createColumn spreads + // the def ({ ...defaultColumn, ...columnDef }), so col.columnDef is never === the original. + // + // Why getAllLeafColumns() index === getRealColumn() index: + // table.options.columns (ColumnDef[]) + // → _getColumnDefs() returns it directly + // → getAllColumns() maps each def → Column, preserving order + // → getAllLeafColumns() flatMaps + applies _getOrderColumnsFn + // (identity when columnOrder state is empty — we never set it) + // NOTE: if user-reorderable columns are added, columnOrder state will be set and + // getAllLeafColumns() will no longer match getRealColumn() by position. At that point + // getRealColumnById must be rewritten to look up by id rather than position. + // getRealColumn is built by buildColumns, which maps built-array position → ColumnTemplate + // using the same table.options.columns as input in the same order. + // Both arrays share the same positional index, so getRealColumn(i) matches getAllLeafColumns()[i]. + // + // Visibility safety: getAllLeafColumns() includes hidden columns, so the index is stable + // regardless of columnVisibility state. getVisibleLeafColumns() would shift indices and + // break the mapping (the root cause of the bug fixed in PR #8226). + const index = table.getAllLeafColumns().findIndex((c) => c.id === id); + if (index === -1) return undefined; + return getRealColumn(index); + }; + const generateAndDownloadCsv = async (): Promise => { const csvData = await generateCsv({ - headers, - rows: () => table.getCoreRowModel().rows, - getRealColumn, + table, + getRealColumn: getRealColumnById, + getExtraHeaderRows: props.columnPicker?.getExtraHeaderRows, + onlySelected: !isEmpty(rowSelection), }); - downloadCsv(csvData, props.csvDownload?.filename); }; @@ -161,12 +277,17 @@ const useTanStackTableBuilder = ( body: { rows: table.getRowModel().rows, getCells: (row) => row.getVisibleCells(), - forEachCell: (cell, row, index) => ({ + // Use getRealColumnById (ID-based) not getRealColumn(index). getVisibleCells() skips hidden + // columns, so its positional index diverges from getRealColumn's full-column-list index + // whenever any column is hidden — the same misalignment fixed for CSV in PR #8226. + forEachCell: (cell, row) => ({ id: cell.id, render: customCellRender(cell), - className: getRealColumn(index)?.className, - colSpan: getRealColumn(index)?.colSpan?.(row.original), - shouldNotRender: getRealColumn(index)?.cellUnless?.(row.original), + className: getRealColumnById(cell.column.id)?.className, + colSpan: getRealColumnById(cell.column.id)?.colSpan?.(row.original), + shouldNotRender: getRealColumnById(cell.column.id)?.cellUnless?.( + row.original, + ), }), forEachRow: (row) => ({ id: row.id, @@ -178,6 +299,18 @@ const useTanStackTableBuilder = ( selected: rowSelection[row.id], })), }), + selectedCount: table.getSelectedRowModel().rows.length, + allFilteredSelected: + table.getFilteredRowModel().rows.length > 0 && + table.getFilteredRowModel().rows.every((r) => r.getIsSelected()), + someFilteredSelected: table + .getFilteredRowModel() + .rows.some((r) => r.getIsSelected()), + toggleAllFiltered: (): void => { + const filteredRows = table.getFilteredRowModel().rows; + const allSelected = filteredRows.every((r) => r.getIsSelected()); + filteredRows.forEach((r) => r.toggleSelected(!allSelected)); + }, }, handles: { getPaginationState: () => pagination, @@ -208,10 +341,19 @@ const useTanStackTableBuilder = ( }, searchKeyword, onSearchKeywordChange: setSearchKeyword, - onDownloadCsv: props.csvDownload && generateAndDownloadCsv, + onDownloadCsv: + props.csvDownload && (props.csvDownload.showDownloadButton ?? true) + ? generateAndDownloadCsv + : undefined, csvDownloadLabel: props.csvDownload?.downloadButtonLabel, searchPlaceholder: props.search?.searchPlaceholder, buttons: props.toolbar?.buttons, + columnPicker: props.columnPicker, + getColumnVisibility: () => columnVisibility, + commitColumnVisibility: (next) => safeSetVisibility(() => next), + onDirectExport: props.columnPicker + ? (): Promise => generateAndDownloadCsv() + : undefined, }, }; }; diff --git a/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx b/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx new file mode 100644 index 00000000000..bcf71eecd68 --- /dev/null +++ b/client/app/lib/components/table/__tests__/ColumnPickerTreeGroup.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent, render, screen } from '@testing-library/react'; + +import { ColumnPickerRenderContext } from '../builder'; +import ColumnPickerTreeGroup from '../MuiTableAdapter/ColumnPickerTreeGroup'; + +const makeCtx = ( + visible: Record, +): ColumnPickerRenderContext & { setManyVisible: jest.Mock } => ({ + isVisible: (id) => visible[id] ?? false, + setVisible: jest.fn(), + setManyVisible: jest.fn(), +}); + +describe('ColumnPickerTreeGroup', () => { + describe('parent checkbox state mirrors children visibility', () => { + it('is checked when all children are visible', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Group' })).toBeChecked(); + }); + + it('is unchecked and not indeterminate when no children are visible', () => { + const ctx = makeCtx({ a: false, b: false }); + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { name: 'Group' }); + expect(checkbox).not.toBeChecked(); + expect(checkbox.getAttribute('data-indeterminate')).toBe('false'); + }); + + it('is indeterminate and not checked when some but not all children are visible', () => { + const ctx = makeCtx({ a: true, b: false }); + render( + + + , + ); + const checkbox = screen.getByRole('checkbox', { name: 'Group' }); + expect(checkbox).not.toBeChecked(); + expect(checkbox.getAttribute('data-indeterminate')).toBe('true'); + }); + }); + + describe('cascading toggle', () => { + it('calls setManyVisible(childIds, true) when parent is clicked while unchecked', () => { + const ctx = makeCtx({ a: false, b: false }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], true); + }); + + it('calls setManyVisible(childIds, false) when parent is clicked while checked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], false); + }); + + it('calls setManyVisible(childIds, true) when parent is clicked while indeterminate', () => { + const ctx = makeCtx({ a: true, b: false }); + render( + + + , + ); + fireEvent.click(screen.getByRole('checkbox', { name: 'Group' })); + expect(ctx.setManyVisible).toHaveBeenCalledWith(['a', 'b'], true); + }); + }); + + describe('locked behavior', () => { + it('disables the parent checkbox when all children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect(screen.getByRole('checkbox', { name: 'Group' })).toBeDisabled(); + }); + + it('does not disable the parent when only some children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: 'Group' }), + ).not.toBeDisabled(); + }); + + it('does not disable the parent when no children are locked', () => { + const ctx = makeCtx({ a: true, b: true }); + render( + + + , + ); + expect( + screen.getByRole('checkbox', { name: 'Group' }), + ).not.toBeDisabled(); + }); + }); + + it('renders children below the parent checkbox', () => { + const ctx = makeCtx({}); + render( + + Child + , + ); + expect(screen.getByTestId('child-node')).toBeInTheDocument(); + }); +}); diff --git a/client/app/lib/components/table/__tests__/MuiColumnPickerPrompt.test.tsx b/client/app/lib/components/table/__tests__/MuiColumnPickerPrompt.test.tsx new file mode 100644 index 00000000000..4acc32fa13c --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiColumnPickerPrompt.test.tsx @@ -0,0 +1,230 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { ColumnPickerRenderContext } from '../builder'; +import MuiColumnPickerPrompt from '../MuiTableAdapter/MuiColumnPickerPrompt'; + +const TITLE = 'Select columns'; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +const makeRender = (ids: readonly string[]): jest.Mock => + jest.fn((ctx: ColumnPickerRenderContext) => ( + <> + {ids.map((id) => ( + + ))} + + )); + +const setup = ( + overrides: Partial> = {}, +): ReturnType & { + commitColumnVisibility: jest.Mock; + props: React.ComponentProps; +} => { + const commitColumnVisibility = jest.fn(); + const props = { + open: true, + onClose: jest.fn(), + initialVisibility: { name: true, email: true }, + locked: ['name'], + columnPicker: { + render: makeRender(['name', 'email']), + dialogTitle: TITLE, + }, + commitColumnVisibility, + ...overrides, + }; + return { + ...render(wrap()), + commitColumnVisibility, + props, + }; +}; + +describe('MuiColumnPickerPrompt', () => { + it('renders the title', () => { + setup(); + expect(screen.getByText(TITLE)).toBeInTheDocument(); + }); + + it('Apply commits staged changes and closes', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('Cancel discards staged and closes without commit', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility, props } = setup(); + await user.click(screen.getByLabelText('email')); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(commitColumnVisibility).not.toHaveBeenCalled(); + expect(props.onClose).toHaveBeenCalled(); + }); + + it('locked id forcibly restored to true on commit even if staged false', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility } = setup({ + initialVisibility: { name: false, email: true }, // malformed input + }); + + await user.click(screen.getByRole('button', { name: /apply/i })); + + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + + describe('locked column behavior', () => { + const makeGroupRender = (ids: readonly string[]): jest.Mock => + jest.fn( + (ctx: ColumnPickerRenderContext): JSX.Element => ( + <> + + + + ), + ); + + it('deselect-all leaves the locked column checked', async () => { + const user = userEvent.setup(); + const commitColumnVisibility = jest.fn(); + render( + wrap( + , + ), + ); + await user.click(screen.getByRole('button', { name: 'Deselect all' })); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: false, + }); + }); + + it('select-all from indeterminate state selects non-locked column', async () => { + const user = userEvent.setup(); + const commitColumnVisibility = jest.fn(); + render( + wrap( + , + ), + ); + await user.click(screen.getByRole('button', { name: 'Select all' })); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + + it('clicking a locked column checkbox has no effect on its visibility', async () => { + const user = userEvent.setup(); + const { commitColumnVisibility } = setup(); + await user.click(screen.getByLabelText('name')); + await user.click(screen.getByRole('button', { name: /apply/i })); + expect(commitColumnVisibility).toHaveBeenCalledWith({ + name: true, + email: true, + }); + }); + }); + + it('Esc key dismisses without committing', () => { + const { commitColumnVisibility, props } = setup(); + fireEvent.keyDown(screen.getByRole('dialog'), { + key: 'Escape', + code: 'Escape', + }); + expect(props.onClose).toHaveBeenCalled(); + expect(commitColumnVisibility).not.toHaveBeenCalled(); + }); + + describe('noDataColumnsHint', () => { + const dataSetup = ( + dataColumnIds: string[], + initialVisibility: Record, + ): ReturnType => + setup({ + initialVisibility, + columnPicker: { + render: makeRender(['name', 'grade']), + dialogTitle: TITLE, + dataColumnIds, + noDataColumnsHint: 'No grade columns selected.', + }, + }); + + it('shows hint when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect( + screen.getByText('No grade columns selected.'), + ).toBeInTheDocument(); + }); + + it('hides hint when at least one data column is selected', () => { + dataSetup(['grade'], { name: true, grade: true }); + expect( + screen.queryByText('No grade columns selected.'), + ).not.toBeInTheDocument(); + }); + + it('Apply button is enabled even when no data columns are selected', () => { + dataSetup(['grade'], { name: true, grade: false }); + expect(screen.getByRole('button', { name: /apply/i })).not.toBeDisabled(); + }); + }); +}); diff --git a/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx new file mode 100644 index 00000000000..e8ef504f02c --- /dev/null +++ b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx @@ -0,0 +1,62 @@ +import { IntlProvider } from 'react-intl'; +import { render, screen } from '@testing-library/react'; + +import { ToolbarProps } from '../adapters'; +import MuiTableToolbar from '../MuiTableAdapter/MuiTableToolbar'; + +const baseToolbar: ToolbarProps = { + renderNative: true, + searchKeyword: '', + onSearchKeywordChange: () => {}, +}; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +describe('MuiTableToolbar columnPicker trigger', () => { + it('does not render Export… button when columnPicker is unset', () => { + render(wrap()); + expect( + screen.queryByRole('button', { name: /export/i }), + ).not.toBeInTheDocument(); + }); + + it('renders Export… button when columnPicker is set', () => { + const props: ToolbarProps = { + ...baseToolbar, + columnPicker: { + render: () => null, + triggerLabel: 'Export…', + }, + getColumnVisibility: () => ({}), + commitColumnVisibility: () => {}, + }; + render(wrap()); + expect( + screen.getByRole('button', { name: /export…/i }), + ).toBeInTheDocument(); + }); +}); + +describe('MuiTableToolbar direct export button', () => { + const directExportProps: ToolbarProps = { + ...baseToolbar, + columnPicker: { + render: () => null, + directExportLabel: 'Export all rows', + }, + getColumnVisibility: () => ({}), + commitColumnVisibility: () => {}, + onDirectExport: async () => {}, + }; + + it('direct export button is enabled by default', () => { + render(wrap()); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).not.toBeDisabled(); + }); +}); diff --git a/client/app/lib/components/table/__tests__/csvGenerator.test.ts b/client/app/lib/components/table/__tests__/csvGenerator.test.ts new file mode 100644 index 00000000000..e4defeb62c2 --- /dev/null +++ b/client/app/lib/components/table/__tests__/csvGenerator.test.ts @@ -0,0 +1,222 @@ +import { + ColumnDef, + getCoreRowModel, + Table, + useReactTable, +} from '@tanstack/react-table'; +import { renderHook } from '@testing-library/react'; + +import { ColumnTemplate } from '../builder'; +import generateCsv from '../TanStackTableBuilder/csvGenerator'; + +interface Row { + id: number; + name: string; + email: string; + score: number; +} + +const fixture: Row[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com', score: 90 }, + { id: 2, name: 'Bob', email: 'bob@example.com', score: 80 }, +]; + +const buildHarness = ( + visibility: Record, + selectedRowIds?: Record, +): { + table: Table; + getRealColumn: (id: string) => ColumnTemplate | undefined; +} => { + const templates: Record> = { + name: { + id: 'name', + title: 'Name', + cell: (r) => r.name, + csvDownloadable: true, + }, + email: { + id: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: true, + }, + score: { + id: 'score', + title: 'Score', + cell: (r) => r.score, + csvDownloadable: false, + }, + }; + + const columnDefs: ColumnDef[] = Object.values(templates).map( + (tpl) => ({ + id: tpl.id, + header: tpl.title as string, + accessorFn: (row) => + (row as unknown as Record)[tpl.id as string], + cell: ({ row: { original } }) => tpl.cell(original), + }), + ); + + const { result } = renderHook(() => + useReactTable({ + data: fixture, + columns: columnDefs, + getCoreRowModel: getCoreRowModel(), + enableRowSelection: true, + state: { + columnVisibility: visibility, + rowSelection: selectedRowIds ?? {}, + }, + onColumnVisibilityChange: () => {}, + onRowSelectionChange: () => {}, + }), + ); + + return { + table: result.current, + getRealColumn: (id: string) => templates[id], + }; +}; + +describe('csvGenerator', () => { + it('emits headers and rows ordered by visible csv-downloadable columns', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); // score has csvDownloadable: false + expect(lines[1]).toBe('Alice,alice@example.com'); + expect(lines[2]).toBe('Bob,bob@example.com'); + }); + + it('excludes hidden columns from output', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name'); + expect(lines[1]).toBe('Alice'); + expect(lines[2]).toBe('Bob'); + }); + + it('row cell count always equals header count', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + + const csv = await generateCsv({ table, getRealColumn }); + + const lines = csv.trim().split(/\r?\n/); + const headerCount = lines[0].split(',').length; + lines + .slice(1) + .forEach((row) => expect(row.split(',')).toHaveLength(headerCount)); + }); + + describe('getExtraHeaderRows', () => { + it('inserts extra rows between the header row and data rows', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + getExtraHeaderRows: () => [['Extra A', 'Extra B']], + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('Extra A,Extra B'); + expect(lines[2]).toBe('Alice,alice@example.com'); + }); + + it('is called with the visible csvDownloadable column ids', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: false, + score: true, + }); + const getExtraHeaderRows = jest.fn(() => []); + + await generateCsv({ table, getRealColumn, getExtraHeaderRows }); + + // email is hidden; score has csvDownloadable: false — only 'name' remains + expect(getExtraHeaderRows).toHaveBeenCalledWith(['name']); + }); + + it('supports multiple extra rows', async () => { + const { table, getRealColumn } = buildHarness({ + name: true, + email: true, + score: true, + }); + + const csv = await generateCsv({ + table, + getRealColumn, + getExtraHeaderRows: () => [ + ['Row1A', 'Row1B'], + ['Row2A', 'Row2B'], + ], + }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('Row1A,Row1B'); + expect(lines[2]).toBe('Row2A,Row2B'); + expect(lines[3]).toBe('Alice,alice@example.com'); + }); + }); + + it('exports only selected rows when onlySelected is true', async () => { + // TanStack uses row index as id by default: '0' = Alice, '1' = Bob + const { table, getRealColumn } = buildHarness( + { name: true, email: true, score: true }, + { '1': true }, // Bob selected + ); + + const csv = await generateCsv({ table, getRealColumn, onlySelected: true }); + + const lines = csv.trim().split(/\r?\n/); + expect(lines).toHaveLength(2); // header + Bob only + expect(lines[1]).toBe('Bob,bob@example.com'); + }); + + it('respects csvValue override', async () => { + const { getRealColumn: baseGet, table } = buildHarness({ + name: true, + email: true, + score: true, + }); + const wrapped = (id: string): ColumnTemplate | undefined => + id === 'name' + ? { ...baseGet('name')!, csvValue: (v: unknown) => `<<${String(v)}>>` } + : baseGet(id); + + const csv = await generateCsv({ table, getRealColumn: wrapped }); + expect(csv).toContain('<>'); + }); +}); diff --git a/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx new file mode 100644 index 00000000000..b8899dbf676 --- /dev/null +++ b/client/app/lib/components/table/__tests__/useTanStackTableBuilder.test.tsx @@ -0,0 +1,819 @@ +import { FC, JSX, ReactNode } from 'react'; +import { Provider } from 'react-redux'; +import { act, renderHook, type RenderHookResult } from '@testing-library/react'; +import { setUpStoreWithState, store as appStore } from 'store'; +import { downloadFile } from 'utilities/downloadFile'; + +import { ColumnTemplate, TableTemplate } from '../builder'; +import useTanStackTableBuilder from '../TanStackTableBuilder'; + +jest.mock('utilities/downloadFile', () => ({ + downloadFile: jest.fn(), +})); + +const mockedDownloadFile = jest.mocked(downloadFile); + +interface Row { + id: number; + name: string; + email: string; +} + +const baseColumns: ColumnTemplate[] = [ + { id: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { id: 'email', title: 'Email', cell: (r) => r.email, csvDownloadable: true }, +]; + +const baseProps = ( + overrides: Partial> = {}, +): TableTemplate => ({ + data: [{ id: 1, name: 'Alice', email: 'alice@example.com' }], + columns: baseColumns, + getRowId: (r) => r.id.toString(), + ...overrides, +}); + +// Wraps renderHook with the given store. Defaults to the global appStore +// (userId=0, no localStorage scoping) for tests that don't exercise persistence. +const withStore = (store = appStore): FC<{ children: ReactNode }> => + Object.assign( + ({ children }: { children: ReactNode }): JSX.Element => ( + {children} + ), + { displayName: 'WithStoreWrapper' }, + ); + +// Creates a store pre-loaded with a specific userId for localStorage isolation tests. +const storeForUser = (userId: number): typeof appStore => + setUpStoreWithState({ + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: userId, name: '', imageUrl: '' }, + }, + }, + }); + +describe('useTanStackTableBuilder columnPicker state', () => { + it('initial visibility marks every column visible', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + render: () => null, + }, + }), + ), + { wrapper: withStore() }, + ); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + }); + }); + + it('locked id cannot be set to false via setVisible', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + render: () => null, + locked: ['name'], + }, + }), + ), + { wrapper: withStore() }, + ); + + const commit = result.current.toolbar!.commitColumnVisibility!; + act(() => commit({ name: false, email: true })); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, // forced back to true + email: true, + }); + }); + + it('setManyVisible toggles only unlocked descendants', () => { + // This test exercises the contract used by BulkSelectors in PR2 callers: + // when a branch deselects, locked descendants must remain visible. + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { + render: () => null, + locked: ['name'], + }, + }), + ), + { wrapper: withStore() }, + ); + + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: false, + }), + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); + + it('dynamic columns: adding a new column with defaultVisible: false defaults it hidden', () => { + const { result, rerender } = renderHook( + ({ extra }: { extra: boolean }) => + useTanStackTableBuilder( + baseProps({ + columns: extra + ? [ + ...baseColumns, + { + id: 'phone', + title: 'Phone', + cell: (): string => '', + csvDownloadable: true, + defaultVisible: false, + }, + ] + : baseColumns, + columnPicker: { + render: () => null, + }, + }), + ), + { initialProps: { extra: false }, wrapper: withStore() }, + ); + + rerender({ extra: true }); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + phone: false, + }); + }); + + it('dynamic columns: adding a new column id after mount defaults it visible', () => { + const { result, rerender } = renderHook( + ({ extra }: { extra: boolean }) => + useTanStackTableBuilder( + baseProps({ + columns: extra + ? [ + ...baseColumns, + { + id: 'phone', + title: 'Phone', + cell: (): string => '', + csvDownloadable: true, + }, + ] + : baseColumns, + columnPicker: { render: () => null }, + }), + ), + { initialProps: { extra: false }, wrapper: withStore() }, + ); + + expect( + Object.keys(result.current.toolbar!.getColumnVisibility?.() ?? {}), + ).toEqual(['name', 'email']); + + rerender({ extra: true }); + + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + phone: true, // new column defaults visible + }); + }); +}); + +// CSV tests use `of:` (accessorKey) so TanStack can extract values via row.getValue(). +// The student statistics table uses the same `of:` pattern. +const csvColumns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { of: 'email', title: 'Email', cell: (r) => r.email, csvDownloadable: true }, +]; + +describe('useTanStackTableBuilder CSV download', () => { + beforeEach(() => { + mockedDownloadFile.mockClear(); + }); + + it('CSV contains headers and rows for all csvDownloadable columns', async () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'test' }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); + + it('CSV with indices: true still maps columns correctly (student statistics pattern)', async () => { + // Student statistics sets indexing.indices: true, which prepends an index column + // at position 0 in getAllLeafColumns(). getRealColumnById must offset correctly. + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'test' }, + indexing: { indices: true }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + // Headers must be Name and Email (not blank or offset titles from wrong template lookup) + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); + + it('CSV columns using accessorFn (not of) emit correct values', async () => { + // Regression: assessment columns have no `of` key — they use accessorFn to + // expose the grade value. row.getValue() must return the fn result, not undefined. + interface ScoreRow { + id: number; + name: string; + grades: Record; + } + const scoreData: ScoreRow[] = [ + { id: 1, name: 'Alice', grades: { 42: 9 } }, + { id: 2, name: 'Bob', grades: { 42: null } }, + ]; + const scoreColumns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { + id: 'asn_42', + title: 'Quiz', + accessorFn: (r) => r.grades[42], + cell: (r) => r.grades[42] ?? '—', + csvDownloadable: true, + }, + ]; + const { result } = renderHook( + () => + useTanStackTableBuilder({ + data: scoreData, + columns: scoreColumns, + getRowId: (r) => r.id.toString(), + csvDownload: { filename: 'test' }, + }), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Quiz'); + expect(lines[1]).toBe('Alice,9'); + expect(lines[2]).toBe('Bob,'); + }); + + it('columnPicker getExtraHeaderRows inserts extra rows after the header', async () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + columnPicker: { + render: () => null, + getExtraHeaderRows: (colIds) => [colIds.map(() => 'max')], + }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toBe('max,max'); + expect(lines[2]).toContain('Alice'); + }); + + it('exports only selected rows when rows are selected', async () => { + const twoRowData = [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + ]; + const { result } = renderHook( + () => + useTanStackTableBuilder({ + data: twoRowData, + columns: csvColumns, + getRowId: (r) => r.id.toString(), + indexing: { rowSelectable: true }, + columnPicker: { + render: () => null, + }, + }), + { wrapper: withStore() }, + ); + + // Select only Alice (row index 0) + act(() => result.current.body.rows[0].toggleSelected()); + + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines).toHaveLength(2); // header + Alice only + expect(lines[1]).toContain('Alice'); + expect(csv).not.toContain('Bob'); + }); + + it('CSV excludes columns where csvDownloadable is false', async () => { + const columns: ColumnTemplate[] = [ + { of: 'name', title: 'Name', cell: (r) => r.name, csvDownloadable: true }, + { + of: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: false, + }, + ]; + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ columns, csvDownload: { filename: 'test' } }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDownloadCsv?.(); + }); + + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name'); + expect(lines[0]).not.toContain('Email'); + }); +}); + +// ---------- columnVisibility alignment regression (PR #8226) ---------- +// +// Root cause: getVisibleLeafColumns() / getVisibleCells() skips hidden columns, +// so its positional index diverges from getRealColumn()'s full-column-list index. +// Both the CSV path (csvGenerator) and the cell render path (forEachCell) must +// use getRealColumnById (ID-based) to stay stable when columns are hidden. +// +// Each scenario is tested on BOTH paths. A test would fail on the relevant path +// if it regressed to positional index. + +interface ThreeColRow { + id: number; + name: string; + email: string; + phone: string; +} + +const threeColData: ThreeColRow[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com', phone: '111' }, +]; + +const threeColCsvColumns: ColumnTemplate[] = [ + { + of: 'name', + title: 'Name', + cell: (r) => r.name, + csvDownloadable: true, + className: 'col-name', + }, + { + of: 'email', + title: 'Email', + cell: (r) => r.email, + csvDownloadable: true, + className: 'col-email', + }, + { + of: 'phone', + title: 'Phone', + cell: (r) => r.phone, + csvDownloadable: true, + className: 'col-phone', + }, +]; + +describe('columnVisibility alignment — hiding a middle column', () => { + let result: RenderHookResult< + ReturnType>, + TableTemplate + >['result']; + + beforeEach(() => { + mockedDownloadFile.mockClear(); + ({ result } = renderHook( + () => + useTanStackTableBuilder({ + data: threeColData, + columns: threeColCsvColumns, + getRowId: (r) => r.id.toString(), + columnPicker: { render: () => null }, + }), + { wrapper: withStore() }, + )); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: true, + email: false, + phone: true, + }), + ); + }); + + it('CSV: remaining columns have correct headers and data', async () => { + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Phone'); + expect(lines[1]).toBe('Alice,111'); + }); + + it('forEachCell: remaining visible cells have correct className', () => { + const row = result.current.body.rows[0]; + const cells = result.current.body.getCells(row); + expect(cells).toHaveLength(2); // email hidden + const renders = cells.map((cell, i) => + result.current.body.forEachCell(cell, row, i), + ); + expect(renders[0].className).toBe('col-name'); + expect(renders[1].className).toBe('col-phone'); // not 'col-email' + }); +}); + +describe('columnVisibility alignment — indices: true + hidden column', () => { + let result: RenderHookResult< + ReturnType>, + TableTemplate + >['result']; + + beforeEach(() => { + mockedDownloadFile.mockClear(); + ({ result } = renderHook( + () => + useTanStackTableBuilder({ + data: threeColData, + columns: threeColCsvColumns, + getRowId: (r) => r.id.toString(), + indexing: { indices: true }, + columnPicker: { render: () => null }, + }), + { wrapper: withStore() }, + )); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: true, + phone: true, + }), + ); + }); + + it('CSV: remaining columns have correct headers and data', async () => { + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Email,Phone'); + expect(lines[1]).toBe('alice@example.com,111'); + }); + + it('forEachCell: remaining visible cells have correct className', () => { + const row = result.current.body.rows[0]; + const cells = result.current.body.getCells(row); + // index col (no template) + email + phone visible; name hidden + const userCells = cells.filter((c) => c.column.id !== 'index'); + const renders = userCells.map((cell, i) => + result.current.body.forEachCell(cell, row, i), + ); + expect(renders[0].className).toBe('col-email'); // not 'col-name' + expect(renders[1].className).toBe('col-phone'); + }); +}); + +// ---------- cross-page row selection ---------- + +const threeRowData: Row[] = [ + { id: 1, name: 'Alice', email: 'alice@example.com' }, + { id: 2, name: 'Bob', email: 'bob@example.com' }, + { id: 3, name: 'Carol', email: 'carol@example.com' }, +]; + +describe('cross-page row selection', () => { + const selectionProps = (): TableTemplate => + baseProps({ + data: threeRowData, + indexing: { rowSelectable: true }, + pagination: { rowsPerPage: [2] }, + }); + + it('body.selectedCount is 0 when nothing is selected', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + expect(result.current.body.selectedCount).toBe(0); + }); + + it('body.selectedCount increments when a row on the current page is selected', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.rows[0].toggleSelected()); + expect(result.current.body.selectedCount).toBe(1); + }); + + it('body.selectedCount persists after navigating away from the page where the selection was made', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + // Page 1: Alice (id 1) and Bob (id 2) + act(() => result.current.body.rows[0].toggleSelected()); // select Alice + expect(result.current.body.selectedCount).toBe(1); + + // Navigate to page 2: Carol (id 3) only + act(() => result.current.pagination!.onPageChange?.(1)); + expect(result.current.body.rows).toHaveLength(1); // only Carol visible + expect(result.current.body.selectedCount).toBe(1); // Alice still counted + }); + + it('toggleAllFiltered selects all rows across all pages', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.toggleAllFiltered?.()); + expect(result.current.body.selectedCount).toBe(3); + expect(result.current.body.allFilteredSelected).toBe(true); + }); + + it('someFilteredSelected is true when only some rows are selected', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.rows[0].toggleSelected()); // Alice only + expect(result.current.body.someFilteredSelected).toBe(true); + expect(result.current.body.allFilteredSelected).toBe(false); + }); + + it('toggleAllFiltered twice deselects all rows', () => { + const { result } = renderHook( + () => useTanStackTableBuilder(selectionProps()), + { + wrapper: withStore(), + }, + ); + act(() => result.current.body.toggleAllFiltered?.()); // select all + act(() => result.current.body.toggleAllFiltered?.()); // deselect all + expect(result.current.body.selectedCount).toBe(0); + expect(result.current.body.allFilteredSelected).toBe(false); + }); +}); + +describe('localStorage persistence', () => { + beforeEach(() => localStorage.clear()); + + it('reads initial visibility from the user-scoped key', () => { + localStorage.setItem( + '42:test_key', + JSON.stringify({ name: false, email: true }), + ); + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'test_key' }, + }), + ), + { wrapper: withStore(storeForUser(42)) }, + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: false, + email: true, + }); + }); + + it('writes visibility to the user-scoped key on change', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'test_key' }, + }), + ), + { wrapper: withStore(storeForUser(42)) }, + ); + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: false, + email: true, + }), + ); + expect(JSON.parse(localStorage.getItem('42:test_key')!)).toMatchObject({ + name: false, + email: true, + }); + // Unsecoped key must not be written + expect(localStorage.getItem('test_key')).toBeNull(); + }); + + it('falls back to defaultVisible when the user-scoped key has no entry', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: [ + baseColumns[0], + { ...baseColumns[1], defaultVisible: false }, + ], + columnPicker: { render: () => null, storageKey: 'missing_key' }, + }), + ), + { wrapper: withStore(storeForUser(42)) }, + ); + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); + + it('does not read or write localStorage when userId is 0 (not yet loaded)', () => { + // Pre-populate a 0-prefixed key to prove it is not read on mount. + const sentinel = JSON.stringify({ name: false, email: false }); + localStorage.setItem('0:test_key', sentinel); + + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'test_key' }, + }), + ), + { wrapper: withStore() }, // appStore has userId=0 + ); + // 0-prefixed key is ignored; visibility falls back to defaultVisible (true) + expect(result.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: true, + }); + + act(() => + result.current.toolbar!.commitColumnVisibility!({ + name: true, + email: false, + }), + ); + // The 0-prefixed key is untouched (still holds the original sentinel value) + expect(localStorage.getItem('0:test_key')).toBe(sentinel); + // The unscoped key was never written + expect(localStorage.getItem('test_key')).toBeNull(); + }); + + it('two users on the same device have independent visibility preferences', () => { + localStorage.setItem( + '1:shared_key', + JSON.stringify({ name: false, email: true }), + ); + localStorage.setItem( + '2:shared_key', + JSON.stringify({ name: true, email: false }), + ); + + const { result: result1 } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'shared_key' }, + }), + ), + { wrapper: withStore(storeForUser(1)) }, + ); + const { result: result2 } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null, storageKey: 'shared_key' }, + }), + ), + { wrapper: withStore(storeForUser(2)) }, + ); + + expect(result1.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: false, + email: true, + }); + expect(result2.current.toolbar!.getColumnVisibility?.()).toEqual({ + name: true, + email: false, + }); + }); +}); + +describe('useTanStackTableBuilder onDirectExport', () => { + beforeEach(() => { + mockedDownloadFile.mockClear(); + }); + + it('toolbar.onDirectExport is defined when columnPicker is provided', () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columnPicker: { render: () => null }, + }), + ), + { wrapper: withStore() }, + ); + expect(result.current.toolbar!.onDirectExport).toBeDefined(); + }); + + it('toolbar.onDirectExport is undefined when no columnPicker is provided', () => { + const { result } = renderHook(() => useTanStackTableBuilder(baseProps()), { + wrapper: withStore(), + }); + expect(result.current.toolbar!.onDirectExport).toBeUndefined(); + }); + + it('toolbar.onDirectExport downloads CSV using committed column visibility', async () => { + const { result } = renderHook( + () => + useTanStackTableBuilder( + baseProps({ + columns: csvColumns, + csvDownload: { filename: 'my_gradebook' }, + columnPicker: { render: () => null }, + }), + ), + { wrapper: withStore() }, + ); + + await act(async () => { + await result.current.toolbar!.onDirectExport?.(); + }); + + expect(mockedDownloadFile).toHaveBeenCalledTimes(1); + const csv: string = mockedDownloadFile.mock.calls[0][1]; + const lines = csv.trim().split(/\r?\n/); + expect(lines[0]).toBe('Name,Email'); + expect(lines[1]).toContain('Alice'); + }); +}); diff --git a/client/app/lib/components/table/__tests__/utils.test.ts b/client/app/lib/components/table/__tests__/utils.test.ts new file mode 100644 index 00000000000..439b4f45576 --- /dev/null +++ b/client/app/lib/components/table/__tests__/utils.test.ts @@ -0,0 +1,33 @@ +import { downloadFile } from 'utilities/downloadFile'; + +import { downloadCsv } from '../utils'; + +jest.mock('utilities/downloadFile', () => ({ downloadFile: jest.fn() })); + +const mockDownloadFile = downloadFile as jest.Mock; + +describe('downloadCsv', () => { + beforeEach(() => mockDownloadFile.mockClear()); + + it('prepends a UTF-8 BOM so Excel detects UTF-8 encoding', () => { + downloadCsv('a,b\n1,2'); + const content: string = mockDownloadFile.mock.calls[0][1]; + expect(content.charCodeAt(0)).toBe(0xfeff); + }); + + it('preserves em dash characters after the BOM', () => { + downloadCsv('Name,Score\nAlice — Test,10'); + const content: string = mockDownloadFile.mock.calls[0][1]; + expect(content).toContain('—'); + }); + + it('uses the provided filename with .csv extension', () => { + downloadCsv('a,b', 'my-file'); + expect(mockDownloadFile.mock.calls[0][2]).toBe('my-file.csv'); + }); + + it('defaults to data.csv when no filename is given', () => { + downloadCsv('a,b'); + expect(mockDownloadFile.mock.calls[0][2]).toBe('data.csv'); + }); +}); diff --git a/client/app/lib/components/table/adapters/Body.ts b/client/app/lib/components/table/adapters/Body.ts index 4230762eb4f..955602d0dd9 100644 --- a/client/app/lib/components/table/adapters/Body.ts +++ b/client/app/lib/components/table/adapters/Body.ts @@ -26,6 +26,10 @@ interface BodyProps { getCells: (row: B) => C[]; forEachCell: (cell: C, row: B, index: number) => CellRender; forEachRow: (row: B, index: number) => RowRender; + selectedCount?: number; + allFilteredSelected?: boolean; + someFilteredSelected?: boolean; + toggleAllFiltered?: () => void; } export default BodyProps; diff --git a/client/app/lib/components/table/adapters/Toolbar.ts b/client/app/lib/components/table/adapters/Toolbar.ts index fe8fad0e38a..6ab16e4d494 100644 --- a/client/app/lib/components/table/adapters/Toolbar.ts +++ b/client/app/lib/components/table/adapters/Toolbar.ts @@ -1,5 +1,7 @@ import { ReactNode } from 'react'; +import { ColumnPickerTemplate } from '../builder'; + interface ToolbarProps { renderNative?: boolean; alternative?: { @@ -13,6 +15,15 @@ interface ToolbarProps { csvDownloadLabel?: string; searchPlaceholder?: string; buttons?: ReactNode[]; + + /** Set when consumer passes `columnPicker` on TableTemplate. Drives "Select Columns" button + dialog. */ + columnPicker?: ColumnPickerTemplate; + /** Read-side accessor — called by the dialog to seed staged state. */ + getColumnVisibility?: () => Record; + /** Commit-side updater — called by the dialog on Apply. */ + commitColumnVisibility?: (next: Record) => void; + /** Export with current visibility (no picker dialog). */ + onDirectExport?: () => Promise; } export default ToolbarProps; diff --git a/client/app/lib/components/table/builder/ColumnPickerTemplate.ts b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts new file mode 100644 index 00000000000..9fc78e76923 --- /dev/null +++ b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts @@ -0,0 +1,54 @@ +import { ReactNode } from 'react'; + +export interface ColumnPickerRenderContext { + isVisible: (columnId: string) => boolean; + setVisible: (columnId: string, value: boolean) => void; + setManyVisible: (columnIds: string[], value: boolean) => void; +} + +interface ColumnPickerTemplate { + /** Caller renders columns using the provided context helpers. */ + render: (context: ColumnPickerRenderContext) => ReactNode; + + /** Column ids that render disabled-checked. Forcibly kept visible on every commit. */ + locked?: string[]; + + /** Toolbar trigger button text, default "Export…". Opens the picker dialog. */ + triggerLabel?: string; + + /** Label for the direct-export button rendered next to the trigger in the toolbar. */ + directExportLabel?: string; + + /** Tooltip shown on the direct-export button. */ + directExportTooltip?: string; + + /** Modal title, default "Select columns". */ + dialogTitle?: string; + + /** + * Called at CSV export time with the ordered visible column IDs. + * Return one array per extra row to insert after the header row. + */ + getExtraHeaderRows?: (columnIds: string[]) => string[][]; + + /** + * localStorage key for persisting column visibility across page loads. + * When set, visibility is read from storage on mount and written on every change. + */ + storageKey?: string; + + /** + * Column ids that count as "data" columns (e.g. grade/gamification columns). + * When provided and none of these ids are visible in the staged selection, + * `noDataColumnsHint` is shown above the dialog actions. + */ + dataColumnIds?: string[]; + + /** + * Hint shown above the dialog actions when no `dataColumnIds` are selected. + * Has no effect if `dataColumnIds` is not provided. + */ + noDataColumnsHint?: string; +} + +export default ColumnPickerTemplate; diff --git a/client/app/lib/components/table/builder/ColumnTemplate.ts b/client/app/lib/components/table/builder/ColumnTemplate.ts index eda11a70c9d..cbfe6132ac7 100644 --- a/client/app/lib/components/table/builder/ColumnTemplate.ts +++ b/client/app/lib/components/table/builder/ColumnTemplate.ts @@ -23,6 +23,7 @@ interface ColumnTemplate { title: StringOrTemplateHeader; cell: (datum: D) => ReactNode; of?: keyof D; + accessorFn?: (datum: D) => unknown; id?: string; unless?: boolean; sortable?: boolean; @@ -36,6 +37,7 @@ interface ColumnTemplate { className?: string; colSpan?: (datum: D) => number; cellUnless?: (datum: D) => boolean; + defaultVisible?: boolean; } 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..d6bba03f117 100644 --- a/client/app/lib/components/table/builder/TableTemplate.ts +++ b/client/app/lib/components/table/builder/TableTemplate.ts @@ -1,3 +1,4 @@ +import ColumnPickerTemplate from './ColumnPickerTemplate'; import ColumnTemplate, { Data } from './ColumnTemplate'; import { CsvDownloadTemplate, @@ -23,6 +24,7 @@ interface TableTemplate { filter?: FilterTemplate; toolbar?: ToolbarTemplate; sort?: SortTemplate; + columnPicker?: ColumnPickerTemplate; } export default TableTemplate; diff --git a/client/app/lib/components/table/builder/featureTemplates.ts b/client/app/lib/components/table/builder/featureTemplates.ts index 2e767fcc33f..7e3e57c3d9c 100644 --- a/client/app/lib/components/table/builder/featureTemplates.ts +++ b/client/app/lib/components/table/builder/featureTemplates.ts @@ -22,6 +22,7 @@ interface SearchProps { export interface CsvDownloadTemplate { filename?: string; downloadButtonLabel?: string; + showDownloadButton?: boolean; } export interface SearchTemplate { diff --git a/client/app/lib/components/table/builder/index.ts b/client/app/lib/components/table/builder/index.ts index 869466251d4..d36a6dc7e0f 100644 --- a/client/app/lib/components/table/builder/index.ts +++ b/client/app/lib/components/table/builder/index.ts @@ -1,4 +1,8 @@ export type { BuiltColumns } from './buildColumns'; export { buildColumns } from './buildColumns'; +export type { + ColumnPickerRenderContext, + default as ColumnPickerTemplate, +} from './ColumnPickerTemplate'; export type { default as ColumnTemplate, Data } from './ColumnTemplate'; export type { default as TableTemplate } from './TableTemplate'; diff --git a/client/app/lib/components/table/index.tsx b/client/app/lib/components/table/index.tsx index 1161e5689ca..4871f2c152c 100644 --- a/client/app/lib/components/table/index.tsx +++ b/client/app/lib/components/table/index.tsx @@ -1,2 +1,7 @@ -export type { ColumnTemplate } from './builder'; +export type { + ColumnPickerRenderContext, + ColumnPickerTemplate, + ColumnTemplate, +} from './builder'; +export { default as ColumnPickerTreeGroup } from './MuiTableAdapter/ColumnPickerTreeGroup'; export { default } from './Table'; diff --git a/client/app/lib/components/table/utils.ts b/client/app/lib/components/table/utils.ts index 6b84e7a373c..916dfdba696 100644 --- a/client/app/lib/components/table/utils.ts +++ b/client/app/lib/components/table/utils.ts @@ -2,10 +2,13 @@ import { downloadFile } from 'utilities/downloadFile'; const DEFAULT_CSV_FILENAME = 'data' as const; +// Prepend UTF-8 BOM so Excel on macOS/Windows detects UTF-8 encoding instead +// of falling back to a legacy code page (Mac Roman / Windows-1252), which +// misreads multibyte characters like em dash (U+2014) as mojibake (e.g. "‚Äî"). export const downloadCsv = (csvData: string, filename?: string): void => { downloadFile( 'text/csv;charset=utf-8', - csvData, + `\uFEFF${csvData}`, `${filename ?? DEFAULT_CSV_FILENAME}.csv`, ); }; diff --git a/client/app/lib/constants/icons.ts b/client/app/lib/constants/icons.ts index e73e495c3f3..9c1d328e12a 100644 --- a/client/app/lib/constants/icons.ts +++ b/client/app/lib/constants/icons.ts @@ -50,6 +50,8 @@ import { Star, StarOutline, SvgIconComponent, + TableChart, + TableChartOutlined, Upload, UploadOutlined, Videocam, @@ -65,6 +67,7 @@ interface IconTuple { export const COURSE_COMPONENT_ICONS = { achievement: { outlined: EmojiEventsOutlined, filled: EmojiEvents }, + gradebook: { outlined: TableChartOutlined, filled: TableChart }, assessment: { outlined: SendOutlined, filled: Send }, material: { outlined: FolderOutlined, filled: Folder }, survey: { outlined: PieChartOutlined, filled: PieChart }, diff --git a/client/app/routers/course/gradebook.tsx b/client/app/routers/course/gradebook.tsx new file mode 100644 index 00000000000..3b366cfa106 --- /dev/null +++ b/client/app/routers/course/gradebook.tsx @@ -0,0 +1,23 @@ +import { RouteObject } from 'react-router-dom'; +import { WithRequired } from 'types'; + +import { Translated } from 'lib/hooks/useTranslation'; + +const gradebookRouter: Translated = (_t) => ({ + path: 'gradebook', + lazy: async (): Promise> => { + const [{ gradebookHandle }, GradebookIndex] = await Promise.all([ + import( + /* webpackChunkName: 'gradebookHandle' */ + 'course/gradebook/handles' + ), + import( + /* webpackChunkName: 'GradebookIndex' */ + 'course/gradebook/pages/GradebookIndex' + ).then((m) => m.default), + ]); + return { Component: GradebookIndex, handle: gradebookHandle }; + }, +}); + +export default gradebookRouter; diff --git a/client/app/routers/course/index.tsx b/client/app/routers/course/index.tsx index 047262ac54e..13bc90b1aca 100644 --- a/client/app/routers/course/index.tsx +++ b/client/app/routers/course/index.tsx @@ -7,6 +7,7 @@ import achievementsRouter from './achievements'; import adminRouter from './admin'; import assessmentsRouter from './assessments'; import forumsRouter from './forums'; +import gradebookRouter from './gradebook'; import groupsRouter from './groups'; import lessonPlanRouter from './lessonPlan'; import materialsRouter from './materials'; @@ -41,6 +42,7 @@ const courseRouter: Translated = (t) => ({ adminRouter(t), assessmentsRouter(t), forumsRouter(t), + gradebookRouter(t), groupsRouter(t), lessonPlanRouter(t), materialsRouter(t), 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/types/course/gradebook.ts b/client/app/types/course/gradebook.ts new file mode 100644 index 00000000000..613df6b1cdb --- /dev/null +++ b/client/app/types/course/gradebook.ts @@ -0,0 +1,40 @@ +export interface CategoryData { + id: number; + title: string; +} + +export interface TabData { + id: number; + title: string; + categoryId: number; +} + +export interface AssessmentData { + id: number; + title: string; + tabId: number; + maxGrade: number; +} + +export interface StudentData { + id: number; + name: string; + email: string; + level: number; + totalXp: number; +} + +export interface SubmissionData { + studentId: number; + assessmentId: number; + grade: number | null; +} + +export interface GradebookData { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; +} diff --git a/client/jest.config.js b/client/jest.config.js index abcae92775b..947fb088890 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -25,6 +25,7 @@ const config = { '^store(.*)$': '/app/store$1', '^lodash-es(.*)$': 'lodash$1', }, + testPathIgnorePatterns: ['/node_modules/', '/dist/'], coveragePathIgnorePatterns: ['/node_modules/', '/__test__/'], }; diff --git a/client/locales/en.json b/client/locales/en.json index 20257536e1b..e18c2303d0a 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" }, @@ -5271,6 +5274,69 @@ "course.forum.forum.markAllAsReadFailed": { "defaultMessage": "Failed to mark all topics in this forum as read. Please try again later." }, + "course.gradebook.GradebookColumnTree.grades": { + "defaultMessage": "Grades" + }, + "course.gradebook.GradebookColumnTree.email": { + "defaultMessage": "Email" + }, + "course.gradebook.GradebookColumnTree.level": { + "defaultMessage": "Level" + }, + "course.gradebook.GradebookColumnTree.alwaysIncluded": { + "defaultMessage": "Always included" + }, + "course.gradebook.GradebookColumnTree.name": { + "defaultMessage": "Name" + }, + "course.gradebook.GradebookColumnTree.studentInfo": { + "defaultMessage": "Student info" + }, + "course.gradebook.GradebookColumnTree.totalXp": { + "defaultMessage": "Total XP" + }, + "course.gradebook.GradebookColumnTree.gamification": { + "defaultMessage": "Gamification" + }, + "course.gradebook.GradebookIndex.dialogTitle": { + "defaultMessage": "Select columns" + }, + "course.gradebook.GradebookIndex.exportAllTooltip": { + "defaultMessage": "No rows selected - all rows will be exported." + }, + "course.gradebook.GradebookIndex.exportButton": { + "defaultMessage": "Export all rows" + }, + "course.gradebook.GradebookIndex.exportRows": { + "defaultMessage": "Export {count, plural, one {# row} other {# rows}}" + }, + "course.gradebook.GradebookIndex.selectColumns": { + "defaultMessage": "Select Columns" + }, + "course.gradebook.GradebookIndex.applyAndExport": { + "defaultMessage": "Apply and Export" + }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "Failed to retrieve Gradebook." + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "Gradebook" + }, + "course.gradebook.GradebookIndex.searchStudents": { + "defaultMessage": "Search by name or email" + }, + "course.gradebook.GradebookIndex.noStudents": { + "defaultMessage": "No students enrolled yet" + }, + "course.gradebook.GradebookIndex.noStudentsHint": { + "defaultMessage": "Grades will appear here once students join the course." + }, + "course.gradebook.GradebookTable.maxMarks": { + "defaultMessage": "Max Marks" + }, + "course.gradebook.GradebookTable.noDataColumnsHint": { + "defaultMessage": "No grade columns selected - export will include student info only." + }, "course.group.GroupCreationForm.description": { "defaultMessage": "Description (Optional)" }, @@ -7878,6 +7944,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "Date range cannot exceed 365 days" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "Apply to view" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "Cancel" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "Select columns" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "Apply and Export" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "Export" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "Export…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "Failed to fetch users." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 931d3bacbd3..682e29fa479 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -4091,6 +4091,9 @@ "course.componentTitles.course_forums_component": { "defaultMessage": "포럼" }, + "course.componentTitles.course_gradebook_component": { + "defaultMessage": "성적부" + }, "course.componentTitles.course_groups_component": { "defaultMessage": "그룹" }, @@ -5252,6 +5255,63 @@ "course.forum.forum.markAllAsReadFailed": { "defaultMessage": "이 포럼의 모든 주제를 읽음으로 표시하는 데 실패했습니다. 나중에 다시 시도하세요." }, + "course.gradebook.GradebookColumnTree.grades": { + "defaultMessage": "성적" + }, + "course.gradebook.GradebookColumnTree.email": { + "defaultMessage": "이메일" + }, + "course.gradebook.GradebookColumnTree.level": { + "defaultMessage": "레벨" + }, + "course.gradebook.GradebookColumnTree.name": { + "defaultMessage": "이름" + }, + "course.gradebook.GradebookColumnTree.studentInfo": { + "defaultMessage": "학생 정보" + }, + "course.gradebook.GradebookColumnTree.totalXp": { + "defaultMessage": "총 경험치" + }, + "course.gradebook.GradebookColumnTree.gamification": { + "defaultMessage": "게임화" + }, + "course.gradebook.GradebookIndex.dialogTitle": { + "defaultMessage": "열 선택" + }, + "course.gradebook.GradebookIndex.exportAllTooltip": { + "defaultMessage": "선택한 행 없음 - 모든 행이 내보내집니다." + }, + "course.gradebook.GradebookIndex.exportButton": { + "defaultMessage": "전체 행 내보내기" + }, + "course.gradebook.GradebookIndex.exportRows": { + "defaultMessage": "{count, plural, other {# 행}} 내보내기" + }, + "course.gradebook.GradebookIndex.selectColumns": { + "defaultMessage": "열 선택" + }, + "course.gradebook.GradebookIndex.applyAndExport": { + "defaultMessage": "적용 및 내보내기" + }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "성적부를 불러오지 못했습니다." + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "성적부" + }, + "course.gradebook.GradebookIndex.searchStudents": { + "defaultMessage": "이름 또는 이메일로 검색" + }, + "course.gradebook.GradebookIndex.noStudents": { + "defaultMessage": "학생 없음" + }, + "course.gradebook.GradebookTable.maxMarks": { + "defaultMessage": "최고 점수" + }, + "course.gradebook.GradebookTable.noDataColumnsHint": { + "defaultMessage": "성적 열이 선택되지 않았습니다 — 학생 정보만 내보내집니다." + }, "course.group.GroupCreationForm.description": { "defaultMessage": "설명 (선택사항)" }, @@ -7868,6 +7928,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "날짜 범위는 365일을 초과할 수 없습니다" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "뷰에 적용" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "취소" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "열 선택" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "적용 및 내보내기" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "내보내기" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "내보내기…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "사용자를 가져오는 데 실패했습니다." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index e0fb2e36398..fb9e9ea1151 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": "组" }, @@ -5246,6 +5249,63 @@ "course.forum.forum.markAllAsReadFailed": { "defaultMessage": "未能将此论坛中的所有主题标记为已读。请稍后再试。" }, + "course.gradebook.GradebookColumnTree.grades": { + "defaultMessage": "成绩" + }, + "course.gradebook.GradebookColumnTree.email": { + "defaultMessage": "电子邮件" + }, + "course.gradebook.GradebookColumnTree.level": { + "defaultMessage": "等级" + }, + "course.gradebook.GradebookColumnTree.name": { + "defaultMessage": "姓名" + }, + "course.gradebook.GradebookColumnTree.studentInfo": { + "defaultMessage": "学生信息" + }, + "course.gradebook.GradebookColumnTree.totalXp": { + "defaultMessage": "总经验值" + }, + "course.gradebook.GradebookColumnTree.gamification": { + "defaultMessage": "游戏化" + }, + "course.gradebook.GradebookIndex.dialogTitle": { + "defaultMessage": "选择列" + }, + "course.gradebook.GradebookIndex.exportAllTooltip": { + "defaultMessage": "未选择行 - 将导出所有行。" + }, + "course.gradebook.GradebookIndex.exportButton": { + "defaultMessage": "导出全部行" + }, + "course.gradebook.GradebookIndex.exportRows": { + "defaultMessage": "导出 {count, plural, other {# 行}}" + }, + "course.gradebook.GradebookIndex.selectColumns": { + "defaultMessage": "选择列" + }, + "course.gradebook.GradebookIndex.applyAndExport": { + "defaultMessage": "应用并导出" + }, + "course.gradebook.GradebookIndex.fetchFailure": { + "defaultMessage": "无法获取成绩册。" + }, + "course.gradebook.GradebookIndex.gradebook": { + "defaultMessage": "成绩册" + }, + "course.gradebook.GradebookIndex.searchStudents": { + "defaultMessage": "按姓名或电子邮件搜索" + }, + "course.gradebook.GradebookIndex.noStudents": { + "defaultMessage": "无学生" + }, + "course.gradebook.GradebookTable.maxMarks": { + "defaultMessage": "最高分" + }, + "course.gradebook.GradebookTable.noDataColumnsHint": { + "defaultMessage": "未选择成绩列——导出内容将仅包含学生信息。" + }, "course.group.GroupCreationForm.description": { "defaultMessage": "说明(可选)" }, @@ -7862,6 +7922,24 @@ "lib.components.getHelp.validation.exceedDateRange": { "defaultMessage": "日期范围不能超过365天" }, + "lib.components.table.MuiColumnPickerDialog.apply": { + "defaultMessage": "应用至视图" + }, + "lib.components.table.MuiColumnPickerDialog.cancel": { + "defaultMessage": "取消" + }, + "lib.components.table.MuiColumnPickerDialog.defaultTitle": { + "defaultMessage": "选择列" + }, + "lib.components.table.MuiColumnPickerDialog.export": { + "defaultMessage": "应用并导出" + }, + "lib.components.table.MuiTableToolbar.directExport": { + "defaultMessage": "导出" + }, + "lib.components.table.MuiTableToolbar.exportTrigger": { + "defaultMessage": "导出…" + }, "lib.translations.course.users.fetchUsersFailure": { "defaultMessage": "无法获取用户。" }, diff --git a/client/package.json b/client/package.json index b187c180b4e..820b6981b84 100644 --- a/client/package.json +++ b/client/package.json @@ -22,7 +22,7 @@ "lint": "yarn run lint-src && yarn run lint-tests && prettier --check \"**/*.{js,jsx,ts,tsx}\"", "lint-fix": "yarn run lint-src --fix && yarn run lint-tests --fix && prettier --write \"**/*.{js,jsx,ts,tsx}\"", "postinstall": "cd vendor/recorderjs && NODE_ENV=development yarn install --force --frozen-lockfile", - "check-types": "tsc" + "check-types": "tsc --noEmit" }, "cacheDirectories": [ "node_modules", diff --git a/config/locales/en/course/gradebook.yml b/config/locales/en/course/gradebook.yml new file mode 100644 index 00000000000..d28d02726ed --- /dev/null +++ b/config/locales/en/course/gradebook.yml @@ -0,0 +1,5 @@ +en: + course: + gradebook: + component: + sidebar_title: 'Gradebook' diff --git a/config/locales/ko/course/gradebook.yml b/config/locales/ko/course/gradebook.yml new file mode 100644 index 00000000000..ddd9d30ee8b --- /dev/null +++ b/config/locales/ko/course/gradebook.yml @@ -0,0 +1,5 @@ +ko: + course: + gradebook: + component: + sidebar_title: '성적부' diff --git a/config/locales/zh/course/gradebook.yml b/config/locales/zh/course/gradebook.yml new file mode 100644 index 00000000000..a07bbfddd02 --- /dev/null +++ b/config/locales/zh/course/gradebook.yml @@ -0,0 +1,5 @@ +zh: + course: + gradebook: + component: + sidebar_title: '成绩册' 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_600_gradebook.rake b/lib/tasks/coursemology/seed_600_gradebook.rake new file mode 100644 index 00000000000..0c4e559e721 --- /dev/null +++ b/lib/tasks/coursemology/seed_600_gradebook.rake @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +# Usage: bundle exec rails coursemology:seed_600_gradebook +# Creates a demo course with 3 categories, multiple assessments, and 600 students +# with varied grades for demonstrating the gradebook. + +namespace :coursemology do + task seed_600_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 600 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 + default_category = course.assessment_categories.first + + structure.each_with_index do |cat_def, cat_i| + category = if cat_i == 0 + default_category.update!(title: cat_def[:title], weight: 1) + default_category + else + course.assessment_categories.create!( + title: cat_def[:title], + weight: cat_i + 1, + creator: admin, + updater: admin + ) + end + + existing_tab = cat_i == 0 ? category.tabs.first : nil + + cat_def[:tabs].each_with_index do |tab_def, tab_i| + tab = if existing_tab && tab_i == 0 + existing_tab.update!(title: tab_def[:title], weight: 1) + existing_tab + else + category.tabs.create!( + title: tab_def[:title], + weight: tab_i + 1, + creator: admin, + updater: admin + ) + end + + 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 ─────────────────────────────────────────────────────────── + + # 600 users: student1000 .. student1599 + student_profiles = (1000..1599).map do |n| + name = "student#{n}" + + # Deterministic but mixed distribution across tiers. + tier = + case n % 10 + when 0, 1, 2 then :high + when 3, 4, 5, 6 then :mid + when 7, 8 then :low + else :sparse + end + + [name, tier] + end + + rng = Random.new(42) # fixed seed for reproducible grades + + student_profiles.each do |name, tier| + email = "#{name}@gradebook.demo" + user = User::Email.find_by_email(email)&.user + unless user + user = User.new(name: name, email: email, password: 'Coursemology!') + user.skip_confirmation! + user.save! + end + + course_user = CourseUser.find_or_create_by!(course: course, user: user) do |cu| + cu.role = :student + cu.name = name + cu.creator = admin + cu.updater = admin + end + + 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/lib/tasks/coursemology/seed_gradebook.rake b/lib/tasks/coursemology/seed_gradebook.rake new file mode 100644 index 00000000000..ef7ffa60fc0 --- /dev/null +++ b/lib/tasks/coursemology/seed_gradebook.rake @@ -0,0 +1,246 @@ +# 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 + default_category = course.assessment_categories.first + + structure.each_with_index do |cat_def, cat_i| + category = if cat_i == 0 + default_category.update!(title: cat_def[:title], weight: 1) + default_category + else + course.assessment_categories.create!( + title: cat_def[:title], + weight: cat_i + 1, + creator: admin, + updater: admin + ) + end + + existing_tab = cat_i == 0 ? category.tabs.first : nil + + cat_def[:tabs].each_with_index do |tab_def, tab_i| + tab = if existing_tab && tab_i == 0 + existing_tab.update!(title: tab_def[:title], weight: 1) + existing_tab + else + category.tabs.create!( + title: tab_def[:title], + weight: tab_i + 1, + creator: admin, + updater: admin + ) + end + + 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| + email = "#{name.downcase.gsub(' ', '.')}@gradebook.demo" + user = User::Email.find_by_email(email)&.user + unless user + user = User.new(name: name, email: email, password: 'Coursemology!') + user.skip_confirmation! + user.save! + end + + course_user = CourseUser.find_or_create_by!(course: course, user: user) do |cu| + cu.role = :student + cu.name = name + cu.creator = admin + cu.updater = admin + end + + 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..4b21113c31b --- /dev/null +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -0,0 +1,189 @@ +# 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) } + let(:student) { create(:course_user, :student, course: course) } + let(:staff) { create(:course_user, :teaching_assistant, course: course) } + + describe '#index' do + render_views + subject { get :index, params: { course_id: course.id }, format: :json } + + 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 a 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 all required top-level keys' do + subject + data = JSON.parse(response.body) + %w[categories tabs assessments students submissions].each do |key| + expect(data).to have_key(key), "expected response to have key '#{key}'" + end + 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 assessments array' 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 tabs array' do + subject + data = JSON.parse(response.body) + expect(data['tabs'].map { |t| t['id'] }).to include(tab.id) + end + + it 'includes the category in the categories array' do + subject + data = JSON.parse(response.body) + expect(data['categories'].map { |c| c['id'] }).to include(tab.category.id) + end + + it 'includes the student with email and level in the students array' do + subject + data = JSON.parse(response.body) + student_data = data['students'].find { |s| s['id'] == student.user_id } + expect(student_data).not_to be_nil + expect(student_data).to have_key('email') + expect(student_data).not_to have_key('externalId') + expect(student_data).to have_key('level') + expect(student_data['level']).to be_a(Integer) + end + + it 'returns the correct grade in the submissions array' do + subject + data = JSON.parse(response.body) + sub = data['submissions'].find do |s| + s['studentId'] == student.user_id && s['assessmentId'] == assessment.id + end + expect(sub).not_to be_nil + expect(sub['grade'].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 + end + + context 'with a graded submission where the answer grade is exactly 0' 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: 0.0, current_answer: true) + controller_sign_in(controller, ta.user) + end + + it 'returns grade 0 (not null) in the submissions array' do + subject + data = JSON.parse(response.body) + sub = data['submissions'].find do |s| + s['studentId'] == student.user_id && s['assessmentId'] == assessment.id + end + expect(sub).not_to be_nil + expect(sub['grade']).to eq(0.0) + end + end + + context 'with a graded submission where answer grades are null (blank)' 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: nil, current_answer: true) + controller_sign_in(controller, ta.user) + end + + it 'returns null grade (not 0) in the submissions array' do + subject + data = JSON.parse(response.body) + sub = data['submissions'].find do |s| + s['studentId'] == student.user_id && s['assessmentId'] == assessment.id + end + expect(sub).not_to be_nil + expect(sub['grade']).to be_nil + end + end + end + end +end diff --git a/spec/models/course/assessment/submission_spec.rb b/spec/models/course/assessment/submission_spec.rb index 77936019685..7da9fe68604 100644 --- a/spec/models/course/assessment/submission_spec.rb +++ b/spec/models/course/assessment/submission_spec.rb @@ -873,4 +873,70 @@ def unsubmit_and_save_subject end end end + + describe '.grade_summary' do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:student) { create(:course_student, course: course) } + let(:graded_assessment) { create(:assessment, :with_mcq_question, course: course) } + + it 'returns empty array for empty student_ids' do + result = Course::Assessment::Submission.grade_summary( + student_ids: [], + assessment_ids: [graded_assessment.id] + ) + expect(result).to eq([]) + end + + it 'returns empty array for empty assessment_ids' do + result = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [] + ) + expect(result).to eq([]) + end + + it 'returns grade data for graded submissions' do + submission = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: student.user) + submission.answers.update_all(grade: 5.0, current_answer: true) + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [graded_assessment.id] + ) + + expect(results.size).to eq(1) + expect(results.first.student_id).to eq(student.user_id) + expect(results.first.assessment_id).to eq(graded_assessment.id) + expect(results.first.grade.to_f).to eq(5.0) + end + + it 'excludes attempting submissions' do + create(:course_assessment_submission, :attempting, + assessment: graded_assessment, creator: student.user) + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [graded_assessment.id] + ) + expect(results).to be_empty + end + + it 'only sums answers where current_answer is true' do + submission = create(:course_assessment_submission, :graded, + assessment: graded_assessment, creator: student.user) + submission.answers.update_all(grade: 3.0, current_answer: true) + # Mark all answers as non-current — grade_summary must return nothing + submission.answers.update_all(current_answer: false) + + results = Course::Assessment::Submission.grade_summary( + student_ids: [student.user_id], + assessment_ids: [graded_assessment.id] + ) + expect(results).to be_empty + end + end + end end diff --git a/spec/models/course/assessment_spec.rb b/spec/models/course/assessment_spec.rb index d02e8ec4c49..6d8b480816a 100644 --- a/spec/models/course/assessment_spec.rb +++ b/spec/models/course/assessment_spec.rb @@ -407,5 +407,34 @@ expect(autograded_assessment.skippable).to be_truthy end end + + describe '.max_grades' do + let(:assessment_with_question) do + create(:assessment, :with_mcq_question, course: course) + end + + it 'returns empty hash for empty assessment_ids' do + expect(Course::Assessment.max_grades([])).to eq({}) + end + + it 'returns the sum of maximum_grades for each assessment' do + assessment_with_question + result = Course::Assessment.max_grades([assessment_with_question.id]) + expected = assessment_with_question.questions.sum(:maximum_grade).to_f + expect(result[assessment_with_question.id]).to eq(expected) + end + + it 'excludes assessments not in the given ids' do + other = create(:assessment, :with_mcq_question, course: course) + result = Course::Assessment.max_grades([assessment_with_question.id]) + expect(result.keys).not_to include(other.id) + end + + it 'excludes assessments with no questions from the result' do + empty_assessment = create(:assessment, course: course) + result = Course::Assessment.max_grades([empty_assessment.id]) + expect(result).not_to have_key(empty_assessment.id) + end + end end end diff --git a/spec/models/instance_spec.rb b/spec/models/instance_spec.rb index ad5f4724f52..970a21e3812 100644 --- a/spec/models/instance_spec.rb +++ b/spec/models/instance_spec.rb @@ -210,6 +210,16 @@ end end + describe '#host' do + context 'when a non-default instance has a nil host attribute' do + it 'raises NoMethodError (nil host has no fallback; RAILS_HOSTNAME is required)' do + instance = build(:instance) + allow(instance).to receive(:read_attribute).with(:host).and_return(nil) + expect { instance.host }.to raise_error(NoMethodError) + end + end + end + let(:instance) { create(:instance) } with_tenant(:instance) do describe '.active_course_count' do