diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..e9c8a8d9424 --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +class Course::GradebookComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Gradebook' + end + + def sidebar_items + main_sidebar_items + settings_sidebar_items + end + + private + + def main_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 + + def settings_sidebar_items + return [] unless can?(:manage_gradebook_settings, current_course) + + [ + { + key: self.class.key, + type: :settings, + weight: 14, + path: course_admin_gradebook_path(current_course) + } + ] + end +end diff --git a/app/controllers/course/admin/gradebook_settings_controller.rb b/app/controllers/course/admin/gradebook_settings_controller.rb new file mode 100644 index 00000000000..6e6f3313ed5 --- /dev/null +++ b/app/controllers/course/admin/gradebook_settings_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Course::Admin::GradebookSettingsController < Course::Admin::Controller + def edit + respond_to(&:json) + end + + def update + if @settings.update(gradebook_settings_params) && current_course.save + render 'edit' + else + render json: { errors: @settings.errors }, status: :bad_request + end + end + + private + + def gradebook_settings_params + params.require(:settings_gradebook_component).permit(:weighted_view_enabled) + end + + def component + current_component_host[:course_gradebook_component] + end + + def authorize_admin + authorize! :manage_gradebook_settings, 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..198be93d613 --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true +class Course::GradebookController < Course::ComponentController + before_action :authorize_read_gradebook! + + def index + respond_to do |format| + format.json do + @weighted_view_enabled = @settings.weighted_view_enabled + @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 + + def update_weights + authorize! :manage_gradebook_weights, current_course + updates = update_weights_params[:weights].map do |entry| + { tab_id: entry[:tabId].to_i, weight: entry[:weight].to_i } + end + Course::Assessment::Tab.update_gradebook_weights(course: current_course, updates: updates) + render json: { weights: updates.map { |u| { tabId: u[:tab_id], weight: u[:weight] } } } + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + private + + def authorize_read_gradebook! + authorize! :read_gradebook, current_course + end + + def update_weights_params + params.permit(weights: [:tabId, :weight]) + 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..d54a56cca62 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + can :read_gradebook, Course, id: course.id if course_user&.staff? + can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner? + can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner? + 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/models/course/assessment/tab.rb b/app/models/course/assessment/tab.rb index bb88b5287f2..4013558f340 100644 --- a/app/models/course/assessment/tab.rb +++ b/app/models/course/assessment/tab.rb @@ -2,6 +2,11 @@ class Course::Assessment::Tab < ApplicationRecord validates :title, length: { maximum: 255 }, presence: true validates :weight, numericality: { only_integer: true }, presence: true + validates :gradebook_weight, + numericality: { only_integer: true, + greater_than_or_equal_to: 0, + less_than_or_equal_to: 100 }, + presence: true validates :creator, presence: true validates :updater, presence: true validates :category, presence: true @@ -24,6 +29,30 @@ class Course::Assessment::Tab < ApplicationRecord select('(array_agg(title))[0:3]') end) + # Bulk-updates the gradebook_weight for a set of tabs belonging to the given course. + # Raises ActiveRecord::RecordNotFound if any tab_id does not belong to the course. + # Raises ActiveRecord::RecordInvalid if any weight fails validation; the transaction is rolled back. + # + # @param course [Course] + # @param updates [Array] array of { tab_id: Integer, weight: Integer } + def self.update_gradebook_weights(course:, updates:) + course_tab_ids = course.assessment_tabs.pluck(:id).to_set + tab_ids_to_update = updates.map { |e| e[:tab_id] } + + tab_ids_to_update.each do |tab_id| + raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(tab_id) + end + + tabs_by_id = where(id: tab_ids_to_update).index_by(&:id) + + transaction do + updates.each do |entry| + tab = tabs_by_id[entry[:tab_id]] + tab.update!(gradebook_weight: entry[:weight]) + end + end + end + # Returns a boolean value indicating if there are other tabs # besides this one remaining in its category. # diff --git a/app/models/course/settings/gradebook_component.rb b/app/models/course/settings/gradebook_component.rb new file mode 100644 index 00000000000..0f788086061 --- /dev/null +++ b/app/models/course/settings/gradebook_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class Course::Settings::GradebookComponent < Course::Settings::Component + # Returns whether weighted view is enabled (disabled by default). + # + # @return [Boolean] Setting on whether weighted view is enabled. + def weighted_view_enabled + ActiveRecord::Type::Boolean.new.cast(settings.weighted_view_enabled) || false + end + + # Enable or disable the weighted view. + # + # @param [Boolean|Integer|String] value Setting on whether weighted view is enabled. + def weighted_view_enabled=(value) + settings.weighted_view_enabled = ActiveRecord::Type::Boolean.new.cast(value) + end +end diff --git a/app/views/course/admin/gradebook_settings/edit.json.jbuilder b/app/views/course/admin/gradebook_settings/edit.json.jbuilder new file mode 100644 index 00000000000..24c730f6bcb --- /dev/null +++ b/app/views/course/admin/gradebook_settings/edit.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.weightedViewEnabled @settings.weighted_view_enabled diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder new file mode 100644 index 00000000000..ede774664ec --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,39 @@ +# frozen_string_literal: true +json.weightedViewEnabled @weighted_view_enabled +json.canManageWeights can?(:manage_gradebook_weights, current_course) + +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 + json.gradebookWeight tab.gradebook_weight if @weighted_view_enabled +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/__test__/mocks/localeMock.js b/client/app/__test__/mocks/localeMock.js new file mode 100644 index 00000000000..1f87539212a --- /dev/null +++ b/client/app/__test__/mocks/localeMock.js @@ -0,0 +1,2 @@ +// File used for jest moduleNameMapper - empty locale messages for tests +module.exports = {}; diff --git a/client/app/__test__/setup.js b/client/app/__test__/setup.js index ce09d53127a..d49da129df3 100644 --- a/client/app/__test__/setup.js +++ b/client/app/__test__/setup.js @@ -65,3 +65,15 @@ jest.mock('react-router-dom', () => ({ useNavigate: jest.fn(), unstable_usePrompt: jest.fn(), })); + +// Replace I18nProvider with a synchronous stub so tests using test-utils +// don't stall on async locale loading. +jest.mock('lib/components/wrappers/I18nProvider', () => { + const { IntlProvider } = require('react-intl'); + const SyncI18nProvider = ({ children }) => ( + + {children} + + ); + return { __esModule: true, default: SyncI18nProvider }; +}); diff --git a/client/app/api/course/Admin/Gradebook.ts b/client/app/api/course/Admin/Gradebook.ts new file mode 100644 index 00000000000..287e4b0c79d --- /dev/null +++ b/client/app/api/course/Admin/Gradebook.ts @@ -0,0 +1,23 @@ +import { AxiosResponse } from 'axios'; +import { + GradebookSettingsData, + GradebookSettingsPostData, +} from 'types/course/admin/gradebook'; + +import BaseAdminAPI from './Base'; + +export default class GradebookAdminAPI extends BaseAdminAPI { + override get urlPrefix(): string { + return `${super.urlPrefix}/gradebook`; + } + + index(): Promise> { + return this.client.get(this.urlPrefix); + } + + update( + data: GradebookSettingsPostData, + ): Promise> { + return this.client.patch(this.urlPrefix, data); + } +} diff --git a/client/app/api/course/Admin/index.ts b/client/app/api/course/Admin/index.ts index 966a1d3b05f..fcd4097b26d 100644 --- a/client/app/api/course/Admin/index.ts +++ b/client/app/api/course/Admin/index.ts @@ -6,6 +6,7 @@ import CommentsAdminAPI from './Comments'; import ComponentsAdminAPI from './Components'; import CourseAdminAPI from './Course'; import ForumsAdminAPI from './Forums'; +import GradebookAdminAPI from './Gradebook'; import LeaderboardAdminAPI from './Leaderboard'; import LessonPlanSettingsAPI from './LessonPlan'; import MaterialsAdminAPI from './Materials'; @@ -28,6 +29,7 @@ const AdminAPI = { lessonPlan: new LessonPlanSettingsAPI(), materials: new MaterialsAdminAPI(), forums: new ForumsAdminAPI(), + gradebook: new GradebookAdminAPI(), videos: new VideosAdminAPI(), notifications: new NotificationsSettingsAPI(), codaveri: new CodaveriAdminAPI(), diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts new file mode 100644 index 00000000000..7603f1f2a1b --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,21 @@ +import { GradebookData, UpdateWeightsPayload } 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); + } + + updateWeights( + payload: UpdateWeightsPayload, + ): APIResponse { + return this.client.patch(`${this.#urlPrefix}/weights`, payload); + } +} 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/admin/pages/GradebookSettings/GradebookSettingsForm.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx new file mode 100644 index 00000000000..2a0c5363c40 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx @@ -0,0 +1,59 @@ +import { forwardRef } from 'react'; +import { Controller } from 'react-hook-form'; +import { Typography } from '@mui/material'; +import { GradebookSettingsData } from 'types/course/admin/gradebook'; + +import Section from 'lib/components/core/layouts/Section'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import Form, { FormRef } from 'lib/components/form/Form'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from './translations'; + +interface GradebookSettingsFormProps { + data: GradebookSettingsData; + onSubmit: (data: GradebookSettingsData) => void; + disabled?: boolean; +} + +const GradebookSettingsForm = forwardRef< + FormRef, + GradebookSettingsFormProps +>((props, ref): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+ {(control): JSX.Element => ( +
+ ( + + )} + /> + + + {t(translations.weightedViewEnabledHint)} + +
+ )} +
+ ); +}); + +GradebookSettingsForm.displayName = 'GradebookSettingsForm'; + +export default GradebookSettingsForm; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx new file mode 100644 index 00000000000..f38c567ae32 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx @@ -0,0 +1,48 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import GradebookSettings from '../index'; + +const mock = createMockAdapter(CourseAPI.admin.gradebook.client); + +describe('', () => { + it('renders the toggle unchecked when weightedViewEnabled is false', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + expect(checkbox).not.toBeChecked(); + }); + + it('PATCHes when toggle is checked and form submitted', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + mock + .onPatch(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: true }); + + const spy = jest.spyOn(CourseAPI.admin.gradebook, 'update'); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + settings_gradebook_component: { weighted_view_enabled: true }, + }); + }); + }); +}); diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx new file mode 100644 index 00000000000..122c063b90b --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx @@ -0,0 +1,49 @@ +import { ComponentRef, useRef, useState } from 'react'; +import { GradebookSettingsData } from 'types/course/admin/gradebook'; + +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; +import translations from 'lib/translations/form'; + +import { useItemsReloader } from '../../components/SettingsNavigation'; + +import GradebookSettingsForm from './GradebookSettingsForm'; +import { fetchGradebookSettings, updateGradebookSettings } from './operations'; + +const GradebookSettings = (): JSX.Element => { + const reloadItems = useItemsReloader(); + const { t } = useTranslation(); + const formRef = useRef>(null); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = (data: GradebookSettingsData): void => { + setSubmitting(true); + + updateGradebookSettings(data) + .then((newData) => { + if (!newData) return; + formRef.current?.resetTo?.(newData); + reloadItems(); + toast.success(t(translations.changesSaved)); + }) + .catch(formRef.current?.receiveErrors) + .finally(() => setSubmitting(false)); + }; + + return ( + } while={fetchGradebookSettings}> + {(data): JSX.Element => ( + + )} + + ); +}; + +export default GradebookSettings; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts b/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts new file mode 100644 index 00000000000..0d19aebc9da --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts @@ -0,0 +1,32 @@ +import { AxiosError } from 'axios'; +import { + GradebookSettingsData, + GradebookSettingsPostData, +} from 'types/course/admin/gradebook'; + +import CourseAPI from 'api/course'; + +type Data = Promise; + +export const fetchGradebookSettings = async (): Data => { + const response = await CourseAPI.admin.gradebook.index(); + return response.data; +}; + +export const updateGradebookSettings = async ( + data: GradebookSettingsData, +): Data => { + const adaptedData: GradebookSettingsPostData = { + settings_gradebook_component: { + weighted_view_enabled: data.weightedViewEnabled, + }, + }; + + try { + const response = await CourseAPI.admin.gradebook.update(adaptedData); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts b/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts new file mode 100644 index 00000000000..1179259e7a5 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts @@ -0,0 +1,17 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + gradebookSettings: { + id: 'course.admin.GradebookSettings.gradebookSettings', + defaultMessage: 'Gradebook settings', + }, + weightedViewEnabled: { + id: 'course.admin.GradebookSettings.weightedViewEnabled', + defaultMessage: 'Enable weighted grade view', + }, + weightedViewEnabledHint: { + id: 'course.admin.GradebookSettings.weightedViewEnabledHint', + defaultMessage: + 'Enables a "By weight" view in the gradebook where staff can configure per-tab weights and see a weighted Total column.', + }, +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx new file mode 100644 index 00000000000..608a665ca56 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx @@ -0,0 +1,132 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import ConfigureWeightsPrompt from '../components/ConfigureWeightsPrompt'; +import * as operations from '../operations'; + +jest + .spyOn(operations, 'updateGradebookWeights') + .mockReturnValue(async () => {}); + +const categories = [{ id: 1, title: 'Missions' }]; +const tabs = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 }, + { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 }, +]; +const assessments = [ + { id: 101, title: 'Assignment 1', tabId: 10, maxGrade: 100 }, + { id: 102, title: 'Assignment 2', tabId: 10, maxGrade: 100 }, +]; + +const setup = (overrides = {}): ReturnType => + render( + , + ); + +describe('', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders one input per tab grouped by category', () => { + setup(); + expect(screen.getByText('Missions')).toBeInTheDocument(); + expect(screen.getByLabelText('Assignments')).toHaveValue(50); + expect(screen.getByLabelText('Optional')).toHaveValue(50); + }); + + it('shows Total: 100% with no warning when sum = 100', () => { + setup(); + expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); + expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument(); + }); + + it('shows warning when sum != 100', () => { + setup(); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '30' }, + }); + expect(screen.getByText(/Total:\s*80%/)).toBeInTheDocument(); + expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument(); + }); + + it('shows inline error for >100', () => { + setup(); + fireEvent.change(screen.getByLabelText('Assignments'), { + target: { value: '101' }, + }); + expect(screen.getByText(/must be at most 100/i)).toBeInTheDocument(); + }); + + it('shows inline error for negative', () => { + setup(); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '-1' }, + }); + expect(screen.getByText(/must be at least 0/i)).toBeInTheDocument(); + }); + + it('Save dispatches updateGradebookWeights with { tabId, weight } only', async () => { + setup(); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '40' }, + }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(operations.updateGradebookWeights).toHaveBeenCalledWith([ + { tabId: 10, weight: 50 }, + { tabId: 11, weight: 40 }, + ]); + }); + }); + + it('Cancel does not dispatch', () => { + setup(); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '40' }, + }); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(operations.updateGradebookWeights).not.toHaveBeenCalled(); + }); + + it('assessment list is hidden by default and shown on expand', () => { + setup(); + expect(screen.queryByText('Assignment 1')).not.toBeVisible(); + const expandBtns = screen.getAllByRole('button', { name: '' }); + fireEvent.click(expandBtns[0]); + expect(screen.getByText('Assignment 1')).toBeVisible(); + expect(screen.getByText('Assignment 2')).toBeVisible(); + }); + + it('shows derived % of grade that updates live when weight changes', () => { + setup(); + const expandBtns = screen.getAllByRole('button', { name: '' }); + fireEvent.click(expandBtns[0]); + // weight=50, each assessment is 50% of tab → 25.0% of grade each + expect(screen.getAllByText('25.0% of grade').length).toBe(2); + fireEvent.change(screen.getByLabelText('Assignments'), { + target: { value: '60' }, + }); + expect(screen.getAllByText('30.0% of grade').length).toBe(2); + }); + + it('disables expand button for tabs with no assessments', () => { + setup(); + const expandBtns = screen.getAllByRole('button', { name: '' }); + expect(expandBtns[1]).toBeDisabled(); + }); + + it('does not render an Exclude checkbox', () => { + setup(); + expect( + screen.queryByRole('checkbox', { name: /exclude/i }), + ).not.toBeInTheDocument(); + }); +}); 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..7e16bb2705b --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,247 @@ +import { fireEvent, render, screen, waitFor, within } 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, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const noStudentsState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const populatedState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + ], + submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const populatedStateWithGamification = { + gradebook: { + ...populatedState.gradebook, + gamificationEnabled: true, + }, +}; + +const populatedStateWithWeightedView = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: false, + }, +}; + +const populatedStateWithWeightedViewAndGamification = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + gamificationEnabled: true, + canManageWeights: false, + }, +}; + +const populatedStateManagerWeightedOff = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: false, + canManageWeights: true, + }, +}; + +const populatedStateManagerWeightedOn = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: 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(); + }); + + it('does not render view toggle when weightedViewEnabled is false', async () => { + render(, { state: populatedState }); + // Wait for loading to finish + await screen.findByRole('button', { name: /export/i }); + expect(screen.queryByText(/by weight/i)).not.toBeInTheDocument(); + }); + + it('renders view toggle when weightedViewEnabled is true', async () => { + render(, { state: populatedStateWithWeightedView }); + expect(await screen.findByText(/all assessments/i)).toBeInTheDocument(); + expect(await screen.findByText(/by weight/i)).toBeInTheDocument(); + }); + + it('switches to By weight view on toggle click', async () => { + render(, { state: populatedStateWithWeightedView }); + const byWeightButton = await screen.findByText(/by weight/i); + fireEvent.click(byWeightButton); + expect( + await screen.findByTestId('gradebook-weighted-table'), + ).toBeInTheDocument(); + }); + + it('weighted view exposes gamification columns in picker when gamification is enabled', async () => { + render(, { + state: populatedStateWithWeightedViewAndGamification, + }); + const byWeightButton = await screen.findByText(/by weight/i); + fireEvent.click(byWeightButton); + await screen.findByTestId('gradebook-weighted-table'); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Level')).toBeInTheDocument(); + expect(within(dialog).getByText('Total XP')).toBeInTheDocument(); + }); + + describe('weighted-view discoverability hint', () => { + it('shows the hint to managers when the weighted view is off', async () => { + render(, { state: populatedStateManagerWeightedOff }); + expect( + await screen.findByRole('link', { name: /gradebook settings/i }), + ).toBeInTheDocument(); + }); + + it('does not show the hint once the weighted view is enabled', async () => { + render(, { state: populatedStateManagerWeightedOn }); + await screen.findByText(/by weight/i); // wait for data to load + expect( + screen.queryByRole('link', { name: /gradebook settings/i }), + ).not.toBeInTheDocument(); + }); + + it('does not show the hint to staff who cannot manage weights', async () => { + render(, { state: populatedState }); + await screen.findByRole('button', { name: /export/i }); // wait for load + expect( + screen.queryByRole('link', { name: /gradebook settings/i }), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx new file mode 100644 index 00000000000..b66bda78333 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,424 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; + +import GradebookTable from '../components/GradebookTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, +]; +const students: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + level: 5, + totalXp: 300, + }, +]; +const submissions: SubmissionData[] = [ + { studentId: 1, assessmentId: 100, grade: 8 }, +]; + +const makeStudents = (n: number): StudentData[] => + Array.from({ length: n }, (_, i) => ({ + id: i + 1, + name: `Student ${i + 1}`, + email: `student${i + 1}@example.com`, + level: 1, + totalXp: 0, + })); + +// User id used in all renders so localStorage is keyed as `${USER_ID}:gradebook_columns_1` +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:gradebook_columns_1`; + +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/__tests__/GradebookWeightedTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx new file mode 100644 index 00000000000..7e36956e9bb --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx @@ -0,0 +1,500 @@ +import { fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; + +import GradebookWeightedTable from '../components/GradebookWeightedTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +const USER_ID = 42; +const WEIGHTED_STORAGE_KEY = `${USER_ID}:gradebook_weighted_columns_1`; +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Minimal shared fixtures +// --------------------------------------------------------------------------- +const makeCategory = (id: number, title: string): CategoryData => ({ + id, + title, +}); + +const makeTab = ( + id: number, + title: string, + categoryId: number, + gradebookWeight = 50, +): TabData => ({ id, title, categoryId, gradebookWeight }); + +const makeAssessment = ( + id: number, + title: string, + tabId: number, + maxGrade: number, +): AssessmentData => ({ id, title, tabId, maxGrade }); + +const makeStudent = (id: number, name: string): StudentData => ({ + id, + name, + email: `${name.toLowerCase()}@example.com`, + level: 1, + totalXp: 0, +}); + +const makeSub = ( + studentId: number, + assessmentId: number, + grade: number | null, +): SubmissionData => ({ studentId, assessmentId, grade }); + +interface RenderWeightedOptions { + categories?: CategoryData[]; + tabs?: TabData[]; + assessments?: AssessmentData[]; + students?: StudentData[]; + submissions?: SubmissionData[]; + canManageWeights?: boolean; + courseTitle?: string; + courseId?: number; + gamificationEnabled?: boolean; +} + +const renderWeighted = ( + opts: RenderWeightedOptions = {}, +): ReturnType => { + const cats = opts.categories ?? [makeCategory(1, 'Cat A')]; + const tabs = opts.tabs ?? [makeTab(10, 'Tab 1', 1, 100)]; + const assessments = opts.assessments ?? [ + makeAssessment(100, 'Quiz 1', 10, 150), + ]; + const students = opts.students ?? [makeStudent(1, 'Alice')]; + const submissions = opts.submissions ?? []; + return render( + , + { state: userState }, + ); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('GradebookWeightedTable', () => { + beforeEach(() => localStorage.clear()); + + // 1. Row 1: category cells with colSpan + it('renders category cells in row 1 with colSpan equal to number of tabs in that category', () => { + const cats = [makeCategory(1, 'Cat A'), makeCategory(2, 'Cat B')]; + const tabs = [ + makeTab(10, 'Tab 1', 1, 50), + makeTab(11, 'Tab 2', 1, 50), + makeTab(20, 'Tab 3', 2, 0), + ]; + const assessments = [ + makeAssessment(100, 'Q1', 10, 10), + makeAssessment(101, 'Q2', 11, 10), + makeAssessment(102, 'Q3', 20, 10), + ]; + renderWeighted({ categories: cats, tabs, assessments }); + const thead = document.querySelector('thead')!; + const rows = thead.querySelectorAll('tr'); + const row1Cells = rows[0].querySelectorAll('th'); + const catACell = Array.from(row1Cells).find( + (c) => c.textContent === 'Cat A', + ); + const catBCell = Array.from(row1Cells).find( + (c) => c.textContent === 'Cat B', + ); + expect(catACell).toBeTruthy(); + expect(catBCell).toBeTruthy(); + expect( + catACell!.getAttribute('colspan') ?? catACell!.colSpan.toString(), + ).toBe('2'); + expect( + catBCell!.getAttribute('colspan') ?? catBCell!.colSpan.toString(), + ).toBe('1'); + }); + + // 1b. Category not in tabs → header absent + it('does not render a category header for categories with no tabs', () => { + renderWeighted({ + categories: [makeCategory(1, 'Active'), makeCategory(2, 'Ghost')], + tabs: [makeTab(10, 'Tab 1', 1, 100)], // only category 1 has a tab + }); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.queryByText('Ghost')).not.toBeInTheDocument(); + }); + + // 2. Row 2: tab title cells + it('renders tab title cells in row 2', () => { + renderWeighted({ + tabs: [makeTab(10, 'Homework', 1, 60), makeTab(11, 'Exams', 1, 40)], + }); + const thead = document.querySelector('thead')!; + const row2 = thead.querySelectorAll('tr')[1]; + expect( + within(row2 as HTMLElement).getByText('Homework'), + ).toBeInTheDocument(); + expect(within(row2 as HTMLElement).getByText('Exams')).toBeInTheDocument(); + }); + + // 3. Weight subheader shows "X% of grade" per tab + it('shows weight subheader "X% of grade" for each tab in row 3', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 70)], + }); + expect(screen.getByText('30% of grade')).toBeInTheDocument(); + expect(screen.getByText('70% of grade')).toBeInTheDocument(); + }); + + // 4a. Total column shows "100% total" when sum = 100 + it('shows "100% total" in total column header when weights sum to 100', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 40)], + }); + expect(screen.getByText('100% total')).toBeInTheDocument(); + }); + + // 4b. Total column shows warning text when sum ≠ 100 + it('shows a warning when weight sum ≠ 100 in total header', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], + }); + expect(screen.getByText(/60%/)).toBeInTheDocument(); + expect(screen.getByText(/does not sum to 100/i)).toBeInTheDocument(); + }); + + // 4c. Tooltip on warning header shows full message on hover + it('warning header tooltip text is "Weights do not sum to 100. Total may be inaccurate."', async () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 20)], + }); + await userEvent.hover(screen.getByText(/does not sum to 100/i)); + await waitFor(() => + expect( + screen.getByText('Weights do not sum to 100. Total may be inaccurate.'), + ).toBeInTheDocument(), + ); + }); + + // 5. Cell renders subtotal × weight as points (not percentage); non-integer → 2dp + it('renders cell as subtotal × weight in points (not a percentage); 2dp when non-integer', () => { + // grade=130, maxGrade=150 → subtotal=130/150≈0.8667; weight=100 + // points = 0.8667 × 100 = 86.67 (non-integer → 2dp) + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 150)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 130)], + }); + expect(screen.getAllByText('86.67').length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText('86.67%')).not.toBeInTheDocument(); + }); + + // 6. Tab with no assessments → cell shows "—" + it('shows "—" for a tab with no assessments', () => { + renderWeighted({ + tabs: [makeTab(10, 'Empty Tab', 1, 100)], + assessments: [], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); + + // 7. Student with no graded submissions → cell shows "—" + it('shows "—" when student has no graded submissions in a tab', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 10)], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); + + // 7b. Total cell shows "—" when all tabs have null subtotals + it('shows "—" in both the tab cell and the total cell when all tabs have null subtotals', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 10)], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + // tab cell = "—", total cell = "—" → at least 2 dashes + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(2); + }); + + // 8. Total equals the sum of the row's cells + it('total cell equals the sum of per-tab point cells', () => { + // tab10 weight=60: subtotal=130/150 → points=52 (integer → no decimals) + // tab20 weight=40: subtotal=0.9 → points=36 (integer → no decimals) + // total = 52 + 36 = 88 (integer → no decimals) + renderWeighted({ + categories: [makeCategory(1, 'Cat A')], + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(20, 'Tab 2', 1, 40)], + assessments: [ + makeAssessment(1, 'A1', 10, 100), + makeAssessment(2, 'A2', 10, 50), + makeAssessment(3, 'A3', 20, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [ + makeSub(1, 1, 80), + makeSub(1, 2, 50), + makeSub(1, 3, 90), + ], + }); + expect(screen.getByText('36')).toBeInTheDocument(); + expect(screen.getByText('88')).toBeInTheDocument(); + }); + + // 9. Treat Ungraded as 0 toggle changes numbers + it('changes subtotal values when "Treat Ungraded as 0" is toggled', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + makeAssessment(100, 'Q1', 10, 50), + makeAssessment(101, 'Q2', 10, 50), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 40)], + }); + + // Without toggle: only Q1 is graded → 40/50=0.8; weight=100 → 80 pts (integer) + expect(screen.getAllByText('80').length).toBeGreaterThanOrEqual(1); + + // MUI Tooltip sets aria-label from its title, so the checkbox accessible name + // is the tooltip description text, not the FormControlLabel label text. + const toggle = screen.getByRole('checkbox', { + name: /counts unsubmitted and ungraded/i, + }); + fireEvent.click(toggle); + + // With toggle: 40/(50+50)=0.4; weight=100 → 40 pts (integer) + expect(screen.getAllByText('40').length).toBeGreaterThanOrEqual(1); + }); + + // 10. All weights zero → empty-state banner visible + it('shows empty-state banner when all weights are 0', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0), makeTab(11, 'Tab 2', 1, 0)], + }); + expect(screen.getByText(/no weights configured/i)).toBeInTheDocument(); + }); + + // 10b. All weights zero + canManageWeights=false → "no access" message + it('shows "no access" empty-state message when canManageWeights is false and all weights are 0', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0)], + canManageWeights: false, + }); + expect( + screen.getByText(/no tab weights have been configured yet/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/no weights configured/i)).not.toBeInTheDocument(); + }); + + // 10c. At least one non-zero weight → banner absent + it('does not show empty-state banner when at least one tab has a non-zero weight', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 50)], + }); + expect( + screen.queryByText(/no weights configured/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/no tab weights have been configured yet/i), + ).not.toBeInTheDocument(); + }); + + // 11. canManageWeights === false → no "Configure Weights" button + it('does not show Configure Weights button when canManageWeights is false', () => { + renderWeighted({ canManageWeights: false }); + expect( + screen.queryByRole('button', { name: /configure weights/i }), + ).not.toBeInTheDocument(); + }); + + // 12. canManageWeights === true → "Configure Weights" button present + it('shows Configure Weights button when canManageWeights is true', () => { + renderWeighted({ canManageWeights: true }); + expect( + screen.getByRole('button', { name: /configure weights/i }), + ).toBeInTheDocument(); + }); + + // 13. Search bar is rendered + it('renders a search bar', () => { + renderWeighted(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + // 14. Typing in the search bar filters student rows + it('filters student rows when typing a name in the search bar', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + + await user.type(screen.getByRole('textbox'), 'Alice'); + + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + // 15. Pagination controls appear when there are more students than the page size + it('shows pagination controls when students exceed the default page size', () => { + const manyStudents = Array.from({ length: 15 }, (_, i) => + makeStudent(i + 1, `Student ${i + 1}`), + ); + renderWeighted({ students: manyStudents }); + expect(screen.getByText('1-10 / 15')).toBeInTheDocument(); + }); + + // 16. Row selection checkboxes are rendered for each student + it('renders checkboxes for row selection', () => { + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + // One "select all" header checkbox + one per student row + expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(3); + }); + + describe('column picker', () => { + it('shows Select Columns and Export buttons', () => { + renderWeighted(); + expect( + screen.getByRole('button', { name: /select columns/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + + it('shows "Export all rows" when no rows are selected', () => { + renderWeighted(); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(); + }); + + it('shows "Export 1 row" when one row is selected', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + const checkboxes = screen.getAllByRole('checkbox'); + // checkboxes[0] is the treatUngradedAsZero switch; [1] is header "select all"; [2] is the first row + await user.click(checkboxes[2]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + }); + + it('lists Email in the picker dialog, and Level/Total XP when gamification is on', async () => { + const user = userEvent.setup(); + renderWeighted({ gamificationEnabled: true }); + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Email')).toBeInTheDocument(); + expect(within(dialog).getByText('Level')).toBeInTheDocument(); + expect(within(dialog).getByText('Total XP')).toBeInTheDocument(); + }); + + it('omits the Gamification group from the dialog when gamification is off', async () => { + const user = userEvent.setup(); + renderWeighted({ gamificationEnabled: false }); + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).queryByText('Gamification')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); + }); + }); + + describe('identity columns rendering', () => { + it('hides Email, Level and Total XP by default', () => { + renderWeighted({ gamificationEnabled: true }); + expect(screen.queryByText('Email')).not.toBeInTheDocument(); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + expect(screen.queryByText('Total XP')).not.toBeInTheDocument(); + expect( + screen.queryByText('alice@example.com'), + ).not.toBeInTheDocument(); + }); + + it('shows the Email column header and value when Email is enabled via storage', async () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ email: true }), + ); + renderWeighted({ students: [makeStudent(1, 'Alice')] }); + // header cell + const thead = document.querySelector('thead')!; + expect(within(thead as HTMLElement).getByText('Email')).toBeInTheDocument(); + // body value + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + }); + + it('shows Level and Total XP values when enabled via storage and gamification is on', () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ level: true, totalXp: true }), + ); + renderWeighted({ + gamificationEnabled: true, + students: [{ id: 1, name: 'Alice', email: 'a@x.com', level: 7, totalXp: 999 }], + }); + const thead = document.querySelector('thead')!; + expect(within(thead as HTMLElement).getByText('Level')).toBeInTheDocument(); + expect( + within(thead as HTMLElement).getByText('Total XP'), + ).toBeInTheDocument(); + expect(screen.getByText('7')).toBeInTheDocument(); + expect(screen.getByText('999')).toBeInTheDocument(); + }); + + it('does not render Level/Total XP even if storage enables them when gamification is off', () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ level: true, totalXp: true }), + ); + renderWeighted({ gamificationEnabled: false }); + const thead = document.querySelector('thead')!; + expect( + within(thead as HTMLElement).queryByText('Level'), + ).not.toBeInTheDocument(); + expect( + within(thead as HTMLElement).queryByText('Total XP'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx new file mode 100644 index 00000000000..1510b5abd64 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx @@ -0,0 +1,53 @@ +import { render, screen, within } from 'test-utils'; + +import WeightedGradebookColumnTree from '../components/WeightedGradebookColumnTree'; + +const baseContext = { + isVisible: (): boolean => false, + setVisible: (): void => {}, + setManyVisible: (): void => {}, +}; + +describe('WeightedGradebookColumnTree', () => { + it('renders Student info group with a locked Name and a toggleable Email', () => { + render( + , + ); + expect(screen.getByText('Student info')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Always included')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + }); + + it('shows the Gamification group with Level and Total XP when gamification is enabled', () => { + render( + , + ); + expect(screen.getByText('Gamification')).toBeInTheDocument(); + expect(screen.getByText('Level')).toBeInTheDocument(); + expect(screen.getByText('Total XP')).toBeInTheDocument(); + }); + + it('hides the Gamification group when gamification is disabled', () => { + render( + , + ); + expect(screen.queryByText('Gamification')).not.toBeInTheDocument(); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + expect(screen.queryByText('Total XP')).not.toBeInTheDocument(); + }); + + it('calls setVisible when Email is toggled', async () => { + const setVisible = jest.fn(); + render( + , + ); + const emailRow = screen.getByText('Email').closest('label')!; + within(emailRow).getByRole('checkbox').click(); + expect(setVisible).toHaveBeenCalledWith('email', true); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedViewHint.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedViewHint.test.tsx new file mode 100644 index 00000000000..28158ff50c4 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/WeightedViewHint.test.tsx @@ -0,0 +1,50 @@ +import { store as appStore } from 'store'; +import { fireEvent, render, screen } from 'test-utils'; + +import WeightedViewHint, { + WEIGHTED_VIEW_HINT_KEY, +} from '../components/WeightedViewHint'; + +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:${WEIGHTED_VIEW_HINT_KEY}`; + +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +const renderHint = (): void => { + render(, { state: userState }); +}; + +beforeEach(() => localStorage.clear()); + +describe('WeightedViewHint', () => { + it('renders the capability hint with a link to gradebook settings', () => { + renderHint(); + // Copy names the capability (weighted total grade), not the mechanism. + expect(screen.getByText(/weighted total/i)).toBeInTheDocument(); + + const link = screen.getByRole('link', { name: /gradebook settings/i }); + expect(link).toHaveAttribute('href', '/courses/7/admin/gradebook'); + }); + + it('hides and persists dismissal when the close button is clicked', () => { + renderHint(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + + expect(screen.queryByText(/weighted total/i)).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); + + it('does not render when already dismissed', () => { + localStorage.setItem(STORAGE_KEY, 'true'); + renderHint(); + expect(screen.queryByText(/weighted total/i)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts new file mode 100644 index 00000000000..ab32d2bcad8 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts @@ -0,0 +1,361 @@ +// client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts +import { + computeStudentTotal, + computeTabSubtotal, + computeWeightedRows, + sumWeights, +} from '../computeWeighted'; + +const assessments = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A' }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B' }, + { id: 3, tabId: 20, maxGrade: 100, title: 'C' }, +]; + +const subs = ( + entries: { studentId: number; assessmentId: number; grade: number | null }[], +): { studentId: number; assessmentId: number; grade: number | null }[] => + entries; + +describe('computeTabSubtotal', () => { + it('returns null when tab has no assessments', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 999, title: 'X', categoryId: 0 }, + assessments, + submissions: [], + treatUngradedAsZero: false, + }), + ).toBeNull(); + }); + + it('returns null when student has no graded submissions and toggle off', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: [], + treatUngradedAsZero: false, + }), + ).toBeNull(); + }); + + it('sum-of-points across graded only when toggle off', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + // assessment 2 ungraded + ]), + treatUngradedAsZero: false, + }), + ).toBeCloseTo(0.8); + }); + + it('includes ungraded as zero when toggle on', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: subs([{ studentId: 1, assessmentId: 1, grade: 80 }]), + treatUngradedAsZero: true, + }), + ).toBeCloseTo(80 / 150); + }); +}); + +describe('computeStudentTotal', () => { + const tabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 40 }, + ]; + + it('returns additive sum of weight × subtotal', () => { + // tab10 subtotal = (80+50)/(100+50) = 130/150 ≈ 0.8667; tab20 subtotal = 90/100 = 0.9 + // total = 60*(130/150) + 40*0.9 = 52 + 36 = 88 + const total = computeStudentTotal({ + studentId: 1, + tabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + treatUngradedAsZero: false, + }); + expect(total).toBeCloseTo(60 * (130 / 150) + 40 * 0.9); + }); + + it('weight-0 tab contributes 0 to the sum', () => { + // tab20 weight=0 → 0 * 0.9 = 0; total = 100*(130/150) + const total = computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 100 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 0 }, + ], + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + treatUngradedAsZero: false, + }); + expect(total).toBeCloseTo(100 * (130 / 150)); + }); + + it('returns null when no tab has a non-null subtotal', () => { + expect( + computeStudentTotal({ + studentId: 1, + tabs: [{ id: 10, title: 'M', categoryId: 0, gradebookWeight: 0 }], + assessments, + submissions: [], + treatUngradedAsZero: false, + }), + ).toBeNull(); + }); + + it('is additive (not normalized) when weights do not sum to 100', () => { + // total = 60*(130/150) + 30*0.9 = 52 + 27 = 79 (NOT divided by 90) + const total = computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 30 }, + ], + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + treatUngradedAsZero: false, + }); + expect(total).toBeCloseTo(60 * (130 / 150) + 30 * 0.9); + }); + + it('bonus: weights summing past 100 yield a total > 100 for a perfect student', () => { + // perfect student: all grades = maxGrade → subtotal = 1.0 per tab + // total = 60*1 + 50*1 = 110 + const bonusTabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 50 }, + ]; + const total = computeStudentTotal({ + studentId: 1, + tabs: bonusTabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 100 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 100 }, + ]), + treatUngradedAsZero: false, + }); + expect(total).toBeCloseTo(110); + expect(total!).toBeGreaterThan(100); + }); + + it('ungraded tab (null subtotal) contributes 0, lowering the total', () => { + // student has grades for tab10 only; tab20 has no submissions → subtotal null → 0 contribution + // total = 60*(130/150) + 0 (tab20 not counted because null) + const total = computeStudentTotal({ + studentId: 1, + tabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + // no submission for assessment 3 (tab20) + ]), + treatUngradedAsZero: false, + }); + expect(total).toBeCloseTo(60 * (130 / 150)); + }); +}); + +describe('sumWeights', () => { + it('returns the sum of all tab weights', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 60 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 40 }, + ]; + expect(sumWeights(tabs)).toBe(100); + }); + + it('includes all tabs regardless of weight value', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 60 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 0 }, + ]; + expect(sumWeights(tabs)).toBe(60); + }); + + it('handles tabs with no gradebookWeight (treats as 0)', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 40 }, + { id: 2, title: 'T2', categoryId: 1 }, + ]; + expect(sumWeights(tabs)).toBe(40); + }); +}); + +describe('computeWeightedRows', () => { + const rowTabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 40 }, + ]; + const rowStudents = [ + { id: 1, name: 'Alice', email: 'alice@e.com', level: 1, totalXp: 0 }, + { id: 2, name: 'Bob', email: 'bob@e.com', level: 1, totalXp: 0 }, + ]; + const rowSubmissions = subs([ + // Alice: full data + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + // Bob: only tab10 graded + { studentId: 2, assessmentId: 1, grade: 100 }, + { studentId: 2, assessmentId: 2, grade: 50 }, + ]); + + it('returns one row per student carrying studentId, name and email', () => { + const rows = computeWeightedRows({ + students: rowStudents, + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: false, + }); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + studentId: 1, + name: 'Alice', + email: 'alice@e.com', + }); + expect(rows[1]).toMatchObject({ + studentId: 2, + name: 'Bob', + email: 'bob@e.com', + }); + }); + + it('produces subtotals and total identical to the per-student helpers', () => { + const rows = computeWeightedRows({ + students: rowStudents, + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: false, + }); + rowStudents.forEach((student, i) => { + rowTabs.forEach((tab, j) => { + expect(rows[i].subtotals[j]).toEqual( + computeTabSubtotal({ + studentId: student.id, + tab, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: false, + }), + ); + }); + expect(rows[i].total).toEqual( + computeStudentTotal({ + studentId: student.id, + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: false, + }), + ); + }); + }); + + it('computes the known additive total for a fully-graded student', () => { + // Alice tab10 = (80+50)/(100+50) = 130/150; tab20 = 90/100 = 0.9 + // total = 60*(130/150) + 40*0.9 + const rows = computeWeightedRows({ + students: [rowStudents[0]], + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: false, + }); + expect(rows[0].subtotals[0]).toBeCloseTo(130 / 150); + expect(rows[0].subtotals[1]).toBeCloseTo(0.9); + expect(rows[0].total).toBeCloseTo(60 * (130 / 150) + 40 * 0.9); + }); + + it('a tab with no graded submissions yields a null subtotal (toggle off)', () => { + // Bob has no tab20 submissions -> subtotal null; total counts tab10 only + const rows = computeWeightedRows({ + students: [rowStudents[1]], + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: false, + }); + expect(rows[0].subtotals[0]).toBeCloseTo(150 / 150); // (100+50)/(100+50)=1 + expect(rows[0].subtotals[1]).toBeNull(); + expect(rows[0].total).toBeCloseTo(60 * 1); + }); + + it('treatUngradedAsZero counts ungraded assessments in the denominator', () => { + // Bob tab20 has assessment 3 ungraded -> with toggle on subtotal = 0/100 = 0 (not null) + const rows = computeWeightedRows({ + students: [rowStudents[1]], + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: true, + }); + expect(rows[0].subtotals[1]).toBe(0); + // tab10 with toggle on stays (100+50)/(100+50)=1 + expect(rows[0].subtotals[0]).toBeCloseTo(1); + expect(rows[0].total).toBeCloseTo(60 * 1 + 40 * 0); + }); + + it('returns an empty array when there are no students', () => { + expect( + computeWeightedRows({ + students: [], + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + treatUngradedAsZero: false, + }), + ).toEqual([]); + }); +}); + +describe('computeWeightedRows — identity passthrough', () => { + it('carries level and totalXp from each student onto the row', () => { + const students = [ + { id: 1, name: 'Alice', email: 'a@x.com', level: 5, totalXp: 1234 }, + ]; + const tabs = [{ id: 10, title: 'Tab 1', categoryId: 1, gradebookWeight: 100 }]; + const assessments = [{ id: 100, title: 'Q1', tabId: 10, maxGrade: 10 }]; + const submissions = [{ studentId: 1, assessmentId: 100, grade: 8 }]; + + const rows = computeWeightedRows({ + students, + tabs, + assessments, + submissions, + treatUngradedAsZero: false, + }); + + expect(rows[0].level).toBe(5); + expect(rows[0].totalXp).toBe(1234); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/store.test.ts b/client/app/bundles/course/gradebook/__tests__/store.test.ts new file mode 100644 index 00000000000..ab9e8122624 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/store.test.ts @@ -0,0 +1,49 @@ +import reducer, { actions } from '../store'; + +const baseState = { + categories: [], + tabs: [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 50 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 50 }, + ], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, +}; + +describe('UPDATE_TAB_WEIGHTS reducer', () => { + it('updates gradebookWeight for the matching tab', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ weights: [{ tabId: 1, weight: 80 }] }), + ); + expect(next.tabs.find((t) => t.id === 1)?.gradebookWeight).toBe(80); + expect(next.tabs.find((t) => t.id === 2)?.gradebookWeight).toBe(50); + }); + + it('does not set any excluded field', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ weights: [{ tabId: 1, weight: 0 }] }), + ); + const tab = next.tabs.find((t) => t.id === 1)!; + expect(tab).not.toHaveProperty('gradebookExcluded'); + }); + + it('updates multiple tabs in one action', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [ + { tabId: 1, weight: 30 }, + { tabId: 2, weight: 70 }, + ], + }), + ); + expect(next.tabs.find((t) => t.id === 1)?.gradebookWeight).toBe(30); + expect(next.tabs.find((t) => t.id === 2)?.gradebookWeight).toBe(70); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx new file mode 100644 index 00000000000..c6874d9cc03 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx @@ -0,0 +1,261 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { + Alert, + Collapse, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import type { AssessmentData, CategoryData, TabData } from 'types/course/gradebook'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { updateGradebookWeights } from '../operations'; + +const translations = defineMessages({ + dialogTitle: { + id: 'course.gradebook.ConfigureWeightsDialog.dialogTitle', + defaultMessage: 'Configure tab weights', + }, + description: { + id: 'course.gradebook.ConfigureWeightsDialog.description', + defaultMessage: + 'Set how much each tab contributes to the total grade. Weights should sum to 100.', + }, + total: { + id: 'course.gradebook.ConfigureWeightsDialog.total', + defaultMessage: 'Total: {sum}%', + }, + weightsDoNotSum: { + id: 'course.gradebook.ConfigureWeightsDialog.weightsDoNotSum', + defaultMessage: + 'Weights do not sum to 100. Saving is allowed; Total may be inaccurate.', + }, + save: { + id: 'course.gradebook.ConfigureWeightsDialog.save', + defaultMessage: 'Save', + }, + valueTooLow: { + id: 'course.gradebook.ConfigureWeightsDialog.valueTooLow', + defaultMessage: 'Value must be at least 0', + }, + valueTooHigh: { + id: 'course.gradebook.ConfigureWeightsDialog.valueTooHigh', + defaultMessage: 'Value must be at most 100', + }, + valueNotInteger: { + id: 'course.gradebook.ConfigureWeightsDialog.valueNotInteger', + defaultMessage: 'Value must be a whole number', + }, + saveError: { + id: 'course.gradebook.ConfigureWeightsDialog.saveError', + defaultMessage: 'Failed to save weights. Please try again.', + }, + ofGrade: { + id: 'course.gradebook.ConfigureWeightsDialog.ofGrade', + defaultMessage: '{pct}% of grade', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; +} + +const ConfigureWeightsPrompt: FC = ({ + open, + onClose, + categories, + tabs, + assessments, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const validate = (value: number): string | null => { + if (Number.isNaN(value)) return t(translations.valueTooLow); + if (!Number.isInteger(value)) return t(translations.valueNotInteger); + if (value < 0) return t(translations.valueTooLow); + if (value > 100) return t(translations.valueTooHigh); + return null; + }; + + const [weights, setWeights] = useState>(() => + Object.fromEntries(tabs.map((tb) => [tb.id, tb.gradebookWeight ?? 0])), + ); + const [expanded, setExpanded] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setWeights( + Object.fromEntries(tabs.map((tb) => [tb.id, tb.gradebookWeight ?? 0])), + ); + setExpanded({}); + } + }, [open]); + + const sum = Object.values(weights).reduce((acc, w) => acc + w, 0); + const hasInvalid = Object.values(weights).some((w) => validate(w) !== null); + + const handleChange = (tabId: number, raw: string): void => { + const parsed = raw === '' ? 0 : Number(raw); + setWeights((prev) => ({ ...prev, [tabId]: parsed })); + }; + + const toggleExpanded = (tabId: number): void => + setExpanded((prev) => ({ ...prev, [tabId]: !prev[tabId] })); + + const handleSave = async (): Promise => { + if (hasInvalid) return; + setSubmitting(true); + try { + await dispatch( + updateGradebookWeights( + tabs.map((tb) => ({ + tabId: tb.id, + weight: weights[tb.id] ?? 0, + })), + ), + ); + onClose(); + } catch { + toast.error(t(translations.saveError)); + } finally { + setSubmitting(false); + } + }; + + return ( + + + {t(translations.description)} + + + {categories.map((cat) => ( +
+ {cat.title} + + {tabs + .filter((tb) => tb.categoryId === cat.id) + .map((tb) => { + const value = weights[tb.id] ?? 0; + const err = validate(value); + const tabAssessments = assessments.filter( + (a) => a.tabId === tb.id, + ); + const tabMaxGrade = tabAssessments.reduce( + (s, a) => s + a.maxGrade, + 0, + ); + const isExpanded = !!expanded[tb.id]; + + return ( +
+
+ toggleExpanded(tb.id)} + size="small" + > + {isExpanded ? ( + + ) : ( + + )} + + + {tb.title} + + handleChange(tb.id, e.target.value)} + size="small" + sx={{ width: 96 }} + type="number" + value={value} + /> +
+ {err && ( + + {err} + + )} + + + {tabAssessments.map((a) => { + const pct = + tabMaxGrade > 0 + ? (a.maxGrade / tabMaxGrade) * value + : 0; + return ( +
+ + {a.title} + + + {t(translations.ofGrade, { + pct: pct.toFixed(1), + })} + +
+ ); + })} +
+
+
+ ); + })} +
+
+ ))} +
+ + {t(translations.total, { sum })} + + {sum !== 100 && ( + + {t(translations.weightsDoNotSum)} + + )} +
+ ); +}; + +export default ConfigureWeightsPrompt; 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/GradebookWeightedTable.tsx b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx new file mode 100644 index 00000000000..228ad6a7c1f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx @@ -0,0 +1,646 @@ +import { useMemo, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Download } from '@mui/icons-material'; +import { + Alert, + Button, + Checkbox, + FormControlLabel, + Paper, + Switch, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; + +import MuiColumnPickerPrompt from 'lib/components/table/MuiTableAdapter/MuiColumnPickerPrompt'; +import MuiTablePagination from 'lib/components/table/MuiTableAdapter/MuiTablePagination'; +import useTanStackTableBuilder from 'lib/components/table/TanStackTableBuilder'; +import SearchField from 'lib/components/core/fields/SearchField'; +import type { ColumnPickerRenderContext } from 'lib/components/table'; +import type { ColumnTemplate } from 'lib/components/table/builder'; +import { + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + DEFAULT_TABLE_ROWS_PER_PAGE, +} from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { computeWeightedRows, sumWeights } from '../computeWeighted'; +import type { WeightedRow } from '../computeWeighted'; + +import ConfigureWeightsPrompt from './ConfigureWeightsPrompt'; +import WeightedGradebookColumnTree from './WeightedGradebookColumnTree'; + +const translations = defineMessages({ + treatUngradedAsZero: { + id: 'course.gradebook.GradebookWeightedTable.treatUngradedAsZero', + defaultMessage: 'Treat Ungraded as 0', + }, + treatUngradedAsZeroTooltip: { + id: 'course.gradebook.GradebookWeightedTable.treatUngradedAsZeroTooltip', + defaultMessage: + 'Counts unsubmitted and ungraded assessments as 0 in the calculation. Use at end of course when all work should be complete.', + }, + configureWeights: { + id: 'course.gradebook.GradebookWeightedTable.configureWeights', + defaultMessage: 'Configure Weights', + }, + noWeightsConfigured: { + id: 'course.gradebook.GradebookWeightedTable.noWeightsConfigured', + defaultMessage: + 'No weights configured — all tab weights are 0. Click "Configure Weights" to assign weights.', + }, + noWeightsNoAccess: { + id: 'course.gradebook.GradebookWeightedTable.noWeightsNoAccess', + defaultMessage: 'No tab weights have been configured yet.', + }, + student: { + id: 'course.gradebook.GradebookWeightedTable.student', + defaultMessage: 'Student', + }, + email: { + id: 'course.gradebook.GradebookWeightedTable.email', + defaultMessage: 'Email', + }, + total: { + id: 'course.gradebook.GradebookWeightedTable.total', + defaultMessage: 'Total', + }, + percentOfGrade: { + id: 'course.gradebook.GradebookWeightedTable.percentOfGrade', + defaultMessage: '{weight}% of grade', + }, + percentTotalExact: { + id: 'course.gradebook.GradebookWeightedTable.percentTotalExact', + defaultMessage: '100% total', + }, + percentTotalWarning: { + id: 'course.gradebook.GradebookWeightedTable.percentTotalWarning', + defaultMessage: '{weight}% total', + }, + doesNotSumTo100: { + id: 'course.gradebook.GradebookWeightedTable.doesNotSumTo100', + defaultMessage: 'does not sum to 100', + }, + weightsDoNotSum: { + id: 'course.gradebook.GradebookWeightedTable.weightsDoNotSum', + defaultMessage: 'Weights do not sum to 100. Total may be inaccurate.', + }, + searchStudents: { + id: 'course.gradebook.GradebookWeightedTable.searchStudents', + defaultMessage: 'Search by name or email', + }, + downloadCsv: { + id: 'course.gradebook.GradebookWeightedTable.downloadCsv', + defaultMessage: 'Download as CSV', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + 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.', + }, + level: { + id: 'course.gradebook.GradebookColumnTree.level', + defaultMessage: 'Level', + }, + totalXp: { + id: 'course.gradebook.GradebookColumnTree.totalXp', + defaultMessage: 'Total XP', + }, +}); + +interface Props { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + canManageWeights: boolean; + courseTitle: string; + courseId: number; + gamificationEnabled: boolean; +} + +const fmt = (v: number | null): string => { + if (v === null) return '—'; + const rounded = Math.round(v); + return Math.abs(v - rounded) < 1e-9 ? String(rounded) : v.toFixed(2); +}; + +const fmtCsv = (v: number | null): string => { + if (v === null) return ''; + return v.toFixed(2); +}; + +const CHECKBOX_WIDTH = 56; + +const GradebookWeightedTable = ({ + categories, + tabs, + assessments, + students, + submissions, + canManageWeights, + courseTitle, + courseId, + gamificationEnabled, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [treatUngradedAsZero, setTreatUngradedAsZero] = useState(false); + const [configureOpen, setConfigureOpen] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + + const totalWeight = sumWeights(tabs); + const allWeightsZero = totalWeight === 0; + + const categoryTabCounts = useMemo(() => { + const counts = new Map(); + tabs.forEach((tab) => { + counts.set(tab.categoryId, (counts.get(tab.categoryId) ?? 0) + 1); + }); + return counts; + }, [tabs]); + + const visibleCategories = useMemo( + () => categories.filter((cat) => categoryTabCounts.has(cat.id)), + [categories, categoryTabCounts], + ); + + const rows = useMemo( + () => + computeWeightedRows({ + students, + tabs, + assessments, + submissions, + treatUngradedAsZero, + }), + [students, tabs, assessments, submissions, treatUngradedAsZero], + ); + + const columns = useMemo[]>(() => { + const cols: ColumnTemplate[] = [ + { + id: 'name', + title: t(translations.student), + of: 'name', + cell: (row) => row.name, + csvDownloadable: true, + searchable: true, + }, + { + id: 'email', + title: t(translations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + defaultVisible: false, + }, + ]; + + if (gamificationEnabled) { + cols.push({ + id: 'level', + title: t(translations.level), + of: 'level', + cell: (row) => row.level, + csvDownloadable: true, + defaultVisible: false, + }); + cols.push({ + id: 'totalXp', + title: t(translations.totalXp), + of: 'totalXp', + cell: (row) => row.totalXp, + csvDownloadable: true, + defaultVisible: false, + }); + } + + tabs.forEach((tab, idx) => { + const weight = tab.gradebookWeight ?? 0; + cols.push({ + id: `tab-${tab.id}`, + title: tab.title, + accessorFn: (row) => { + const sub = row.subtotals[idx]; + return fmtCsv(sub !== null ? sub * weight : null); + }, + cell: (row) => { + const sub = row.subtotals[idx]; + return fmt(sub !== null ? sub * weight : null); + }, + csvDownloadable: true, + }); + }); + + cols.push({ + id: 'total', + title: t(translations.total), + accessorFn: (row) => fmtCsv(row.total), + cell: (row) => fmt(row.total), + csvDownloadable: true, + }); + + return cols; + }, [tabs, t, gamificationEnabled]); + + const columnPicker = useMemo( + () => ({ + render: (context: ColumnPickerRenderContext) => ( + + ), + locked: ['name'], + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + storageKey: `gradebook_weighted_columns_${courseId}`, + }), + [gamificationEnabled, courseId, t], + ); + + const { toolbar: toolbarProps, 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}_weighted_gradebook`, + showDownloadButton: false, + }, + columnPicker, + }); + + const toolbar = toolbarProps!; + + const selectedCount = body.selectedCount ?? 0; + const directExportLabel = useMemo((): string => { + const isPartial = selectedCount > 0 && selectedCount < rows.length; + if (isPartial) return t(translations.exportRows, { count: selectedCount }); + return t(translations.exportButton); + }, [selectedCount, rows.length, t]); + + const visibility = toolbar.getColumnVisibility?.() ?? {}; + const showEmail = (visibility.email ?? false) === true; + const showLevel = gamificationEnabled && (visibility.level ?? false) === true; + const showTotalXp = + gamificationEnabled && (visibility.totalXp ?? false) === true; + + const allRowsSelected = body.allFilteredSelected ?? false; + const someRowsSelected = body.someFilteredSelected ?? false; + const toggleAllRows = (): void => body.toggleAllFiltered?.(); + + return ( +
+ {/* Table + toolbar share a fit-content container so toolbar never outruns the table */} +
+ + {/* Single-row toolbar */} +
+ +
+ + setTreatUngradedAsZero(e.target.checked)} + size="small" + /> + } + label={t(translations.treatUngradedAsZero)} + sx={{ ml: 0 }} + /> + + {canManageWeights && ( + + )} + + {toolbar.onDirectExport && ( + + + + + + )} +
+
+ + {/* Empty state banner when all weights are 0 */} + {allWeightsZero && ( + + {canManageWeights + ? t(translations.noWeightsConfigured) + : t(translations.noWeightsNoAccess)} + + )} + + { + // One definition for every grid line so horizontal and vertical + // separators share the same width and colour. + const gridLine = `1px solid ${theme.palette.divider}`; + return { + tableLayout: 'auto', + borderCollapse: 'separate', + borderSpacing: 0, + '& th, & td': { + boxSizing: 'border-box', + border: 0, + borderBottom: gridLine, + }, + '& thead th': { + borderLeft: gridLine, + }, + // MUI's default `.MuiTableRow-root:last-child th { border: 0 }` + // (specificity 0,2,1, the `:last-child` pseudo-class) outranks + // `& thead th`/`& th` (0,1,2 / 0,1,1) and silently zeroes ALL + // borders on the weight row — it is the last in . + // Re-assert both the column separators (borderLeft) and the + // header's bottom edge (borderBottom) with a higher-specificity + // selector (0,2,3) so they survive through the "% of grade" row. + '& thead tr:last-of-type th': { + borderLeft: gridLine, + borderBottom: gridLine, + }, + // Remove the outer-left line: only the checkbox (row 1, col 1). + '& thead tr:first-of-type th:first-of-type': { + borderLeft: 0, + }, + }; + }} + > + + {/* Row 1: Checkbox + Student + Categories + Total */} + + + + + + {t(translations.student)} + + {showEmail && ( + + {t(translations.email)} + + )} + {showLevel && ( + + {t(translations.level)} + + )} + {showTotalXp && ( + + {t(translations.totalXp)} + + )} + {visibleCategories.map((cat) => ( + + {cat.title} + + ))} + + {t(translations.total)} + + + + {/* Row 2: Tab titles */} + + {tabs.map((tab) => ( + + {tab.title} + + ))} + + + {/* Row 3: Weight subheaders */} + + {tabs.map((tab) => ( + + {t(translations.percentOfGrade, { weight: tab.gradebookWeight ?? 0 })} + + ))} + + {totalWeight === 100 ? ( + t(translations.percentTotalExact) + ) : ( + + + + {t(translations.percentTotalWarning, { + weight: totalWeight, + })} + + + {t(translations.doesNotSumTo100)} + + + + )} + + + + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + return ( + + + + + {row.original.name} + {showEmail && {row.original.email}} + {showLevel && ( + {row.original.level} + )} + {showTotalXp && ( + + {row.original.totalXp} + + )} + {row.original.subtotals.map((subtotal, i) => { + const weight = tabs[i].gradebookWeight ?? 0; + return ( + + {fmt(subtotal !== null ? subtotal * weight : null)} + + ); + })} + + {fmt(row.original.total)} + + + ); + })} + +
+
+ {pagination && } +
+
+ + {canManageWeights && ( + setConfigureOpen(false)} + open={configureOpen} + tabs={tabs} + /> + )} + + {toolbar.columnPicker && toolbar.commitColumnVisibility && ( + setPickerOpen(false)} + open={pickerOpen} + /> + )} +
+ ); +}; + +export default GradebookWeightedTable; diff --git a/client/app/bundles/course/gradebook/components/WeightedGradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/WeightedGradebookColumnTree.tsx new file mode 100644 index 00000000000..68c9d6731fa --- /dev/null +++ b/client/app/bundles/course/gradebook/components/WeightedGradebookColumnTree.tsx @@ -0,0 +1,130 @@ +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'; + +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', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +interface WeightedGradebookColumnTreeProps extends ColumnPickerRenderContext { + gamificationEnabled: boolean; +} + +const STUDENT_ALL_IDS = [...STUDENT_INFO_COL_IDS]; +const GAMIFICATION_ALL_IDS = [...GAMIFICATION_COL_IDS]; + +const WeightedGradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, + gamificationEnabled, +}: WeightedGradebookColumnTreeProps): JSX.Element => { + const { t } = useTranslation(); + const context: ColumnPickerRenderContext = { + isVisible, + setVisible, + setManyVisible, + }; + + 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)} + /> + ))} + + )} +
+ ); +}; + +export default WeightedGradebookColumnTree; diff --git a/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx b/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx new file mode 100644 index 00000000000..aaa54991392 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import Link from 'lib/components/core/Link'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const WEIGHTED_VIEW_HINT_KEY = 'gradebook_weighted_view_hint'; + +const translations = defineMessages({ + hint: { + id: 'course.gradebook.WeightedViewHint.hint', + defaultMessage: + 'Want a weighted total grade? You can set how much each tab counts toward each student’s overall grade and view the weighted total here. Turn it on in {link}.', + }, + settingsLink: { + id: 'course.gradebook.WeightedViewHint.settingsLink', + defaultMessage: 'Gradebook settings', + }, +}); + +interface Props { + courseId: number; +} + +/** + * One-time, dismissable nudge shown to managers in the (always-visible) gradebook view + * when the weighted view is turned off. It advertises the capability and links to the + * setting that enables it, since that setting is otherwise buried in course admin. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const WeightedViewHint: FC = ({ courseId }) => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + WEIGHTED_VIEW_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
+ + + {t(translations.hint, { + link: ( + + {t(translations.settingsLink)} + + ), + })} + + +
+ ); +}; + +export default WeightedViewHint; 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/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts new file mode 100644 index 00000000000..8df0869b0df --- /dev/null +++ b/client/app/bundles/course/gradebook/computeWeighted.ts @@ -0,0 +1,174 @@ +// client/app/bundles/course/gradebook/computeWeighted.ts +import { + AssessmentData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; + +export interface WeightedRow { + studentId: number; + name: string; + email: string; + level: number; + totalXp: number; + subtotals: (number | null)[]; + total: number | null; +} + +type GradeLookup = Map; + +const gradeKey = (studentId: number, assessmentId: number): string => + `${studentId}:${assessmentId}`; + +// Index submissions by (student, assessment) once: O(submissions). +const buildGradeLookup = (submissions: SubmissionData[]): GradeLookup => { + const lookup: GradeLookup = new Map(); + submissions.forEach((s) => { + if (s.grade != null) lookup.set(gradeKey(s.studentId, s.assessmentId), s.grade); + }); + return lookup; +}; + +// Group assessments by tab once: O(assessments). +const buildAssessmentsByTab = ( + assessments: AssessmentData[], +): Map => { + const byTab = new Map(); + assessments.forEach((a) => { + const list = byTab.get(a.tabId); + if (list) list.push(a); + else byTab.set(a.tabId, [a]); + }); + return byTab; +}; + +// Single source of truth for the subtotal math, operating on prebuilt indexes. +const subtotalFromLookup = ( + studentId: number, + tabAssessments: AssessmentData[] | undefined, + gradeLookup: GradeLookup, + treatUngradedAsZero: boolean, +): number | null => { + if (!tabAssessments || tabAssessments.length === 0) return null; + let numerator = 0; + let denominator = 0; + tabAssessments.forEach((a) => { + const grade = gradeLookup.get(gradeKey(studentId, a.id)); + if (grade != null) { + numerator += grade; + denominator += a.maxGrade; + } else if (treatUngradedAsZero) { + denominator += a.maxGrade; + } + }); + return denominator > 0 ? numerator / denominator : null; +}; + +// Weighted, additive total from already-computed subtotals. +const totalFromSubtotals = ( + subtotals: (number | null)[], + tabs: TabData[], +): number | null => { + let contributingCount = 0; + let total = 0; + subtotals.forEach((sub, i) => { + if (sub == null) return; + contributingCount += 1; + total += (tabs[i].gradebookWeight ?? 0) * sub; + }); + return contributingCount > 0 ? total : null; +}; + +interface SubtotalArgs { + studentId: number; + tab: TabData; + assessments: AssessmentData[]; + submissions: SubmissionData[]; + treatUngradedAsZero: boolean; +} + +export const computeTabSubtotal = ({ + studentId, + tab, + assessments, + submissions, + treatUngradedAsZero, +}: SubtotalArgs): number | null => + subtotalFromLookup( + studentId, + assessments.filter((a) => a.tabId === tab.id), + buildGradeLookup(submissions), + treatUngradedAsZero, + ); + +interface TotalArgs { + studentId: number; + tabs: TabData[]; + assessments: AssessmentData[]; + submissions: SubmissionData[]; + treatUngradedAsZero: boolean; +} + +export const computeStudentTotal = ({ + studentId, + tabs, + assessments, + submissions, + treatUngradedAsZero, +}: TotalArgs): number | null => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + const subtotals = tabs.map((tab) => + subtotalFromLookup( + studentId, + assessmentsByTab.get(tab.id), + gradeLookup, + treatUngradedAsZero, + ), + ); + return totalFromSubtotals(subtotals, tabs); +}; + +interface WeightedRowsArgs { + students: StudentData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + submissions: SubmissionData[]; + treatUngradedAsZero: boolean; +} + +// Batch entry point used by the table: builds the indexes ONCE and reuses them +// across every student, computing each subtotal a single time. +export const computeWeightedRows = ({ + students, + tabs, + assessments, + submissions, + treatUngradedAsZero, +}: WeightedRowsArgs): WeightedRow[] => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + return students.map((student) => { + const subtotals = tabs.map((tab) => + subtotalFromLookup( + student.id, + assessmentsByTab.get(tab.id), + gradeLookup, + treatUngradedAsZero, + ), + ); + return { + studentId: student.id, + name: student.name, + email: student.email, + level: student.level, + totalXp: student.totalXp, + subtotals, + total: totalFromSubtotals(subtotals, tabs), + }; + }); +}; + +export const sumWeights = (tabs: TabData[]): number => + tabs.reduce((acc, t) => acc + (t.gradebookWeight ?? 0), 0); 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..ae2f962fbbf --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,20 @@ +import type { Operation } from 'store'; +import type { UpdateWeightsPayload } from 'types/course/gradebook'; + +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 const updateGradebookWeights = + (weights: UpdateWeightsPayload['weights']): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.updateWeights({ weights }); + dispatch(actions.updateTabWeights(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..3227d0e9678 --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,169 @@ +import { FC, useEffect, useState, useTransition } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { PeopleAlt } from '@mui/icons-material'; +import { Tab, Tabs, 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 GradebookWeightedTable from '../../components/GradebookWeightedTable'; +import WeightedViewHint from '../../components/WeightedViewHint'; +import fetchGradebook from '../../operations'; +import { + getAssessments, + getCanManageWeights, + getCategories, + getGamificationEnabled, + getStudents, + getSubmissions, + getTabs, + getWeightedViewEnabled, +} 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.', + }, + allAssessments: { + id: 'course.gradebook.GradebookIndex.allAssessments', + defaultMessage: 'All assessments', + }, + byWeight: { + id: 'course.gradebook.GradebookIndex.byWeight', + defaultMessage: 'By weight', + }, +}); + +const GradebookIndex: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseTitle } = useCourseContext(); + const { courseId: courseIdParam } = useParams(); + const courseId = parseInt(courseIdParam!, 10); + const [searchParams, setSearchParams] = useSearchParams(); + const [isLoading, setIsLoading] = useState(true); + const [viewMode, setViewMode] = useState<'all' | 'weighted'>( + searchParams.get('view') === 'weighted' ? 'weighted' : 'all', + ); + const [isPending, startTransition] = useTransition(); + + const assessments = useAppSelector(getAssessments); + const categories = useAppSelector(getCategories); + const tabs = useAppSelector(getTabs); + const students = useAppSelector(getStudents); + const submissions = useAppSelector(getSubmissions); + const gamificationEnabled = useAppSelector(getGamificationEnabled); + const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); + const canManageWeights = useAppSelector(getCanManageWeights); + + 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 if (weightedViewEnabled && viewMode === 'weighted') { + content = ( + + ); + } else { + content = ( + + ); + } + + return ( + + {!isLoading && canManageWeights && !weightedViewEnabled && ( + + )} + {weightedViewEnabled && !isLoading && students.length > 0 && ( + + startTransition(() => { + setViewMode(v); + setSearchParams(v === 'weighted' ? { view: 'weighted' } : {}); + }) + } + TabIndicatorProps={{ style: { height: 2 } }} + value={viewMode} + > + + + + )} +
+ {isPending && ( +
+ +
+ )} +
+ {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..0fb7d1398d5 --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,28 @@ +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 getWeightedViewEnabled = (state: AppState): boolean => + getLocalState(state).weightedViewEnabled; +export const getCanManageWeights = (state: AppState): boolean => + getLocalState(state).canManageWeights; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..760e365aba3 --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,96 @@ +import { produce } from 'immer'; +import type { + GradebookData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from './types'; + +const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; +const UPDATE_TAB_WEIGHTS = 'course/gradebook/UPDATE_TAB_WEIGHTS'; + +interface GradebookState { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +interface UpdateTabWeightsAction { + type: typeof UPDATE_TAB_WEIGHTS; + payload: UpdateWeightsPayload; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, +}; + +const reducer = produce( + ( + draft: GradebookState, + action: SaveGradebookAction | UpdateTabWeightsAction, + ) => { + 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.weightedViewEnabled = action.payload.weightedViewEnabled; + draft.canManageWeights = action.payload.canManageWeights; + break; + } + case UPDATE_TAB_WEIGHTS: { + action.payload.weights.forEach(({ tabId, weight }) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (tab) { + tab.gradebookWeight = weight; + } + }); + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: (data: GradebookData): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + payload: data, + }), + updateTabWeights: ( + payload: UpdateWeightsPayload, + ): UpdateTabWeightsAction => ({ + type: UPDATE_TAB_WEIGHTS, + payload, + }), +}; + +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..b91689df872 --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,9 @@ +export type { + AssessmentData, + CategoryData, + GradebookData, + StudentData, + SubmissionData, + TabData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx index 5f22c48ce17..038540e0c4b 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/students/StudentStatisticsTable.tsx @@ -61,7 +61,7 @@ const translations = defineMessages({ }, searchBar: { id: 'course.statistics.StatisticsIndex.students.searchBar', - defaultMessage: 'Search by Student Name or Student Type', + defaultMessage: 'Search by Student Name, Student Type or External ID', }, }); diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index b92b165a744..c52ce359071 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -75,6 +75,10 @@ const translations = defineMessages({ id: 'course.componentTitles.course_forums_component', defaultMessage: 'Forums', }, + course_gradebook_component: { + id: 'course.componentTitles.course_gradebook_component', + defaultMessage: 'Gradebook', + }, course_groups_component: { id: 'course.componentTitles.course_groups_component', defaultMessage: 'Groups', diff --git a/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx b/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx index c24b976f1f8..89c4c7694a0 100644 --- a/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/UserInvitationsTable.tsx @@ -170,8 +170,8 @@ const UserInvitationsTable: FC = (props) => { of: 'externalId', title: t(tableTranslations.externalId), sortable: false, - searchable: false, - cell: (datum) => datum.externalId ?? '', + searchable: true, + cell: (datum) => datum.externalId ?? null, } satisfies ColumnTemplate, ] : []), @@ -280,6 +280,9 @@ const UserInvitationsTable: FC = (props) => { data={processedInvitations} getRowId={(datum) => datum.id.toString()} indexing={{ indices: true }} + search={{ + searchPlaceholder: t(translations.searchText), + }} sort={{ initially: { by: 'status', order: 'asc' }, }} diff --git a/client/app/bundles/course/user-invitations/components/tables/__test__/UserInvitationsTable.test.tsx b/client/app/bundles/course/user-invitations/components/tables/__test__/UserInvitationsTable.test.tsx new file mode 100644 index 00000000000..62bde5be532 --- /dev/null +++ b/client/app/bundles/course/user-invitations/components/tables/__test__/UserInvitationsTable.test.tsx @@ -0,0 +1,115 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitForElementToBeRemoved } from 'test-utils'; +import { InvitationMiniEntity } from 'types/course/userInvitations'; + +import UserInvitationsTable from '../UserInvitationsTable'; + +const baseInvitation: InvitationMiniEntity = { + id: 1, + name: 'Alice Lim', + email: 'alice@example.com', + externalId: null, + role: 'student', + phantom: false, + confirmed: false, + isRetryable: true, + invitationKey: 'KEY001', + sentAt: '2024-01-01T00:00:00Z', + confirmedAt: null, +}; + +const SEARCH_PLACEHOLDER = 'Search by name, email or external ID'; + +describe('', () => { + describe('search', () => { + it('filters invitations by external ID', async () => { + const user = userEvent.setup(); + const invitations: InvitationMiniEntity[] = [ + { + ...baseInvitation, + id: 1, + name: 'Alice Lim', + externalId: 'EXT-ALICE', + }, + { + ...baseInvitation, + id: 2, + name: 'Bob Tan', + email: 'bob@example.com', + externalId: 'EXT-BOB', + }, + ]; + + render(); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + await user.type( + screen.getByPlaceholderText(SEARCH_PLACEHOLDER), + 'EXT-ALICE', + ); + + expect(screen.getByText('Alice Lim')).toBeInTheDocument(); + expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument(); + }); + + it('searches external ID case-insensitively', async () => { + const user = userEvent.setup(); + const invitations: InvitationMiniEntity[] = [ + { + ...baseInvitation, + id: 1, + name: 'Alice Lim', + externalId: 'ext-alice', + }, + { + ...baseInvitation, + id: 2, + name: 'Bob Tan', + email: 'bob@example.com', + externalId: 'EXT-BOB', + }, + ]; + + render(); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + await user.type( + screen.getByPlaceholderText(SEARCH_PLACEHOLDER), + 'EXT-ALICE', + ); + + expect(screen.getByText('Alice Lim')).toBeInTheDocument(); + expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument(); + }); + + it('shows all invitations when search is cleared', async () => { + const user = userEvent.setup(); + const invitations: InvitationMiniEntity[] = [ + { + ...baseInvitation, + id: 1, + name: 'Alice Lim', + externalId: 'EXT-ALICE', + }, + { + ...baseInvitation, + id: 2, + name: 'Bob Tan', + email: 'bob@example.com', + externalId: 'EXT-BOB', + }, + ]; + + render(); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchInput, 'EXT-ALICE'); + expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument(); + + await user.clear(searchInput); + expect(screen.getByText('Alice Lim')).toBeInTheDocument(); + expect(screen.getByText('Bob Tan')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/user-invitations/translations.ts b/client/app/bundles/course/user-invitations/translations.ts index c3c249f6b4c..0569076b635 100644 --- a/client/app/bundles/course/user-invitations/translations.ts +++ b/client/app/bundles/course/user-invitations/translations.ts @@ -22,6 +22,10 @@ const translations = defineMessages({ id: 'course.userInvitations.UserInvitationsTable.noInvitations', defaultMessage: 'There are no invitations.', }, + searchText: { + id: 'course.userInvitations.UserInvitationsTable.searchText', + defaultMessage: 'Search by name, email or external ID', + }, pending: { id: 'course.userInvitations.UserInvitationsTable.pending', defaultMessage: 'Pending', diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx new file mode 100644 index 00000000000..fdbaedfc26c --- /dev/null +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx @@ -0,0 +1,99 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitForElementToBeRemoved } from 'test-utils'; +import { CourseUserMiniEntity } from 'types/course/courseUsers'; + +import ManageUsersTable from '../index'; + +const baseUser: CourseUserMiniEntity = { + id: 1, + name: 'Alice Lim', + email: 'alice@example.com', + role: 'student', +}; + +const SEARCH_PLACEHOLDER = 'Search by name, email or external ID'; + +describe('', () => { + describe('search', () => { + it('filters users by external ID', async () => { + const user = userEvent.setup(); + const users: CourseUserMiniEntity[] = [ + { ...baseUser, id: 1, name: 'Alice Lim', externalId: 'EXT-ALICE' }, + { + ...baseUser, + id: 2, + name: 'Bob Tan', + email: 'bob@example.com', + externalId: 'EXT-BOB', + }, + ]; + + render( + , + ); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + await user.type( + screen.getByPlaceholderText(SEARCH_PLACEHOLDER), + 'EXT-ALICE', + ); + + expect(screen.getByText('Alice Lim')).toBeInTheDocument(); + expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument(); + }); + + it('searches external ID case-insensitively', async () => { + const user = userEvent.setup(); + const users: CourseUserMiniEntity[] = [ + { ...baseUser, id: 1, name: 'Alice Lim', externalId: 'ext-alice' }, + { + ...baseUser, + id: 2, + name: 'Bob Tan', + email: 'bob@example.com', + externalId: 'EXT-BOB', + }, + ]; + + render( + , + ); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + await user.type( + screen.getByPlaceholderText(SEARCH_PLACEHOLDER), + 'EXT-ALICE', + ); + + expect(screen.getByText('Alice Lim')).toBeInTheDocument(); + expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument(); + }); + + it('shows all users when search is cleared', async () => { + const user = userEvent.setup(); + const users: CourseUserMiniEntity[] = [ + { ...baseUser, id: 1, name: 'Alice Lim', externalId: 'EXT-ALICE' }, + { + ...baseUser, + id: 2, + name: 'Bob Tan', + email: 'bob@example.com', + externalId: 'EXT-BOB', + }, + ]; + + render( + , + ); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER); + await user.type(searchInput, 'EXT-ALICE'); + expect(screen.queryByText('Bob Tan')).not.toBeInTheDocument(); + + await user.clear(searchInput); + expect(screen.getByText('Alice Lim')).toBeInTheDocument(); + expect(screen.getByText('Bob Tan')).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx index 31da6ed8c16..72bf3413222 100644 --- a/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx @@ -195,15 +195,11 @@ const ManageUsersTable = (props: ManageUsersTableProps): JSX.Element => { if (!user.name && !user.email) return false; if (!filterValue?.length) return true; + const query = filterValue.toLowerCase().trim(); return ( - user.name - .toLowerCase() - .trim() - .includes(filterValue.toLowerCase().trim()) || - user.email - .toLowerCase() - .trim() - .includes(filterValue.toLowerCase().trim()) + user.name.toLowerCase().trim().includes(query) || + user.email.toLowerCase().trim().includes(query) || + (user.externalId?.toLowerCase().trim().includes(query) ?? false) ); }, }, diff --git a/client/app/bundles/course/users/translations.ts b/client/app/bundles/course/users/translations.ts index cb7aec0635d..c4e66a140b5 100644 --- a/client/app/bundles/course/users/translations.ts +++ b/client/app/bundles/course/users/translations.ts @@ -7,7 +7,7 @@ const translations = defineMessages({ }, searchText: { id: 'course.users.ManageUsersTable.ManageUsersTable.searchText', - defaultMessage: 'Search by name or email', + defaultMessage: 'Search by name, email or external ID', }, renameSuccess: { id: 'course.users.ManageUsersTable.renameSuccess', 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 ? (