From 4e790c1dc37480e0bb8043c716ad7e9fbe84f463 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 20 May 2026 09:51:45 +0000 Subject: [PATCH 1/3] feat(gradebook): add gradebook page with column picker and CSV export Introduces a course-wide gradebook showing per-student grades across all assessments. Instructors can toggle which assessment columns are visible via a hierarchical column picker (grouped by category/tab), then export the current view to CSV. Backend adds GradebookController#index (JSON), ability guard, and model methods on Assessment and Submission for fetching grade data. Table lib gains reusable ColumnPickerTemplate, MuiColumnPickerPrompt, ColumnPickerTreeGroup, and toolbar integration used by the gradebook. --- .../components/course/gradebook_component.rb | 23 + .../course/gradebook_controller.rb | 49 ++ .../course/gradebook_ability_component.rb | 9 + app/models/course/assessment.rb | 16 + app/models/course/assessment/submission.rb | 21 + .../course/gradebook/index.json.jbuilder | 35 + client/app/api/course/Gradebook.ts | 15 + client/app/api/course/index.js | 2 + .../__tests__/GradebookColumnTree.test.tsx | 265 ++++++++ .../__tests__/GradebookIndex.test.tsx | 149 ++++ .../__tests__/GradebookTable.test.tsx | 424 ++++++++++++ .../components/GradebookColumnTree.tsx | 235 +++++++ .../gradebook/components/GradebookTable.tsx | 636 ++++++++++++++++++ .../components/buildAssessmentColumnIds.ts | 7 + .../app/bundles/course/gradebook/constants.ts | 5 + .../app/bundles/course/gradebook/handles.ts | 21 + .../bundles/course/gradebook/operations.ts | 12 + .../gradebook/pages/GradebookIndex/index.tsx | 102 +++ .../app/bundles/course/gradebook/selectors.ts | 26 + client/app/bundles/course/gradebook/store.ts | 66 ++ client/app/bundles/course/gradebook/types.ts | 8 + client/app/bundles/course/translations.ts | 4 + .../lib/components/core/dialogs/Prompt.tsx | 3 + .../MuiTableAdapter/MuiColumnPickerPrompt.tsx | 12 +- .../TanStackTableBuilder/csvGenerator.ts | 16 +- .../useTanStackTableBuilder.tsx | 15 +- .../table/__tests__/csvGenerator.test.ts | 22 +- .../components/table/__tests__/utils.test.ts | 33 + .../lib/components/table/adapters/Toolbar.ts | 2 +- .../table/builder/featureTemplates.ts | 1 + client/app/lib/components/table/utils.ts | 5 +- client/app/lib/constants/icons.ts | 3 + client/app/routers/course/gradebook.tsx | 23 + client/app/routers/course/index.tsx | 2 + client/app/store.ts | 2 + client/app/types/course/gradebook.ts | 41 ++ client/jest.config.js | 1 + client/locales/en.json | 66 ++ client/locales/ko.json | 60 ++ client/locales/zh.json | 60 ++ client/package.json | 2 +- config/locales/en/course/gradebook.yml | 5 + config/locales/ko/course/gradebook.yml | 5 + config/locales/zh/course/gradebook.yml | 5 + config/routes.rb | 4 + .../coursemology/seed_600_gradebook.rake | 239 +++++++ lib/tasks/coursemology/seed_gradebook.rake | 246 +++++++ .../course/gradebook_controller_spec.rb | 189 ++++++ .../course/assessment/submission_spec.rb | 66 ++ spec/models/course/assessment_spec.rb | 29 + spec/models/instance_spec.rb | 10 + 51 files changed, 3269 insertions(+), 28 deletions(-) create mode 100644 app/controllers/components/course/gradebook_component.rb create mode 100644 app/controllers/course/gradebook_controller.rb create mode 100644 app/models/components/course/gradebook_ability_component.rb create mode 100644 app/views/course/gradebook/index.json.jbuilder create mode 100644 client/app/api/course/Gradebook.ts create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx create mode 100644 client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx create mode 100644 client/app/bundles/course/gradebook/components/GradebookTable.tsx create mode 100644 client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts create mode 100644 client/app/bundles/course/gradebook/constants.ts create mode 100644 client/app/bundles/course/gradebook/handles.ts create mode 100644 client/app/bundles/course/gradebook/operations.ts create mode 100644 client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx create mode 100644 client/app/bundles/course/gradebook/selectors.ts create mode 100644 client/app/bundles/course/gradebook/store.ts create mode 100644 client/app/bundles/course/gradebook/types.ts create mode 100644 client/app/lib/components/table/__tests__/utils.test.ts create mode 100644 client/app/routers/course/gradebook.tsx create mode 100644 client/app/types/course/gradebook.ts create mode 100644 config/locales/en/course/gradebook.yml create mode 100644 config/locales/ko/course/gradebook.yml create mode 100644 config/locales/zh/course/gradebook.yml create mode 100644 lib/tasks/coursemology/seed_600_gradebook.rake create mode 100644 lib/tasks/coursemology/seed_gradebook.rake create mode 100644 spec/controllers/course/gradebook_controller_spec.rb 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..55d9becbf4a --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,35 @@ +# 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? +json.userId current_user&.id 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/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..de1dee2b32c --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,149 @@ +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, + userId: 0, + }, +}; + +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, + userId: 0, + }, +}; + +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, + userId: 0, + }, +}; + +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..c4ff1978ece --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,424 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from 'test-utils'; +import { store as appStore } from 'store'; + +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`; + +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..34857cf27c3 --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,26 @@ +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; +export const getCurrentUserId = (state: AppState): GradebookState['userId'] => + getLocalState(state).userId; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..f386b9a7fc5 --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,66 @@ +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; + userId: number; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + userId: 0, +}; + +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; + draft.userId = action.payload.userId ?? 0; + 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/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 ? ( - - setOpenDialog(false)} - open={openDialog} - primaryColor="info" - primaryLabel={t(translations.download)} - title={t(translations.downloadCsvDialogTitle)} - > - - {assessments.map((assessment) => ( -
  • {assessment.title}
  • - ))} -
    -
    - - ); -}; - -export default AssessmentsScoreSummaryDownload; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx index f8a8a4ed78a..930eef2ed5d 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx @@ -12,6 +12,7 @@ const AssessmentsStatistics: FC = () => { {(data) => ( )} diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx index 0b686edd539..2d8f36be2cf 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; -import { Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { CourseAssessment } from 'course/statistics/types'; import Link from 'lib/components/core/Link'; @@ -14,13 +14,12 @@ import { getAssessmentStatisticsURL, getAssessmentWithCategoryURL, getAssessmentWithTabURL, + getCourseGradebookURL, } from 'lib/helpers/url-builders'; import { getCourseId } from 'lib/helpers/url-helpers'; import useTranslation from 'lib/hooks/useTranslation'; import { formatMiniDateTime, formatSecondsDuration } from 'lib/moment'; -import AssessmentsScoreSummaryDownload from './AssessmentsScoreSummaryDownload'; - const translations = defineMessages({ title: { id: 'course.statistics.StatisticsIndex.assessments.title', @@ -78,15 +77,21 @@ const translations = defineMessages({ id: 'course.statistics.StatisticsIndex.assessments.searchBar', defaultMessage: 'Search by Assessment Title, Tab, or Category', }, + subtitle: { + id: 'course.statistics.StatisticsIndex.assessments.subtitle', + defaultMessage: + 'To view and export individual student grades, open Gradebook.', + }, }); interface Props { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } const AssessmentsStatisticsTable: FC = (props) => { - const { numStudents, assessments } = props; + const { numStudents, assessments, gradebookEnabled } = props; const courseId = getCourseId(); const { t } = useTranslation(); @@ -243,9 +248,20 @@ const AssessmentsStatisticsTable: FC = (props) => { return ( <> - - {t(translations.tableTitle, { numStudents })} - + + + {t(translations.tableTitle, { numStudents })} + + {gradebookEnabled && ( + + {t(translations.subtitle, { + url: (chunks) => ( + {chunks} + ), + })} + + )} + = (props) => { } getRowEqualityData={(assessment): CourseAssessment => assessment} getRowId={(assessment): string => assessment.id.toString()} - indexing={{ indices: true, rowSelectable: true }} + indexing={{ indices: true }} pagination={{ rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], showAllRows: true, @@ -285,15 +301,7 @@ const AssessmentsStatisticsTable: FC = (props) => { }, }, }} - toolbar={{ - show: true, - activeToolbar: (selectedAssessments): JSX.Element => ( - - ), - keepNative: true, - }} + toolbar={{ show: true }} /> ); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx new file mode 100644 index 00000000000..e55c9f87165 --- /dev/null +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx @@ -0,0 +1,60 @@ +import { render, screen } from 'test-utils'; + +import { CourseAssessment } from 'course/statistics/types'; + +import AssessmentsStatisticsTable from '../AssessmentsStatisticsTable'; + +const assessments: CourseAssessment[] = [ + { + id: 1, + title: 'Quiz 1', + startAt: new Date('2026-01-01T00:00:00Z'), + tab: { id: 1, title: 'Tab 1' }, + category: { id: 1, title: 'Category 1' }, + maximumGrade: 10, + numSubmitted: 2, + numAttempted: 3, + numLate: 1, + }, +]; + +const renderTable = (gradebookEnabled = true): void => { + render( + , + { at: ['/courses/1/statistics/assessments'] }, + ); +}; + +describe('', () => { + // Regression guard: an orphaned rowSelectable (selection with no activeToolbar) + // makes the toolbar render an empty 6.5rem bar on select, shifting every row down. + // Re-enabling row selection here brings that layout bug back. + it('keeps row selection off so selecting cannot shift the table down', async () => { + renderTable(); + await screen.findByText('Quiz 1'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('renders the native CSV download button', async () => { + renderTable(); + expect(await screen.findByTestId('DownloadIcon')).toBeInTheDocument(); + }); + + it('points to the Gradebook for individual student grades when enabled', async () => { + renderTable(true); + const link = await screen.findByRole('link', { name: /gradebook/i }); + expect(link).toHaveAttribute('href', '/courses/1/gradebook'); + }); + + it('omits the Gradebook pointer when the gradebook is disabled', async () => { + renderTable(false); + await screen.findByText('Quiz 1'); + expect( + screen.queryByRole('link', { name: /gradebook/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx index d1dd39720a3..c1e230f8964 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx @@ -115,7 +115,10 @@ const StatisticsIndex: FC = () => { return ( - + { diff --git a/client/app/bundles/course/statistics/types.ts b/client/app/bundles/course/statistics/types.ts index 8dea0dfc4bd..076743f504c 100644 --- a/client/app/bundles/course/statistics/types.ts +++ b/client/app/bundles/course/statistics/types.ts @@ -137,6 +137,7 @@ export interface CourseAssessment { export interface AssessmentsStatistics { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } export interface CourseGetHelpActivity { diff --git a/client/app/lib/components/core/buttons/DownloadButton.tsx b/client/app/lib/components/core/buttons/DownloadButton.tsx index f241597144d..a9788884b08 100644 --- a/client/app/lib/components/core/buttons/DownloadButton.tsx +++ b/client/app/lib/components/core/buttons/DownloadButton.tsx @@ -24,7 +24,7 @@ const DownloadButton = (props: DownloadButtonProps): JSX.Element => ( > - {props.children} + {props.children} diff --git a/client/app/lib/helpers/url-builders.js b/client/app/lib/helpers/url-builders.js index bacb69c6e77..4b56d8ba7f9 100644 --- a/client/app/lib/helpers/url-builders.js +++ b/client/app/lib/helpers/url-builders.js @@ -3,6 +3,9 @@ export const getCourseURL = (courseId) => `/courses/${courseId}`; export const getCourseStatisticsURL = (courseId) => `/courses/${courseId}/statistics`; +export const getCourseGradebookURL = (courseId) => + `/courses/${courseId}/gradebook`; + export const getCourseAnnouncementURL = (courseId, announcementId) => `/courses/${courseId}/announcements/${announcementId}`; diff --git a/client/locales/en.json b/client/locales/en.json index 86aaf999956..fa547608bd7 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -522,8 +522,7 @@ "defaultMessage": "The AI model used by Codaveri to generate help conversations with students for programming questions." }, "course.admin.CodaveriSettings.codaveriSystemPromptDescription": { - "defaultMessage": - "You may customize the behavior of the Codaveri model by providing instructions here. {br} When assisting students, these instructions will be followed in addition to any you have set on the question itself.{br}To reference question-specific details, you may use the following variables within the prompt, writing them with brackets as shown below:" + "defaultMessage": "You may customize the behavior of the Codaveri model by providing instructions here. {br} When assisting students, these instructions will be followed in addition to any you have set on the question itself.{br}To reference question-specific details, you may use the following variables within the prompt, writing them with brackets as shown below:" }, "course.admin.CodaveriSettings.codaveriSystemPromptProblemDescriptionLine": { "defaultMessage": "{problemDescriptionVar} : The full description of the coding problem." @@ -6066,7 +6065,7 @@ "course.plagiarism.PlagiarismIndex.assessments.noPlagiarismCheckableQuestions": { "defaultMessage": "No checkable questions" }, - "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { + "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { "defaultMessage": "Not enough submissions" }, "course.plagiarism.PlagiarismIndex.assessments.runAssessmentsPlagiarism": { @@ -6228,6 +6227,9 @@ "course.statistics.StatisticsIndex.assessments.startAt": { "defaultMessage": "Starts At" }, + "course.statistics.StatisticsIndex.assessments.subtitle": { + "defaultMessage": "To view and export individual student grades, open Gradebook." + }, "course.statistics.StatisticsIndex.assessments.tab": { "defaultMessage": "Tab" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index e48c1705e13..885f3bc67d5 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6041,7 +6041,7 @@ "course.plagiarism.PlagiarismIndex.assessments.noPlagiarismCheckableQuestions": { "defaultMessage": "검사 가능한 질문이 없습니다" }, - "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { + "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { "defaultMessage": "제출이 충분하지 않습니다" }, "course.plagiarism.PlagiarismIndex.assessments.runAssessmentsPlagiarism": { @@ -6230,6 +6230,9 @@ "course.statistics.StatisticsIndex.assessments.stdevTimeTaken": { "defaultMessage": "소요 시간 표준편차" }, + "course.statistics.StatisticsIndex.assessments.subtitle": { + "defaultMessage": "개별 학생 성적을 확인하고 내보내려면 성적부를 여세요." + }, "course.statistics.StatisticsIndex.assessments.tableTitle": { "defaultMessage": "평가 통계 ({numStudents}명의 학생)" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 5f4b694d53a..1802191e8a8 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6035,7 +6035,7 @@ "course.plagiarism.PlagiarismIndex.assessments.noPlagiarismCheckableQuestions": { "defaultMessage": "没有可检查的问题" }, - "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { + "course.plagiarism.PlagiarismIndex.assessments.notEnoughSubmissions": { "defaultMessage": "提交数量不足" }, "course.plagiarism.PlagiarismIndex.assessments.runAssessmentsPlagiarism": { @@ -6227,6 +6227,9 @@ "course.statistics.StatisticsIndex.assessments.tableTitle": { "defaultMessage": "测验统计({numStudents} 个学生)" }, + "course.statistics.StatisticsIndex.assessments.subtitle": { + "defaultMessage": "若要查看和导出单个学生的成绩,请打开成绩簿。" + }, "course.statistics.StatisticsIndex.assessments.csvFileTitle": { "defaultMessage": "测验统计" }, diff --git a/config/locales/en/csv.yml b/config/locales/en/csv.yml index 0d1cf158af1..bdf9c03734a 100644 --- a/config/locales/en/csv.yml +++ b/config/locales/en/csv.yml @@ -56,10 +56,3 @@ en: values: unknown_question_type: 'UNKNOWN QUESTION TYPE' - # Assessment score summary statistics export - score_summary: - headers: - name: 'Name' - type: 'Student Type' - email: 'Email' - external_id: 'External ID' diff --git a/config/locales/ko/csv.yml b/config/locales/ko/csv.yml index 0a58bcd615a..7da57392770 100644 --- a/config/locales/ko/csv.yml +++ b/config/locales/ko/csv.yml @@ -56,10 +56,3 @@ ko: values: unknown_question_type: '알 수 없는 질문 유형' - # Assessment score summary statistics export - score_summary: - headers: - name: '이름' - type: '학생 유형' - email: '이메일' - external_id: '외부 ID' diff --git a/config/locales/zh/csv.yml b/config/locales/zh/csv.yml index 0e86f4e740b..30f912c0bd2 100644 --- a/config/locales/zh/csv.yml +++ b/config/locales/zh/csv.yml @@ -55,11 +55,3 @@ zh: role: '角色' values: unknown_question_type: '未知问题类型' - - # Assessment score summary statistics export - score_summary: - headers: - name: '用户名' - type: '学生类型' - email: '电子邮箱' - external_id: '外部编号' diff --git a/config/routes.rb b/config/routes.rb index 3f45ad22bc6..9746e03277f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -515,7 +515,6 @@ namespace :statistics do get '/' => 'statistics#index' get 'assessments' => 'aggregate#all_assessments' - get 'assessments/download' => 'aggregate#download_score_summary' get 'students' => 'aggregate#all_students' get 'staff' => 'aggregate#all_staff' get 'course/progression' => 'aggregate#course_progression' diff --git a/spec/controllers/course/statistics/aggregate_controller_spec.rb b/spec/controllers/course/statistics/aggregate_controller_spec.rb index c0acf6393d3..07ee104ab43 100644 --- a/spec/controllers/course/statistics/aggregate_controller_spec.rb +++ b/spec/controllers/course/statistics/aggregate_controller_spec.rb @@ -241,6 +241,29 @@ expect(result).not_to have_key('stdevTimeTaken') end end + + context 'when the gradebook component is enabled' do + let(:user) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, user) } + + it 'sets gradebookEnabled to true' do + subject + expect(JSON.parse(response.body)['gradebookEnabled']).to eq(true) + end + end + + context 'when the gradebook component is disabled' do + let(:user) { create(:course_manager, course: course).user } + before do + course.set_component_enabled_boolean!(:course_gradebook_component, false) + controller_sign_in(controller, user) + end + + it 'sets gradebookEnabled to false' do + subject + expect(JSON.parse(response.body)['gradebookEnabled']).to eq(false) + end + end end describe '#activity_get_help' do diff --git a/spec/services/course/statistics/assessment_score_summary_download_service_spec.rb b/spec/services/course/statistics/assessment_score_summary_download_service_spec.rb deleted file mode 100644 index 0049b3bacf4..00000000000 --- a/spec/services/course/statistics/assessment_score_summary_download_service_spec.rb +++ /dev/null @@ -1,162 +0,0 @@ -# frozen_string_literal: true -require 'rails_helper' - -RSpec.describe Course::Statistics::AssessmentsScoreSummaryDownloadService do - let(:instance) { Instance.default } - with_tenant(:instance) do - let!(:course) { create(:course) } - let!(:course2) { create(:course) } - let!(:student1) { create(:course_user, course: course, name: 'Student 1') } - let!(:student2) { create(:course_user, :phantom, course: course, name: 'Student 2') } - let!(:student3) { create(:course_user, course: course, name: 'Student 3') } - let!(:student4) { create(:course_user, course: course2, name: 'Student 4') } - - let!(:assessment1) do - create(:assessment, :published_with_mrq_question, course: course, end_at: 3.days.from_now, published: true) - end - let!(:assessment2) do - create(:assessment, :published_with_text_response_question, course: course, end_at: 3.days.from_now, - published: true) - end - let!(:assessment3) do - create(:assessment, :published_with_text_response_question, course: course2, end_at: 3.days.from_now, - published: true) - end - - let!(:submission11) { create(:submission, :published, assessment: assessment1, creator: student1.user) } - let!(:submission12) { create(:submission, :published, assessment: assessment2, creator: student1.user) } - - let!(:submission21) { create(:submission, :published, assessment: assessment1, creator: student2.user) } - let!(:submission22) { create(:submission, :published, assessment: assessment2, creator: student2.user) } - - describe '#generate' do - let(:file_name) do - "#{Pathname.normalize_filename(course.title)}_score_summary_#{Time.now.strftime '%Y-%m-%d %H%M'}.csv" - end - let(:assessment_ids_list) { [assessment1.id, assessment2.id, assessment3.id] } - let(:service) { described_class.new(course, assessment_ids_list, file_name) } - - subject { service.generate } - - after { service.cleanup } - - context 'when all assessments are chosen for score summary export' do - let!(:filepath) { subject } - let!(:csv_lines) { CSV.open(filepath, 'r').readlines } - - it 'cleans up temporary files after cleanup is called' do - entries = service.send(:cleanup_entries) - - service.cleanup - - entries.each do |entry| - expect(Pathname.new(entry).exist?).to be false - end - end - - it 'downloads non-empty csv with correct information' do - expect(csv_lines.size).to eq(4) - expect(csv_lines[0].size).to eq(5) - - first_student_record = csv_lines[1] - expect(first_student_record[0]).to eq(student1.name) - expect(first_student_record[1]).to eq(student1.user.email) - expect(first_student_record[2]).to eq(student1.phantom? ? 'phantom' : 'normal') - expect(first_student_record[3]).to eq(submission11.grade.to_s) - expect(first_student_record[4]).to eq(submission12.grade.to_s) - - second_student_record = csv_lines[2] - expect(second_student_record[0]).to eq(student2.name) - expect(second_student_record[1]).to eq(student2.user.email) - expect(second_student_record[2]).to eq(student2.phantom? ? 'phantom' : 'normal') - expect(second_student_record[3]).to eq(submission21.grade.to_s) - expect(second_student_record[4]).to eq(submission22.grade.to_s) - - third_student_record = csv_lines[3] - expect(third_student_record[0]).to eq(student3.name) - expect(third_student_record[1]).to eq(student3.user.email) - expect(third_student_record[2]).to eq(student3.phantom? ? 'phantom' : 'normal') - expect(third_student_record[3]).to eq('') - expect(third_student_record[4]).to eq('') - end - end - - context 'when students have no external_id set (backwards compatibility)' do - let!(:filepath) { subject } - let!(:csv_lines) { CSV.open(filepath, 'r').readlines } - - it 'CSV has same column count as before (no external_id column)' do - expect(csv_lines[0].size).to eq(5) - end - - it 'CSV header does NOT include external_id header' do - expect(csv_lines[0]).not_to include('external_id') - end - - it 'existing columns (name, email, type, scores) are at same indices' do - header = csv_lines[0] - expect(header[0]).to eq(I18n.t('csv.score_summary.headers.name')) - expect(header[1]).to eq(I18n.t('csv.score_summary.headers.email')) - expect(header[2]).to eq(I18n.t('csv.score_summary.headers.type')) - expect(header[3]).to eq(assessment1.title) - expect(header[4]).to eq(assessment2.title) - end - end - - context 'when some students have external_id set' do - let!(:student_with_ext_id) { create(:course_user, course: course, name: 'Student 4', external_id: 'EXT001') } - let!(:student_without_ext_id) { create(:course_user, course: course, name: 'Student 5') } - - let!(:submission_a1) do - create(:submission, :published, assessment: assessment1, creator: student_with_ext_id.user) - end - let!(:submission_a2) do - create(:submission, :published, assessment: assessment2, creator: student_with_ext_id.user) - end - let!(:submission_b1) do - create(:submission, :published, assessment: assessment1, creator: student_without_ext_id.user) - end - let!(:submission_b2) do - create(:submission, :published, assessment: assessment2, creator: student_without_ext_id.user) - end - - let(:assessment_ids_list) { [assessment1.id, assessment2.id] } - let(:service) { described_class.new(course, assessment_ids_list, file_name) } - let!(:filepath) { service.generate } - let!(:csv_lines) { CSV.open(filepath, 'r').readlines } - - it 'CSV header includes external_id column' do - expect(csv_lines[0].size).to eq(6) - expect(csv_lines[0]).to include(I18n.t('csv.score_summary.headers.external_id')) - end - - it 'CSV header has external_id before assessment scores' do - header = csv_lines[0] - ext_id_index = header.index(I18n.t('csv.score_summary.headers.external_id')) - first_score_index = header.index(assessment1.title) - expect(ext_id_index).to be < first_score_index - end - - it 'student with external_id has it before assessment scores' do - fourth_student_record = csv_lines[4] - expect(fourth_student_record[0]).to eq(student_with_ext_id.name) - expect(fourth_student_record[1]).to eq(student_with_ext_id.user.email) - expect(fourth_student_record[2]).to eq(student_with_ext_id.phantom? ? 'phantom' : 'normal') - expect(fourth_student_record[3]).to eq('EXT001') - expect(fourth_student_record[4]).to eq(submission_a1.grade.to_s) - expect(fourth_student_record[5]).to eq(submission_a2.grade.to_s) - end - - it 'student without external_id has empty string in external_id column before scores' do - fifth_student_record = csv_lines[5] - expect(fifth_student_record[0]).to eq(student_without_ext_id.name) - expect(fifth_student_record[1]).to eq(student_without_ext_id.user.email) - expect(fifth_student_record[2]).to eq(student_without_ext_id.phantom? ? 'phantom' : 'normal') - expect(fifth_student_record[3]).to eq('') - expect(fifth_student_record[4]).to eq(submission_b1.grade.to_s) - expect(fifth_student_record[5]).to eq(submission_b2.grade.to_s) - end - end - end - end -end From 0db7ef267842a9e875aa2fe48d676fdbd20bbee7 Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 9 Jun 2026 17:21:40 +0800 Subject: [PATCH 3/3] feat(gradebook): add external ID in column picker in gradebook --- .../course/gradebook/index.json.jbuilder | 1 + .../__tests__/GradebookColumnTree.test.tsx | 42 +++++++++- .../__tests__/GradebookIndex.test.tsx | 1 + .../__tests__/GradebookTable.test.tsx | 81 +++++++++++++++++++ .../components/GradebookColumnTree.tsx | 10 ++- .../gradebook/components/GradebookTable.tsx | 25 +++++- client/app/types/course/gradebook.ts | 1 + .../course/gradebook_controller_spec.rb | 17 +++- 8 files changed, 173 insertions(+), 5 deletions(-) diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder index 55d9becbf4a..510bc8be01c 100644 --- a/app/views/course/gradebook/index.json.jbuilder +++ b/app/views/course/gradebook/index.json.jbuilder @@ -21,6 +21,7 @@ json.students @students do |course_user| json.id course_user.user_id json.name course_user.name json.email course_user.user.email + json.externalId course_user.external_id json.level course_user.level_number json.totalXp course_user.experience_points end diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx index 261abaa3cdf..280f3c71b7d 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx @@ -14,7 +14,7 @@ const assessments: AssessmentData[] = [ const asnId100 = buildAssessmentColumnId(100); const asnId101 = buildAssessmentColumnId(101); -const allIds = ['name', 'email', 'level', asnId100, asnId101]; +const allIds = ['name', 'email', 'externalId', 'level', asnId100, asnId101]; const wrap = (node: JSX.Element): JSX.Element => ( @@ -87,6 +87,46 @@ describe('GradebookColumnTree', () => { ).not.toBeInTheDocument(); }); + it('renders an External ID checkbox in the Student info group', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect( + screen.getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + + it('clicking the External ID 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: /external id/i })); + expect(setVisible).toHaveBeenCalledWith('externalId', expect.any(Boolean)); + }); + it('name checkbox is disabled and always checked', () => { const visibility: Record = { name: false, diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index de1dee2b32c..18074f643aa 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -58,6 +58,7 @@ const populatedState = { id: 1, name: 'Alice', email: 'alice@example.com', + externalId: null, level: 3, totalXp: 150, }, diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx index b66bda78333..79ca1cdb1b8 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -21,6 +21,7 @@ const students: StudentData[] = [ id: 1, name: 'Alice', email: 'alice@example.com', + externalId: null, level: 3, totalXp: 150, }, @@ -28,6 +29,7 @@ const students: StudentData[] = [ id: 2, name: 'Bob', email: 'bob@example.com', + externalId: null, level: 5, totalXp: 300, }, @@ -41,6 +43,7 @@ const makeStudents = (n: number): StudentData[] => id: i + 1, name: `Student ${i + 1}`, email: `student${i + 1}@example.com`, + externalId: null, level: 1, totalXp: 0, })); @@ -374,6 +377,84 @@ describe('GradebookTable', () => { }); }); + describe('external ID column', () => { + const studentsWithExtId: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: 'EXT-001', + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + externalId: null, + level: 5, + totalXp: 300, + }, + ]; + + const renderWith = (studs: StudentData[]): void => { + render( + , + { state: userState }, + ); + }; + + it('shows the External ID column by default when a student has an external ID', async () => { + renderWith(studentsWithExtId); + expect(await screen.findByText('External ID')).toBeInTheDocument(); + expect(screen.getByText('EXT-001')).toBeInTheDocument(); + }); + + it('hides the External ID column by default when no student has an external ID', async () => { + renderWith(students); + await screen.findByText('Alice'); + expect(screen.queryByText('External ID')).not.toBeInTheDocument(); + }); + + it('treats a blank external ID as none and hides the column by default', async () => { + const studentsWithBlankExtId: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: '', + level: 3, + totalXp: 150, + }, + ]; + renderWith(studentsWithBlankExtId); + await screen.findByText('Alice'); + expect(screen.queryByText('External ID')).not.toBeInTheDocument(); + }); + + it('offers the External ID checkbox in the picker even when no student has one', async () => { + const user = userEvent.setup(); + renderWith(students); + const btn = await screen.findByRole('button', { + name: /select columns/i, + }); + await user.click(btn); + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + }); + describe('cross-page selection', () => { it('export label reflects selection count across pages', async () => { const user = userEvent.setup(); diff --git a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx index 84dd01108cb..7d0be91961f 100644 --- a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -8,6 +8,7 @@ import { ColumnPickerTreeGroup, } from 'lib/components/table'; import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; import { GAMIFICATION_COL_IDS, @@ -64,7 +65,8 @@ interface GradebookColumnTreeProps extends ColumnPickerRenderContext { gamificationEnabled: boolean; } -const STUDENT_ALL_IDS = [...STUDENT_INFO_COL_IDS]; +const EXTERNAL_ID = 'externalId'; +const STUDENT_ALL_IDS = [...STUDENT_INFO_COL_IDS, EXTERNAL_ID]; const GAMIFICATION_ALL_IDS = [...GAMIFICATION_COL_IDS]; const GradebookColumnTree = ({ @@ -158,6 +160,12 @@ const GradebookColumnTree = ({ /> ), )} + setVisible(EXTERNAL_ID, e.target.checked)} + /> {gamificationEnabled && ( diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx index 43e160ab23e..1d3ce57eb0b 100644 --- a/client/app/bundles/course/gradebook/components/GradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -32,6 +32,7 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE, } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; import { GAMIFICATION_COL_IDS } from '../constants'; import type { @@ -51,6 +52,7 @@ import GradebookColumnTree from './GradebookColumnTree'; const COL_WIDTHS = { name: 160, email: 220, + externalId: 160, level: 70, totalXp: 100, assessment: 150, @@ -61,7 +63,8 @@ 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 isLeftAligned = (id: string): boolean => + id === 'name' || id === 'email' || id === 'externalId'; const translations = defineMessages({ searchStudents: { @@ -189,6 +192,7 @@ interface GradebookRow { studentId: number; name: string; email: string; + externalId: string | null; level: number; totalXp: number; grades: Partial>; @@ -243,6 +247,7 @@ const GradebookTable = ({ studentId: student.id, name: student.name, email: student.email, + externalId: student.externalId, level: student.level, totalXp: student.totalXp, grades, @@ -251,6 +256,11 @@ const GradebookTable = ({ [students, assessments, submissionsByStudent], ); + const hasExternalIds = useMemo( + () => students.some((s) => s.externalId != null && s.externalId !== ''), + [students], + ); + const columns = useMemo[]>(() => { const cols: ColumnTemplate[] = [ { @@ -272,6 +282,17 @@ const GradebookTable = ({ }, ]; + // The External ID column is always offered in the picker, but only shown by + // default when the course actually uses external IDs (see column picker). + cols.push({ + id: 'externalId', + title: t(tableTranslations.externalId), + of: 'externalId', + cell: (row) => row.externalId ?? '', + csvDownloadable: true, + defaultVisible: hasExternalIds, + }); + if (gamificationEnabled) { cols.push({ id: 'level', @@ -306,7 +327,7 @@ const GradebookTable = ({ }); }); return cols; - }, [assessments, gamificationEnabled, t]); + }, [assessments, gamificationEnabled, hasExternalIds, t]); const assessmentMaxGrades = useMemo( () => new Map(assessments.map((a) => [a.id, a.maxGrade])), diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts index 8c4d8a0eb3e..67dad1bed1c 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -20,6 +20,7 @@ export interface StudentData { id: number; name: string; email: string; + externalId: string | null; level: number; totalXp: number; } diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb index 4b21113c31b..a213e80f476 100644 --- a/spec/controllers/course/gradebook_controller_spec.rb +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -104,7 +104,8 @@ 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('externalId') + expect(student_data['externalId']).to be_nil expect(student_data).to have_key('level') expect(student_data['level']).to be_a(Integer) end @@ -127,6 +128,20 @@ end end + context 'when a student has an external ID' do + let(:ta) { create(:course_teaching_assistant, course: course) } + let!(:student) { create(:course_student, course: course, external_id: 'EXT-123') } + before { controller_sign_in(controller, ta.user) } + + it 'returns the external ID 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['externalId']).to eq('EXT-123') + 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 }