From da2242e84e02345da97917c237ca5a1afb5f6bd8 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 11 Jun 2026 17:35:03 +0800 Subject: [PATCH 1/4] feat(gradebook): add gradebook with column picker, CSV export, grade links & sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the course gradebook: a frozen-column table of students × assessments with a column picker, CSV export, and per-grade links into submissions. Gradebook table - Page with TanStack-backed table: pinned checkbox + Name columns, sticky header and Max Marks rows, frozen-column border seams that survive sticky scroll compositing. - Column picker (assessments grouped by tab/category) and CSV export of the selected columns; empty-state hint when no data columns are chosen. - External ID column, shown when any student has one. - Grade cells link to the student's submission; a dismissible GradeLinkHint banner explains the affordance (persisted per-user via useDismissibleOnce). - "Search students" global search. - Default sort with name ascending, null/undefined at bottom of sort regardless of order Shared table builder - getColumnCanGlobalFilter is gated on column visibility ("search what you see"): hiding a column via the picker removes it from search, and the nullable-first-row type sniff is bypassed. Affects all TanStack tables. Backend - Gradebook controller, ability and course component; index JSON serializes students, assessments, submissions (with submissionId) and gamification. - Submission grade query also selects the submission id for grade links. - Remove the redundant ScoreAssessmentSummary download from Statistics. --- .../components/course/gradebook_component.rb | 23 + .../course/gradebook_controller.rb | 49 ++ .../course/statistics/aggregate_controller.rb | 7 - .../assessments_score_summary_download_job.rb | 17 - .../course/gradebook_ability_component.rb | 9 + app/models/course/assessment.rb | 16 + app/models/course/assessment/submission.rb | 21 + ...essments_score_summary_download_service.rb | 82 -- .../course/courses/sidebar.json.jbuilder | 1 + .../course/gradebook/index.json.jbuilder | 36 + .../aggregate/all_assessments.json.jbuilder | 3 + client/app/api/course/Gradebook.ts | 15 + .../api/course/Statistics/CourseStatistics.ts | 8 - client/app/api/course/index.js | 2 + .../bundles/course/container/CourseLoader.ts | 8 + .../container/__tests__/CourseLoader.test.ts | 46 + .../__tests__/GradeLinkHint.test.tsx | 53 ++ .../__tests__/GradebookColumnTree.test.tsx | 305 +++++++ .../__tests__/GradebookIndex.test.tsx | 149 ++++ .../__tests__/GradebookTable.test.tsx | 789 +++++++++++++++++ .../gradebook/components/GradeLinkHint.tsx | 47 + .../components/GradebookColumnTree.tsx | 219 +++++ .../gradebook/components/GradebookTable.tsx | 809 ++++++++++++++++++ .../components/buildAssessmentColumnIds.ts | 7 + .../app/bundles/course/gradebook/constants.ts | 5 + .../app/bundles/course/gradebook/handles.ts | 21 + .../bundles/course/gradebook/operations.ts | 12 + .../gradebook/pages/GradebookIndex/index.tsx | 106 +++ .../app/bundles/course/gradebook/selectors.ts | 24 + client/app/bundles/course/gradebook/store.ts | 63 ++ client/app/bundles/course/gradebook/types.ts | 8 + .../bundles/course/statistics/operations.ts | 24 - .../AssessmentsScoreSummaryDownload.tsx | 115 --- .../assessments/AssessmentsStatistics.tsx | 1 + .../AssessmentsStatisticsTable.tsx | 53 +- .../AssessmentsStatisticsTable.test.tsx | 58 ++ .../pages/StatisticsIndex/index.tsx | 5 +- client/app/bundles/course/statistics/types.ts | 1 + client/app/bundles/course/translations.ts | 4 + .../tables/InvitationResultExistingTable.tsx | 1 - .../tables/InvitationResultPrimaryTable.tsx | 1 - client/app/bundles/users/store.ts | 13 + client/app/bundles/users/types.ts | 7 + .../core/buttons/DownloadButton.tsx | 2 +- .../lib/components/core/dialogs/Prompt.tsx | 3 + .../MuiTableAdapter/MuiColumnPickerPrompt.tsx | 12 +- .../TanStackTableBuilder/columnsBuilder.ts | 3 + .../useTanStackTableBuilder.tsx | 104 ++- .../table/__tests__/csvGenerator.test.ts | 22 +- .../useTanStackTableBuilder.test.tsx | 239 +++++- .../components/table/__tests__/utils.test.ts | 33 + .../lib/components/table/adapters/Toolbar.ts | 2 +- .../table/builder/ColumnTemplate.ts | 1 + .../table/builder/featureTemplates.ts | 3 + client/app/lib/components/table/utils.ts | 5 +- client/app/lib/constants/icons.ts | 3 + client/app/lib/helpers/url-builders.js | 3 + .../__tests__/useDismissibleOnce.test.tsx | 62 ++ client/app/lib/hooks/useDismissibleOnce.ts | 48 ++ client/app/lib/translations/table.ts | 4 + client/app/routers/course/gradebook.tsx | 23 + client/app/routers/course/index.tsx | 2 + client/app/store.ts | 2 + client/app/types/course/courses.ts | 1 + client/app/types/course/gradebook.ts | 42 + client/locales/en.json | 77 +- client/locales/ko.json | 72 +- client/locales/zh.json | 76 +- config/locales/en/course/gradebook.yml | 5 + config/locales/en/csv.yml | 8 - config/locales/ko/course/gradebook.yml | 5 + config/locales/ko/csv.yml | 8 - config/locales/zh/course/gradebook.yml | 5 + config/locales/zh/csv.yml | 8 - config/routes.rb | 5 +- .../coursemology/seed_600_gradebook.rake | 239 ++++++ lib/tasks/coursemology/seed_gradebook.rake | 246 ++++++ .../course/gradebook_controller_spec.rb | 204 +++++ .../statistics/aggregate_controller_spec.rb | 23 + .../course/assessment/submission_spec.rb | 66 ++ spec/models/course/assessment_spec.rb | 29 + ...ent_score_summary_download_service_spec.rb | 162 ---- 82 files changed, 4585 insertions(+), 515 deletions(-) create mode 100644 app/controllers/components/course/gradebook_component.rb create mode 100644 app/controllers/course/gradebook_controller.rb delete mode 100644 app/jobs/course/statistics/assessments_score_summary_download_job.rb create mode 100644 app/models/components/course/gradebook_ability_component.rb delete mode 100644 app/services/course/statistics/assessments_score_summary_download_service.rb create mode 100644 app/views/course/gradebook/index.json.jbuilder create mode 100644 client/app/api/course/Gradebook.ts create mode 100644 client/app/bundles/course/container/__tests__/CourseLoader.test.ts create mode 100644 client/app/bundles/course/gradebook/__tests__/GradeLinkHint.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx create mode 100644 client/app/bundles/course/gradebook/components/GradeLinkHint.tsx create mode 100644 client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx create mode 100644 client/app/bundles/course/gradebook/components/GradebookTable.tsx create mode 100644 client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts create mode 100644 client/app/bundles/course/gradebook/constants.ts create mode 100644 client/app/bundles/course/gradebook/handles.ts create mode 100644 client/app/bundles/course/gradebook/operations.ts create mode 100644 client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx create mode 100644 client/app/bundles/course/gradebook/selectors.ts create mode 100644 client/app/bundles/course/gradebook/store.ts create mode 100644 client/app/bundles/course/gradebook/types.ts delete mode 100644 client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx create mode 100644 client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx create mode 100644 client/app/lib/components/table/__tests__/utils.test.ts create mode 100644 client/app/lib/hooks/__tests__/useDismissibleOnce.test.tsx create mode 100644 client/app/lib/hooks/useDismissibleOnce.ts create mode 100644 client/app/routers/course/gradebook.tsx create mode 100644 client/app/types/course/gradebook.ts create mode 100644 config/locales/en/course/gradebook.yml create mode 100644 config/locales/ko/course/gradebook.yml create mode 100644 config/locales/zh/course/gradebook.yml create mode 100644 lib/tasks/coursemology/seed_600_gradebook.rake create mode 100644 lib/tasks/coursemology/seed_gradebook.rake create mode 100644 spec/controllers/course/gradebook_controller_spec.rb delete mode 100644 spec/services/course/statistics/assessment_score_summary_download_service_spec.rb diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..fe2ccfe09e2 --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +class Course::GradebookComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Gradebook' + end + + def sidebar_items + return [] unless can?(:read_gradebook, current_course) + + [ + { + key: self.class.key, + icon: :gradebook, + title: I18n.t('course.gradebook.component.sidebar_title'), + type: :normal, + weight: 9, + path: course_gradebook_path(current_course) + } + ] + end +end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb new file mode 100644 index 00000000000..71cfa4fc145 --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true +class Course::GradebookController < Course::ComponentController + before_action :authorize_read_gradebook! + + def index + respond_to do |format| + format.json do + @published_assessments = fetch_published_assessments + @categories, @tabs = fetch_categories_and_tabs + @students = fetch_students + assessment_ids = @published_assessments.pluck(:id) + @assessment_max_grades = Course::Assessment.max_grades(assessment_ids) + @submissions = Course::Assessment::Submission.grade_summary( + student_ids: @students.map(&:user_id), + assessment_ids: assessment_ids + ) + end + end + end + + private + + def authorize_read_gradebook! + authorize! :read_gradebook, current_course + end + + def component + current_component_host[:course_gradebook_component] + end + + def fetch_categories_and_tabs + tabs = @published_assessments.map(&:tab).uniq(&:id) + [tabs.map(&:category).uniq(&:id), tabs] + end + + def fetch_students + current_course.levels.to_a + current_course.course_users.students.without_phantom_users. + calculated(:experience_points).includes(:user).to_a + end + + def fetch_published_assessments + current_course.assessments. + published. + includes(tab: :category). + joins(tab: :category). + reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id') + end +end diff --git a/app/controllers/course/statistics/aggregate_controller.rb b/app/controllers/course/statistics/aggregate_controller.rb index 2e44dc06f81..306984f30e6 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -47,13 +47,6 @@ def activity_get_help @course_user_hash = current_course.course_users.index_by(&:user_id) end - def download_score_summary - job = Course::Statistics::AssessmentsScoreSummaryDownloadJob. - perform_later(current_course, params[:assessment_ids]).job - - render partial: 'jobs/submitted', locals: { job: job } - end - private def sanitize_date_range(start_at_param, end_at_param) diff --git a/app/jobs/course/statistics/assessments_score_summary_download_job.rb b/app/jobs/course/statistics/assessments_score_summary_download_job.rb deleted file mode 100644 index 86ce040b994..00000000000 --- a/app/jobs/course/statistics/assessments_score_summary_download_job.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -class Course::Statistics::AssessmentsScoreSummaryDownloadJob < ApplicationJob - include TrackableJob - queue_as :lowest - retry_on StandardError, attempts: 0 - - protected - - def perform_tracked(course, assessment_ids) - file_name = "#{Pathname.normalize_filename(course.title)}_score_summary_#{Time.now.strftime '%Y%m%d_%H%M'}.csv" - service = Course::Statistics::AssessmentsScoreSummaryDownloadService.new(course, assessment_ids, file_name) - csv_file = service.generate - redirect_to SendFile.send_file(csv_file, file_name) - ensure - service&.cleanup - end -end diff --git a/app/models/components/course/gradebook_ability_component.rb b/app/models/components/course/gradebook_ability_component.rb new file mode 100644 index 00000000000..d5b9862f299 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + can :read_gradebook, Course, id: course.id if course_user&.staff? + super + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 3128ed42528..aec93f9de08 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -160,6 +160,22 @@ def self.use_relative_model_naming? true end + # Returns a hash of assessment_id => max_grade (sum of question maximum_grades). + def self.max_grades(assessment_ids) + return {} if assessment_ids.empty? + + rows = find_by_sql( + sanitize_sql_array([<<-SQL.squish, assessment_ids]) + SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade + FROM course_question_assessments cqa + JOIN course_assessment_questions caq ON caq.id = cqa.question_id + WHERE cqa.assessment_id IN (?) + GROUP BY cqa.assessment_id + SQL + ) + rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] } + end + def to_partial_path 'course/assessment/assessments/assessment' end diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index c4919d6ec14..cb5df4c80f6 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, + cas.id AS submission_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, cas.id + SQL + ) + end + private # Queues the submission for auto grading, after the submission has changed to the submitted state. diff --git a/app/services/course/statistics/assessments_score_summary_download_service.rb b/app/services/course/statistics/assessments_score_summary_download_service.rb deleted file mode 100644 index 88e98756c6a..00000000000 --- a/app/services/course/statistics/assessments_score_summary_download_service.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true -require 'csv' -class Course::Statistics::AssessmentsScoreSummaryDownloadService - include TmpCleanupHelper - include ApplicationFormattersHelper - - def initialize(course, assessment_ids, file_name) - @course = course - @assessment_ids = assessment_ids - @file_name = file_name - @base_dir = Dir.mktmpdir('assessment-score-summary-') - end - - def generate - ActsAsTenant.without_tenant do - generate_csv_report - end - end - - def generate_csv_report - assessment_score_summary_file_path = File.join(@base_dir, @file_name) - - load_total_grades - CSV.open(assessment_score_summary_file_path, 'w') do |csv| - download_score_summary(csv) - end - - assessment_score_summary_file_path - end - - private - - def cleanup_entries - [@base_dir] - end - - def load_total_grades - @course_assessment_hash = Course::Assessment.where(id: @assessment_ids, course_id: @course.id).to_h do |assessment| - [assessment.id, assessment] - end - - @assessments = assessments - @submissions = Course::Assessment::Submission.where(assessment_id: @assessments.map(&:id)). - calculated(:grade). - preload(creator: :course_users) - - @submission_grade_hash = submission_grade_hash - @all_students = @course.course_users.students.order_alphabetically.preload(user: :emails) - @include_external_id = @course.course_users.students.where.not(external_id: [nil, '']).exists? - end - - def submission_grade_hash - @submissions.to_h do |submission| - course_user = submission.creator.course_users.find { |u| u.course_id == @course.id } - [[course_user.id, submission.assessment_id], submission.grade] - end - end - - def assessments - @assessment_ids.filter { |assessment_id| !@course_assessment_hash[assessment_id.to_i].nil? }.map do |assessment_id| - @course_assessment_hash[assessment_id.to_i] - end - end - - def download_score_summary(csv) - # header - csv << [ - I18n.t('csv.score_summary.headers.name'), - I18n.t('csv.score_summary.headers.email'), - I18n.t('csv.score_summary.headers.type'), - *(@include_external_id ? [I18n.t('csv.score_summary.headers.external_id')] : []), - *@assessments.map(&:title) - ] - - # content - @all_students.each do |student| - csv << [student.name, student.user.email, student.phantom? ? 'phantom' : 'normal', - *(@include_external_id ? [student.external_id.presence || ''] : []), - *@assessments.flat_map { |a| @submission_grade_hash[[student.id, a.id]] || '' }] - end - end -end diff --git a/app/views/course/courses/sidebar.json.jbuilder b/app/views/course/courses/sidebar.json.jbuilder index d7de1bebca1..1a31ba3f24f 100644 --- a/app/views/course/courses/sidebar.json.jbuilder +++ b/app/views/course/courses/sidebar.json.jbuilder @@ -4,6 +4,7 @@ json.courseUrl course_path(current_course) json.courseLogoUrl url_to_course_logo(current_course) json.courseUserUrl url_to_user_or_course_user(current_course, current_course_user) json.userName current_user&.name +json.userId current_user&.id if current_course_user.present? && can?(:read, current_course) json.courseUserName current_course_user.name diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder new file mode 100644 index 00000000000..d2180773a40 --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,36 @@ +# frozen_string_literal: true +json.categories @categories do |cat| + json.id cat.id + json.title cat.title +end + +json.tabs @tabs do |tab| + json.id tab.id + json.title tab.title + json.categoryId tab.category_id +end + +json.assessments @published_assessments do |assessment| + json.id assessment.id + json.title assessment.title + json.tabId assessment.tab_id + json.maxGrade @assessment_max_grades[assessment.id] || 0 +end + +json.students @students do |course_user| + json.id course_user.user_id + json.name course_user.name + json.email course_user.user.email + json.externalId course_user.external_id + 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.submissionId sub.submission_id + json.grade sub.grade&.to_f +end + +json.gamificationEnabled current_course.gamified? diff --git a/app/views/course/statistics/aggregate/all_assessments.json.jbuilder b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder index 458efa1067c..7bc08582f3d 100644 --- a/app/views/course/statistics/aggregate/all_assessments.json.jbuilder +++ b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder @@ -35,3 +35,6 @@ json.assessments @assessments do |assessment| json.numAttempted @num_attempted_students_hash[assessment.id] || 0 json.numLate @num_late_students_hash[assessment.id] || 0 end + +json.gradebookEnabled current_course.component_enabled?(Course::GradebookComponent) && + can?(:read_gradebook, current_course) diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts new file mode 100644 index 00000000000..e00c94a64c3 --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,15 @@ +import { GradebookData } from 'types/course/gradebook'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from './Base'; + +export default class GradebookAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/gradebook`; + } + + index(): APIResponse { + return this.client.get(this.#urlPrefix); + } +} diff --git a/client/app/api/course/Statistics/CourseStatistics.ts b/client/app/api/course/Statistics/CourseStatistics.ts index 6e2d3e370f4..b478bec85df 100644 --- a/client/app/api/course/Statistics/CourseStatistics.ts +++ b/client/app/api/course/Statistics/CourseStatistics.ts @@ -1,5 +1,3 @@ -import { JobSubmitted } from 'types/jobs'; - import { APIResponse } from 'api/types'; import { AssessmentsStatistics, @@ -53,10 +51,4 @@ export default class CourseStatisticsAPI extends BaseCourseAPI { params, }); } - - downloadScoreSummary(assessmentIds: number[]): APIResponse { - return this.client.get(`${this.#urlPrefix}/assessments/download`, { - params: { assessment_ids: assessmentIds }, - }); - } } 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/container/CourseLoader.ts b/client/app/bundles/course/container/CourseLoader.ts index b55a5866b8f..8abe2df1ec1 100644 --- a/client/app/bundles/course/container/CourseLoader.ts +++ b/client/app/bundles/course/container/CourseLoader.ts @@ -3,9 +3,11 @@ import { useLoaderData, useOutletContext, } from 'react-router-dom'; +import { dispatch as imperativeDispatch } from 'store'; import { CourseLayoutData, SidebarItemData } from 'types/course/courses'; import CourseAPI from 'api/course'; +import { actions as userActions } from 'bundles/users/store'; import { syncSignals } from 'lib/hooks/unread'; const extractUnreadCountsInto = ( @@ -34,6 +36,12 @@ export const loader: LoaderFunction = async ({ params }) => { const response = await CourseAPI.courses.fetchLayout(id); + // Hydrate the authenticated user's id into the global store. It is otherwise + // only populated on the user profile page, so any course page relying on it + // (e.g. per-user localStorage namespacing for table column prefs) would read 0. + const { userId } = response.data; + if (userId != null) imperativeDispatch(userActions.setCurrentUserId(userId)); + syncSignals(extractUnreadCountsFromLayoutData(response.data)); return response.data; diff --git a/client/app/bundles/course/container/__tests__/CourseLoader.test.ts b/client/app/bundles/course/container/__tests__/CourseLoader.test.ts new file mode 100644 index 00000000000..3db67b15dce --- /dev/null +++ b/client/app/bundles/course/container/__tests__/CourseLoader.test.ts @@ -0,0 +1,46 @@ +import { store } from 'store'; + +import CourseAPI from 'api/course'; + +import { loader } from '../CourseLoader'; + +jest.mock('api/course', () => ({ + __esModule: true, + default: { courses: { fetchLayout: jest.fn() } }, +})); + +const mockFetchLayout = CourseAPI.courses.fetchLayout as jest.Mock; + +const runLoader = (courseId: string): Promise => + // The router passes { request, params }; only params.courseId is read here, so + // we route through `unknown` to call it with just the params it reads. + ( + loader as unknown as (args: { + params: { courseId: string }; + }) => Promise + )({ + params: { courseId }, + }); + +describe('CourseLoader loader', () => { + it('hydrates the authenticated user id into the global store', async () => { + mockFetchLayout.mockResolvedValueOnce({ + data: { courseTitle: 'C', userName: 'Alice', userId: 77 }, + }); + + await runLoader('1'); + + expect(store.getState().global.user.user.id).toBe(77); + }); + + it('leaves the global user id untouched when the payload has no user id', async () => { + store.dispatch({ type: 'system/SET_CURRENT_USER_ID', userId: 5 }); + mockFetchLayout.mockResolvedValueOnce({ + data: { courseTitle: 'C', userName: null, userId: null }, + }); + + await runLoader('1'); + + expect(store.getState().global.user.user.id).toBe(5); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradeLinkHint.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradeLinkHint.test.tsx new file mode 100644 index 00000000000..2ad267268aa --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradeLinkHint.test.tsx @@ -0,0 +1,53 @@ +import { store as appStore } from 'store'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import GradeLinkHint, { + GRADE_LINK_HINT_KEY, +} from '../components/GradeLinkHint'; + +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:${GRADE_LINK_HINT_KEY}`; + +// The dismissal flag is namespaced by the authenticated user id from the global +// user store, which the course layout loader hydrates on every course page. +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('GradeLinkHint', () => { + it('explains that grades are clickable and lead to the submission', async () => { + renderHint(); + expect(await screen.findByText(/click any grade/i)).toBeInTheDocument(); + }); + + it('hides and persists dismissal when the close button is clicked', async () => { + renderHint(); + fireEvent.click(await screen.findByRole('button', { name: /close/i })); + + expect(screen.queryByText(/click any grade/i)).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); + + it('does not render when already dismissed', async () => { + localStorage.setItem(STORAGE_KEY, 'true'); + renderHint(); + // Wait for the async locale load to settle (spinner gone) before asserting + // absence, so we are not just observing the pre-load loading state. + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), + ); + expect(screen.queryByText(/click any grade/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..280f3c71b7d --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx @@ -0,0 +1,305 @@ +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', 'externalId', '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('renders an External ID checkbox in the Student info group', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect( + screen.getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + + it('clicking the External ID checkbox calls setVisible with its column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /external id/i })); + expect(setVisible).toHaveBeenCalledWith('externalId', expect.any(Boolean)); + }); + + it('name checkbox is disabled and always checked', () => { + const visibility: Record = { + name: false, + 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..25b66fd3ed3 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import fetchGradebook from '../operations'; +import GradebookIndex from '../pages/GradebookIndex'; + +jest.mock('../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; id: number } => ({ + courseTitle: 'Test Course', + id: 1, + }), +})); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn(), success: jest.fn() }, +})); + +jest.mock('../operations', () => ({ + __esModule: true, + default: jest.fn(() => (): Promise => Promise.resolve()), +})); + +const mockFetchGradebook = fetchGradebook as jest.Mock; + +const emptyState = { + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + }, +}; + +const noStudentsState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [], + submissions: [], + gamificationEnabled: false, + }, +}; + +const populatedState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: null, + level: 3, + totalXp: 150, + }, + ], + submissions: [ + { studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 }, + ], + gamificationEnabled: false, + }, +}; + +const populatedStateWithGamification = { + gradebook: { + ...populatedState.gradebook, + gamificationEnabled: true, + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); +}); + +describe('GradebookIndex', () => { + it('shows loading indicator initially', () => { + render(, { state: emptyState }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows the gradebook table after data loads', async () => { + render(, { state: populatedState }); + expect( + await screen.findByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + + it('shows the page title', async () => { + render(, { state: populatedState }); + expect(await screen.findByText('Gradebook')).toBeInTheDocument(); + }); + + it('shows empty students message when there are no students', async () => { + render(, { state: noStudentsState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows empty students message when both assessments and students are absent', async () => { + render(, { state: emptyState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows error toast when fetch fails', async () => { + mockFetchGradebook.mockReturnValueOnce( + (): Promise => Promise.reject(new Error('Network error')), + ); + render(, { state: emptyState }); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + }); + + it('shows grade-only hint in column picker when gamification is disabled and no data cols selected', async () => { + render(, { state: populatedState }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + expect( + await screen.findByText( + 'No grade columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); + + it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => { + render(, { state: populatedStateWithGamification }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + fireEvent.click( + await screen.findByRole('checkbox', { name: /gamification/i }), + ); + expect( + await screen.findByText( + 'No grade or gamification columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx new file mode 100644 index 00000000000..904d4513cb6 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,789 @@ +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', + externalId: null, + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + externalId: null, + level: 5, + totalXp: 300, + }, +]; +const submissions: SubmissionData[] = [ + { submissionId: 0, 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`, + externalId: null, + level: 1, + totalXp: 0, + })); + +// Asserts the given texts appear in this top-to-bottom DOM order. +const expectInOrder = (names: string[]): void => { + for (let i = 0; i < names.length - 1; i += 1) { + const earlier = screen.getByText(names[i]); + const later = screen.getByText(names[i + 1]); + expect( + // eslint-disable-next-line no-bitwise + earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } +}; + +// User id used in all renders so localStorage is keyed as `${USER_ID}:gradebook_columns_1`. +// It is seeded into the global user store, which the course layout loader hydrates +// on every course page; the builder namespaces column persistence by that id. +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:gradebook_columns_1`; +const SORT_STORAGE_KEY = `${STORAGE_KEY}_sort`; + +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(); + }); + + // An assessment selected (and persisted) earlier may be deleted before the next + // visit, leaving a stale column id in localStorage. The table must ignore it + // rather than crash, and still render the surviving assessment columns. + it('does not crash when localStorage references a deleted assessment', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + 'asn-100': true, + 'asn-999': true, + }), + ); + renderTable(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + // The surviving assessment still renders; the deleted one is silently dropped. + expect(screen.getByText('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('external ID column', () => { + const studentsWithExtId: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: 'EXT-001', + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + externalId: null, + level: 5, + totalXp: 300, + }, + ]; + + const renderWith = (studs: StudentData[]): void => { + render( + , + { state: userState }, + ); + }; + + it('shows the External ID column by default when a student has an external ID', async () => { + renderWith(studentsWithExtId); + expect(await screen.findByText('External ID')).toBeInTheDocument(); + expect(screen.getByText('EXT-001')).toBeInTheDocument(); + }); + + it('hides the External ID column by default when no student has an external ID', async () => { + renderWith(students); + await screen.findByText('Alice'); + expect(screen.queryByText('External ID')).not.toBeInTheDocument(); + }); + + it('treats a blank external ID as none and hides the column by default', async () => { + const studentsWithBlankExtId: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: '', + level: 3, + totalXp: 150, + }, + ]; + renderWith(studentsWithBlankExtId); + await screen.findByText('Alice'); + expect(screen.queryByText('External ID')).not.toBeInTheDocument(); + }); + + it('offers the External ID checkbox in the picker even when no student has one', async () => { + const user = userEvent.setup(); + renderWith(students); + const btn = await screen.findByRole('button', { + name: /select columns/i, + }); + await user.click(btn); + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + + it('filters by external ID', async () => { + const user = userEvent.setup(); + renderWith(studentsWithExtId); + const input = await screen.findByRole('textbox'); + await user.type(input, 'EXT-001'); + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + }); + + describe('sorting', () => { + it('defaults to sorting by name ascending on first load', async () => { + // Raw prop order is Bob, then Alice. A working default name-asc sort must + // reorder the rendered rows to Alice, then Bob. + render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + expectInOrder(['Alice', 'Bob']); + }); + + it('cycles back to ascending on a third click (sort is never cleared)', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /name/i })); // → desc + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + await user.click(screen.getByRole('button', { name: /name/i })); // → asc again + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + }); + + it('toggles to descending when the name header is clicked', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expectInOrder(['Alice', 'Bob']); // default ascending + await user.click(screen.getByRole('button', { name: /name/i })); + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + }); + + it('preserves the active sort after searching', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + // Switch to descending so the order is distinct from both raw and default. + await user.click(screen.getByRole('button', { name: /name/i })); + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + // A search matching both students must not reset the descending sort. + await user.type(screen.getByRole('textbox'), 'example.com'); + await waitFor(() => + expect(screen.getByText('Alice')).toBeInTheDocument(), + ); + expectInOrder(['Bob', 'Alice']); + }); + + it('resets to name ascending when the sorted column is hidden', async () => { + const user = userEvent.setup(); + // Start with Quiz 1 visible and sort by it descending. + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // sort by grade asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // → desc + await waitFor(() => expectInOrder(['Alice', 'Bob'])); // Alice has grade 8, Bob has none + + // Hide Quiz 1 via the column picker. + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + await user.click( + within(dialog).getByRole('checkbox', { name: /quiz 1/i }), + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // Sort should reset to name ascending: Alice before Bob. + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + }); + + it('saves the sort to localStorage when the user clicks a column header', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /name/i })); // → desc + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + expect( + JSON.parse(localStorage.getItem(SORT_STORAGE_KEY) ?? 'null'), + ).toEqual([{ id: 'name', desc: true }]); + }); + + it('restores sort from localStorage on re-mount', async () => { + // Pre-seed descending name sort so the table should render Bob before Alice. + localStorage.setItem( + SORT_STORAGE_KEY, + JSON.stringify([{ id: 'name', desc: true }]), + ); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expectInOrder(['Bob', 'Alice']); + }); + + it('persists the name-ascending reset to localStorage when a sorted column is hidden', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc + + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + await user.click( + within(dialog).getByRole('checkbox', { name: /quiz 1/i }), + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + expect( + JSON.parse(localStorage.getItem(SORT_STORAGE_KEY) ?? 'null'), + ).toEqual([{ id: 'name', desc: false }]); + }); + + describe('assessment grade sorting', () => { + const studentFor = (id: number, name: string): StudentData => ({ + id, + name, + email: `${name.toLowerCase()}@example.com`, + externalId: null, + level: 1, + totalXp: 0, + }); + + const renderGrades = ( + studs: StudentData[], + subs: SubmissionData[], + ): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + }; + + // Alice graded 8, Bob graded 3, Carol no submission (undefined), + // Dave submitted but ungraded (null). + const mixedStudents: StudentData[] = [ + studentFor(1, 'Alice'), + studentFor(2, 'Bob'), + studentFor(3, 'Carol'), + studentFor(4, 'Dave'), + ]; + const mixedSubmissions: SubmissionData[] = [ + { submissionId: 1, studentId: 1, assessmentId: 100, grade: 8 }, + { submissionId: 2, studentId: 2, assessmentId: 100, grade: 3 }, + { submissionId: 4, studentId: 4, assessmentId: 100, grade: null }, + ]; + + it('sorts missing grades to the bottom in ascending order', async () => { + const user = userEvent.setup(); + renderGrades(mixedStudents, mixedSubmissions); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); + // Ascending: Bob(3), Alice(8), then the two missing rows last. + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + expectInOrder(['Alice', 'Carol']); + expectInOrder(['Alice', 'Dave']); + }); + + it('keeps missing grades at the bottom in descending order', async () => { + const user = userEvent.setup(); + renderGrades(mixedStudents, mixedSubmissions); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc + // Descending: Alice(8), Bob(3), then the two missing rows still last. + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + expectInOrder(['Bob', 'Carol']); + expectInOrder(['Bob', 'Dave']); + }); + + it('sorts grades numerically (9 before 10), not lexically', async () => { + const user = userEvent.setup(); + renderGrades( + [studentFor(1, 'Alice'), studentFor(2, 'Bob')], + [ + { submissionId: 1, studentId: 1, assessmentId: 100, grade: 9 }, + { submissionId: 2, studentId: 2, assessmentId: 100, grade: 10 }, + ], + ); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); + // Numeric ascending: 9 (Alice) before 10 (Bob). Lexical would reverse this. + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + }); + }); + }); + + 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(), + ); + + // Default page size is DEFAULT_TABLE_ROWS_PER_PAGE (100), so 101 students + // span two pages: Student 1 is on page 1, Student 101 alone on page 2. + await user.click( + screen.getByRole('button', { name: /go to next page/i }), + ); + await waitFor(() => + expect(screen.getByText('Student 101')).toBeInTheDocument(), + ); + expect(screen.queryByText('Student 1')).not.toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx b/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx new file mode 100644 index 00000000000..7b2e8f44a1f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const GRADE_LINK_HINT_KEY = 'gradebook_grade_link_hint'; + +const translations = defineMessages({ + hint: { + id: 'course.gradebook.GradeLinkHint.hint', + defaultMessage: + "Each grade is the total of the marks in a student's submission. Click any grade to open that submission and adjust the marks.", + }, +}); + +/** + * One-time, dismissable nudge explaining that each grade in the "All assessments" + * table is a link into the student's submission. Grades have no gradebook-level edit + * (an assessment's total is always the sum of its question marks), so clicking into the + * submission is the only path to changing a grade — an affordance that surprises users + * because a number is not expected to be clickable. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const GradeLinkHint: FC = () => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + GRADE_LINK_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
+ + {t(translations.hint)} + +
+ ); +}; + +export default GradeLinkHint; 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..e2b5d526485 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -0,0 +1,219 @@ +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 tableTranslations from 'lib/translations/table'; + +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', + }, + 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 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(tableTranslations[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..5c6c7abe4d1 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -0,0 +1,809 @@ +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { defineMessages } from 'react-intl'; +import { + Checkbox, + Paper, + type SxProps, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + type Theme, + Tooltip, +} from '@mui/material'; +import { flexRender } from '@tanstack/react-table'; + +import Link from 'lib/components/core/Link'; +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 { getEditSubmissionURL } from 'lib/helpers/url-builders'; +import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; + +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: 250, + externalId: 160, + level: 80, + totalXp: 120, + 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' || id === 'externalId'; + +const translations = defineMessages({ + searchStudents: { + id: 'course.gradebook.GradebookIndex.searchStudents', + defaultMessage: 'Search students', + }, + 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', + }, + 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; + externalId: string | null; + level: number; + totalXp: number; + grades: Partial>; + submissionIds: 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> = {}; + const submissionIds: Partial> = {}; + assessments.forEach((a) => { + const sub = subs.find((s) => s.assessmentId === a.id); + if (sub != null) { + grades[a.id] = sub.grade; + submissionIds[a.id] = sub.submissionId; + } + }); + return { + studentId: student.id, + name: student.name, + email: student.email, + externalId: student.externalId, + level: student.level, + totalXp: student.totalXp, + grades, + submissionIds, + }; + }), + [students, assessments, submissionsByStudent], + ); + + const hasExternalIds = useMemo( + () => students.some((s) => s.externalId != null && s.externalId !== ''), + [students], + ); + + const columns = useMemo[]>(() => { + const cols: ColumnTemplate[] = [ + { + id: 'name', + title: t(tableTranslations.name), + of: 'name', + cell: (row) => row.name, + csvDownloadable: true, + searchable: true, + sortable: true, + searchProps: { getValue: (row) => row.name }, + }, + { + id: 'email', + title: t(tableTranslations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + sortable: true, + }, + ]; + + // The External ID column is always offered in the picker, but only shown by + // default when the course actually uses external IDs (see column picker). + cols.push({ + id: 'externalId', + title: t(tableTranslations.externalId), + of: 'externalId', + cell: (row) => row.externalId ?? '', + csvDownloadable: true, + searchable: true, + sortable: true, + defaultVisible: hasExternalIds, + }); + + if (gamificationEnabled) { + cols.push({ + id: 'level', + title: t(tableTranslations.level), + of: 'level', + cell: (row) => row.level, + csvDownloadable: true, + sortable: true, + }); + cols.push({ + id: 'totalXp', + title: t(tableTranslations.totalXp), + of: 'totalXp', + cell: (row) => row.totalXp, + csvDownloadable: true, + sortable: true, + }); + } + + assessments.forEach((asn) => { + const colId = buildAssessmentColumnId(asn.id); + cols.push({ + id: colId, + title: asn.title, + // null (ungraded) → undefined so sortUndefined: 'last' fires for both missing and ungraded rows + accessorFn: (row) => row.grades[asn.id] ?? undefined, + sortable: true, + sortProps: { + undefinedPriority: 'last', + descFirst: false, + sort: (a, b) => { + const aGrade = a.grades[asn.id]; + const bGrade = b.grades[asn.id]; + if (aGrade == null || bGrade == null) return 0; + return aGrade - bGrade; + }, + }, + cell: (row) => { + const grade = row.grades[asn.id]; + if (grade === undefined) return '—'; + if (grade === null) return ''; + const submissionId = row.submissionIds[asn.id]; + if (submissionId != null) + return ( + + {grade} + + ); + return grade; + }, + csvDownloadable: true, + defaultVisible: false, + }); + }); + return cols; + }, [assessments, gamificationEnabled, hasExternalIds, 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, header } = + useTanStackTableBuilder({ + data: rows, + columns, + getRowId: (row) => row.studentId.toString(), + getRowEqualityData: (row) => row, + indexing: { rowSelectable: true }, + pagination: { + initialPageSize: DEFAULT_TABLE_ROWS_PER_PAGE, + rowsPerPage: [ + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + 25, + 50, + DEFAULT_TABLE_ROWS_PER_PAGE, + ], + showAllRows: true, + }, + search: { searchPlaceholder: t(translations.searchStudents) }, + sort: { + initially: { by: 'name', order: 'asc' }, + enableRemoval: false, + resetOnHide: true, + }, + 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 sortByColId = new Map( + (header?.headers ?? []).map( + (h, i) => [h.id, header?.forEach(h, i).sorting] as const, + ), + ); + + 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 ( +
+ +
+ + {/* A bounded maxHeight is what makes `stickyHeader` actually stick: + `overflowX: 'auto'` already promotes this container to a scroll + container on both axes, so the sticky and the frozen + checkbox/Name columns pin relative to THIS element. Without a height + cap the container grows to fit every row and never scrolls + internally, leaving the header no scroll range. */} + + ({ + 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 ; + })} + + + + ({ + top: 0, + zIndex: 3, + position: 'sticky', + left: 0, + bgcolor: 'background.default', + width: CHECKBOX_WIDTH, + minWidth: CHECKBOX_WIDTH, + maxWidth: CHECKBOX_WIDTH, + px: 0, + textAlign: 'center', + verticalAlign: 'middle', + // Solid 1px bottom seam under the frozen header columns. The + // table's 0.5px grid border (specificity 0,1,1) outranks a + // plain per-cell sx (0,1,0), so double the selector with `&&` + // (0,2,0) to win — and a full 1px survives sticky scroll + // compositing where 0.5px can drop and let the body show + // through the row1/row2 seam. + '&&': { + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {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; + const sort = sortByColId.get(id); + const labelNode = ( + + + + + + ); + return ( + ({ + verticalAlign: isLeft || fits ? 'middle' : 'bottom', + ...(id === 'name' && { + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 3, + bgcolor: 'background.default', + // Right edge of the frozen region + matching 1px + // bottom seam. `&&` (0,2,0) is needed to beat the + // table's `& th` border rule (0,1,1). + '&&': { + borderRight: `1px solid ${theme.palette.grey[200]}`, + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + }), + })} + > + {sort ? ( + + {labelNode} + + ) : ( + labelNode + )} + + ); + })} + + {hasVisibleAssessments && ( + ({ + '& .MuiTableCell-stickyHeader': { + top: row2Top, + }, + // Solid 1px bottom edge under the whole Max Marks row so the + // frozen columns read as a complete header block and the + // body never shows through on scroll. `& .MuiTableCell-root` + // (0,2,0) outranks the table's `& th` rule (0,1,1). + '& .MuiTableCell-root': { + borderTop: `1px solid ${theme.palette.grey[200]}`, + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + })} + > + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const asnId = parseAssessmentColumnId(id); + let cellContent: string = ''; + if (id === 'name') cellContent = t(translations.maxMarks); + else if (asnId !== null) { + const maxGrade = assessmentMaxGrades.get(asnId); + cellContent = maxGrade != null ? `/${maxGrade}` : ''; + } + return ( + ({ + bgcolor: 'grey.100', + ...(id === 'name' && { + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 3, + // Continue the frozen region's right edge. + '&&': { + borderTop: `1px solid ${theme.palette.grey[200]}`, + borderRight: `1px solid ${theme.palette.grey[200]}`, + }, + }), + })} + > + {cellContent} + + ); + })} + + )} + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + return ( + + ({ + position: 'sticky', + left: 0, + zIndex: 1, + bgcolor: 'background.paper', + width: CHECKBOX_WIDTH, + minWidth: CHECKBOX_WIDTH, + maxWidth: CHECKBOX_WIDTH, + px: 0, + textAlign: 'center', + // Sticky cells composite on their own layer, so this + // cell's `borderBottom` gets covered by the next row's + // opaque sticky background (Blink) — dropping the + // separator. Draw it as the lower row's `borderTop` + // instead; that border is owned by the row's own + // layer and always paints. Row 0's top edge is already + // the header cell's (higher z-index) bottom border. + borderBottom: 'none', + borderTop: + idx === 0 + ? undefined + : `0.5px solid ${theme.palette.grey[200]}`, + })} + > + + + {row + .getVisibleCells() + .filter((cell) => cell.column.id !== 'rowSelector') + .map((cell) => { + // Sticky cover for the frozen `name` column, mirroring + // the checkbox cell above. Declared as a directly-typed + // const so the callback is contextually typed (a ternary + // in the `sx` prop would strip that context). + const nameCellSx: SxProps = (theme) => ({ + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 1, + bgcolor: 'background.paper', + // Same sticky-layer cover as the checkbox column: draw + // the separator as the lower row's `borderTop`, not a + // covered `borderBottom`. + borderBottom: 'none', + borderTop: + idx === 0 + ? undefined + : `0.5px solid ${theme.palette.grey[200]}`, + // Continue the frozen region's right edge down the data + // rows. `&&` (0,2,0) beats the table's `& td` border + // rule (0,1,1). + '&&': { + borderRight: `1px solid ${theme.palette.grey[200]}`, + }, + }); + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
+
+ {pagination && } +
+
+
+ ); +}; + +export default GradebookTable; diff --git a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts new file mode 100644 index 00000000000..d12a4bd26a7 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts @@ -0,0 +1,7 @@ +export const buildAssessmentColumnId = (asnId: number): string => + `asn-${asnId}`; + +export const parseAssessmentColumnId = (colId: string): number | null => { + const match = colId.match(/^asn-(\d+)$/); + return match ? Number(match[1]) : null; +}; diff --git a/client/app/bundles/course/gradebook/constants.ts b/client/app/bundles/course/gradebook/constants.ts new file mode 100644 index 00000000000..4c61d2723fb --- /dev/null +++ b/client/app/bundles/course/gradebook/constants.ts @@ -0,0 +1,5 @@ +export const STUDENT_INFO_COL_IDS = ['name', 'email', 'externalId'] as const; +export type StudentInfoColId = (typeof STUDENT_INFO_COL_IDS)[number]; + +export const GAMIFICATION_COL_IDS = ['level', 'totalXp'] as const; +export type GamificationColId = (typeof GAMIFICATION_COL_IDS)[number]; diff --git a/client/app/bundles/course/gradebook/handles.ts b/client/app/bundles/course/gradebook/handles.ts new file mode 100644 index 00000000000..0022bfbd02c --- /dev/null +++ b/client/app/bundles/course/gradebook/handles.ts @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl'; + +import type { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +const translations = defineMessages({ + header: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, +}); + +export const gradebookHandle: DataHandle = (match) => { + const courseId = match.params.courseId; + + return { + getData: async (): Promise => ({ + activePath: `/courses/${courseId}/gradebook`, + content: { title: translations.header }, + }), + }; +}; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts new file mode 100644 index 00000000000..35790580ed0 --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,12 @@ +import type { Operation } from 'store'; + +import CourseAPI from 'api/course'; + +import { actions } from './store'; + +const fetchGradebook = (): Operation => async (dispatch) => { + const response = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(response.data)); +}; + +export default fetchGradebook; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx new file mode 100644 index 00000000000..6d90823b5e4 --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,106 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { PeopleAlt } from '@mui/icons-material'; +import { Typography } from '@mui/material'; + +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { useCourseContext } from '../../../container/CourseLoader'; +import GradebookTable from '../../components/GradebookTable'; +import GradeLinkHint from '../../components/GradeLinkHint'; +import fetchGradebook from '../../operations'; +import { + getAssessments, + getCategories, + getGamificationEnabled, + getStudents, + getSubmissions, + getTabs, +} from '../../selectors'; + +const translations = defineMessages({ + gradebook: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, + fetchFailure: { + id: 'course.gradebook.GradebookIndex.fetchFailure', + defaultMessage: 'Failed to retrieve Gradebook.', + }, + noStudents: { + id: 'course.gradebook.GradebookIndex.noStudents', + defaultMessage: 'No students enrolled yet', + }, + noStudentsHint: { + id: 'course.gradebook.GradebookIndex.noStudentsHint', + defaultMessage: 'Grades will appear here once students join the course.', + }, +}); + +const GradebookIndex: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseTitle } = useCourseContext(); + const { courseId: courseIdParam } = useParams(); + const courseId = parseInt(courseIdParam!, 10); + const [isLoading, setIsLoading] = useState(true); + + const assessments = useAppSelector(getAssessments); + const categories = useAppSelector(getCategories); + const tabs = useAppSelector(getTabs); + const students = useAppSelector(getStudents); + const submissions = useAppSelector(getSubmissions); + const gamificationEnabled = useAppSelector(getGamificationEnabled); + + useEffect(() => { + dispatch(fetchGradebook()) + .finally(() => setIsLoading(false)) + .catch(() => toast.error(t(translations.fetchFailure))); + }, [dispatch]); + + let content: JSX.Element; + if (isLoading) { + content = ; + } else if (students.length === 0) { + content = ( +
+ + + {t(translations.noStudents)} + + + {t(translations.noStudentsHint)} + +
+ ); + } else { + content = ( + <> + + + + ); + } + + return ( + + {content} + + ); +}; + +export default GradebookIndex; diff --git a/client/app/bundles/course/gradebook/selectors.ts b/client/app/bundles/course/gradebook/selectors.ts new file mode 100644 index 00000000000..fbe62e2611a --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,24 @@ +import type { AppState } from 'store'; + +type GradebookState = AppState['gradebook']; + +function getLocalState(state: AppState): GradebookState { + return state.gradebook; +} + +export const getCategories = (state: AppState): GradebookState['categories'] => + getLocalState(state).categories; +export const getTabs = (state: AppState): GradebookState['tabs'] => + getLocalState(state).tabs; +export const getAssessments = ( + state: AppState, +): GradebookState['assessments'] => getLocalState(state).assessments; +export const getStudents = (state: AppState): GradebookState['students'] => + getLocalState(state).students; +export const getSubmissions = ( + state: AppState, +): GradebookState['submissions'] => getLocalState(state).submissions; +export const getGamificationEnabled = ( + state: AppState, +): GradebookState['gamificationEnabled'] => + getLocalState(state).gamificationEnabled; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..00e3291032b --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,63 @@ +import { produce } from 'immer'; +import type { GradebookData } from 'types/course/gradebook'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from './types'; + +const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; + +interface GradebookState { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, +}; + +const reducer = produce( + (draft: GradebookState, action: SaveGradebookAction) => { + switch (action.type) { + case SAVE_GRADEBOOK: { + draft.categories = action.payload.categories; + draft.tabs = action.payload.tabs; + draft.assessments = action.payload.assessments; + draft.students = action.payload.students; + draft.submissions = action.payload.submissions; + draft.gamificationEnabled = action.payload.gamificationEnabled; + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: (data: GradebookData): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + payload: data, + }), +}; + +export default reducer; diff --git a/client/app/bundles/course/gradebook/types.ts b/client/app/bundles/course/gradebook/types.ts new file mode 100644 index 00000000000..f94aa7bf9c5 --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,8 @@ +export type { + AssessmentData, + CategoryData, + GradebookData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; diff --git a/client/app/bundles/course/statistics/operations.ts b/client/app/bundles/course/statistics/operations.ts index c2060ed41fe..1c071b810ef 100644 --- a/client/app/bundles/course/statistics/operations.ts +++ b/client/app/bundles/course/statistics/operations.ts @@ -1,8 +1,4 @@ -import { AxiosError } from 'axios'; -import { JobCompleted, JobErrored } from 'types/jobs'; - import CourseAPI from 'api/course'; -import pollJob from 'lib/helpers/jobHelpers'; import { AssessmentsStatistics, @@ -14,8 +10,6 @@ import { StudentsStatistics, } from './types'; -const DOWNLOAD_JOB_POLL_INTERVAL_MS = 2000; - export const fetchStatisticsIndex = async (): Promise => { const response = await CourseAPI.statistics.course.fetchStatisticsIndex(); return response.data; @@ -62,21 +56,3 @@ export const fetchCourseGetHelpActivity = async (params?: { await CourseAPI.statistics.course.fetchCourseGetHelpActivity(params); return response.data; }; - -export const downloadScoreSummary = ( - handleSuccess: (successData: JobCompleted) => void, - handleFailure: (error: JobErrored | AxiosError) => void, - assessmentIds: number[], -): void => { - CourseAPI.statistics.course - .downloadScoreSummary(assessmentIds) - .then((response) => { - pollJob( - response.data.jobUrl, - handleSuccess, - handleFailure, - DOWNLOAD_JOB_POLL_INTERVAL_MS, - ); - }) - .catch(handleFailure); -}; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx deleted file mode 100644 index 355bb97ee45..00000000000 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { Button, Typography } from '@mui/material'; -import { AxiosError } from 'axios'; -import { JobCompleted, JobErrored } from 'types/jobs'; - -import { downloadScoreSummary } from 'course/statistics/operations'; -import { CourseAssessment } from 'course/statistics/types'; -import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; -import loadingToast, { LoadingToast } from 'lib/hooks/toast/loadingToast'; -import useTranslation from 'lib/hooks/useTranslation'; - -interface AssessmentsScoreSummaryDownloadProps { - assessments: CourseAssessment[]; -} - -const translations = defineMessages({ - selectedNUsers: { - id: 'course.statistics.StatisticsIndex.assessments.selectedNUsers', - defaultMessage: - 'Download Score Summary ({n, plural, =1 {# assessment} other {# assessments}})', - }, - download: { - id: 'course.statistics.StatisticsIndex.assessments.downloadCsv', - defaultMessage: 'Download', - }, - downloadCsvDialogTitle: { - id: 'course.statistics.StatisticsIndex.assessments.downloadCsv', - defaultMessage: 'Download Score Summary for the following Assessments?', - }, - downloadScoreSummarySuccess: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummarySuccess', - defaultMessage: 'Successfully downloaded score summary', - }, - downloadScoreSummaryFailure: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummaryFailure', - defaultMessage: 'An error occurred while downloading score summary', - }, - downloadScoreSummaryPending: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummaryPending', - defaultMessage: - 'Please wait as your request to download is being processed', - }, -}); - -const AssessmentsScoreSummaryDownload = ( - props: AssessmentsScoreSummaryDownloadProps, -): JSX.Element => { - const { assessments } = props; - const { t } = useTranslation(); - - const [openDialog, setOpenDialog] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); - - const handleSuccess = - (loadToast: LoadingToast) => - (successData: JobCompleted): void => { - window.location.href = successData.redirectUrl!; - loadToast.success(t(translations.downloadScoreSummarySuccess)); - setIsDownloading(false); - setOpenDialog(false); - }; - - const handleFailure = - (loadToast: LoadingToast) => - (error: JobErrored | AxiosError): void => { - const message = - error?.message || t(translations.downloadScoreSummaryFailure); - loadToast.error(message); - setIsDownloading(false); - }; - - const handleOnClick = (): void => { - setIsDownloading(true); - const loadToast = loadingToast(t(translations.downloadScoreSummaryPending)); - downloadScoreSummary( - handleSuccess(loadToast), - handleFailure(loadToast), - assessments.map((assessment) => assessment.id), - ); - }; - - return ( - <> - - - setOpenDialog(false)} - open={openDialog} - primaryColor="info" - primaryLabel={t(translations.download)} - title={t(translations.downloadCsvDialogTitle)} - > - - {assessments.map((assessment) => ( -
  • {assessment.title}
  • - ))} -
    -
    - - ); -}; - -export default AssessmentsScoreSummaryDownload; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx index f8a8a4ed78a..930eef2ed5d 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx @@ -12,6 +12,7 @@ const AssessmentsStatistics: FC = () => { {(data) => ( )} diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx index 0b686edd539..450e1208850 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; -import { Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { CourseAssessment } from 'course/statistics/types'; import Link from 'lib/components/core/Link'; @@ -14,13 +14,12 @@ import { getAssessmentStatisticsURL, getAssessmentWithCategoryURL, getAssessmentWithTabURL, + getCourseGradebookURL, } from 'lib/helpers/url-builders'; import { getCourseId } from 'lib/helpers/url-helpers'; import useTranslation from 'lib/hooks/useTranslation'; import { formatMiniDateTime, formatSecondsDuration } from 'lib/moment'; -import AssessmentsScoreSummaryDownload from './AssessmentsScoreSummaryDownload'; - const translations = defineMessages({ title: { id: 'course.statistics.StatisticsIndex.assessments.title', @@ -78,15 +77,26 @@ const translations = defineMessages({ id: 'course.statistics.StatisticsIndex.assessments.searchBar', defaultMessage: 'Search by Assessment Title, Tab, or Category', }, + subtitle: { + id: 'course.statistics.StatisticsIndex.assessments.subtitle', + defaultMessage: + 'To view and export individual student grades, open Gradebook.', + }, + subtitleDisabled: { + id: 'course.statistics.StatisticsIndex.assessments.subtitleDisabled', + defaultMessage: + 'To view and export individual student grades, enable Gradebook.', + }, }); interface Props { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } const AssessmentsStatisticsTable: FC = (props) => { - const { numStudents, assessments } = props; + const { numStudents, assessments, gradebookEnabled } = props; const courseId = getCourseId(); const { t } = useTranslation(); @@ -243,9 +253,26 @@ const AssessmentsStatisticsTable: FC = (props) => { return ( <> - - {t(translations.tableTitle, { numStudents })} - + + + {t(translations.tableTitle, { numStudents })} + + + {gradebookEnabled + ? t(translations.subtitle, { + url: (chunks) => ( + {chunks} + ), + }) + : t(translations.subtitleDisabled, { + url: (chunks) => ( + + {chunks} + + ), + })} + + = (props) => { } getRowEqualityData={(assessment): CourseAssessment => assessment} getRowId={(assessment): string => assessment.id.toString()} - indexing={{ indices: true, rowSelectable: true }} + indexing={{ indices: true }} pagination={{ rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], showAllRows: true, @@ -285,15 +312,7 @@ const AssessmentsStatisticsTable: FC = (props) => { }, }, }} - toolbar={{ - show: true, - activeToolbar: (selectedAssessments): JSX.Element => ( - - ), - keepNative: true, - }} + toolbar={{ show: true }} /> ); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx new file mode 100644 index 00000000000..5eb96b0bd7b --- /dev/null +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from 'test-utils'; + +import { CourseAssessment } from 'course/statistics/types'; + +import AssessmentsStatisticsTable from '../AssessmentsStatisticsTable'; + +const assessments: CourseAssessment[] = [ + { + id: 1, + title: 'Quiz 1', + startAt: new Date('2026-01-01T00:00:00Z'), + tab: { id: 1, title: 'Tab 1' }, + category: { id: 1, title: 'Category 1' }, + maximumGrade: 10, + numSubmitted: 2, + numAttempted: 3, + numLate: 1, + }, +]; + +const renderTable = (gradebookEnabled = true): void => { + render( + , + { at: ['/courses/1/statistics/assessments'] }, + ); +}; + +describe('', () => { + // Regression guard: an orphaned rowSelectable (selection with no activeToolbar) + // makes the toolbar render an empty 6.5rem bar on select, shifting every row down. + // Re-enabling row selection here brings that layout bug back. + it('keeps row selection off so selecting cannot shift the table down', async () => { + renderTable(); + await screen.findByText('Quiz 1'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('renders the native CSV download button', async () => { + renderTable(); + expect(await screen.findByTestId('DownloadIcon')).toBeInTheDocument(); + }); + + it('points to the Gradebook for individual student grades when enabled', async () => { + renderTable(true); + const link = await screen.findByRole('link', { name: /gradebook/i }); + expect(link).toHaveAttribute('href', '/courses/1/gradebook'); + }); + + it('points to course settings to enable Gradebook when it is disabled', async () => { + renderTable(false); + const link = await screen.findByRole('link', { name: /gradebook/i }); + expect(link).toHaveAttribute('href', '/courses/1/admin/components'); + }); +}); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx index d1dd39720a3..c1e230f8964 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx @@ -115,7 +115,10 @@ const StatisticsIndex: FC = () => { return ( - + { diff --git a/client/app/bundles/course/statistics/types.ts b/client/app/bundles/course/statistics/types.ts index 8dea0dfc4bd..076743f504c 100644 --- a/client/app/bundles/course/statistics/types.ts +++ b/client/app/bundles/course/statistics/types.ts @@ -137,6 +137,7 @@ export interface CourseAssessment { export interface AssessmentsStatistics { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } export interface CourseGetHelpActivity { diff --git a/client/app/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/InvitationResultExistingTable.tsx b/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx index 51915776959..9f517937cf0 100644 --- a/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx @@ -7,7 +7,6 @@ import { ColumnTemplate } from 'lib/components/table'; import Table from 'lib/components/table/Table'; import { DEFAULT_MINI_TABLE_ROWS_PER_PAGE, - DEFAULT_TABLE_ROWS_PER_PAGE, TIMELINE_ALGORITHMS, } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx b/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx index 3da6e4d2da0..5b2ad57a8ae 100644 --- a/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx @@ -5,7 +5,6 @@ import { ColumnTemplate } from 'lib/components/table'; import Table from 'lib/components/table/Table'; import { DEFAULT_MINI_TABLE_ROWS_PER_PAGE, - DEFAULT_TABLE_ROWS_PER_PAGE, TIMELINE_ALGORITHMS, } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/users/store.ts b/client/app/bundles/users/store.ts index 230d7149d51..44ff5de5596 100644 --- a/client/app/bundles/users/store.ts +++ b/client/app/bundles/users/store.ts @@ -12,6 +12,8 @@ import { SaveCourseListAction, SaveInstanceListAction, SaveUserAction, + SET_CURRENT_USER_ID, + SetCurrentUserIdAction, } from './types'; const initialState: GlobalUserState = { @@ -33,6 +35,13 @@ const reducer = produce((draft: GlobalUserState, action: GlobalActionType) => { draft.user = userEntity; break; } + // Sets only the authenticated user's id, leaving name/imageUrl untouched. + // The course layout fetch knows the current user id but not the full profile; + // this is enough to namespace per-user client state (e.g. table column prefs). + case SET_CURRENT_USER_ID: { + draft.user.id = action.userId; + break; + } case SAVE_COURSE_LIST: { if (action.courses) { const coursesList = action.courses; @@ -67,6 +76,10 @@ export const actions = { return { type: SAVE_USER, user }; }, + setCurrentUserId: (userId: number): SetCurrentUserIdAction => { + return { type: SET_CURRENT_USER_ID, userId }; + }, + saveCourses: ( courses: UserCourseListData[], courseType: 'current' | 'completed', diff --git a/client/app/bundles/users/types.ts b/client/app/bundles/users/types.ts index f4cf9580e59..6a073977c78 100644 --- a/client/app/bundles/users/types.ts +++ b/client/app/bundles/users/types.ts @@ -12,6 +12,7 @@ import { // Action Names export const SAVE_USER = 'system/SAVE_USER'; +export const SET_CURRENT_USER_ID = 'system/SET_CURRENT_USER_ID'; export const SAVE_COURSE_LIST = 'system/SAVE_COURSE_LIST'; export const SAVE_INSTANCE_LIST = 'system/SAVE_INSTANCE_LIST'; @@ -21,6 +22,11 @@ export interface SaveUserAction { user: UserBasicListData; } +export interface SetCurrentUserIdAction { + type: typeof SET_CURRENT_USER_ID; + userId: number; +} + export interface SaveCourseListAction { type: typeof SAVE_COURSE_LIST; courses: UserCourseListData[]; @@ -34,6 +40,7 @@ export interface SaveInstanceListAction { export type GlobalActionType = | SaveUserAction + | SetCurrentUserIdAction | SaveCourseListAction | SaveInstanceListAction; diff --git a/client/app/lib/components/core/buttons/DownloadButton.tsx b/client/app/lib/components/core/buttons/DownloadButton.tsx index f241597144d..a9788884b08 100644 --- a/client/app/lib/components/core/buttons/DownloadButton.tsx +++ b/client/app/lib/components/core/buttons/DownloadButton.tsx @@ -24,7 +24,7 @@ const DownloadButton = (props: DownloadButtonProps): JSX.Element => ( > - {props.children} + {props.children} diff --git a/client/app/lib/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 ? ( + )} + + {toolbar.onDirectExport && ( + + + + + + )} + + + + {/* Default-weights banner: weights are unconfigured, so the table is + showing the equal-split fallback. Keeps the Configure CTA for managers. */} + {showingDefaults && ( + + {canManageWeights + ? t(translations.defaultWeights) + : t(translations.defaultWeightsNoAccess)} + + )} + {/* Degenerate case: weights are 0 and there is nothing to default + (no tab has any assessment). */} + {allWeightsZero && !showingDefaults && ( + + {canManageWeights + ? t(translations.noWeightsConfigured) + : t(translations.noWeightsNoAccess)} + + )} + {/* A bounded maxHeight is what makes `stickyHeader` actually stick: + `overflowX: 'auto'` already promotes this container to a scroll + container on BOTH axes (CSS computes overflow-y to `auto` when + overflow-x isn't `visible`), so the sticky sticks relative + to THIS element, not the page. Without a height cap the container + grows to fit all rows and never scrolls internally, leaving the + header no scroll range. Capping the height makes the body scroll + within the container while the header (and the frozen Name/checkbox + columns, same scroll context) stay pinned. The cap subtracts the + chrome above the body — breadcrumb, page header, view tabs, + toolbar — plus the pagination below, so the table fills the + remaining viewport; shorter classes shrink to fit (no whitespace). */} + +
    { + // 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, + // Outer top + left edges. Interior lines come from each cell's + // own bottom + right, so every separator stays a single 1px + // (no doubling) and the whole table reads as one uniform grid. + borderTop: gridLine, + borderLeft: gridLine, + '& th, & td': { + boxSizing: 'border-box', + border: 0, + borderBottom: gridLine, + borderRight: gridLine, + py: 0.25, + px: 1, + lineHeight: 1.2, + height: 32, + }, + // MUI's default `.MuiTableRow-root:last-child th { border: 0 }` + // (specificity 0,2,1, the `:last-child` pseudo-class) outranks + // the `& th` rule above (0,1,1) and silently zeroes ALL borders + // on the weight row — it is the last in . Re-assert + // the grid lines with a higher-specificity selector so they + // survive through the "% of grade" row. + '& thead tr:last-of-type th': { + borderTop: gridLine, + borderBottom: gridLine, + borderRight: gridLine, + }, + }; + }} + > + + {/* Row 1: Checkbox + Student + Categories + Total */} + + + + + + {t(tableTranslations.name)} + + {showEmail && ( + + {t(tableTranslations.email)} + + )} + {showExternalId && ( + + {t(tableTranslations.externalId)} + + )} + {visibleCategories.map((cat) => ( + + {cat.title} + + ))} + + + {t(translations.total)} + {/* The policy moved out of the header label into this ⓘ — + the descriptive sentence stays available on demand after + the one-time banner is dismissed. */} + + + + + + + + + + {/* Row 2: Tab titles */} + + {resolvedTabs.map((tab) => ( + + {tab.title} + + ))} + + + {/* Row 3: Weight subheaders */} + + {resolvedTabs.map((tab) => ( + + {tabSubheaderLabel(tab)} + + ))} + + {totalWeight === 100 ? ( + totalWeightHeaderLabel + ) : ( + + + {displayMode === 'percent' + ? t(translations.percentTotalWarning, { + weight: totalWeight, + }) + : t(translations.outOfWeight, { + weight: totalWeight, + })} + + + )} + + + + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + const studentId = row.original.studentId; + const isExpanded = expandedIds.has(studentId); + return ( + + + {/* Body sticky-left cells sit at zIndex 1 — strictly + below the header's sticky cells (MUI gives every + stickyHeader cell zIndex 2). On a z-index tie the cell + later in the DOM (the body) wins, so matching z2 here + lets scrolled rows bleed up over the header in any + column the frozen Name cell (z4) doesn't cover — i.e. + the identity columns once they're toggled on. */} + + + + + toggleExpanded(studentId)} + size="small" + sx={{ + mr: 0.5, + p: 0.25, + }} + > + {isExpanded ? ( + + ) : ( + + )} + + {row.original.name} + + {showEmail && ( + {row.original.email} + )} + {showExternalId && ( + {row.original.externalId ?? ''} + )} + {row.original.subtotals.map((subtotal, i) => { + const weight = resolvedTabs[i].gradebookWeight ?? 0; + return ( + + {fmtDisplay( + tabDisplayValue(subtotal, weight), + columnPrecisions.tabs[i], + )} + + ); + })} + + {fmtDisplay( + totalDisplayValue(row.original.total), + columnPrecisions.total, + )} + + + {isExpanded && + (breakdownsByStudent.get(studentId) ?? []).flatMap( + (tb, tabIdx) => + tb.assessments.map((a) => { + const isExcluded = a.excluded; + // Weightage is always "% of grade" — it never + // follows the points/percent lens. + const weightText = t( + translations.percentOfGrade, + { + weight: + Math.round(a.effectiveWeight * 100) / 100, + }, + ); + const gradeText = + a.grade === null + ? `—/${a.maxGrade}` + : `${a.grade}/${a.maxGrade}`; + return ( + + {/* Empty checkbox cell so the breakdown row + carries the same checkbox | name divider (the + universal cell borderRight) as the rows above. */} + + {/* Title over a muted "raw mark · weightage" + subtitle, stacked and confined to the (sticky) + Name column. The breakdown row freezes the same + checkbox | Name region as the student rows above — + the identity columns get their own empty cells + after this — so the layout reads identically + whether identity columns are shown or not. A left + indent sits the title under the student name (past + the expand chevron), signalling these are that + student's assessments. */} + + {/* nowrap keeps the title on one line: its + max-content width then drives the table's auto + layout, expanding the (frozen) Name column to fit + the longest title. With the metadata line also + nowrap, every breakdown row is exactly 2 lines — + no fixed widths, no JS measurement. */} + + {a.title} + + {/* Muted metadata on its own line below the + title: raw mark · effective weightage, kept on + one line (nowrap). Weightage is always "% of + grade" — never routed through the points/percent + lens. */} + + {`${gradeText} · ${isExcluded ? t(translations.excluded) : weightText}`} + + + {/* One empty cell per visible identity column so + the grid lines stay aligned with the rows above. + These scroll with the table (only checkbox + Name + are frozen), matching the student rows. */} + {showEmail && } + {showExternalId && } + {resolvedTabs.map((tab, i) => { + const tabCellValue = isExcluded + ? '—' + : fmtDisplay( + breakdownDisplayValue(a), + columnPrecisions.tabs[i], + ); + return ( + + {i === tabIdx ? tabCellValue : ''} + + ); + })} + + + ); + }), + )} + + ); + })} + +
    + + {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/ProjectedTotalHint.tsx b/client/app/bundles/course/gradebook/components/ProjectedTotalHint.tsx new file mode 100644 index 00000000000..3ff0f0a4d2f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/ProjectedTotalHint.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const PROJECTED_TOTAL_POLICY_HINT_KEY = + 'gradebook_projected_total_policy_hint'; + +// Single source for the projected-total policy sentence: the one-time banner +// below and the ⓘ tooltip on the Total header (in GradebookWeightedTable) both +// render this exact message, so the explanation never drifts between the two. +export const projectedTotalPolicyTranslations = defineMessages({ + policy: { + id: 'course.gradebook.ProjectedTotalHint.policy', + defaultMessage: 'Totals count ungraded assessments as 0.', + }, +}); + +/** + * One-time, dismissable banner shown the first time a manager opens the weighted + * (Weighted total) gradebook view. It teaches the projected-total policy — ungraded + * assessments are counted as 0 — so a low total isn't mistaken for a bug. After + * dismissal the policy stays available via the ⓘ tooltip on the Total header. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const ProjectedTotalHint: FC = () => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + PROJECTED_TOTAL_POLICY_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
    + + + {t(projectedTotalPolicyTranslations.policy)} + + +
    + ); +}; + +export default ProjectedTotalHint; 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..fb09b295390 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/WeightedGradebookColumnTree.tsx @@ -0,0 +1,79 @@ +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 tableTranslations from 'lib/translations/table'; + +import { STUDENT_INFO_COL_IDS, type StudentInfoColId } from '../constants'; + +const translations = defineMessages({ + studentInfo: { + id: 'course.gradebook.GradebookColumnTree.studentInfo', + defaultMessage: 'Student info', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +const WeightedGradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, +}: ColumnPickerRenderContext): JSX.Element => { + const { t } = useTranslation(); + const context: ColumnPickerRenderContext = { + isVisible, + setVisible, + setManyVisible, + }; + + return ( +
    + + {STUDENT_INFO_COL_IDS.map((id: StudentInfoColId) => + id === 'name' ? ( + + {t(tableTranslations[id])} + + + } + /> + ) : ( + 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/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts new file mode 100644 index 00000000000..d4bb0424f70 --- /dev/null +++ b/client/app/bundles/course/gradebook/computeWeighted.ts @@ -0,0 +1,312 @@ +// client/app/bundles/course/gradebook/computeWeighted.ts +import { + AssessmentData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; + +type GradeEntry = Pick; + +export interface WeightedRow { + studentId: number; + name: string; + email: string; + externalId: string | null; + subtotals: (number | null)[]; + total: number | null; +} + +export interface AssessmentContribution { + assessmentId: number; + title: string; + grade: number | null; + maxGrade: number; + points: number; // contribution to this tab's weighted-points cell + // Share of the overall grade this assessment carries, in percentage points. + // Equal mode: the tab's weight split evenly across its assessments. + // Custom mode: the assessment's own configured weight. + effectiveWeight: number; + excluded: boolean; +} + +export interface TabBreakdown { + tabId: number; + assessments: AssessmentContribution[]; +} + +type GradeLookup = Map; + +const gradeKey = (studentId: number, assessmentId: number): string => + `${studentId}:${assessmentId}`; + +// Index submissions by (student, assessment) once: O(submissions). +const buildGradeLookup = (submissions: GradeEntry[]): 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; +}; + +// Equal-weight formula: average of (grade/maxGrade) ratios over INCLUDED assessments. +// Excluded assessments are dropped from both numerator and count; ungraded included +// contribute 0. Returns null when no assessment is included. +const equalSubtotal = ( + studentId: number, + tabAssessments: AssessmentData[], + gradeLookup: GradeLookup, +): number | null => { + const included = tabAssessments.filter((a) => !a.gradebookExcluded); + if (included.length === 0) return null; + const ratios = included.map((a) => { + const grade = gradeLookup.get(gradeKey(studentId, a.id)); + return grade != null ? grade / a.maxGrade : 0; + }); + return ratios.reduce((acc, r) => acc + r, 0) / ratios.length; +}; + +// Custom-weight formula: Σ(grade_i/maxGrade_i × assessmentWeight_i) / tabWeight over +// INCLUDED assessments. Returns null if tabWeight=0 or no assessment is included; +// ungraded included assessments contribute 0. +const customSubtotal = ( + studentId: number, + tab: TabData, + tabAssessments: AssessmentData[], + gradeLookup: GradeLookup, +): number | null => { + const tabWeight = tab.gradebookWeight ?? 0; + if (tabWeight === 0) return null; + let numerator = 0; + let hasContributing = false; + tabAssessments.forEach((a) => { + if (a.gradebookExcluded) return; + const grade = gradeLookup.get(gradeKey(studentId, a.id)); + const assessmentWeight = a.gradebookWeight ?? 0; + if (grade != null) numerator += (grade / a.maxGrade) * assessmentWeight; + hasContributing = true; + }); + return hasContributing ? numerator / tabWeight : null; +}; + +// Single source of truth for the subtotal math, operating on prebuilt indexes. +const subtotalFromLookup = ( + studentId: number, + tab: TabData, + tabAssessments: AssessmentData[] | undefined, + gradeLookup: GradeLookup, +): number | null => { + if (!tabAssessments || tabAssessments.length === 0) return null; + if (tab.weightMode === 'custom') { + return customSubtotal(studentId, tab, tabAssessments, gradeLookup); + } + return equalSubtotal(studentId, tabAssessments, gradeLookup); +}; + +// 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: GradeEntry[]; +} + +export const computeTabSubtotal = ({ + studentId, + tab, + assessments, + submissions, +}: SubtotalArgs): number | null => + subtotalFromLookup( + studentId, + tab, + assessments.filter((a) => a.tabId === tab.id), + buildGradeLookup(submissions), + ); + +interface TotalArgs { + studentId: number; + tabs: TabData[]; + assessments: AssessmentData[]; + submissions: GradeEntry[]; +} + +export const computeStudentTotal = ({ + studentId, + tabs, + assessments, + submissions, +}: TotalArgs): number | null => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + const subtotals = tabs.map((tab) => + subtotalFromLookup( + studentId, + tab, + assessmentsByTab.get(tab.id), + gradeLookup, + ), + ); + return totalFromSubtotals(subtotals, tabs); +}; + +export const computeStudentBreakdown = ({ + studentId, + tabs, + assessments, + submissions, +}: TotalArgs): TabBreakdown[] => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + return tabs.map((tab) => { + const list = assessmentsByTab.get(tab.id) ?? []; + const weight = tab.gradebookWeight ?? 0; + const includedCount = list.filter((a) => !a.gradebookExcluded).length; + + const contributions = list.map((a) => { + const excluded = !!a.gradebookExcluded; + const grade = gradeLookup.get(gradeKey(studentId, a.id)) ?? null; + const ratio = grade != null ? grade / a.maxGrade : 0; + let points: number; + let effectiveWeight: number; + if (excluded) { + points = 0; + effectiveWeight = 0; + } else if (tab.weightMode === 'custom') { + points = ratio * (a.gradebookWeight ?? 0); + effectiveWeight = a.gradebookWeight ?? 0; + } else { + points = includedCount > 0 ? (ratio / includedCount) * weight : 0; + effectiveWeight = includedCount > 0 ? weight / includedCount : 0; + } + return { + assessmentId: a.id, + title: a.title, + grade, + maxGrade: a.maxGrade, + points, + effectiveWeight, + excluded, + }; + }); + return { tabId: tab.id, assessments: contributions }; + }); +}; + +interface WeightedRowsArgs { + students: StudentData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + submissions: GradeEntry[]; +} + +// 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, +}: WeightedRowsArgs): WeightedRow[] => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + return students.map((student) => { + const subtotals = tabs.map((tab) => + subtotalFromLookup( + student.id, + tab, + assessmentsByTab.get(tab.id), + gradeLookup, + ), + ); + return { + studentId: student.id, + name: student.name, + email: student.email, + externalId: student.externalId, + subtotals, + total: totalFromSubtotals(subtotals, tabs), + }; + }); +}; + +export const sumWeights = (tabs: TabData[]): number => + tabs.reduce((acc, t) => acc + (t.gradebookWeight ?? 0), 0); + +const r2 = (n: number): number => Math.round(n * 100) / 100; + +// Ids of tabs that have at least one assessment — only these are eligible for a +// default weight (an empty tab carries no grades, so weighting it is wasted). +const nonEmptyTabIds = ( + tabs: TabData[], + assessments: Pick[], +): number[] => { + const populated = new Set(assessments.map((a) => a.tabId)); + return tabs.filter((t) => populated.has(t.id)).map((t) => t.id); +}; + +// True when no tab weight has been configured (every weight is 0/null) yet there +// is at least one tab to weight — i.e. the table is showing the equal-split +// default rather than an instructor's configuration. Drives the "default weights" +// banner and dialog pre-fill so both read from one source of truth. +export const usingDefaultWeights = ( + tabs: TabData[], + assessments: Pick[], +): boolean => + sumWeights(tabs) === 0 && nonEmptyTabIds(tabs, assessments).length > 0; + +// When no weights are configured, distribute 100 equally across non-empty tabs so +// the weighted view is meaningful out of the box; the last such tab absorbs the +// rounding remainder so the result sums to exactly 100. Returns the input array +// unchanged (same reference) once any tab carries a weight, so a real configuration +// is never overwritten. +export const resolveTabWeights = ( + tabs: TabData[], + assessments: Pick[], +): TabData[] => { + if (sumWeights(tabs) !== 0) return tabs; + const ids = nonEmptyTabIds(tabs, assessments); + const n = ids.length; + if (n === 0) return tabs; + const base = r2(100 / n); + const weightById = new Map( + ids.map((id, i) => [id, i === n - 1 ? r2(100 - base * (n - 1)) : base]), + ); + return tabs.map((tab) => + weightById.has(tab.id) + ? { + ...tab, + gradebookWeight: weightById.get(tab.id), + weightMode: tab.weightMode ?? 'equal', + } + : tab, + ); +}; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts index 35790580ed0..ae2f962fbbf 100644 --- a/client/app/bundles/course/gradebook/operations.ts +++ b/client/app/bundles/course/gradebook/operations.ts @@ -1,4 +1,5 @@ import type { Operation } from 'store'; +import type { UpdateWeightsPayload } from 'types/course/gradebook'; import CourseAPI from 'api/course'; @@ -9,4 +10,11 @@ const fetchGradebook = (): Operation => async (dispatch) => { 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 index 6d90823b5e4..ae7fc55e029 100644 --- a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -1,8 +1,8 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useState, useTransition } from 'react'; import { defineMessages } from 'react-intl'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { PeopleAlt } from '@mui/icons-material'; -import { Typography } from '@mui/material'; +import { Tab, Tabs, Typography } from '@mui/material'; import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; @@ -12,15 +12,19 @@ import useTranslation from 'lib/hooks/useTranslation'; import { useCourseContext } from '../../../container/CourseLoader'; import GradebookTable from '../../components/GradebookTable'; +import GradebookWeightedTable from '../../components/GradebookWeightedTable'; import GradeLinkHint from '../../components/GradeLinkHint'; +import WeightedViewHint from '../../components/WeightedViewHint'; import fetchGradebook from '../../operations'; import { getAssessments, + getCanManageWeights, getCategories, getGamificationEnabled, getStudents, getSubmissions, getTabs, + getWeightedViewEnabled, } from '../../selectors'; const translations = defineMessages({ @@ -40,6 +44,14 @@ const translations = defineMessages({ 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: 'Weighted total', + }, }); const GradebookIndex: FC = () => { @@ -48,7 +60,12 @@ const GradebookIndex: FC = () => { 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); @@ -56,6 +73,8 @@ const GradebookIndex: FC = () => { const students = useAppSelector(getStudents); const submissions = useAppSelector(getSubmissions); const gamificationEnabled = useAppSelector(getGamificationEnabled); + const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); + const canManageWeights = useAppSelector(getCanManageWeights); useEffect(() => { dispatch(fetchGradebook()) @@ -78,27 +97,74 @@ const GradebookIndex: FC = () => { ); + } else if (weightedViewEnabled && viewMode === 'weighted') { + content = ( + + ); } else { content = ( - <> - - - + ); } return ( - {content} + {!isLoading && canManageWeights && !weightedViewEnabled && ( + + )} + {weightedViewEnabled && !isLoading && students.length > 0 && ( + + startTransition(() => { + setViewMode(v); + setSearchParams(v === 'weighted' ? { view: 'weighted' } : {}); + }) + } + TabIndicatorProps={{ style: { height: 2 } }} + value={viewMode} + > + + + + )} + {!isLoading && + students.length > 0 && + !(weightedViewEnabled && viewMode === 'weighted') && } +
    + {isPending && ( +
    + +
    + )} +
    + {content} +
    +
    ); }; diff --git a/client/app/bundles/course/gradebook/selectors.ts b/client/app/bundles/course/gradebook/selectors.ts index fbe62e2611a..0fb7d1398d5 100644 --- a/client/app/bundles/course/gradebook/selectors.ts +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -22,3 +22,7 @@ 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 index 00e3291032b..4777e16b3b6 100644 --- a/client/app/bundles/course/gradebook/store.ts +++ b/client/app/bundles/course/gradebook/store.ts @@ -1,5 +1,8 @@ import { produce } from 'immer'; -import type { GradebookData } from 'types/course/gradebook'; +import type { + GradebookData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; import type { AssessmentData, @@ -10,6 +13,7 @@ import type { } from './types'; const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; +const UPDATE_TAB_WEIGHTS = 'course/gradebook/UPDATE_TAB_WEIGHTS'; interface GradebookState { categories: CategoryData[]; @@ -18,6 +22,8 @@ interface GradebookState { students: StudentData[]; submissions: SubmissionData[]; gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; } interface SaveGradebookAction { @@ -25,6 +31,11 @@ interface SaveGradebookAction { payload: GradebookData; } +interface UpdateTabWeightsAction { + type: typeof UPDATE_TAB_WEIGHTS; + payload: UpdateWeightsPayload; +} + const initialState: GradebookState = { categories: [], tabs: [], @@ -32,10 +43,15 @@ const initialState: GradebookState = { students: [], submissions: [], gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, }; const reducer = produce( - (draft: GradebookState, action: SaveGradebookAction) => { + ( + draft: GradebookState, + action: SaveGradebookAction | UpdateTabWeightsAction, + ) => { switch (action.type) { case SAVE_GRADEBOOK: { draft.categories = action.payload.categories; @@ -44,6 +60,43 @@ const reducer = produce( 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, + weightMode, + assessmentWeights, + excludedAssessmentIds, + }) => { + const tab = draft.tabs.find((t) => t.id === tabId); + if (tab) { + tab.gradebookWeight = weight; + tab.weightMode = weightMode; + } + const excludedSet = new Set(excludedAssessmentIds ?? []); + const tabAssessments = draft.assessments.filter( + (a) => a.tabId === tabId, + ); + tabAssessments.forEach((a) => { + a.gradebookExcluded = excludedSet.has(a.id); + }); + if (weightMode === 'equal') { + tabAssessments.forEach((a) => { + a.gradebookWeight = null; + }); + } else if (assessmentWeights) { + assessmentWeights.forEach(({ assessmentId, weight: aw }) => { + const a = draft.assessments.find((x) => x.id === assessmentId); + if (a) a.gradebookWeight = aw; + }); + } + }, + ); break; } default: @@ -58,6 +111,12 @@ export const actions = { 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 index f94aa7bf9c5..b91689df872 100644 --- a/client/app/bundles/course/gradebook/types.ts +++ b/client/app/bundles/course/gradebook/types.ts @@ -5,4 +5,5 @@ export type { StudentData, SubmissionData, TabData, + UpdateWeightsPayload, } from 'types/course/gradebook'; diff --git a/client/app/lib/components/core/buttons/SegmentedSwitch.tsx b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx new file mode 100644 index 00000000000..ad82714b64d --- /dev/null +++ b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx @@ -0,0 +1,235 @@ +import { + KeyboardEvent, + ReactNode, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { Box, ButtonBase, Tooltip } from '@mui/material'; + +export interface SegmentedSwitchOption { + value: T; + /** Visible content. Keep it short — one word reads best in this control. */ + label: ReactNode; + /** Optional hint shown on hover/focus of the segment. */ + tooltip?: ReactNode; + /** + * Accessible name for the segment. Falls back to `label` when that is a + * plain string; supply this when `label` is an icon or other non-text node. + */ + ariaLabel?: string; +} + +interface SegmentedSwitchProps { + /** The currently selected option's value. */ + value: T; + /** The options, left to right. Designed for 2 but renders any count. */ + options: SegmentedSwitchOption[]; + /** Fired with the next value when a different segment is chosen. */ + onChange: (value: T) => void; + /** Accessible name for the whole control (the radiogroup). */ + ariaLabel: string; + disabled?: boolean; + /** + * Pass `self-stretch` to grow the switch to a taller row neighbour (e.g. a + * small `TextField`), so the two align without hardcoding a height. + */ + className?: string; +} + +// Sized to MUI `size="small"` controls: 13px text, ~30.75px tall. Pixels are +// resolved through the theme's `pxToRem` so the switch matches its siblings +// regardless of the app's `htmlFontSize` (Coursemology uses 10, so a hardcoded +// rem would render ~38% too small). `MIN_HEIGHT` is a floor — `self-stretch` +// lets the switch grow to match a taller neighbour in the same flex row. +const FONT_PX = 13; +const PADDING_X = 1.5; +const MIN_HEIGHT = 30.75; + +/** + * A compact, peer-state mode switcher: a pill track with the options side by + * side and a single elevated thumb that slides to the active one. + * + * Unlike a `Switch`, neither option reads as "off" — both are equally valid — + * and unlike `ToggleButtonGroup` it stays content-width, so it fits a packed + * toolbar or a dense prompt row. Use it when a binary choice has no default + * "on" state (e.g. Points vs. Percentage, Equal vs. Custom). + * + * Keyboard: arrows move and select; roving tabindex keeps it a single tab stop. + */ +const SegmentedSwitch = ( + props: SegmentedSwitchProps, +): JSX.Element => { + const { value, options, onChange, ariaLabel, disabled, className } = props; + + const containerRef = useRef(null); + const optionRefs = useRef<(HTMLButtonElement | null)[]>([]); + const [thumb, setThumb] = useState<{ left: number; width: number }>({ + left: 0, + width: 0, + }); + + const activeIndex = Math.max( + 0, + options.findIndex((o) => o.value === value), + ); + + // Slide the thumb under the active segment. Segments are content-width, so + // both offset and width are measured. Re-measure on selection change and on + // any resize (font load, container reflow) — a one-shot measure would leave + // the thumb stranded after either. + useLayoutEffect(() => { + const measure = (): void => { + const el = optionRefs.current[activeIndex]; + const container = containerRef.current; + if (!el || !container) return; + // `offsetLeft` is measured from the container's border box, but the thumb + // (`left: 0`) is anchored to its padding box — just inside the border. + // Subtract the border width (`clientLeft`) so the two share a coordinate + // system; without it the active-left thumb loses its inset and sits flush + // against the border. + setThumb({ + left: el.offsetLeft - container.clientLeft, + width: el.offsetWidth, + }); + }; + measure(); + const observer = new ResizeObserver(measure); + if (containerRef.current) observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [activeIndex, options.length]); + + const select = (index: number): void => { + const next = options[index]; + if (next && next.value !== value) onChange(next.value); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (disabled) return; + const last = options.length - 1; + let next = activeIndex; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + next = activeIndex === last ? 0 : activeIndex + 1; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + next = activeIndex === 0 ? last : activeIndex - 1; + } else { + return; + } + event.preventDefault(); + select(next); + optionRefs.current[next]?.focus(); + }; + + return ( + ({ + position: 'relative', + display: 'inline-flex', + alignItems: 'stretch', + minHeight: MIN_HEIGHT, + boxSizing: 'border-box', + p: '3px', + borderRadius: 999, + bgcolor: theme.palette.action.hover, + border: `1px solid ${theme.palette.divider}`, + lineHeight: 1, + opacity: disabled ? 0.5 : 1, + pointerEvents: disabled ? 'none' : 'auto', + })} + > + {/* The sliding thumb. Hidden until measured (width 0) to avoid a flash + from the top-left corner on first paint. */} + ({ + position: 'absolute', + top: '3px', + bottom: '3px', + left: 0, + width: thumb.width, + borderRadius: 999, + bgcolor: theme.palette.background.paper, + boxShadow: theme.shadows[1], + opacity: thumb.width === 0 ? 0 : 1, + transform: `translateX(${thumb.left}px)`, + transition: theme.transitions.create(['transform', 'width'], { + duration: 260, + easing: 'cubic-bezier(0.34, 1.36, 0.64, 1)', + }), + zIndex: 0, + })} + /> + {options.map((option, index) => { + const selected = index === activeIndex; + const label = + option.ariaLabel ?? + (typeof option.label === 'string' ? option.label : undefined); + const segment = ( + { + optionRefs.current[index] = el; + }} + aria-checked={selected} + aria-label={label} + disabled={disabled} + disableRipple + onClick={() => select(index)} + role="radio" + sx={(theme) => ({ + position: 'relative', + zIndex: 1, + fontFamily: 'inherit', + fontSize: theme.typography.pxToRem(FONT_PX), + fontWeight: selected ? 650 : 550, + letterSpacing: '0.01em', + color: selected + ? theme.palette.text.primary + : theme.palette.text.secondary, + px: PADDING_X, + // Height comes from the track's `minHeight` + `align-items: + // stretch`; the segment fills it and centers its label, so no + // vertical padding of its own. + py: 0, + borderRadius: 999, + whiteSpace: 'nowrap', + transition: theme.transitions.create('color', { duration: 180 }), + '&:hover': { color: theme.palette.text.primary }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: 2, + }, + })} + tabIndex={selected ? 0 : -1} + > + {option.label} + + ); + + return option.tooltip ? ( + + {segment} + + ) : ( + + {segment} + + ); + })} + + ); +}; + +export default SegmentedSwitch; diff --git a/client/app/lib/components/core/buttons/__test__/SegmentedSwitch.test.tsx b/client/app/lib/components/core/buttons/__test__/SegmentedSwitch.test.tsx new file mode 100644 index 00000000000..ee3eb271832 --- /dev/null +++ b/client/app/lib/components/core/buttons/__test__/SegmentedSwitch.test.tsx @@ -0,0 +1,106 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, within } from 'test-utils'; + +import SegmentedSwitch from '../SegmentedSwitch'; + +// Render synchronously without the real provider's locale-loading spinner +// (uses the manual mock at lib/components/wrappers/__mocks__/I18nProvider). +jest.mock('lib/components/wrappers/I18nProvider'); + +const OPTIONS = [ + { value: 'points', label: 'Points' }, + { value: 'percent', label: 'Percentage' }, +] as const; + +const setup = ( + value: 'points' | 'percent', + overrides: Partial< + Parameters>[0] + > = {}, +): { onChange: jest.Mock } => { + const onChange = jest.fn(); + render( + , + ); + return { onChange }; +}; + +describe('', () => { + it('renders a radiogroup with one radio per option, named by ariaLabel', () => { + setup('points'); + const group = screen.getByRole('radiogroup', { name: 'Display mode' }); + expect(within(group).getAllByRole('radio')).toHaveLength(2); + expect(screen.getByRole('radio', { name: 'Points' })).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: 'Percentage' }), + ).toBeInTheDocument(); + }); + + it('marks only the selected option aria-checked', () => { + setup('percent'); + expect(screen.getByRole('radio', { name: 'Points' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + expect(screen.getByRole('radio', { name: 'Percentage' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('keeps a single tab stop via roving tabindex', () => { + setup('points'); + expect(screen.getByRole('radio', { name: 'Points' })).toHaveAttribute( + 'tabindex', + '0', + ); + expect(screen.getByRole('radio', { name: 'Percentage' })).toHaveAttribute( + 'tabindex', + '-1', + ); + }); + + it('fires onChange with the chosen value when an inactive option is clicked', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + await user.click(screen.getByRole('radio', { name: 'Percentage' })); + expect(onChange).toHaveBeenCalledWith('percent'); + }); + + it('does not fire onChange when the already-active option is clicked', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + await user.click(screen.getByRole('radio', { name: 'Points' })); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('moves selection with arrow keys', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + screen.getByRole('radio', { name: 'Points' }).focus(); + await user.keyboard('{ArrowRight}'); + expect(onChange).toHaveBeenCalledWith('percent'); + }); + + it('wraps arrow navigation at the ends', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + screen.getByRole('radio', { name: 'Points' }).focus(); + await user.keyboard('{ArrowLeft}'); + expect(onChange).toHaveBeenCalledWith('percent'); + }); + + it('disables every option and suppresses onChange when disabled', () => { + const { onChange } = setup('points', { disabled: true }); + const percent = screen.getByRole('radio', { name: 'Percentage' }); + expect(percent).toBeDisabled(); + fireEvent.click(percent); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/client/app/lib/components/wrappers/__mocks__/I18nProvider.tsx b/client/app/lib/components/wrappers/__mocks__/I18nProvider.tsx new file mode 100644 index 00000000000..71d61318e33 --- /dev/null +++ b/client/app/lib/components/wrappers/__mocks__/I18nProvider.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react'; +import { IntlProvider } from 'react-intl'; + +// Manual mock for lib/components/wrappers/I18nProvider. The real provider renders +// a LoadingIndicator while it async-loads locale messages, so suites that assert +// synchronously would only ever see the spinner. Activate this synchronous +// stand-in per-suite with `jest.mock('lib/components/wrappers/I18nProvider')`. +// It is NOT applied automatically — only when a test opts in — so suites relying +// on the real provider's loading transition are unaffected. +const I18nProvider = ({ children }: { children: ReactNode }): JSX.Element => ( + + {children} + +); + +export default I18nProvider; diff --git a/client/app/lib/containers/AppContainer/AppContainer.tsx b/client/app/lib/containers/AppContainer/AppContainer.tsx index 817c6e3ce40..f51baac146e 100644 --- a/client/app/lib/containers/AppContainer/AppContainer.tsx +++ b/client/app/lib/containers/AppContainer/AppContainer.tsx @@ -1,6 +1,9 @@ +import { useEffect } from 'react'; import { Outlet, useRouteError } from 'react-router-dom'; +import { actions } from 'bundles/users/store'; import NotificationPopup from 'lib/containers/NotificationPopup'; +import { useAppDispatch } from 'lib/hooks/store'; import { loader, useAppLoader } from './AppLoader'; import GlobalAnnouncements from './GlobalAnnouncements'; @@ -9,6 +12,19 @@ import ServerUnreachableBanner from './ServerUnreachableBanner'; const AppContainer = (): JSX.Element => { const data = useAppLoader(); const homeData = data.home; + const dispatch = useAppDispatch(); + + useEffect(() => { + if (homeData.user) { + dispatch( + actions.saveUser({ + id: homeData.user.id, + name: homeData.user.name, + imageUrl: homeData.user.avatarUrl, + }), + ); + } + }, [homeData.user?.id]); return (
    diff --git a/client/app/routers/course/admin.tsx b/client/app/routers/course/admin.tsx index f9e1e1028b1..a157415aaf6 100644 --- a/client/app/routers/course/admin.tsx +++ b/client/app/routers/course/admin.tsx @@ -119,6 +119,17 @@ const adminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'gradebook', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'GradebookSettings' */ + 'course/admin/pages/GradebookSettings' + ) + ).default, + }), + }, { path: 'comments', lazy: async (): Promise> => ({ diff --git a/client/app/types/course/admin/gradebook.ts b/client/app/types/course/admin/gradebook.ts new file mode 100644 index 00000000000..13301111fa0 --- /dev/null +++ b/client/app/types/course/admin/gradebook.ts @@ -0,0 +1,9 @@ +export interface GradebookSettingsData { + weightedViewEnabled: boolean; +} + +export interface GradebookSettingsPostData { + settings_gradebook_component: { + weighted_view_enabled: GradebookSettingsData['weightedViewEnabled']; + }; +} diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts index e57b2cdd6d2..c9009afbfe0 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -7,6 +7,8 @@ export interface TabData { id: number; title: string; categoryId: number; + gradebookWeight?: number; + weightMode?: 'equal' | 'custom'; } export interface AssessmentData { @@ -14,6 +16,8 @@ export interface AssessmentData { title: string; tabId: number; maxGrade: number; + gradebookWeight?: number | null; + gradebookExcluded?: boolean; } export interface StudentData { @@ -39,4 +43,16 @@ export interface GradebookData { students: StudentData[]; submissions: SubmissionData[]; gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; +} + +export interface UpdateWeightsPayload { + weights: { + tabId: number; + weight: number; + weightMode?: 'equal' | 'custom'; + excludedAssessmentIds?: number[]; + assessmentWeights?: { assessmentId: number; weight: number }[]; + }[]; } diff --git a/client/jest.config.js b/client/jest.config.js index 947fb088890..38d6c438357 100644 --- a/client/jest.config.js +++ b/client/jest.config.js @@ -24,6 +24,7 @@ const config = { '^course(.*)$': '/app/bundles/course$1', '^store(.*)$': '/app/store$1', '^lodash-es(.*)$': 'lodash$1', + 'compiled-locales/.*\\.json$': '/app/__test__/mocks/localeMock.js', }, testPathIgnorePatterns: ['/node_modules/', '/dist/'], coveragePathIgnorePatterns: ['/node_modules/', '/__test__/'], diff --git a/client/locales/en.json b/client/locales/en.json index 5a536c15e2f..1d9bf1cce66 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9268,5 +9268,164 @@ }, "lib.translations.table.column.newExternalId": { "defaultMessage": "New External ID" + }, + "course.admin.GradebookSettings.gradebookSettings": { + "defaultMessage": "Gradebook settings" + }, + "course.admin.GradebookSettings.weightedViewEnabled": { + "defaultMessage": "Enable weighted grade view" + }, + "course.admin.GradebookSettings.weightedViewEnabledHint": { + "defaultMessage": "Enables a \"Weighted total\" view in the gradebook where staff can configure per-tab weights and see a weighted Total column." + }, + "course.gradebook.ConfigureWeightsPrompt.allExcluded": { + "defaultMessage": "All assessments in \"{tab}\" are excluded — it contributes nothing to the total." + }, + "course.gradebook.ConfigureWeightsPrompt.allExcludedCount": { + "defaultMessage": "All {n} excluded" + }, + "course.gradebook.ConfigureWeightsPrompt.customMode": { + "defaultMessage": "Custom" + }, + "course.gradebook.ConfigureWeightsPrompt.customSum": { + "defaultMessage": "Assessment weights: {sum} / {total}" + }, + "course.gradebook.ConfigureWeightsPrompt.defaultsHint": { + "defaultMessage": "No weights set yet — these are suggested defaults with every tab counting equally. Save to confirm, or adjust below." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionDrop": { + "defaultMessage": "In Equal mode, optionally drop each student's N lowest-scoring assessments before averaging." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionExclusion": { + "defaultMessage": "Expand a tab to include or exclude individual assessments from grading." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionIntro": { + "defaultMessage": "Control how tabs and assessments count toward each student's total grade." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionModes": { + "defaultMessage": "Choose Equal (all assessments share the tab's weight) or Custom (set each assessment's share)." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionWeights": { + "defaultMessage": "Set each tab's weight — how much it contributes to the total (weights should sum to 100)." + }, + "course.gradebook.ConfigureWeightsPrompt.equalMode": { + "defaultMessage": "Equal" + }, + "course.gradebook.ConfigureWeightsPrompt.excluded": { + "defaultMessage": "Excluded" + }, + "course.gradebook.ConfigureWeightsPrompt.excludedCount": { + "defaultMessage": "{n} excluded" + }, + "course.gradebook.ConfigureWeightsPrompt.includeAssessment": { + "defaultMessage": "Include {assessment} in grade" + }, + "course.gradebook.ConfigureWeightsPrompt.modeAria": { + "defaultMessage": "{tab} weight mode" + }, + "course.gradebook.ConfigureWeightsPrompt.ofGrade": { + "defaultMessage": "{pct}% of grade" + }, + "course.gradebook.ConfigureWeightsPrompt.promptTitle": { + "defaultMessage": "Configure contributions" + }, + "course.gradebook.ConfigureWeightsPrompt.saveError": { + "defaultMessage": "Failed to save weights. Please try again." + }, + "course.gradebook.ConfigureWeightsPrompt.total": { + "defaultMessage": "Total: {sum}%" + }, + "course.gradebook.ConfigureWeightsPrompt.unbalanced": { + "defaultMessage": "Assessment weights for \"{tab}\" must sum to its tab total before saving." + }, + "course.gradebook.ConfigureWeightsPrompt.valueTooHigh": { + "defaultMessage": "Value must be at most 100" + }, + "course.gradebook.ConfigureWeightsPrompt.valueTooLow": { + "defaultMessage": "Value must be at least 0" + }, + "course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum": { + "defaultMessage": "Weights do not sum to 100. Saving is allowed; Total may be inaccurate." + }, + "course.gradebook.GradebookIndex.allAssessments": { + "defaultMessage": "All assessments" + }, + "course.gradebook.GradebookIndex.byWeight": { + "defaultMessage": "Weighted total" + }, + "course.gradebook.GradebookWeightedTable.collapseRow": { + "defaultMessage": "Collapse {name}" + }, + "course.gradebook.GradebookWeightedTable.configureWeights": { + "defaultMessage": "Configure Weights" + }, + "course.gradebook.GradebookWeightedTable.defaultWeights": { + "defaultMessage": "Showing default weights — every tab counts equally. Click \"Configure Weights\" to set your own." + }, + "course.gradebook.GradebookWeightedTable.defaultWeightsNoAccess": { + "defaultMessage": "Showing default weights — every tab counts equally until weights are configured." + }, + "course.gradebook.GradebookWeightedTable.displayPercent": { + "defaultMessage": "Percentage" + }, + "course.gradebook.GradebookWeightedTable.displayPercentTooltip": { + "defaultMessage": "What fraction of each tab the student earned. 100% on a tab worth 20% = the student earned all 20 grade points from that tab." + }, + "course.gradebook.GradebookWeightedTable.displayPoints": { + "defaultMessage": "Points" + }, + "course.gradebook.GradebookWeightedTable.displayPointsTooltip": { + "defaultMessage": "How many grade points each tab contributes. Columns add up to the projected total." + }, + "course.gradebook.GradebookWeightedTable.downloadCsv": { + "defaultMessage": "Download as CSV" + }, + "course.gradebook.GradebookWeightedTable.email": { + "defaultMessage": "Email" + }, + "course.gradebook.GradebookWeightedTable.excluded": { + "defaultMessage": "Excluded" + }, + "course.gradebook.GradebookWeightedTable.expandRow": { + "defaultMessage": "Expand {name}" + }, + "course.gradebook.GradebookWeightedTable.name": { + "defaultMessage": "Name" + }, + "course.gradebook.GradebookWeightedTable.noWeightsConfigured": { + "defaultMessage": "No weights configured — all tab weights are 0. Click \"Configure Weights\" to assign weights." + }, + "course.gradebook.GradebookWeightedTable.noWeightsNoAccess": { + "defaultMessage": "No tab weights have been configured yet." + }, + "course.gradebook.GradebookWeightedTable.outOfWeight": { + "defaultMessage": "/{weight}" + }, + "course.gradebook.GradebookWeightedTable.percentOfGrade": { + "defaultMessage": "{weight}% of grade" + }, + "course.gradebook.GradebookWeightedTable.percentTotalExact": { + "defaultMessage": "100% total" + }, + "course.gradebook.GradebookWeightedTable.percentTotalWarning": { + "defaultMessage": "{weight}% total" + }, + "course.gradebook.GradebookWeightedTable.total": { + "defaultMessage": "Total" + }, + "course.gradebook.GradebookWeightedTable.searchStudents": { + "defaultMessage": "Search students" + }, + "course.gradebook.GradebookWeightedTable.weightsDoNotSum": { + "defaultMessage": "Weights do not sum to 100. Total may be inaccurate." + }, + "course.gradebook.TotalHint.policy": { + "defaultMessage": "Totals count ungraded assessments as 0." + }, + "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}." + }, + "course.gradebook.WeightedViewHint.settingsLink": { + "defaultMessage": "Gradebook settings" } -} +} \ No newline at end of file diff --git a/client/locales/ko.json b/client/locales/ko.json index 2855b1a3a4e..9f9c0c593cb 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9253,5 +9253,164 @@ }, "lib.translations.table.column.newExternalId": { "defaultMessage": "새 외부 ID" + }, + "course.admin.GradebookSettings.gradebookSettings": { + "defaultMessage": "성적부 설정" + }, + "course.admin.GradebookSettings.weightedViewEnabled": { + "defaultMessage": "가중 성적 보기 사용" + }, + "course.admin.GradebookSettings.weightedViewEnabledHint": { + "defaultMessage": "성적부에 \"가중 총점\" 보기를 추가하여 교직원이 탭별 가중치를 설정하고 가중 총점 열을 확인할 수 있습니다." + }, + "course.gradebook.ConfigureWeightsPrompt.allExcluded": { + "defaultMessage": "“{tab}”의 모든 평가가 제외되어 총점에 반영되지 않습니다." + }, + "course.gradebook.ConfigureWeightsPrompt.allExcludedCount": { + "defaultMessage": "전체 {n}개 제외됨" + }, + "course.gradebook.ConfigureWeightsPrompt.customMode": { + "defaultMessage": "사용자 지정" + }, + "course.gradebook.ConfigureWeightsPrompt.customSum": { + "defaultMessage": "평가 가중치: {sum} / {total}" + }, + "course.gradebook.ConfigureWeightsPrompt.defaultsHint": { + "defaultMessage": "아직 설정된 가중치가 없습니다. 모든 탭이 동일한 비중으로 반영되도록 제안된 기본값입니다. 저장하여 확정하거나 아래에서 조정하세요." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionDrop": { + "defaultMessage": "동일 모드에서는 평균을 내기 전에 각 학생의 점수가 가장 낮은 평가 N개를 선택적으로 제외할 수 있습니다." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionExclusion": { + "defaultMessage": "탭을 펼쳐 개별 평가를 성적에 포함하거나 제외하세요." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionIntro": { + "defaultMessage": "각 탭과 평가가 학생의 총 성적에 어떻게 반영되는지 관리합니다." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionModes": { + "defaultMessage": "“동일”(모든 평가가 탭의 가중치를 동일하게 나눔)또는 “사용자 지정”(각 평가의 비중을 설정)을 선택하세요." + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionWeights": { + "defaultMessage": "각 탭의 가중치, 즉 총 성적에 기여하는 비중을 설정하세요. 가중치 합계는 100이어야 합니다." + }, + "course.gradebook.ConfigureWeightsPrompt.equalMode": { + "defaultMessage": "동일" + }, + "course.gradebook.ConfigureWeightsPrompt.excluded": { + "defaultMessage": "제외됨" + }, + "course.gradebook.ConfigureWeightsPrompt.excludedCount": { + "defaultMessage": "{n}개 제외됨" + }, + "course.gradebook.ConfigureWeightsPrompt.includeAssessment": { + "defaultMessage": "{assessment} 성적에 포함" + }, + "course.gradebook.ConfigureWeightsPrompt.modeAria": { + "defaultMessage": "{tab} 가중치 모드" + }, + "course.gradebook.ConfigureWeightsPrompt.ofGrade": { + "defaultMessage": "성적의 {pct}%" + }, + "course.gradebook.ConfigureWeightsPrompt.promptTitle": { + "defaultMessage": "기여도 설정" + }, + "course.gradebook.ConfigureWeightsPrompt.saveError": { + "defaultMessage": "가중치를 저장하지 못했습니다. 다시 시도해 주세요." + }, + "course.gradebook.ConfigureWeightsPrompt.total": { + "defaultMessage": "합계: {sum}%" + }, + "course.gradebook.ConfigureWeightsPrompt.unbalanced": { + "defaultMessage": "저장하기 전에 “{tab}”의 평가 가중치 합계가 해당 탭의 총 가중치와 같아야 합니다." + }, + "course.gradebook.ConfigureWeightsPrompt.valueTooHigh": { + "defaultMessage": "값은 최대 100이어야 합니다" + }, + "course.gradebook.ConfigureWeightsPrompt.valueTooLow": { + "defaultMessage": "값은 최소 0이어야 합니다" + }, + "course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum": { + "defaultMessage": "가중치 합계가 100이 아닙니다. 저장은 가능하지만 총점이 정확하지 않을 수 있습니다." + }, + "course.gradebook.GradebookIndex.allAssessments": { + "defaultMessage": "전체 평가" + }, + "course.gradebook.GradebookIndex.byWeight": { + "defaultMessage": "가중 총점" + }, + "course.gradebook.GradebookWeightedTable.collapseRow": { + "defaultMessage": "{name} 접기" + }, + "course.gradebook.GradebookWeightedTable.configureWeights": { + "defaultMessage": "가중치 설정" + }, + "course.gradebook.GradebookWeightedTable.defaultWeights": { + "defaultMessage": "기본 가중치를 표시하고 있습니다. 모든 탭이 동일하게 반영됩니다. \"가중치 설정\"을 클릭하여 직접 설정하세요." + }, + "course.gradebook.GradebookWeightedTable.defaultWeightsNoAccess": { + "defaultMessage": "기본 가중치를 표시하고 있습니다. 가중치가 설정되기 전까지 모든 탭이 동일하게 반영됩니다." + }, + "course.gradebook.GradebookWeightedTable.displayPercent": { + "defaultMessage": "백분율" + }, + "course.gradebook.GradebookWeightedTable.displayPercentTooltip": { + "defaultMessage": "학생이 각 탭에서 획득한 비율입니다. 비중이 20%인 탭에서 100%는 해당 탭의 20점을 모두 획득했음을 의미합니다." + }, + "course.gradebook.GradebookWeightedTable.displayPoints": { + "defaultMessage": "점수" + }, + "course.gradebook.GradebookWeightedTable.displayPointsTooltip": { + "defaultMessage": "각 탭이 기여하는 성적 점수입니다. 각 열의 합이 예상 총점이 됩니다." + }, + "course.gradebook.GradebookWeightedTable.downloadCsv": { + "defaultMessage": "CSV로 다운로드" + }, + "course.gradebook.GradebookWeightedTable.email": { + "defaultMessage": "이메일" + }, + "course.gradebook.GradebookWeightedTable.excluded": { + "defaultMessage": "제외됨" + }, + "course.gradebook.GradebookWeightedTable.expandRow": { + "defaultMessage": "{name} 펼치기" + }, + "course.gradebook.GradebookWeightedTable.name": { + "defaultMessage": "이름" + }, + "course.gradebook.GradebookWeightedTable.noWeightsConfigured": { + "defaultMessage": "설정된 가중치가 없습니다. 모든 탭의 가중치가 0입니다. \"가중치 설정\"을 클릭하여 가중치를 지정하세요." + }, + "course.gradebook.GradebookWeightedTable.noWeightsNoAccess": { + "defaultMessage": "아직 탭 가중치가 설정되지 않았습니다." + }, + "course.gradebook.GradebookWeightedTable.outOfWeight": { + "defaultMessage": "/{weight}" + }, + "course.gradebook.GradebookWeightedTable.percentOfGrade": { + "defaultMessage": "성적의 {weight}%" + }, + "course.gradebook.GradebookWeightedTable.percentTotalExact": { + "defaultMessage": "합계 100%" + }, + "course.gradebook.GradebookWeightedTable.percentTotalWarning": { + "defaultMessage": "합계 {weight}%" + }, + "course.gradebook.GradebookWeightedTable.total": { + "defaultMessage": "총점" + }, + "course.gradebook.GradebookWeightedTable.searchStudents": { + "defaultMessage": "학생 검색" + }, + "course.gradebook.GradebookWeightedTable.weightsDoNotSum": { + "defaultMessage": "가중치 합계가 100이 아닙니다. 총점이 정확하지 않을 수 있습니다." + }, + "course.gradebook.TotalHint.policy": { + "defaultMessage": "총점은 채점되지 않은 평가를 0점으로 계산합니다." + }, + "course.gradebook.WeightedViewHint.hint": { + "defaultMessage": "가중 총점이 필요하신가요? 각 탭이 학생의 전체 성적에 반영되는 비중을 설정하고 여기에서 가중 총점을 확인할 수 있습니다. {link}에서 활성화하세요." + }, + "course.gradebook.WeightedViewHint.settingsLink": { + "defaultMessage": "성적부 설정" } } diff --git a/client/locales/zh.json b/client/locales/zh.json index 41c456b03e7..9d5c753e3e3 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9247,5 +9247,164 @@ }, "lib.translations.table.column.newExternalId": { "defaultMessage": "新外部编号" + }, + "course.admin.GradebookSettings.gradebookSettings": { + "defaultMessage": "成绩册设置" + }, + "course.admin.GradebookSettings.weightedViewEnabled": { + "defaultMessage": "启用加权成绩视图" + }, + "course.admin.GradebookSettings.weightedViewEnabledHint": { + "defaultMessage": "在成绩册中启用“加权总成绩”视图,教职员可以配置各标签页的权重并查看加权总成绩列。" + }, + "course.gradebook.ConfigureWeightsPrompt.allExcluded": { + "defaultMessage": "“{tab}”中的所有评估均已排除,且不会计入总成绩。" + }, + "course.gradebook.ConfigureWeightsPrompt.allExcludedCount": { + "defaultMessage": "已排除全部 {n} 个" + }, + "course.gradebook.ConfigureWeightsPrompt.customMode": { + "defaultMessage": "自定义" + }, + "course.gradebook.ConfigureWeightsPrompt.customSum": { + "defaultMessage": "评估权重:{sum} / {total}" + }, + "course.gradebook.ConfigureWeightsPrompt.defaultsHint": { + "defaultMessage": "尚未设置权重。以下为建议默认值,所有标签页均按相同比重计算。保存以确认,或在下方进行调整。" + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionDrop": { + "defaultMessage": "在等权模式下,可选择在求平均分前剔除每位学生得分最低的 N 个评估。" + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionExclusion": { + "defaultMessage": "展开标签页以将个别评估纳入或排除在评分之外。" + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionIntro": { + "defaultMessage": "控制各标签页和评估如何计入每位学生的总成绩。" + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionModes": { + "defaultMessage": "选择“等权”(所有评估平分该标签页的权重)或“自定义”(设置每个评估所占的份额)。" + }, + "course.gradebook.ConfigureWeightsPrompt.descriptionWeights": { + "defaultMessage": "设置每个标签页的权重,即其对总成绩的贡献比例(权重合计应为 100)。" + }, + "course.gradebook.ConfigureWeightsPrompt.equalMode": { + "defaultMessage": "等权" + }, + "course.gradebook.ConfigureWeightsPrompt.excluded": { + "defaultMessage": "已排除" + }, + "course.gradebook.ConfigureWeightsPrompt.excludedCount": { + "defaultMessage": "已排除 {n} 个" + }, + "course.gradebook.ConfigureWeightsPrompt.includeAssessment": { + "defaultMessage": "将 {assessment} 计入成绩" + }, + "course.gradebook.ConfigureWeightsPrompt.modeAria": { + "defaultMessage": "{tab} 权重模式" + }, + "course.gradebook.ConfigureWeightsPrompt.ofGrade": { + "defaultMessage": "占成绩的 {pct}%" + }, + "course.gradebook.ConfigureWeightsPrompt.promptTitle": { + "defaultMessage": "配置贡献比例" + }, + "course.gradebook.ConfigureWeightsPrompt.saveError": { + "defaultMessage": "保存权重失败。请重试。" + }, + "course.gradebook.ConfigureWeightsPrompt.total": { + "defaultMessage": "总计:{sum}%" + }, + "course.gradebook.ConfigureWeightsPrompt.unbalanced": { + "defaultMessage": "保存前,“{tab}”的评估权重合计必须等于该标签页的总权重。" + }, + "course.gradebook.ConfigureWeightsPrompt.valueTooHigh": { + "defaultMessage": "数值最多为 100" + }, + "course.gradebook.ConfigureWeightsPrompt.valueTooLow": { + "defaultMessage": "数值至少为 0" + }, + "course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum": { + "defaultMessage": "权重合计不为 100。仍可保存;总成绩可能不准确。" + }, + "course.gradebook.GradebookIndex.allAssessments": { + "defaultMessage": "全部评估" + }, + "course.gradebook.GradebookIndex.byWeight": { + "defaultMessage": "加权总成绩" + }, + "course.gradebook.GradebookWeightedTable.collapseRow": { + "defaultMessage": "收起 {name}" + }, + "course.gradebook.GradebookWeightedTable.configureWeights": { + "defaultMessage": "配置权重" + }, + "course.gradebook.GradebookWeightedTable.defaultWeights": { + "defaultMessage": "正在显示默认权重——所有标签页比重相同。点击“配置权重”进行自定义设置。" + }, + "course.gradebook.GradebookWeightedTable.defaultWeightsNoAccess": { + "defaultMessage": "正在显示默认权重——在配置权重之前,所有标签页比重相同。" + }, + "course.gradebook.GradebookWeightedTable.displayPercent": { + "defaultMessage": "百分比" + }, + "course.gradebook.GradebookWeightedTable.displayPercentTooltip": { + "defaultMessage": "学生在各标签页所获得的比例。在比重为 20% 的标签页获得 100%,即表示学生获得了该标签页的全部 20 个成绩分。" + }, + "course.gradebook.GradebookWeightedTable.displayPoints": { + "defaultMessage": "分数" + }, + "course.gradebook.GradebookWeightedTable.displayPointsTooltip": { + "defaultMessage": "各标签页贡献的成绩分数。各列相加即为预计总成绩。" + }, + "course.gradebook.GradebookWeightedTable.downloadCsv": { + "defaultMessage": "下载为 CSV" + }, + "course.gradebook.GradebookWeightedTable.email": { + "defaultMessage": "电子邮件" + }, + "course.gradebook.GradebookWeightedTable.excluded": { + "defaultMessage": "已排除" + }, + "course.gradebook.GradebookWeightedTable.expandRow": { + "defaultMessage": "展开 {name}" + }, + "course.gradebook.GradebookWeightedTable.name": { + "defaultMessage": "姓名" + }, + "course.gradebook.GradebookWeightedTable.noWeightsConfigured": { + "defaultMessage": "尚未配置权重——所有标签页的权重均为 0。点击“配置权重”以分配权重。" + }, + "course.gradebook.GradebookWeightedTable.noWeightsNoAccess": { + "defaultMessage": "尚未配置任何标签页权重。" + }, + "course.gradebook.GradebookWeightedTable.outOfWeight": { + "defaultMessage": "/{weight}" + }, + "course.gradebook.GradebookWeightedTable.percentOfGrade": { + "defaultMessage": "占成绩的 {weight}%" + }, + "course.gradebook.GradebookWeightedTable.percentTotalExact": { + "defaultMessage": "合计 100%" + }, + "course.gradebook.GradebookWeightedTable.percentTotalWarning": { + "defaultMessage": "合计 {weight}%" + }, + "course.gradebook.GradebookWeightedTable.total": { + "defaultMessage": "总成绩" + }, + "course.gradebook.GradebookWeightedTable.searchStudents": { + "defaultMessage": "搜索学生" + }, + "course.gradebook.GradebookWeightedTable.weightsDoNotSum": { + "defaultMessage": "权重合计不为 100。总成绩可能不准确。" + }, + "course.gradebook.TotalHint.policy": { + "defaultMessage": "总成绩将未评分的评估按 0 分计算。" + }, + "course.gradebook.WeightedViewHint.hint": { + "defaultMessage": "需要加权总成绩吗?您可以设置每个标签页在每位学生总成绩中所占的比重,并在此查看加权总成绩。请在{link}中启用。" + }, + "course.gradebook.WeightedViewHint.settingsLink": { + "defaultMessage": "成绩册设置" } } diff --git a/config/locales/en/activerecord/errors.yml b/config/locales/en/activerecord/errors.yml index 95f0da1660a..713858a6dcb 100644 --- a/config/locales/en/activerecord/errors.yml +++ b/config/locales/en/activerecord/errors.yml @@ -97,6 +97,7 @@ en: autograded_no_partial_answer: 'There are updated answers which have not been re-submitted yet. Please re-submit all answers before finalising your submission.' course/assessment/tab: deletion: 'the last tab cannot be deleted' + custom_weights_mismatch: 'Custom assessment weights must sum to the tab total' course/condition: attributes: conditional: diff --git a/config/locales/ko/activerecord/errors.yml b/config/locales/ko/activerecord/errors.yml index 62c4016a70f..6b6c01e11c2 100644 --- a/config/locales/ko/activerecord/errors.yml +++ b/config/locales/ko/activerecord/errors.yml @@ -95,6 +95,7 @@ ko: autograded_no_partial_answer: '업데이트된 답변이 아직 다시 제출되지 않았습니다. 제출을 완료하기 전에 모든 답변을 다시 제출하세요.' course/assessment/tab: deletion: '마지막 탭은 삭제할 수 없습니다' + custom_weights_mismatch: '사용자 지정 평가 가중치의 합은 해당 탭의 총점과 같아야 합니다.' course/condition: attributes: conditional: diff --git a/config/locales/zh/activerecord/errors.yml b/config/locales/zh/activerecord/errors.yml index 5cbb439debc..6a654814f36 100644 --- a/config/locales/zh/activerecord/errors.yml +++ b/config/locales/zh/activerecord/errors.yml @@ -95,6 +95,7 @@ zh: autograded_no_partial_answer: '有一些更新的答案还没有重新提交。请在最后提交前重新提交所有答案。' course/assessment/tab: deletion: '最后一项无法被删除' + custom_weights_mismatch: '自定义评估权重的总和必须等于该标签的总分。' course/condition: attributes: conditional: diff --git a/config/routes.rb b/config/routes.rb index 9746e03277f..0ff051754ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -196,6 +196,9 @@ get 'leaderboard' => 'leaderboard_settings#edit' patch 'leaderboard' => 'leaderboard_settings#update' + get 'gradebook' => 'gradebook_settings#edit' + patch 'gradebook' => 'gradebook_settings#update' + get 'comments' => 'discussion/topic_settings#edit', as: 'topics' patch 'comments' => 'discussion/topic_settings#update' @@ -498,6 +501,7 @@ resource :gradebook, only: [] do get '/' => 'gradebook#index' + patch '/weights' => 'gradebook#update_weights' end scope module: :discussion do diff --git a/db/migrate/20260611000000_create_gradebook_contribution_tables.rb b/db/migrate/20260611000000_create_gradebook_contribution_tables.rb new file mode 100644 index 00000000000..bebf1873fb4 --- /dev/null +++ b/db/migrate/20260611000000_create_gradebook_contribution_tables.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +class CreateGradebookContributionTables < ActiveRecord::Migration[7.2] + def change + create_table :course_gradebook_contributions do |t| + t.references :course, null: false, foreign_key: { to_table: :courses }, + index: { name: 'fk__course_gradebook_contributions_course_id' } + t.references :tab, null: true, + foreign_key: { to_table: :course_assessment_tabs, on_delete: :cascade }, + index: { unique: true, + name: 'index_course_gradebook_contributions_on_tab_id' } + t.decimal :weight, precision: 5, scale: 2, null: false, default: 0 + t.integer :weight_mode, null: false, default: 0 + t.integer :keep_highest, null: false, default: 0 + + t.references :creator, null: false, foreign_key: { to_table: :users }, + index: { name: 'fk__course_gradebook_contributions_creator_id' } + t.references :updater, null: false, foreign_key: { to_table: :users }, + index: { name: 'fk__course_gradebook_contributions_updater_id' } + t.timestamps null: false + end + + create_table :course_gradebook_assessment_contributions do |t| + t.references :assessment, null: false, + foreign_key: { to_table: :course_assessments, on_delete: :cascade }, + index: { unique: true, + name: 'index_cgac_on_assessment_id' } + t.decimal :weight, precision: 5, scale: 2, null: true + t.boolean :excluded, null: false, default: false + + t.references :creator, null: false, foreign_key: { to_table: :users }, + index: { name: 'fk__cgac_creator_id' } + t.references :updater, null: false, foreign_key: { to_table: :users }, + index: { name: 'fk__cgac_updater_id' } + t.timestamps null: false + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 1d320432f9a..d1a22b40e89 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_14_052933) do +ActiveRecord::Schema[7.2].define(version: 2026_06_11_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -856,6 +856,35 @@ t.index ["updater_id"], name: "fk__course_forums_updater_id" end + create_table "course_gradebook_assessment_contributions", force: :cascade do |t| + t.bigint "assessment_id", null: false + t.decimal "weight", precision: 5, scale: 2 + t.boolean "excluded", default: false, null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assessment_id"], name: "index_cgac_on_assessment_id", unique: true + t.index ["creator_id"], name: "fk__cgac_creator_id" + t.index ["updater_id"], name: "fk__cgac_updater_id" + end + + create_table "course_gradebook_contributions", force: :cascade do |t| + t.bigint "course_id", null: false + t.bigint "tab_id" + t.decimal "weight", precision: 5, scale: 2, default: "0.0", null: false + t.integer "weight_mode", default: 0, null: false + t.integer "keep_highest", default: 0, null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["course_id"], name: "fk__course_gradebook_contributions_course_id" + t.index ["creator_id"], name: "fk__course_gradebook_contributions_creator_id" + t.index ["tab_id"], name: "index_course_gradebook_contributions_on_tab_id", unique: true + t.index ["updater_id"], name: "fk__course_gradebook_contributions_updater_id" + end + create_table "course_group_categories", force: :cascade do |t| t.bigint "course_id", null: false t.string "name", default: "", null: false @@ -1915,6 +1944,13 @@ add_foreign_key "course_forums", "courses", name: "fk_course_forums_course_id" add_foreign_key "course_forums", "users", column: "creator_id", name: "fk_course_forums_creator_id" add_foreign_key "course_forums", "users", column: "updater_id", name: "fk_course_forums_updater_id" + add_foreign_key "course_gradebook_assessment_contributions", "course_assessments", column: "assessment_id", on_delete: :cascade + add_foreign_key "course_gradebook_assessment_contributions", "users", column: "creator_id" + add_foreign_key "course_gradebook_assessment_contributions", "users", column: "updater_id" + add_foreign_key "course_gradebook_contributions", "course_assessment_tabs", column: "tab_id", on_delete: :cascade + add_foreign_key "course_gradebook_contributions", "courses" + add_foreign_key "course_gradebook_contributions", "users", column: "creator_id" + add_foreign_key "course_gradebook_contributions", "users", column: "updater_id" add_foreign_key "course_group_categories", "courses" add_foreign_key "course_group_categories", "users", column: "creator_id" add_foreign_key "course_group_categories", "users", column: "updater_id" diff --git a/spec/controllers/course/admin/gradebook_settings_controller_spec.rb b/spec/controllers/course/admin/gradebook_settings_controller_spec.rb new file mode 100644 index 00000000000..0423499d2c9 --- /dev/null +++ b/spec/controllers/course/admin/gradebook_settings_controller_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Admin::GradebookSettingsController, type: :controller do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + let(:teaching_assistant) { create(:course_teaching_assistant, course: course) } + + describe '#edit' do + context 'as manager' do + render_views + before { controller_sign_in(controller, manager.user) } + + it 'returns settings JSON' do + get :edit, params: { course_id: course.id }, format: :json + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body).to include('weightedViewEnabled' => false) + end + end + + context 'as teaching assistant' do + before { controller_sign_in(controller, teaching_assistant.user) } + + it 'is denied' do + expect do + get :edit, params: { course_id: course.id }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + end + + describe '#update' do + context 'as manager' do + render_views + before { controller_sign_in(controller, manager.user) } + + it 'updates weighted_view_enabled and returns 200' do + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: true } }, + format: :json + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body).to include('weightedViewEnabled' => true) + end + + it 'preserves existing tab gradebook_weights when toggling setting' do + category = create(:course_assessment_category, course: course) + tab = create(:course_assessment_tab, category: category) + contribution = create(:course_gradebook_contribution, tab: tab, course: course, weight: 50) + + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: true } }, + format: :json + expect(contribution.reload.weight).to eq(50) + + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: false } }, + format: :json + expect(contribution.reload.weight).to eq(50) + end + end + + context 'as teaching assistant' do + before { controller_sign_in(controller, teaching_assistant.user) } + + it 'is denied' do + expect do + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: true } }, + format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + end + end +end diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb index a213e80f476..59397f4120d 100644 --- a/spec/controllers/course/gradebook_controller_spec.rb +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -200,5 +200,228 @@ end end end + + describe 'PATCH update_weights' do + let(:manager) { create(:course_manager, course: course) } + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:student) { create(:course_student, course: course) } + let(:category) { create(:course_assessment_category, course: course) } + let!(:tab1) { create(:course_assessment_tab, category: category) } + let!(:tab2) { create(:course_assessment_tab, category: category) } + def weight_for(tab) + Course::Gradebook::Contribution.find_by(tab_id: tab.id)&.weight + end + + let(:valid_payload) do + { weights: [{ tabId: tab1.id, weight: 60 }, { tabId: tab2.id, weight: 40 }] } + end + + context 'as manager' do + before { controller_sign_in(controller, manager.user) } + + it 'updates and returns 200' do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + expect(response).to have_http_status(:ok) + expect(weight_for(tab1)).to eq(60) + expect(weight_for(tab2)).to eq(40) + end + + it 'accepts sum < 100' do + patch :update_weights, + params: { course_id: course.id, weights: [tabId: tab1.id, weight: 30] }, + format: :json + expect(response).to have_http_status(:ok) + end + + it 'accepts sum > 100' do + patch :update_weights, + params: { course_id: course.id, + weights: [{ tabId: tab1.id, weight: 70 }, { tabId: tab2.id, weight: 70 }] }, + format: :json + expect(response).to have_http_status(:ok) + end + + it 'rejects negative with 422 and no partial write' do + create(:course_gradebook_contribution, tab: tab1, course: course, weight: 10) + patch :update_weights, + params: { course_id: course.id, + weights: [{ tabId: tab1.id, weight: 50 }, { tabId: tab2.id, weight: -1 }] }, + format: :json + expect(response).to have_http_status(:unprocessable_entity) + expect(weight_for(tab1)).to eq(10) + end + + it 'rejects >100 with 422' do + patch :update_weights, + params: { course_id: course.id, weights: [tabId: tab1.id, weight: 101] }, + format: :json + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'rejects foreign tab id with 422' do + other_course = create(:course) + other_tab = create(:course_assessment_tab, + category: create(:course_assessment_category, course: other_course)) + patch :update_weights, + params: { course_id: course.id, weights: [tabId: other_tab.id, weight: 50] }, + format: :json + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as TA' do + before { controller_sign_in(controller, ta.user) } + it 'is denied' do + expect do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + + context 'as student' do + before { controller_sign_in(controller, student.user) } + it 'is denied' do + expect do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + + context 'when setting is disabled' do + before { controller_sign_in(controller, manager.user) } + + it 'still allows update (storage independent of display)' do + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + expect(response).to have_http_status(:ok) + expect(weight_for(tab1)).to eq(60) + end + end + + describe '#update_weights with modes' do + render_views + + let(:category) { create(:course_assessment_category, course: course) } + let(:tab) { create(:course_assessment_tab, category: category) } + let!(:a1) { create(:assessment, course: course, tab: tab) } + let!(:a2) { create(:assessment, course: course, tab: tab) } + + before { controller_sign_in(controller, manager.user) } + + it 'persists custom mode + assessment weights and echoes them back' do + post :update_weights, as: :json, params: { + course_id: course.id, + weights: [{ + tabId: tab.id, weight: '50', weightMode: 'custom', + assessmentWeights: [ + { assessmentId: a1.id, weight: '30' }, + { assessmentId: a2.id, weight: '20' } + ] + }] + } + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + entry = body['weights'].first + expect(entry['weightMode']).to eq('custom') + expect(entry['assessmentWeights']).to contain_exactly( + { 'assessmentId' => a1.id, 'weight' => 30.0 }, + { 'assessmentId' => a2.id, 'weight' => 20.0 } + ) + expect(a1.reload.gradebook_assessment_contribution.weight).to eq(30.0) + end + + it 'returns 422 when custom weights do not sum to the tab total' do + post :update_weights, as: :json, params: { + course_id: course.id, + weights: [{ + tabId: tab.id, weight: '50', weightMode: 'custom', + assessmentWeights: [{ assessmentId: a1.id, weight: '10' }] + }] + } + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'persists and echoes per-assessment exclusion in equal mode' do + post :update_weights, as: :json, params: { + course_id: course.id, + weights: [{ + tabId: tab.id, weight: '50', weightMode: 'equal', + excludedAssessmentIds: [a1.id] + }] + } + expect(response).to have_http_status(:ok) + expect(a1.reload.gradebook_assessment_contribution.excluded).to eq(true) + expect(a2.reload.gradebook_assessment_contribution.excluded).to eq(false) + entry = JSON.parse(response.body)['weights'].first + expect(entry['excludedAssessmentIds']).to eq([a1.id]) + end + end + end + + describe 'GET index — weighted view fields' do + render_views + let(:manager) { create(:course_manager, course: course) } + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:category) { create(:course_assessment_category, course: course) } + let!(:tab) { create(:course_assessment_tab, category: category) } + let!(:contribution) { create(:course_gradebook_contribution, tab: tab, course: course, weight: 30) } + let!(:assessment) do + create(:course_assessment_assessment, :published_with_mcq_question, + course: course, tab: tab) + end + + context 'when setting is disabled (default)' do + before { controller_sign_in(controller, manager.user) } + + it 'returns weightedViewEnabled false and omits gradebookWeight per tab' do + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + expect(body['weightedViewEnabled']).to eq(false) + tab_json = body['tabs'].find { |t| t['id'] == tab.id } + expect(tab_json).not_to have_key('gradebookWeight') + end + end + + context 'when setting is enabled' do + before do + ctx = Struct.new(:current_course, :key).new(course, Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(ctx).weighted_view_enabled = true + course.save! + end + + it 'includes weightedViewEnabled true and gradebookWeight per tab for manager' do + controller_sign_in(controller, manager.user) + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + expect(body['weightedViewEnabled']).to eq(true) + expect(body['canManageWeights']).to eq(true) + tab_json = body['tabs'].find { |t| t['id'] == tab.id } + expect(tab_json['gradebookWeight']).to eq(30) + end + + it 'returns canManageWeights false for TA' do + controller_sign_in(controller, ta.user) + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + expect(body['canManageWeights']).to eq(false) + end + + it 'serializes weightMode on tabs and gradebookWeight on assessments when weighted view is enabled' do + controller_sign_in(controller, manager.user) + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + tab_json = body['tabs'].find { |t| t['id'] == tab.id } + expect(tab_json).to have_key('weightMode') + expect(body['assessments'].first).to have_key('gradebookWeight') + end + + it 'serializes gradebookExcluded on assessments when weighted view is enabled' do + controller_sign_in(controller, manager.user) + get :index, params: { course_id: course.id }, format: :json + body = JSON.parse(response.body) + expect(body['assessments'].first).to have_key('gradebookExcluded') + expect(body['assessments'].first['gradebookExcluded']).to eq(false) + end + end + end end end diff --git a/spec/factories/course_gradebook_assessment_contributions.rb b/spec/factories/course_gradebook_assessment_contributions.rb new file mode 100644 index 00000000000..6a0126e520f --- /dev/null +++ b/spec/factories/course_gradebook_assessment_contributions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_gradebook_assessment_contribution, class: Course::Gradebook::AssessmentContribution.name do + assessment + excluded { false } + end +end diff --git a/spec/factories/course_gradebook_contributions.rb b/spec/factories/course_gradebook_contributions.rb new file mode 100644 index 00000000000..606994f4aa2 --- /dev/null +++ b/spec/factories/course_gradebook_contributions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_gradebook_contribution, class: Course::Gradebook::Contribution.name do + association :tab, factory: :course_assessment_tab + course { tab.category.course } + weight { 0 } + weight_mode { :equal } + end +end diff --git a/spec/models/course/gradebook/assessment_contribution_spec.rb b/spec/models/course/gradebook/assessment_contribution_spec.rb new file mode 100644 index 00000000000..5bfe3af8635 --- /dev/null +++ b/spec/models/course/gradebook/assessment_contribution_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Gradebook::AssessmentContribution do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:tab) { create(:course_assessment_tab, course: course) } + let(:assessment) { create(:assessment, course: course, tab: tab) } + + it { is_expected.to belong_to(:assessment) } + + it 'allows a nil weight (equal mode)' do + contribution = build(:course_gradebook_assessment_contribution, assessment: assessment, weight: nil) + expect(contribution).to be_valid + end + + it 'rejects a negative weight' do + contribution = build(:course_gradebook_assessment_contribution, assessment: assessment, weight: -1) + expect(contribution).not_to be_valid + end + + it 'defaults excluded to false' do + contribution = create(:course_gradebook_assessment_contribution, assessment: assessment) + expect(contribution.excluded).to eq(false) + end + + it 'enforces one row per assessment' do + create(:course_gradebook_assessment_contribution, assessment: assessment) + duplicate = build(:course_gradebook_assessment_contribution, assessment: assessment) + expect(duplicate).not_to be_valid + end + + it 'is destroyed when its assessment is destroyed' do + create(:course_gradebook_assessment_contribution, assessment: assessment) + expect { assessment.destroy! }.to change { described_class.count }.by(-1) + end + end +end diff --git a/spec/models/course/gradebook/contribution_spec.rb b/spec/models/course/gradebook/contribution_spec.rb new file mode 100644 index 00000000000..d4d433e028a --- /dev/null +++ b/spec/models/course/gradebook/contribution_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Gradebook::Contribution do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:category) { create(:course_assessment_category, course: course) } + let(:tab) { create(:course_assessment_tab, category: category) } + + describe 'validations' do + it 'is valid with a tab and matching course' do + expect(build(:course_gradebook_contribution, tab: tab, course: course)).to be_valid + end + + it 'requires a tab' do + contribution = build(:course_gradebook_contribution, tab: tab, course: course) + contribution.tab = nil + expect(contribution).not_to be_valid + end + + it 'enforces one row per tab' do + create(:course_gradebook_contribution, tab: tab, course: course) + duplicate = build(:course_gradebook_contribution, tab: tab, course: course) + expect(duplicate).not_to be_valid + end + + it 'rejects a course that does not match the tab course' do + contribution = build(:course_gradebook_contribution, tab: tab) + contribution.course = create(:course) + expect(contribution).not_to be_valid + end + + it 'rejects a negative weight' do + contribution = build(:course_gradebook_contribution, tab: tab, course: course, weight: -1) + expect(contribution).not_to be_valid + end + + it 'rejects a weight above 100' do + contribution = build(:course_gradebook_contribution, tab: tab, course: course, weight: 101) + expect(contribution).not_to be_valid + end + + it 'rejects a negative keep_highest' do + contribution = build(:course_gradebook_contribution, tab: tab, course: course, keep_highest: -1) + expect(contribution).not_to be_valid + end + + it 'defaults weight 0, mode equal, keep_highest 0' do + contribution = create(:course_gradebook_contribution, tab: tab, course: course) + expect(contribution.weight).to eq(0) + expect(contribution.weight_mode).to eq('equal') + expect(contribution.keep_highest).to eq(0) + end + end + + describe 'dependent destroy' do + it 'is destroyed when its tab is destroyed' do + create(:course_assessment_tab, category: category) # sibling so the tab is deletable + create(:course_gradebook_contribution, tab: tab, course: course) + expect { tab.destroy! }.to change { described_class.count }.by(-1) + end + end + + describe '.bulk_update' do + let(:tab1) { create(:course_assessment_tab, category: category) } + let(:tab2) { create(:course_assessment_tab, category: category) } + + def weight_for(tab) + described_class.find_by(tab_id: tab.id)&.weight + end + + it 'upserts a contribution per given tab' do + described_class.bulk_update( + course: course, + updates: [{ tab_id: tab1.id, weight: 60 }, { tab_id: tab2.id, weight: 40 }] + ) + expect(weight_for(tab1)).to eq(60) + expect(weight_for(tab2)).to eq(40) + end + + it 'is transactional — an invalid value rolls everything back' do + create(:course_gradebook_contribution, tab: tab1, course: course, weight: 10) + create(:course_gradebook_contribution, tab: tab2, course: course, weight: 20) + expect do + described_class.bulk_update( + course: course, + updates: [{ tab_id: tab1.id, weight: 50 }, { tab_id: tab2.id, weight: 999 }] + ) + end.to raise_error(ActiveRecord::RecordInvalid) + expect(weight_for(tab1)).to eq(10) + expect(weight_for(tab2)).to eq(20) + end + + it 'rejects a foreign tab_id' do + other_course = create(:course) + other_tab = create(:course_assessment_tab, + category: create(:course_assessment_category, course: other_course)) + expect do + described_class.bulk_update(course: course, updates: [tab_id: other_tab.id, weight: 50]) + end.to raise_error(ActiveRecord::RecordNotFound) + end + + context 'with assessments in the tab' do + let(:tab) { create(:course_assessment_tab, category: category) } + let!(:a1) { create(:assessment, course: course, tab: tab) } + let!(:a2) { create(:assessment, course: course, tab: tab) } + + def assessment_weight(assessment) + assessment.reload.gradebook_assessment_contribution&.weight + end + + def excluded?(assessment) + assessment.reload.gradebook_assessment_contribution&.excluded + end + + it 'persists custom mode + assessment weights that sum to the tab total' do + described_class.bulk_update( + course: course, + updates: [ + tab_id: tab.id, weight: 50.0, weight_mode: 'custom', + assessment_weights: [ + { assessment_id: a1.id, weight: 30.0 }, + { assessment_id: a2.id, weight: 20.0 } + ] + ] + ) + expect(tab.reload.gradebook_contribution.weight_mode).to eq('custom') + expect(assessment_weight(a1)).to eq(30.0) + expect(assessment_weight(a2)).to eq(20.0) + end + + it 'raises RecordInvalid when custom weights do not sum to the tab total' do + expect do + described_class.bulk_update( + course: course, + updates: [ + tab_id: tab.id, weight: 50.0, weight_mode: 'custom', + assessment_weights: [ + { assessment_id: a1.id, weight: 30.0 }, + { assessment_id: a2.id, weight: 5.0 } + ] + ] + ) + end.to raise_error(ActiveRecord::RecordInvalid) + expect(assessment_weight(a1)).to be_nil # rolled back + end + + it 'nulls assessment weights when switching the tab to equal mode' do + create(:course_gradebook_assessment_contribution, assessment: a1, weight: 30.0) + described_class.bulk_update( + course: course, + updates: [tab_id: tab.id, weight: 50.0, weight_mode: 'equal'] + ) + expect(tab.reload.gradebook_contribution.weight_mode).to eq('equal') + expect(assessment_weight(a1)).to be_nil + end + + it 'persists per-assessment exclusion in equal mode' do + described_class.bulk_update( + course: course, + updates: [tab_id: tab.id, weight: 50.0, weight_mode: 'equal', + excluded_assessment_ids: [a1.id]] + ) + expect(excluded?(a1)).to eq(true) + expect(excluded?(a2)).to eq(false) + end + + it 'drops excluded assessments from the custom balance check and keeps their weight' do + described_class.bulk_update( + course: course, + updates: [ + tab_id: tab.id, weight: 30.0, weight_mode: 'custom', + excluded_assessment_ids: [a2.id], + assessment_weights: [ + { assessment_id: a1.id, weight: 30.0 }, + { assessment_id: a2.id, weight: 20.0 } + ] + ] + ) + expect(excluded?(a1)).to eq(false) + expect(excluded?(a2)).to eq(true) + expect(assessment_weight(a2)).to eq(20.0) # retained for restore, not zeroed + end + + it 'skips the custom balance check when every assessment is excluded' do + expect do + described_class.bulk_update( + course: course, + updates: [ + tab_id: tab.id, weight: 30.0, weight_mode: 'custom', + excluded_assessment_ids: [a1.id, a2.id], + assessment_weights: [ + { assessment_id: a1.id, weight: 0.0 }, + { assessment_id: a2.id, weight: 0.0 } + ] + ] + ) + end.not_to raise_error + end + + it 're-including a previously excluded assessment clears the flag' do + create(:course_gradebook_assessment_contribution, assessment: a1, excluded: true) + described_class.bulk_update( + course: course, + updates: [tab_id: tab.id, weight: 50.0, weight_mode: 'equal', excluded_assessment_ids: []] + ) + expect(excluded?(a1)).to eq(false) + end + end + end + end +end diff --git a/spec/models/course/gradebook_ability_spec.rb b/spec/models/course/gradebook_ability_spec.rb new file mode 100644 index 00000000000..7247c2084c8 --- /dev/null +++ b/spec/models/course/gradebook_ability_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::GradebookAbilityComponent do + let!(:instance) { Instance.default } + with_tenant(:instance) do + subject { Ability.new(user, course, course_user) } + let(:course) { create(:course) } + + context 'when the user is a Course Manager' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + it { is_expected.to be_able_to(:read_gradebook, course) } + it { is_expected.to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.to be_able_to(:manage_gradebook_settings, course) } + end + + context 'when the user is a Course Owner' do + let(:course_user) { create(:course_owner, course: course) } + let(:user) { course_user.user } + it { is_expected.to be_able_to(:read_gradebook, course) } + it { is_expected.to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.to be_able_to(:manage_gradebook_settings, course) } + end + + context 'when the user is a Teaching Assistant' do + let(:course_user) { create(:course_teaching_assistant, course: course) } + let(:user) { course_user.user } + it { is_expected.to be_able_to(:read_gradebook, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_settings, course) } + end + + context 'when the user is a Course Student' do + let(:course_user) { create(:course_student, course: course) } + let(:user) { course_user.user } + it { is_expected.not_to be_able_to(:read_gradebook, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_weights, course) } + it { is_expected.not_to be_able_to(:manage_gradebook_settings, course) } + end + end +end diff --git a/spec/models/course/settings/gradebook_component_spec.rb b/spec/models/course/settings/gradebook_component_spec.rb new file mode 100644 index 00000000000..51e602521c5 --- /dev/null +++ b/spec/models/course/settings/gradebook_component_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Settings::GradebookComponent do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:settings) do + context = OpenStruct.new(current_course: course, key: Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(context) + end + + describe '#weighted_view_enabled' do + it 'returns false by default' do + expect(settings.weighted_view_enabled).to eq(false) + end + end + + describe '#weighted_view_enabled=' do + it 'persists true when set to true' do + settings.weighted_view_enabled = true + course.save! + expect(settings.weighted_view_enabled).to eq(true) + end + + it 'persists false when set to false after being true' do + settings.weighted_view_enabled = true + course.save! + settings.weighted_view_enabled = false + course.save! + expect(settings.weighted_view_enabled).to eq(false) + end + + it 'handles string "1" as truthy' do + settings.weighted_view_enabled = '1' + expect(settings.weighted_view_enabled).to eq(true) + end + + it 'handles string "0" as falsy' do + settings.weighted_view_enabled = '0' + expect(settings.weighted_view_enabled).to eq(false) + end + end + end +end From 59be75d9ad5528bebe7ffbdf14aead75add2ecf1 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 26 Jun 2026 11:53:54 +0800 Subject: [PATCH 3/4] feat(gradebook): external assessments with bounded grades and CSV import --- .../external_assessment_imports_controller.rb | 53 ++ .../course/external_assessments_controller.rb | 107 ++++ .../course/gradebook_controller.rb | 13 +- .../course/gradebook_ability_component.rb | 1 + app/models/course.rb | 2 + app/models/course/assessment/tab.rb | 1 - app/models/course/external_assessment.rb | 47 ++ .../course/external_assessment_grade.rb | 16 + app/models/course/gradebook/contribution.rb | 44 +- app/models/course_user.rb | 2 + .../external_assessment_import_service.rb | 242 +++++++++ .../create.json.jbuilder | 4 + .../preview.json.jbuilder | 15 + .../_external_assessment.json.jbuilder | 9 + .../external_assessments/create.json.jbuilder | 20 + .../external_assessments/update.json.jbuilder | 16 + .../update_grade.json.jbuilder | 4 + .../course/gradebook/index.json.jbuilder | 100 +++- client/app/api/course/Gradebook.ts | 70 ++- .../EditExternalAssessmentPrompt.test.tsx | 28 ++ .../__tests__/GradebookIndex.test.tsx | 9 + .../ImportExternalAssessmentsWizard.test.tsx | 281 +++++++++++ .../buildAssessmentColumnIds.test.ts | 31 ++ .../gradebook/__tests__/buildTemplate.test.ts | 45 ++ .../__tests__/computeWeighted.test.ts | 42 ++ .../course/gradebook/__tests__/store.test.ts | 237 +++++++++ .../components/AddExternalColumnPrompt.tsx | 116 +++++ .../components/ConfigureWeightsPrompt.tsx | 57 ++- .../components/DeleteExternalColumnPrompt.tsx | 94 ++++ .../gradebook/components/GradebookTable.tsx | 311 +++++++++++- .../components/RenameExternalColumnPrompt.tsx | 100 ++++ .../components/buildAssessmentColumnIds.ts | 2 +- .../import/ExternalGradeConflictPrompt.tsx | 88 ++++ .../import/ExternalGradeConflictTable.tsx | 86 ++++ .../ImportExternalAssessmentsButton.tsx | 44 ++ .../ImportExternalAssessmentsWizard.tsx | 460 ++++++++++++++++++ .../components/import/buildTemplate.ts | 34 ++ .../manage/EditExternalAssessmentPrompt.tsx | 118 +++++ .../course/gradebook/computeWeighted.ts | 40 +- .../bundles/course/gradebook/operations.ts | 106 +++- .../gradebook/pages/GradebookIndex/index.tsx | 25 +- .../app/bundles/course/gradebook/selectors.ts | 4 + client/app/bundles/course/gradebook/store.ts | 117 ++++- client/app/types/course/gradebook.ts | 58 ++- client/locales/en.json | 192 ++++++++ client/locales/ko.json | 198 ++++++++ client/locales/zh.json | 198 ++++++++ config/locales/en/activerecord/errors.yml | 4 + config/routes.rb | 10 + ..._course_external_assessments_and_grades.rb | 47 ++ ...l_assessment_to_gradebook_contributions.rb | 14 + ...d_bounds_to_course_external_assessments.rb | 7 + ...essment_grade_precision_to_two_decimals.rb | 20 + db/schema.rb | 45 +- ...rnal_assessment_imports_controller_spec.rb | 153 ++++++ .../external_assessments_controller_spec.rb | 318 ++++++++++++ .../course/gradebook_controller_spec.rb | 122 ++++- spec/factories/course_external_assessments.rb | 14 + .../course_gradebook_contributions.rb | 7 +- .../course/external_assessment_grade_spec.rb | 78 +++ .../models/course/external_assessment_spec.rb | 128 +++++ ...external_assessment_import_service_spec.rb | 273 +++++++++++ 62 files changed, 5056 insertions(+), 71 deletions(-) create mode 100644 app/controllers/course/external_assessment_imports_controller.rb create mode 100644 app/controllers/course/external_assessments_controller.rb create mode 100644 app/models/course/external_assessment.rb create mode 100644 app/models/course/external_assessment_grade.rb create mode 100644 app/services/course/gradebook/external_assessment_import_service.rb create mode 100644 app/views/course/external_assessment_imports/create.json.jbuilder create mode 100644 app/views/course/external_assessment_imports/preview.json.jbuilder create mode 100644 app/views/course/external_assessments/_external_assessment.json.jbuilder create mode 100644 app/views/course/external_assessments/create.json.jbuilder create mode 100644 app/views/course/external_assessments/update.json.jbuilder create mode 100644 app/views/course/external_assessments/update_grade.json.jbuilder create mode 100644 client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/buildAssessmentColumnIds.test.ts create mode 100644 client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts create mode 100644 client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx create mode 100644 client/app/bundles/course/gradebook/components/DeleteExternalColumnPrompt.tsx create mode 100644 client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/buildTemplate.ts create mode 100644 client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx create mode 100644 db/migrate/20260615000000_create_course_external_assessments_and_grades.rb create mode 100644 db/migrate/20260616000000_add_external_assessment_to_gradebook_contributions.rb create mode 100644 db/migrate/20260622000000_add_bounds_to_course_external_assessments.rb create mode 100644 db/migrate/20260623000000_change_external_assessment_grade_precision_to_two_decimals.rb create mode 100644 spec/controllers/course/external_assessment_imports_controller_spec.rb create mode 100644 spec/controllers/course/external_assessments_controller_spec.rb create mode 100644 spec/factories/course_external_assessments.rb create mode 100644 spec/models/course/external_assessment_grade_spec.rb create mode 100644 spec/models/course/external_assessment_spec.rb create mode 100644 spec/services/course/gradebook/external_assessment_import_service_spec.rb diff --git a/app/controllers/course/external_assessment_imports_controller.rb b/app/controllers/course/external_assessment_imports_controller.rb new file mode 100644 index 00000000000..a85d636e744 --- /dev/null +++ b/app/controllers/course/external_assessment_imports_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true +class Course::ExternalAssessmentImportsController < Course::ComponentController + Service = Course::Gradebook::ExternalAssessmentImportService + + def preview + authorize! :manage_gradebook_weights, current_course + @result = build_service.preview + render 'preview' + rescue Service::ImportError => e + render json: { errors: e.payload }, status: :unprocessable_entity + end + + def create + authorize! :manage_gradebook_weights, current_course + @summary = build_service.commit(on_conflict: import_params[:onConflict]) + render 'create' + rescue Service::ImportError => e + render json: { errors: e.payload }, status: :unprocessable_entity + end + + private + + def component + current_component_host[:course_gradebook_component] + end + + def build_service + permitted_params = import_params + weighted_view_enabled = gradebook_settings.weighted_view_enabled + Service.new( + course: current_course, + actor: current_user, + components: permitted_params[:components].map do |c| + { name: c[:name], + weightage: weighted_view_enabled ? c[:weightage].to_i : 0, + maximum_grade: c[:maximumGrade].to_f } + end, + identifier_mode: permitted_params[:identifierMode], + csv_data: permitted_params[:csvData] + ) + end + + def gradebook_settings + @gradebook_settings ||= Course::Settings::GradebookComponent.new(component) + end + + def import_params + @import_params ||= params.slice(:identifierMode, :csvData, :onConflict, :components).permit( + :identifierMode, :csvData, :onConflict, + components: [:name, :weightage, :maximumGrade] + ) + end +end diff --git a/app/controllers/course/external_assessments_controller.rb b/app/controllers/course/external_assessments_controller.rb new file mode 100644 index 00000000000..141983de0e1 --- /dev/null +++ b/app/controllers/course/external_assessments_controller.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true +class Course::ExternalAssessmentsController < Course::ComponentController + before_action :load_external_assessment, only: [:update, :destroy, :grades] + + def create + authorize! :manage_gradebook_weights, current_course + @weighted_view_enabled = gradebook_settings.weighted_view_enabled + @external_assessment = Course::ExternalAssessment.create_for_course!( + course: current_course, + title: create_params[:title], + maximum_grade: create_params[:maximumGrade], + weight: create_weight, + floor_at_zero: bound_flag(:floorAtZero, default: true), + cap_at_maximum: bound_flag(:capAtMaximum, default: true) + ) + render 'create' + rescue ActiveRecord::RecordInvalid => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + def update + authorize! :manage_gradebook_weights, current_course + @weighted_view_enabled = gradebook_settings.weighted_view_enabled + @external_assessment.update!(update_params_attrs) + update_weight if @weighted_view_enabled && params.key?(:weight) + render 'update' + rescue ActiveRecord::RecordInvalid => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + def destroy + authorize! :manage_gradebook_weights, current_course + @external_assessment.destroy! + head :ok + end + + def grades + authorize! :grade, @external_assessment + # The gradebook keys students by user_id (see index/update_grade jbuilders), so the + # `studentId` param is a user_id, not a course_user PK. + course_user = current_course.course_users.find_by!(user_id: grade_params[:studentId]) + @grade = @external_assessment.external_assessment_grades. + find_or_initialize_by(course_user: course_user) + @grade.grade = normalized_grade(grade_params[:grade]) + @grade.save! + render 'update_grade' + rescue ActiveRecord::RecordNotUnique + retry + rescue ActiveRecord::RecordNotFound + head :not_found + rescue ActiveRecord::RecordInvalid => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + private + + def component + current_component_host[:course_gradebook_component] + end + + def gradebook_settings + @gradebook_settings ||= Course::Settings::GradebookComponent.new(component) + end + + def load_external_assessment + @external_assessment = Course::ExternalAssessment.for_course(current_course).find(params[:id]) + rescue ActiveRecord::RecordNotFound + head :not_found + end + + def create_params + params.permit(:title, :maximumGrade, :weight, :floorAtZero, :capAtMaximum) + end + + def create_weight + @weighted_view_enabled ? (create_params[:weight].presence || 0).to_f : 0 + end + + def update_weight + @external_assessment.gradebook_contribution&.update!(weight: (params[:weight].presence || 0).to_f) + end + + def update_params_attrs + attrs = {} + attrs[:title] = params[:title] if params.key?(:title) + attrs[:maximum_grade] = params[:maximumGrade] if params.key?(:maximumGrade) + attrs[:floor_at_zero] = bound_flag(:floorAtZero, default: true) if params.key?(:floorAtZero) + attrs[:cap_at_maximum] = bound_flag(:capAtMaximum, default: true) if params.key?(:capAtMaximum) + attrs + end + + # Coerce a string/bool HTTP param into a Ruby boolean (defaults when absent). + def bound_flag(key, default:) + return default unless params.key?(key) + + ActiveRecord::Type::Boolean.new.cast(params[key]) + end + + def grade_params + params.permit(:studentId, :grade) + end + + # Blank cell clears the grade to null (ungraded), never zero (decision #7). + def normalized_grade(value) + value.blank? ? nil : value + end +end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb index b5ef6c71c2d..a7fb12c6c0b 100644 --- a/app/controllers/course/gradebook_controller.rb +++ b/app/controllers/course/gradebook_controller.rb @@ -16,6 +16,7 @@ def index student_ids: @students.map(&:user_id), assessment_ids: assessment_ids ) + load_externals end end end @@ -83,11 +84,19 @@ def fetch_categories_and_tabs [tabs.map(&:category).uniq(&:id), tabs] end + def load_externals + @external_assessments = Course::ExternalAssessment.for_course(current_course). + includes(:gradebook_contribution, external_assessment_grades: :course_user).to_a + @external_grades = @external_assessments.flat_map(&:external_assessment_grades) + @external_contributions = @external_assessments. + index_by(&:id). + transform_values(&:gradebook_contribution) + end + def fetch_students current_course.levels.to_a current_course.course_users.students.without_phantom_users. - calculated(:experience_points).includes(:user).to_a. - sort_by { |cu| cu.user.name } + calculated(:experience_points).includes(user: :emails).to_a end def fetch_published_assessments diff --git a/app/models/components/course/gradebook_ability_component.rb b/app/models/components/course/gradebook_ability_component.rb index d54a56cca62..7c6d0990d69 100644 --- a/app/models/components/course/gradebook_ability_component.rb +++ b/app/models/components/course/gradebook_ability_component.rb @@ -6,6 +6,7 @@ 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? + can :grade, Course::ExternalAssessment if course_user&.teaching_staff? super end end diff --git a/app/models/course.rb b/app/models/course.rb index 7952e5a7501..f52778fa958 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -55,6 +55,8 @@ class Course < ApplicationRecord has_many :assessments, through: :assessment_categories has_many :gradebook_contributions, class_name: 'Course::Gradebook::Contribution', dependent: :destroy, inverse_of: :course + has_many :external_assessments, class_name: 'Course::ExternalAssessment', + inverse_of: :course, dependent: :destroy has_many :assessment_skills, class_name: 'Course::Assessment::Skill', dependent: :destroy has_many :assessment_skill_branches, class_name: 'Course::Assessment::SkillBranch', diff --git a/app/models/course/assessment/tab.rb b/app/models/course/assessment/tab.rb index f1305a645c1..79bbb5452ba 100644 --- a/app/models/course/assessment/tab.rb +++ b/app/models/course/assessment/tab.rb @@ -10,7 +10,6 @@ class Course::Assessment::Tab < ApplicationRecord has_many :assessments, class_name: 'Course::Assessment', dependent: :destroy, inverse_of: :tab has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution', dependent: :destroy, inverse_of: :tab - has_many :folders, class_name: 'Course::Material::Folder', through: :assessments, inverse_of: nil diff --git a/app/models/course/external_assessment.rb b/app/models/course/external_assessment.rb new file mode 100644 index 00000000000..84aa12273fc --- /dev/null +++ b/app/models/course/external_assessment.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +# A gradebook component graded outside Coursemology (e.g. a midterm or final). +# It is a first-class gradebook contributor, NOT a Course::Assessment: it never +# touches attempts, EXP, statistics, todos, or the lesson plan. Its weight lives on +# its course_gradebook_contributions row; its display grouping is synthesised by the +# gradebook serializer (no real tab/category exists). +class Course::ExternalAssessment < ApplicationRecord + # Sentinel id for the serializer's synthetic "External Assessments" category. + # Native categories are positive; externals and their synthetic grouping are negative. + SYNTHETIC_CATEGORY_ID = -1 + SYNTHETIC_CATEGORY_TITLE = 'External Assessments' + + validates :title, length: { maximum: 255 }, presence: true + validates :title, uniqueness: { scope: :course_id } + validates :maximum_grade, presence: true + validates :maximum_grade, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :creator, presence: true + validates :updater, presence: true + + belongs_to :course, inverse_of: :external_assessments + has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution', + inverse_of: :external_assessment, dependent: :destroy + has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade', + inverse_of: :external_assessment, dependent: :destroy + + scope :for_course, ->(course) { where(course_id: course.id) } + + # The negative serialized id used by the synthetic tab AND the leaf assessment. + def synthetic_tab_id + -id + end + + # Creates an external assessment and its gradebook contribution in one transaction. + # Raises ActiveRecord::RecordInvalid on a duplicate title within the course. + def self.create_for_course!(course:, title:, maximum_grade:, weight: 0, + floor_at_zero: true, cap_at_maximum: true) + transaction do + external = course.external_assessments.create!( + title: title, maximum_grade: maximum_grade, + floor_at_zero: floor_at_zero, cap_at_maximum: cap_at_maximum + ) + Course::Gradebook::Contribution.create!(course: course, external_assessment: external, + weight: weight, weight_mode: 'equal', keep_highest: 0) + external + end + end +end diff --git a/app/models/course/external_assessment_grade.rb b/app/models/course/external_assessment_grade.rb new file mode 100644 index 00000000000..70518c79c9a --- /dev/null +++ b/app/models/course/external_assessment_grade.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# One external grade for a (external assessment, course_user). The binding key is +# course_user_id (the authoritative link to the person); imported_identifier is a +# non-authoritative snapshot of the email/Student ID used at import (audit + upsert +# mismatch detection), null for grades typed/edited inline. +class Course::ExternalAssessmentGrade < ApplicationRecord + validates :course_user, presence: true + validates :grade, numericality: true, allow_nil: true + validates :course_user_id, uniqueness: { scope: :external_assessment_id } + validates :creator, presence: true + validates :updater, presence: true + + belongs_to :external_assessment, class_name: 'Course::ExternalAssessment', + inverse_of: :external_assessment_grades + belongs_to :course_user, inverse_of: :external_assessment_grades +end diff --git a/app/models/course/gradebook/contribution.rb b/app/models/course/gradebook/contribution.rb index 734e8a6b00f..fcad0bb792c 100644 --- a/app/models/course/gradebook/contribution.rb +++ b/app/models/course/gradebook/contribution.rb @@ -8,13 +8,16 @@ class Course::Gradebook::Contribution < ApplicationRecord belongs_to :course, inverse_of: :gradebook_contributions belongs_to :tab, class_name: 'Course::Assessment::Tab', inverse_of: :gradebook_contribution, optional: true + belongs_to :external_assessment, class_name: 'Course::ExternalAssessment', + inverse_of: :gradebook_contribution, optional: true validates :creator, presence: true validates :updater, presence: true validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } validates :weight_mode, presence: true validates :keep_highest, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :tab_id, uniqueness: true + validates :tab_id, uniqueness: { allow_nil: true } + validates :external_assessment_id, uniqueness: { allow_nil: true } validate :exactly_one_contributor validate :course_matches_contributor # Bulk-upserts tab contributions and their per-assessment contributions for a course. @@ -27,13 +30,22 @@ class Course::Gradebook::Contribution < ApplicationRecord # @param updates [Array] each { tab_id:, weight:, weight_mode:, keep_highest:, # excluded_assessment_ids: [Integer], assessment_weights: [{ assessment_id:, weight: }] } def self.bulk_update(course:, updates:) + external_updates, tab_updates = updates.partition { |e| e[:tab_id].to_i < 0 } + course_tab_ids = course.assessment_tabs.pluck(:id).to_set - updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) } + tab_updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) } + + external_ids = external_updates.map { |e| -e[:tab_id] } + externals_by_id = course.external_assessments.where(id: external_ids).index_by(&:id) + external_updates.each { |e| raise ActiveRecord::RecordNotFound unless externals_by_id.key?(-e[:tab_id]) } - tabs_by_id = Course::Assessment::Tab.where(id: updates.map { |e| e[:tab_id] }). + tabs_by_id = Course::Assessment::Tab.where(id: tab_updates.map { |e| e[:tab_id] }). includes(:assessments).index_by(&:id) - transaction { updates.each { |entry| apply_entry(course, tabs_by_id, entry) } } + transaction do + tab_updates.each { |entry| apply_entry(course, tabs_by_id, entry) } + external_updates.each { |entry| apply_external_entry(course, externals_by_id[-entry[:tab_id]], entry) } + end end # @api private @@ -58,6 +70,16 @@ def self.apply_entry(course, tabs_by_id, entry) end private_class_method :apply_entry + # @api private + def self.apply_external_entry(course, external, entry) + contribution = find_or_initialize_by(external_assessment_id: external.id) + contribution.tab = nil + contribution.course = course + contribution.assign_attributes(weight: entry[:weight], weight_mode: 'equal', keep_highest: 0) + contribution.save! + end + private_class_method :apply_external_entry + # @api private def self.assessment_contribution_for(assessment) Course::Gradebook::AssessmentContribution.find_or_initialize_by(assessment_id: assessment.id) @@ -117,15 +139,19 @@ def self.validate_custom_assessment_weights_sum!(tab, entry, included_sum, inclu private - # Until the external-alignment design adds `external_assessment_id`, the only - # contributor is the tab, so "exactly one" reduces to "tab present". def exactly_one_contributor - errors.add(:tab, :blank) if tab_id.blank? + return if [tab_id, external_assessment_id].compact.size == 1 + + errors.add(:base, :exactly_one_contributor) end def course_matches_contributor - return if tab.nil? || course.nil? + return if course.nil? - errors.add(:course, :invalid) if tab.category.course_id != course_id + contributor_course_id = + if tab then tab.category.course_id + elsif external_assessment then external_assessment.course_id + end + errors.add(:course, :invalid) if contributor_course_id && contributor_course_id != course_id end end diff --git a/app/models/course_user.rb b/app/models/course_user.rb index 944fe25e09c..6682fb1fac4 100644 --- a/app/models/course_user.rb +++ b/app/models/course_user.rb @@ -50,6 +50,8 @@ class CourseUser < ApplicationRecord inverse_of: :course_user, dependent: :destroy has_many :groups, through: :group_users, class_name: 'Course::Group', source: :group has_many :personal_times, class_name: 'Course::PersonalTime', inverse_of: :course_user, dependent: :destroy + has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade', + inverse_of: :course_user, dependent: :destroy belongs_to :reference_timeline, class_name: 'Course::ReferenceTimeline', inverse_of: :course_users, optional: true default_scope { where(deleted_at: nil) } diff --git a/app/services/course/gradebook/external_assessment_import_service.rb b/app/services/course/gradebook/external_assessment_import_service.rb new file mode 100644 index 00000000000..65128f89df9 --- /dev/null +++ b/app/services/course/gradebook/external_assessment_import_service.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true +require 'csv' + +class Course::Gradebook::ExternalAssessmentImportService # rubocop:disable Metrics/ClassLength + class ImportError < StandardError + attr_reader :payload + + def initialize(payload) + @payload = payload + super(payload.is_a?(Hash) ? payload[:message].to_s : payload.to_s) + end + end + + def initialize(course:, actor:, components:, identifier_mode:, csv_data:) + @course = course + @actor = actor + @components = components.map(&:symbolize_keys) + @identifier_mode = identifier_mode.to_s + @csv_data = csv_data + end + + def preview + rows = parsed_rows + resolution = resolve(rows) + { + ok: resolution[:unresolved].empty? && resolution[:malformed].empty?, + unresolved: resolution[:unresolved], + malformed: resolution[:malformed], + sample: sample(resolution[:resolved]), + conflicts: conflicts(resolution[:resolved]) + } + end + + def commit(on_conflict:) + rows = parsed_rows + resolution = resolve(rows) + unless resolution[:unresolved].empty? && resolution[:malformed].empty? + raise ImportError, { message: 'validation_failed', + unresolved: resolution[:unresolved], + malformed: resolution[:malformed] } + end + + summary = { createdComponents: 0, updatedComponents: 0, gradesWritten: 0 } + ActiveRecord::Base.transaction { write_components(summary, resolution[:resolved], on_conflict) } + summary + end + + private + + def write_components(summary, resolved, on_conflict) + @components.each do |component| + external = existing_external(component[:name]) + if external + summary[:updatedComponents] += 1 + summary[:gradesWritten] += upsert_grades(external, component, resolved, + on_conflict: on_conflict) + else + summary[:createdComponents] += 1 + summary[:gradesWritten] += create_component(component, resolved) + end + end + end + + def parsed_rows + guard_no_duplicate_components! + table = CSV.parse(@csv_data.to_s, headers: true) + guard_header!(table.headers) + table + end + + def guard_no_duplicate_components! + names = @components.map { |c| c[:name] } + return if names.uniq.length == names.length + + raise ImportError, { message: 'duplicate_component_name' } + end + + def guard_header!(headers) + expected = ['Identifier'] + @components.map { |c| c[:name] } + return if headers == expected + + raise ImportError, { message: 'bad_header', expected: expected, got: headers } + end + + def roster_lookup + @roster_lookup ||= + if @identifier_mode == 'email' + @course.course_users.includes(:user).index_by { |cu| cu.user.email.downcase } + else + @course.course_users.where.not(external_id: [nil, '']).index_by(&:external_id) + end + end + + def lookup_key(identifier) + (@identifier_mode == 'email') ? identifier.to_s.downcase : identifier.to_s + end + + def resolve(table) + resolved = [] + unresolved = [] + malformed = [] + table.each_with_index do |row, idx| + identifier = row['Identifier'].to_s.strip + course_user = roster_lookup[lookup_key(identifier)] + if course_user.nil? + unresolved << identifier + next + end + grades, bad = parse_grades(row, idx) + malformed.concat(bad) + resolved << { course_user: course_user, identifier: identifier, grades: grades } + end + { resolved: resolved, unresolved: unresolved.uniq, malformed: malformed } + end + + def parse_grades(row, row_idx) + grades = {} + malformed = [] + @components.each do |component| + raw = row[component[:name]] + if raw.nil? || raw.to_s.strip.empty? + grades[component[:name]] = nil + elsif numeric?(raw) + grades[component[:name]] = Float(raw) + else + malformed << "row #{row_idx + 2}, #{component[:name]}: #{raw}" + end + end + [grades, malformed] + end + + def numeric?(value) + Float(value) + true + rescue ArgumentError, TypeError + false + end + + def sample(resolved) + resolved.first(5).map do |r| + { studentName: r[:course_user].name, grades: r[:grades] } + end + end + + def conflicts(resolved) + result = [] + @components.each do |component| + conflicts_for_component(component, resolved, result) + end + result + end + + def conflicts_for_component(component, resolved, result) + external = existing_external(component[:name]) + return unless external + + grade_by_cu = external.external_assessment_grades.index_by(&:course_user_id) + resolved.each do |row| + conflict = conflict_entry(component[:name], row, grade_by_cu) + result << conflict if conflict + end + end + + def conflict_entry(component_name, row, grade_by_cu) + in_file = row[:grades][component_name] + return nil if in_file.nil? + + existing = grade_by_cu[row[:course_user].id] + return nil if existing.nil? || existing.grade.nil? + + { + component: component_name, + studentName: row[:course_user].name, + existingGrade: existing.grade.to_f, + inFileGrade: in_file, + identifierMismatch: existing.imported_identifier.present? && + existing.imported_identifier != row[:identifier] + } + end + + def existing_external(name) + @existing_externals ||= {} + @existing_externals[name] ||= Course::ExternalAssessment.for_course(@course).find_by(title: name) + end + + def create_component(component, resolved) + external = User.with_stamper(@actor) do + Course::ExternalAssessment.create_for_course!( + course: @course, title: component[:name], + maximum_grade: component[:maximum_grade], weight: component[:weightage] + ) + end + rows = resolved.filter_map do |r| + build_grade(external, r) unless r[:grades][component[:name]].nil? + end + bulk_insert(rows) + rows.size + end + + def upsert_grades(external, component, resolved, on_conflict:) + grade_by_cu = external.external_assessment_grades.index_by(&:course_user_id) + written = 0 + resolved.each do |row| + written += upsert_one(grade_by_cu, component, row, on_conflict) + end + written + end + + def upsert_one(grade_by_cu, component, row, on_conflict) + in_file = row[:grades][component[:name]] + return 0 if in_file.nil? + + existing = grade_by_cu[row[:course_user].id] + if existing.nil? + bulk_insert([build_grade(existing_external(component[:name]), row, value: in_file)]) + 1 + elsif on_conflict.to_s == 'replace' || existing.grade.nil? + User.with_stamper(@actor) do + existing.update!(grade: in_file, imported_identifier: row[:identifier]) + end + 1 + else + 0 + end + end + + def build_grade(external, resolved_row, value: nil) + Course::ExternalAssessmentGrade.new( + external_assessment: external, + course_user: resolved_row[:course_user], + grade: value.nil? ? resolved_row[:grades][external.title] : value, + imported_identifier: resolved_row[:identifier], + creator: @actor, updater: @actor + ) + end + + def bulk_insert(records) + return if records.empty? + + Course::ExternalAssessmentGrade.import(records, validate: true) + end +end diff --git a/app/views/course/external_assessment_imports/create.json.jbuilder b/app/views/course/external_assessment_imports/create.json.jbuilder new file mode 100644 index 00000000000..b8db877400d --- /dev/null +++ b/app/views/course/external_assessment_imports/create.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true +json.createdComponents @summary[:createdComponents] +json.updatedComponents @summary[:updatedComponents] +json.gradesWritten @summary[:gradesWritten] diff --git a/app/views/course/external_assessment_imports/preview.json.jbuilder b/app/views/course/external_assessment_imports/preview.json.jbuilder new file mode 100644 index 00000000000..ccbe5f3c8f7 --- /dev/null +++ b/app/views/course/external_assessment_imports/preview.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true +json.ok @result[:ok] +json.unresolved @result[:unresolved] +json.malformed @result[:malformed] +json.sample @result[:sample] do |row| + json.studentName row[:studentName] + json.grades row[:grades] +end +json.conflicts @result[:conflicts] do |c| + json.component c[:component] + json.studentName c[:studentName] + json.existingGrade c[:existingGrade] + json.inFileGrade c[:inFileGrade] + json.identifierMismatch c[:identifierMismatch] +end diff --git a/app/views/course/external_assessments/_external_assessment.json.jbuilder b/app/views/course/external_assessments/_external_assessment.json.jbuilder new file mode 100644 index 00000000000..4c062d9b387 --- /dev/null +++ b/app/views/course/external_assessments/_external_assessment.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true +json.id(-external_assessment.id) +json.title external_assessment.title +json.tabId external_assessment.synthetic_tab_id +json.maxGrade external_assessment.maximum_grade.to_f +json.external true +json.floorAtZero external_assessment.floor_at_zero +json.capAtMaximum external_assessment.cap_at_maximum +json.gradebookExcluded false diff --git a/app/views/course/external_assessments/create.json.jbuilder b/app/views/course/external_assessments/create.json.jbuilder new file mode 100644 index 00000000000..dc35181ad4f --- /dev/null +++ b/app/views/course/external_assessments/create.json.jbuilder @@ -0,0 +1,20 @@ +# frozen_string_literal: true +json.assessment do + json.partial! 'external_assessment', external_assessment: @external_assessment + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + end +end +json.tab do + json.id @external_assessment.synthetic_tab_id + json.title @external_assessment.title + json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + json.weightMode 'equal' + end +end +json.category do + json.id Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + json.title Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE +end diff --git a/app/views/course/external_assessments/update.json.jbuilder b/app/views/course/external_assessments/update.json.jbuilder new file mode 100644 index 00000000000..4fadac238cb --- /dev/null +++ b/app/views/course/external_assessments/update.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true +json.assessment do + json.partial! 'external_assessment', external_assessment: @external_assessment + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + end +end +json.tab do + json.id @external_assessment.synthetic_tab_id + json.title @external_assessment.title + json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + json.weightMode 'equal' + end +end diff --git a/app/views/course/external_assessments/update_grade.json.jbuilder b/app/views/course/external_assessments/update_grade.json.jbuilder new file mode 100644 index 00000000000..f997450dfc3 --- /dev/null +++ b/app/views/course/external_assessments/update_grade.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true +json.studentId @grade.course_user.user_id +json.assessmentId(-@grade.external_assessment_id) +json.grade @grade.grade&.to_f diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder index b3cc4ec4d02..8cab4699a09 100644 --- a/app/views/course/gradebook/index.json.jbuilder +++ b/app/views/course/gradebook/index.json.jbuilder @@ -2,31 +2,71 @@ 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 +json.categories do + json.array!(@categories) do |cat| + json.id cat.id + json.title cat.title + end + if @external_assessments.any? + json.child! do + json.id Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + json.title Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE + end + end end -json.tabs @tabs do |tab| - json.id tab.id - json.title tab.title - json.categoryId tab.category_id - if @weighted_view_enabled - contribution = @tab_contributions[tab.id] - json.gradebookWeight (contribution&.weight || 0).to_f - json.weightMode(contribution&.weight_mode || 'equal') +json.tabs do + json.array!(@tabs) do |tab| + json.id tab.id + json.title tab.title + json.categoryId tab.category_id + if @weighted_view_enabled + contribution = @tab_contributions[tab.id] + json.gradebookWeight (contribution&.weight || 0).to_f + json.weightMode(contribution&.weight_mode || 'equal') + end + end + @external_assessments.each do |external| + json.child! do + json.id external.synthetic_tab_id + json.title external.title + json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + if @weighted_view_enabled + contribution = @external_contributions[external.id] + json.gradebookWeight (contribution&.weight || 0).to_f + json.weightMode 'equal' + end + end end 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 - if @weighted_view_enabled - contribution = @assessment_contributions[assessment.id] - json.gradebookWeight contribution&.weight&.to_f - json.gradebookExcluded(contribution&.excluded || false) +json.assessments do + json.array!(@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 + if @weighted_view_enabled + contribution = @assessment_contributions[assessment.id] + json.gradebookWeight contribution&.weight&.to_f + json.gradebookExcluded(contribution&.excluded || false) + end + end + @external_assessments.each do |external| + json.child! do + json.id(-external.id) + json.title external.title + json.tabId external.synthetic_tab_id + json.maxGrade external.maximum_grade.to_f + json.external true + json.floorAtZero external.floor_at_zero + json.capAtMaximum external.cap_at_maximum + if @weighted_view_enabled + contribution = @external_contributions[external.id] + json.gradebookWeight contribution&.weight&.to_f + json.gradebookExcluded false + end + end end end @@ -39,11 +79,21 @@ json.students @students do |course_user| json.totalXp course_user.experience_points end -json.submissions @submissions do |sub| - json.submissionId sub.submission_id - json.studentId sub.student_id - json.assessmentId sub.assessment_id - json.grade sub.grade&.to_f +json.submissions do + json.array!(@submissions) do |sub| + json.submissionId sub.submission_id + json.studentId sub.student_id + json.assessmentId sub.assessment_id + json.grade sub.grade&.to_f + end + @external_grades.each do |grade| + json.child! do + json.studentId grade.course_user.user_id + json.assessmentId(-grade.external_assessment_id) + json.grade grade.grade&.to_f + end + end end json.gamificationEnabled current_course.gamified? +json.userId current_user&.id diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts index 7603f1f2a1b..85b6a84a2c7 100644 --- a/client/app/api/course/Gradebook.ts +++ b/client/app/api/course/Gradebook.ts @@ -1,4 +1,13 @@ -import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook'; +import { + ExternalAssessmentNode, + ExternalAssessmentUpdate, + ExternalGradePayload, + GradebookData, + ImportCommitSummary, + ImportPreviewRequest, + ImportPreviewResult, + UpdateWeightsPayload, +} from 'types/course/gradebook'; import { APIResponse } from 'api/types'; @@ -18,4 +27,63 @@ export default class GradebookAPI extends BaseCourseAPI { ): APIResponse { return this.client.patch(`${this.#urlPrefix}/weights`, payload); } + + createExternal(payload: { + title: string; + maximumGrade: number; + floorAtZero: boolean; + capAtMaximum: boolean; + weight?: number; + }): APIResponse { + return this.client.post(`${this.#urlPrefix}/external_assessments`, payload); + } + + // `id` is the positive external id (negate the negative serialized id before calling). + updateExternal( + id: number, + payload: { + title?: string; + maximumGrade?: number; + floorAtZero?: boolean; + capAtMaximum?: boolean; + weight?: number; + }, + ): APIResponse { + return this.client.patch( + `${this.#urlPrefix}/external_assessments/${id}`, + payload, + ); + } + + deleteExternal(id: number): APIResponse { + return this.client.delete(`${this.#urlPrefix}/external_assessments/${id}`); + } + + setExternalGrade( + id: number, + payload: { studentId: number; grade: number | null }, + ): APIResponse { + return this.client.put( + `${this.#urlPrefix}/external_assessments/${id}/grades`, + payload, + ); + } + + importPreview( + payload: ImportPreviewRequest, + ): APIResponse { + return this.client.post( + `${this.#urlPrefix}/external_assessment_imports/preview`, + payload, + ); + } + + importCommit( + payload: ImportPreviewRequest & { onConflict: 'keep' | 'replace' }, + ): APIResponse { + return this.client.post( + `${this.#urlPrefix}/external_assessment_imports`, + payload, + ); + } } diff --git a/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx new file mode 100644 index 00000000000..131e1facaf2 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx @@ -0,0 +1,28 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; +import EditExternalAssessmentPrompt from '../components/manage/EditExternalAssessmentPrompt'; +import { editExternalAssessment } from '../operations'; + +jest.mock('../operations', () => ({ + editExternalAssessment: jest.fn(() => () => Promise.resolve()), +})); + +const assessment = { + id: -3, title: 'Quiz', tabId: -3, maxGrade: 20, external: true, + floorAtZero: true, capAtMaximum: true, +}; + +it( + 'saves edited name, max and bound flags', + async () => { + render(); + fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Quiz 1' } }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz 1', maximumGrade: 20, floorAtZero: true, capAtMaximum: false, + }), + ); + }, + 10000, +); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index c9f01f0dd02..c9f1d80b490 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -223,6 +223,15 @@ describe('GradebookIndex', () => { expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); }); + it('renders the import external assessments button when manager and students exist', async () => { + render(, { state: populatedStateManagerWeightedOff }); + expect( + await screen.findByRole('button', { + name: /import external assessments/i, + }), + ).toBeInTheDocument(); + }); + describe('weighted-view discoverability hint', () => { it('shows the hint to managers when the weighted view is off', async () => { render(, { state: populatedStateManagerWeightedOff }); diff --git a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx new file mode 100644 index 00000000000..dee3b631260 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx @@ -0,0 +1,281 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import ImportExternalAssessmentsWizard from '../components/import/ImportExternalAssessmentsWizard'; + +jest.mock('api/course'); +jest.mock('lib/components/wrappers/I18nProvider'); + +const renderWizard = (): void => { + render( + , + { store: appStore }, + ); +}; + +const file = (text: string): File => + new File([text], 'marks.csv', { type: 'text/csv' }); + +describe('ImportExternalAssessmentsWizard', () => { + beforeEach(() => jest.clearAllMocks()); + + it('walks define → upload → verify → commit with no conflicts', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + sample: [{ studentName: 'Alice', grades: { Midterm: 41 } }], + conflicts: [], + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 1 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + + renderWizard(); + // Step 1: type a component + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + + // Step 2: upload + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nA001,41\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + // Step 3: preview shows the sample + expect(await screen.findByText('Alice')).toBeVisible(); + await userEvent.click( + screen.getByRole('button', { name: /continue|confirm/i }), + ); + + await waitFor(() => + expect(CourseAPI.gradebook.importCommit).toHaveBeenCalled(), + ); + const payload = (CourseAPI.gradebook.importCommit as jest.Mock).mock + .calls[0][0]; + expect(payload.onConflict).toBe('replace'); + expect(payload.identifierMode).toBe('student_id'); + expect(payload.components[0]).toMatchObject({ + name: 'Midterm', + weightage: 30, + maximumGrade: 50, + }); + }); + + it('shows unresolved identifiers and stays on the upload step', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ'], + malformed: [], + sample: [], + conflicts: [], + }, + }); + renderWizard(); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nZZZ,1\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + expect(await screen.findByText(/ZZZ/)).toBeVisible(); + expect(CourseAPI.gradebook.importCommit).not.toHaveBeenCalled(); + }); + + it('hides weightage field when weightedViewEnabled is false', async () => { + render( + , + { store: appStore }, + ); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + expect(screen.queryByLabelText(/weightage/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/max marks/i)).toBeInTheDocument(); + }); + + it('disables Next when component name is empty', () => { + renderWizard(); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + it('commits with keep when Keep Existing is clicked on the conflict prompt', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + sample: [{ studentName: 'Alice', grades: { Midterm: 20 } }], + conflicts: [ + { + component: 'Midterm', + studentName: 'Alice', + existingGrade: 10, + inFileGrade: 20, + identifierMismatch: false, + }, + ], + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 0 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + renderWizard(); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nA001,20\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /confirm import/i }), + ); + await userEvent.click( + await screen.findByRole('button', { name: /keep existing/i }), + ); + await waitFor(() => + expect( + (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] + .onConflict, + ).toBe('keep'), + ); + }); + + it('does not show Confirm import button when preview has errors', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ'], + malformed: [], + sample: [], + conflicts: [], + }, + }); + renderWizard(); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nZZZ,1\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await screen.findByText(/ZZZ/); + expect( + screen.queryByRole('button', { name: /confirm import/i }), + ).not.toBeInTheDocument(); + }); + + it('opens the conflict prompt and commits with keep/replace', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + sample: [{ studentName: 'Alice', grades: { Midterm: 20 } }], + conflicts: [ + { + component: 'Midterm', + studentName: 'Alice', + existingGrade: 10, + inFileGrade: 20, + identifierMismatch: false, + }, + ], + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 1 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + render( + , + { store: appStore }, + ); + // Step 1: 'Midterm' matches existing → max/weightage locked; just Next. + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nA001,20\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /continue|confirm/i }), + ); + // conflict prompt + await userEvent.click( + await screen.findByRole('button', { name: /replace/i }), + ); + await waitFor(() => + expect( + (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] + .onConflict, + ).toBe('replace'), + ); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/buildAssessmentColumnIds.test.ts b/client/app/bundles/course/gradebook/__tests__/buildAssessmentColumnIds.test.ts new file mode 100644 index 00000000000..d231ce65598 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/buildAssessmentColumnIds.test.ts @@ -0,0 +1,31 @@ +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from '../components/buildAssessmentColumnIds'; + +describe('assessment column ids', () => { + it('round-trips a positive (regular) assessment id', () => { + expect(parseAssessmentColumnId(buildAssessmentColumnId(100))).toBe(100); + }); + + it('round-trips a negative (external) assessment id', () => { + expect(parseAssessmentColumnId(buildAssessmentColumnId(-5))).toBe(-5); + }); + + it('returns null for a non-assessment column id', () => { + expect(parseAssessmentColumnId('name')).toBeNull(); + expect(parseAssessmentColumnId('totalXp')).toBeNull(); + }); + + it('round-trips a zero id (falsy but valid)', () => { + expect(parseAssessmentColumnId(buildAssessmentColumnId(0))).toBe(0); + }); + + it('returns null for a malformed or unanchored asn- column id', () => { + expect(parseAssessmentColumnId('asn-')).toBeNull(); + expect(parseAssessmentColumnId('asn-abc')).toBeNull(); + expect(parseAssessmentColumnId('asn-1.5')).toBeNull(); + expect(parseAssessmentColumnId('asn-12x')).toBeNull(); + expect(parseAssessmentColumnId('xasn-12')).toBeNull(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts new file mode 100644 index 00000000000..a0283fdfb98 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts @@ -0,0 +1,45 @@ +import { buildTemplateCsv } from '../components/import/buildTemplate'; + +describe('buildTemplateCsv', () => { + it('builds a header of Identifier + component names in order', () => { + const csv = buildTemplateCsv([ + { name: 'Midterm', weightage: 30, maximumGrade: 50 }, + { name: 'Final', weightage: 50, maximumGrade: 100 }, + ]); + expect(csv.split('\n')[0]).toBe('Identifier,Midterm,Final'); + }); + + it('quotes a component name containing a comma', () => { + const csv = buildTemplateCsv([ + { name: 'Lab, week 1', weightage: 10, maximumGrade: 20 }, + ]); + expect(csv.split('\n')[0]).toBe('Identifier,"Lab, week 1"'); + }); + + it('returns "Identifier\\n" for empty components array', () => { + expect(buildTemplateCsv([])).toBe('Identifier\n'); + }); + + it('quotes a component name containing a double-quote', () => { + const csv = buildTemplateCsv([ + { name: 'My "Best" Quiz', weightage: 10, maximumGrade: 20 }, + ]); + expect(csv.split('\n')[0]).toBe('Identifier,"My ""Best"" Quiz"'); + }); + + it('quotes a component name containing a newline', () => { + const csv = buildTemplateCsv([ + { name: 'Lab\nWeek1', weightage: 10, maximumGrade: 20 }, + ]); + // The quoted cell spans two lines; verify the full header row content. + expect(csv.startsWith('Identifier,"Lab\nWeek1"')).toBe(true); + }); + + it('always ends with exactly one newline', () => { + const csv = buildTemplateCsv([ + { name: 'A', weightage: 0, maximumGrade: 100 }, + ]); + expect(csv.endsWith('\n')).toBe(true); + expect(csv.split('\n')).toHaveLength(2); // header line + empty string after trailing \n + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts index c4f2ed866fa..0cfe294c02a 100644 --- a/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts @@ -7,6 +7,7 @@ import { resolveTabWeights, sumWeights, usingDefaultWeights, + materializedDefaultWeights, } from '../computeWeighted'; const assessments = [ @@ -686,3 +687,44 @@ describe('breakdown — exclusion', () => { expect(a.points).toBeCloseTo(60); }); }); + +describe('materializedDefaultWeights', () => { + it('freezes the equal split across populated tabs as an equal-mode payload', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: 2, title: 'T2', categoryId: 1 }, + ]; + const assessments = [{ tabId: 1 }, { tabId: 2 }]; + + expect(materializedDefaultWeights(tabs, assessments)).toEqual([ + { tabId: 1, weight: 50, weightMode: 'equal' }, + { tabId: 2, weight: 50, weightMode: 'equal' }, + ]); + }); + + it('omits tabs with no assessments', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: 2, title: 'T2', categoryId: 1 }, + { id: 3, title: 'Empty', categoryId: 1 }, + ]; + const assessments = [{ tabId: 1 }, { tabId: 2 }]; + + expect(materializedDefaultWeights(tabs, assessments).map((w) => w.tabId)).toEqual([ + 1, 2, + ]); + }); + + it('routes external tabs by their negative store id (matches bulk_update)', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: -5, title: 'Ext', categoryId: -1 }, + ]; + const assessments = [{ tabId: 1 }, { tabId: -5 }]; + + expect(materializedDefaultWeights(tabs, assessments)).toEqual([ + { tabId: 1, weight: 50, weightMode: 'equal' }, + { tabId: -5, weight: 50, weightMode: 'equal' }, + ]); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/store.test.ts b/client/app/bundles/course/gradebook/__tests__/store.test.ts index 97143ade931..d4211d00014 100644 --- a/client/app/bundles/course/gradebook/__tests__/store.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/store.test.ts @@ -151,3 +151,240 @@ describe('UPDATE_TAB_WEIGHTS reducer', () => { ); }); }); + +describe('external assessment reducers', () => { + const state = { + 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: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: false, + userId: 0, + weightedViewEnabled: false, + canManageWeights: true, + }; + + it('applyCreatedExternal adds category, tab and assessment', () => { + const next = reducer( + state, + actions.applyCreatedExternal({ + assessment: { + id: -5, + title: 'Midterm', + tabId: 200, + maxGrade: 50, + external: true, + }, + tab: { id: 200, title: 'Midterm', categoryId: 2 }, + category: { id: 2, title: 'External Assessments' }, + }), + ); + expect(next.categories.find((c) => c.id === 2)?.title).toBe( + 'External Assessments', + ); + expect(next.tabs.find((t) => t.id === 200)?.title).toBe('Midterm'); + expect(next.assessments.find((a) => a.id === -5)?.external).toBe(true); + }); + + it('applyCreatedExternal does not duplicate an existing category/tab', () => { + const seeded = { + ...state, + categories: [ + ...state.categories, + { id: 2, title: 'External Assessments' }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer( + seeded, + actions.applyCreatedExternal({ + assessment: { + id: -6, + title: 'Final', + tabId: 200, + maxGrade: 100, + external: true, + }, + tab: { id: 200, title: 'Midterm', categoryId: 2 }, + category: { id: 2, title: 'External Assessments' }, + }), + ); + expect(next.categories.filter((c) => c.id === 2)).toHaveLength(1); + expect(next.tabs.filter((t) => t.id === 200)).toHaveLength(1); + }); + + it('updateExternalAssessment changes title and maxGrade and syncs tab title', () => { + const seeded = { + ...state, + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer( + seeded, + actions.updateExternalAssessment({ + assessment: { + id: -5, + title: 'Midterm Exam', + tabId: 200, + maxGrade: 60, + external: true, + }, + tab: { id: 200, title: 'Midterm Exam', categoryId: 2 }, + }), + ); + expect(next.assessments.find((a) => a.id === -5)?.title).toBe( + 'Midterm Exam', + ); + expect(next.assessments.find((a) => a.id === -5)?.maxGrade).toBe(60); + expect(next.tabs.find((t) => t.id === 200)?.title).toBe('Midterm Exam'); + }); + + it('deleteExternalAssessment removes the assessment and its now-empty tab', () => { + const seeded = { + ...state, + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + submissions: [ + ...state.submissions, + { studentId: 1, assessmentId: -5, grade: 30 }, + ], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-5)); + expect(next.assessments.find((a) => a.id === -5)).toBeUndefined(); + expect(next.tabs.find((t) => t.id === 200)).toBeUndefined(); + expect(next.submissions.find((s) => s.assessmentId === -5)).toBeUndefined(); + }); + + it('deleteExternalAssessment drops the synthetic category once its last external is gone', () => { + const seeded = { + ...state, + categories: [ + ...state.categories, + { id: 2, title: 'External Assessments' }, + ], + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-5)); + expect(next.categories.find((c) => c.id === 2)).toBeUndefined(); + expect(next.categories.find((c) => c.id === 1)).toBeDefined(); + }); + + it('deleteExternalAssessment keeps the category while other externals remain', () => { + const seeded = { + ...state, + categories: [ + ...state.categories, + { id: 2, title: 'External Assessments' }, + ], + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + { id: -6, title: 'Final', tabId: 201, maxGrade: 100, external: true }, + ], + tabs: [ + ...state.tabs, + { id: 200, title: 'Midterm', categoryId: 2 }, + { id: 201, title: 'Final', categoryId: 2 }, + ], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-5)); + expect(next.categories.find((c) => c.id === 2)).toBeDefined(); + expect(next.tabs.find((t) => t.id === 201)).toBeDefined(); + }); + + it('setExternalGrade upserts a submission row by (studentId, assessmentId)', () => { + const inserted = reducer( + state, + actions.setExternalGrade({ studentId: 1, assessmentId: -5, grade: 42 }), + ); + expect( + inserted.submissions.find( + (s) => s.studentId === 1 && s.assessmentId === -5, + )?.grade, + ).toBe(42); + + const updated = reducer( + inserted, + actions.setExternalGrade({ studentId: 1, assessmentId: -5, grade: 17 }), + ); + expect( + updated.submissions.filter( + (s) => s.studentId === 1 && s.assessmentId === -5, + ), + ).toHaveLength(1); + expect( + updated.submissions.find( + (s) => s.studentId === 1 && s.assessmentId === -5, + )?.grade, + ).toBe(17); + }); + + it('setExternalGrade can clear a grade to null', () => { + const next = reducer( + state, + actions.setExternalGrade({ studentId: 1, assessmentId: -5, grade: null }), + ); + expect( + next.submissions.find((s) => s.studentId === 1 && s.assessmentId === -5) + ?.grade, + ).toBeNull(); + }); + + it('UPDATE_EXTERNAL_ASSESSMENT copies bound flags', () => { + const seed = (overrides = {}) => + reducer( + undefined, + actions.saveGradebook({ + categories: [], + tabs: [{ id: -1, title: 'Ext', categoryId: -1 }], + assessments: [ + { + id: -1, + title: 'Ext', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + ...overrides, + }), + ); + + const next = reducer( + seed(), + actions.updateExternalAssessment({ + assessment: { + id: -1, + title: 'Ext', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: false, + capAtMaximum: false, + }, + tab: { id: -1, title: 'Ext', categoryId: -1 }, + }), + ); + const a = next.assessments.find((x) => x.id === -1)!; + expect(a.floorAtZero).toBe(false); + expect(a.capAtMaximum).toBe(false); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx new file mode 100644 index 00000000000..12cf4cc1107 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx @@ -0,0 +1,116 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, +} from '@mui/material'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { createExternalAssessment } from '../operations'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.AddExternalColumnPrompt.title', + defaultMessage: 'Add external assessment', + }, + nameLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.nameLabel', + defaultMessage: 'Name', + }, + maxLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.maxLabel', + defaultMessage: 'Max marks', + }, + cancel: { + id: 'course.gradebook.AddExternalColumnPrompt.cancel', + defaultMessage: 'Cancel', + }, + create: { + id: 'course.gradebook.AddExternalColumnPrompt.create', + defaultMessage: 'Create', + }, + error: { + id: 'course.gradebook.AddExternalColumnPrompt.error', + defaultMessage: 'Could not create the external assessment.', + }, + success: { + id: 'course.gradebook.AddExternalColumnPrompt.success', + defaultMessage: 'External assessment created.', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; +} + +const AddExternalColumnPrompt: FC = ({ open, onClose }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [name, setName] = useState(''); + const [max, setMax] = useState(''); + const [saving, setSaving] = useState(false); + + const reset = (): void => { + setName(''); + setMax(''); + }; + + const canSave = + name.trim() !== '' && max.trim() !== '' && Number(max) >= 0 && !saving; + + const submit = async (): Promise => { + setSaving(true); + try { + await dispatch(createExternalAssessment(name.trim(), Number(max))); + toast.success(t(translations.success)); + reset(); + onClose(); + } catch { + toast.error(t(translations.error)); + } finally { + setSaving(false); + } + }; + + return ( + + {t(translations.title)} + + setName(e.target.value)} + value={name} + /> + setMax(e.target.value)} + type="number" + value={max} + /> + + + + + + + ); +}; + +export default AddExternalColumnPrompt; diff --git a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx index 0783ab32f71..1c4ebd19a6a 100644 --- a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx @@ -239,6 +239,12 @@ const ConfigureWeightsPrompt: FC = ({ const effectiveWeight = (tabId: number): number => isAllExcluded(tabId) ? 0 : weights[tabId] ?? 0; + // Skip a category with no tabs — e.g. the synthetic "External Assessments" + // group once its last external is deleted — so no bare header lingers. + const visibleCategories = categories.filter((cat) => + tabs.some((tb) => tb.categoryId === cat.id), + ); + const sum = tabs.reduce((acc, tb) => acc + effectiveWeight(tb.id), 0); const hasInvalid = Object.values(weights).some((w) => validate(w) !== null) || @@ -348,7 +354,7 @@ const ConfigureWeightsPrompt: FC = ({ ))} - {categories.map((cat) => ( + {visibleCategories.map((cat) => (
    {cat.title} @@ -360,6 +366,55 @@ const ConfigureWeightsPrompt: FC = ({ const tabAssessments = assessments.filter( (a) => a.tabId === tb.id, ); + // An external assessment is always one-per-tab; its tab has + // no internal structure, so render just name + weight (no + // mode toggle, no expand, no per-assessment exclusion). + const isExternal = + tabAssessments.length > 0 && + tabAssessments.every((a) => a.external); + if (isExternal) { + return ( +
    +
    +
    + + {tb.title} + + + setWeights((prev) => ({ + ...prev, + [tb.id]: r2(prev[tb.id] ?? 0), + })) + } + onChange={(e) => + handleChange(tb.id, e.target.value) + } + size="small" + sx={{ width: 96 }} + type="number" + value={value} + /> +
    + {err && ( + + {err} + + )} +
    + ); + } const mode = modes[tb.id] ?? 'equal'; const isExpanded = !!expanded[tb.id]; const unbalanced = isUnbalanced(tb.id); diff --git a/client/app/bundles/course/gradebook/components/DeleteExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/DeleteExternalColumnPrompt.tsx new file mode 100644 index 00000000000..528fdc6afbe --- /dev/null +++ b/client/app/bundles/course/gradebook/components/DeleteExternalColumnPrompt.tsx @@ -0,0 +1,94 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { deleteExternalAssessment } from '../operations'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.DeleteExternalColumnPrompt.title', + defaultMessage: 'Delete external assessment', + }, + body: { + id: 'course.gradebook.DeleteExternalColumnPrompt.body', + defaultMessage: + 'Delete "{title}"? This permanently removes the column and every student grade in it. This cannot be undone.', + }, + cancel: { + id: 'course.gradebook.DeleteExternalColumnPrompt.cancel', + defaultMessage: 'Cancel', + }, + confirm: { + id: 'course.gradebook.DeleteExternalColumnPrompt.confirm', + defaultMessage: 'Delete', + }, + error: { + id: 'course.gradebook.DeleteExternalColumnPrompt.error', + defaultMessage: 'Could not delete the external assessment.', + }, +}); + +interface Props { + open: boolean; + assessmentId: number; + title: string; + onClose: () => void; +} + +const DeleteExternalColumnPrompt: FC = ({ + open, + assessmentId, + title, + onClose, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [saving, setSaving] = useState(false); + + const submit = async (): Promise => { + setSaving(true); + try { + await dispatch(deleteExternalAssessment(assessmentId)); + onClose(); + } catch { + toast.error(t(translations.error)); + } finally { + setSaving(false); + } + }; + + return ( + + {t(translations.title)} + + {t(translations.body, { title })} + + + + + + + ); +}; + +export default DeleteExternalColumnPrompt; diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx index 5c6c7abe4d1..3190c6273bd 100644 --- a/client/app/bundles/course/gradebook/components/GradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -7,8 +7,13 @@ import { useState, } from 'react'; import { defineMessages } from 'react-intl'; +import { MoreVert } from '@mui/icons-material'; import { Checkbox, + Chip, + IconButton, + Menu, + MenuItem, Paper, type SxProps, Table, @@ -18,6 +23,7 @@ import { TableHead, TableRow, TableSortLabel, + TextField, type Theme, Tooltip, } from '@mui/material'; @@ -36,10 +42,13 @@ import { DEFAULT_TABLE_ROWS_PER_PAGE, } from 'lib/constants/sharedConstants'; import { getEditSubmissionURL } from 'lib/helpers/url-builders'; +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; import tableTranslations from 'lib/translations/table'; import { GAMIFICATION_COL_IDS } from '../constants'; +import { setExternalGrade, updateExternalMaxGrade } from '../operations'; import type { AssessmentData, CategoryData, @@ -52,7 +61,9 @@ import { buildAssessmentColumnId, parseAssessmentColumnId, } from './buildAssessmentColumnIds'; +import DeleteExternalColumnPrompt from './DeleteExternalColumnPrompt'; import GradebookColumnTree from './GradebookColumnTree'; +import RenameExternalColumnPrompt from './RenameExternalColumnPrompt'; const COL_WIDTHS = { name: 160, @@ -110,6 +121,38 @@ const translations = defineMessages({ defaultMessage: 'No grade or gamification columns selected - export will include student info only.', }, + externalBadge: { + id: 'course.gradebook.GradebookTable.externalBadge', + defaultMessage: 'External', + }, + externalGradeAria: { + id: 'course.gradebook.GradebookTable.externalGradeAria', + defaultMessage: '{title} grade for {name}', + }, + gradeSaveError: { + id: 'course.gradebook.GradebookTable.gradeSaveError', + defaultMessage: 'Could not save the grade. Please try again.', + }, + externalMaxAria: { + id: 'course.gradebook.GradebookTable.externalMaxAria', + defaultMessage: '{title} max marks', + }, + maxSaveError: { + id: 'course.gradebook.GradebookTable.maxSaveError', + defaultMessage: 'Could not save the max marks. Please try again.', + }, + rename: { + id: 'course.gradebook.GradebookTable.rename', + defaultMessage: 'Rename', + }, + deleteAction: { + id: 'course.gradebook.GradebookTable.delete', + defaultMessage: 'Delete', + }, + manageAria: { + id: 'course.gradebook.GradebookTable.manageAria', + defaultMessage: 'manage {title}', + }, }); const HeaderLabel = forwardRef< @@ -177,6 +220,144 @@ const HeaderLabel = forwardRef< }); HeaderLabel.displayName = 'HeaderLabel'; +const ExternalGradeCell = ({ + assessmentId, + studentId, + studentName, + title, + value, +}: { + assessmentId: number; + studentId: number; + studentName: string; + title: string; + value: number | null | undefined; +}): JSX.Element => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [editing, setEditing] = useState(false); + const [text, setText] = useState(''); + const [localValue, setLocalValue] = useState( + value, + ); + + const commit = async (): Promise => { + setEditing(false); + const trimmed = text.trim(); + const next = trimmed === '' ? null : Number(trimmed); + if (trimmed !== '' && Number.isNaN(next)) return; + if (next === (localValue ?? null)) return; + const prev = localValue; + setLocalValue(next); + try { + await dispatch(setExternalGrade(assessmentId, studentId, next)); + } catch { + setLocalValue(prev); + toast.error(t(translations.gradeSaveError)); + } + }; + + if (editing) { + return ( + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') setEditing(false); + }} + size="small" + value={text} + variant="standard" + /> + ); + } + + return ( + { + setText(localValue == null ? '' : String(localValue)); + setEditing(true); + }} + role="button" + style={{ cursor: 'pointer', display: 'inline-block', minWidth: 24 }} + tabIndex={0} + > + {localValue == null ? '—' : localValue} + + ); +}; + +const ExternalMaxCell = ({ + assessmentId, + title, + value, +}: { + assessmentId: number; + title: string; + value: number; +}): JSX.Element => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [editing, setEditing] = useState(false); + const [text, setText] = useState(''); + + const commit = async (): Promise => { + setEditing(false); + const next = Number(text.trim()); + if (text.trim() === '' || Number.isNaN(next) || next < 0) return; + if (next === value) return; + try { + await dispatch(updateExternalMaxGrade(assessmentId, next)); + } catch { + toast.error(t(translations.maxSaveError)); + } + }; + + if (editing) { + return ( + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') setEditing(false); + }} + size="small" + value={text} + variant="standard" + /> + ); + } + + return ( + { + setText(String(value)); + setEditing(true); + }} + role="button" + style={{ cursor: 'pointer' }} + tabIndex={0} + > + {`/${value}`} + + ); +}; + interface GradebookRow { studentId: number; name: string; @@ -211,6 +392,15 @@ const GradebookTable = ({ }: GradebookTableProps): JSX.Element => { const { t } = useTranslation(); + const [menuAnchor, setMenuAnchor] = useState(null); + const [menuAsnId, setMenuAsnId] = useState(null); + const [renameOpen, setRenameOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const menuAsn = + menuAsnId !== null + ? assessments.find((a) => a.id === menuAsnId) + : undefined; + const submissionsByStudent = useMemo(() => { const map = new Map(); submissions.forEach((s) => { @@ -330,6 +520,17 @@ const GradebookTable = ({ }, }, cell: (row) => { + if (asn.external) { + return ( + + ); + } const grade = row.grades[asn.id]; if (grade === undefined) return '—'; if (grade === null) return ''; @@ -598,6 +799,11 @@ const GradebookTable = ({ const isLeft = isLeftAligned(id); const fits = headerFits[id] ?? false; const sort = sortByColId.get(id); + const asnId = parseAssessmentColumnId(id); + const isExternalCol = + asnId !== null && + assessments.find((a) => a.id === asnId)?.external === + true; const labelNode = ( @@ -608,6 +814,17 @@ const GradebookTable = ({ ); + const sortedLabel = sort ? ( + + {labelNode} + + ) : ( + labelNode + ); return ( - {sort ? ( - - {labelNode} - + {sortedLabel} + + { + setMenuAnchor(e.currentTarget); + setMenuAsnId(asnId); + }} + size="small" + > + + + ) : ( - labelNode + sortedLabel )} ); @@ -676,11 +911,23 @@ const GradebookTable = ({ {visibleCols.map((c) => { const id = c.id ?? (c.of as string); const asnId = parseAssessmentColumnId(id); - let cellContent: string = ''; - if (id === 'name') cellContent = t(translations.maxMarks); - else if (asnId !== null) { + const asn = + asnId !== null + ? assessments.find((a) => a.id === asnId) + : undefined; + let cellNode: React.ReactNode = ''; + if (id === 'name') cellNode = t(translations.maxMarks); + else if (asn?.external) { + cellNode = ( + + ); + } else if (asnId !== null) { const maxGrade = assessmentMaxGrades.get(asnId); - cellContent = maxGrade != null ? `/${maxGrade}` : ''; + cellNode = maxGrade != null ? `/${maxGrade}` : ''; } return ( - {cellContent} + {cellNode} ); })} @@ -802,6 +1049,44 @@ const GradebookTable = ({ {pagination && }
    + setMenuAnchor(null)} + open={Boolean(menuAnchor)} + > + { + setMenuAnchor(null); + setRenameOpen(true); + }} + > + {t(translations.rename)} + + { + setMenuAnchor(null); + setDeleteOpen(true); + }} + > + {t(translations.deleteAction)} + + + {menuAsn && ( + setRenameOpen(false)} + open={renameOpen} + /> + )} + {menuAsn && ( + setDeleteOpen(false)} + open={deleteOpen} + title={menuAsn.title} + /> + )}
    ); }; diff --git a/client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx new file mode 100644 index 00000000000..d5876fe2f63 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx @@ -0,0 +1,100 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + TextField, +} from '@mui/material'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { renameExternalAssessment } from '../operations'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.RenameExternalColumnPrompt.title', + defaultMessage: 'Rename external assessment', + }, + nameLabel: { + id: 'course.gradebook.RenameExternalColumnPrompt.nameLabel', + defaultMessage: 'Name', + }, + cancel: { + id: 'course.gradebook.RenameExternalColumnPrompt.cancel', + defaultMessage: 'Cancel', + }, + save: { + id: 'course.gradebook.RenameExternalColumnPrompt.save', + defaultMessage: 'Save', + }, + error: { + id: 'course.gradebook.RenameExternalColumnPrompt.error', + defaultMessage: 'Could not rename the external assessment.', + }, +}); + +interface Props { + open: boolean; + assessmentId: number; + currentTitle: string; + onClose: () => void; +} + +const RenameExternalColumnPrompt: FC = ({ + open, + assessmentId, + currentTitle, + onClose, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [name, setName] = useState(currentTitle); + const [saving, setSaving] = useState(false); + + const submit = async (): Promise => { + setSaving(true); + try { + await dispatch(renameExternalAssessment(assessmentId, name.trim())); + onClose(); + } catch { + toast.error(t(translations.error)); + } finally { + setSaving(false); + } + }; + + return ( + + {t(translations.title)} + + setName(e.target.value)} + value={name} + /> + + + + + + + ); +}; + +export default RenameExternalColumnPrompt; diff --git a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts index d12a4bd26a7..13f84cf48a9 100644 --- a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts +++ b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts @@ -2,6 +2,6 @@ export const buildAssessmentColumnId = (asnId: number): string => `asn-${asnId}`; export const parseAssessmentColumnId = (colId: string): number | null => { - const match = colId.match(/^asn-(\d+)$/); + const match = colId.match(/^asn-(-?\d+)$/); return match ? Number(match[1]) : null; }; diff --git a/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx new file mode 100644 index 00000000000..209f8be72b6 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx @@ -0,0 +1,88 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; +import type { ImportConflict } from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import ExternalGradeConflictTable from './ExternalGradeConflictTable'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ExternalGradeConflictPrompt.title', + defaultMessage: 'Resolve grade conflicts', + }, + body: { + id: 'course.gradebook.ExternalGradeConflictPrompt.body', + defaultMessage: + 'These students already have a grade for these components. Keep their existing grades, or replace them with the values from your file? New students and blank cells are unaffected.', + }, + goBack: { + id: 'course.gradebook.ExternalGradeConflictPrompt.goBack', + defaultMessage: 'Go Back', + }, + keepExisting: { + id: 'course.gradebook.ExternalGradeConflictPrompt.keepExisting', + defaultMessage: 'Keep Existing', + }, + replace: { + id: 'course.gradebook.ExternalGradeConflictPrompt.replace', + defaultMessage: 'Replace', + }, +}); + +interface Props { + open: boolean; + conflicts: ImportConflict[]; + disabled?: boolean; + onKeepExisting: () => void; + onReplaceAll: () => void; + onCancel: () => void; +} + +const ExternalGradeConflictPrompt: FC = ({ + open, + conflicts, + disabled = false, + onKeepExisting, + onReplaceAll, + onCancel, +}) => { + const { t } = useTranslation(); + return ( + + {t(translations.title)} + + {t(translations.body)} + + + + + + + + + + + ); +}; + +export default ExternalGradeConflictPrompt; diff --git a/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx new file mode 100644 index 00000000000..19972bef837 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx @@ -0,0 +1,86 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { WarningAmber } from '@mui/icons-material'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import type { ImportConflict } from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + component: { + id: 'course.gradebook.ExternalGradeConflictTable.component', + defaultMessage: 'Component', + }, + student: { + id: 'course.gradebook.ExternalGradeConflictTable.student', + defaultMessage: 'Student', + }, + existing: { + id: 'course.gradebook.ExternalGradeConflictTable.existing', + defaultMessage: 'Existing grade', + }, + inFile: { + id: 'course.gradebook.ExternalGradeConflictTable.inFile', + defaultMessage: 'In-file grade', + }, + mismatch: { + id: 'course.gradebook.ExternalGradeConflictTable.mismatch', + defaultMessage: + 'This identifier now resolves to a different student than the existing grade was imported under.', + }, +}); + +interface Props { + rows: ImportConflict[]; +} + +const ExternalGradeConflictTable: FC = ({ rows }) => { + const { t } = useTranslation(); + return ( + + + + {t(translations.component)} + {t(translations.student)} + {t(translations.existing)} + {t(translations.inFile)} + + + + {rows.map((row) => ( + + {row.component} + + {row.studentName} + {row.identifierMismatch && ( + + + + )} + + {row.existingGrade} + + + {row.inFileGrade} + + + + ))} + +
    + ); +}; + +export default ExternalGradeConflictTable; diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx new file mode 100644 index 00000000000..279b239603f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx @@ -0,0 +1,44 @@ +import { FC, useMemo, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button } from '@mui/material'; + +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { getAssessments, getWeightedViewEnabled } from '../../selectors'; + +import ImportExternalAssessmentsWizard from './ImportExternalAssessmentsWizard'; + +const translations = defineMessages({ + importButton: { + id: 'course.gradebook.ImportExternalAssessmentsButton.label', + defaultMessage: 'Import external assessments', + }, +}); + +const ImportExternalAssessmentsButton: FC = () => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); + const assessments = useAppSelector(getAssessments); + const existingExternalTitles = useMemo( + () => assessments.filter((a) => a.external).map((a) => a.title), + [assessments], + ); + + return ( + <> + + setOpen(false)} + open={open} + weightedViewEnabled={weightedViewEnabled} + /> + + ); +}; + +export default ImportExternalAssessmentsButton; diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx new file mode 100644 index 00000000000..032caf39d1a --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx @@ -0,0 +1,460 @@ +import { FC, useEffect, useMemo, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Add, Delete } from '@mui/icons-material'; +import { + Alert, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Link as MuiLink, + Step, + StepLabel, + Stepper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + ToggleButton, + ToggleButtonGroup, +} from '@mui/material'; +import type { + IdentifierMode, + ImportComponent, + ImportPreviewResult, +} from 'types/course/gradebook'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { commitImport, previewImport } from '../../operations'; + +import { downloadTemplate, readFileText } from './buildTemplate'; +import ExternalGradeConflictPrompt from './ExternalGradeConflictPrompt'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ImportWizard.title', + defaultMessage: 'Import external assessments', + }, + stepDefine: { + id: 'course.gradebook.ImportWizard.stepDefine', + defaultMessage: 'Define components', + }, + stepUpload: { + id: 'course.gradebook.ImportWizard.stepUpload', + defaultMessage: 'Template & upload', + }, + stepVerify: { + id: 'course.gradebook.ImportWizard.stepVerify', + defaultMessage: 'Verify', + }, + componentName: { + id: 'course.gradebook.ImportWizard.componentName', + defaultMessage: 'Component name', + }, + weightage: { + id: 'course.gradebook.ImportWizard.weightage', + defaultMessage: 'Weightage', + }, + maxMarks: { + id: 'course.gradebook.ImportWizard.maxMarks', + defaultMessage: 'Max marks', + }, + addComponent: { + id: 'course.gradebook.ImportWizard.addComponent', + defaultMessage: 'Add component', + }, + updatesExisting: { + id: 'course.gradebook.ImportWizard.updatesExisting', + defaultMessage: 'Updates existing — managed in the gradebook', + }, + identifierMode: { + id: 'course.gradebook.ImportWizard.identifierMode', + defaultMessage: 'Match students by', + }, + studentId: { + id: 'course.gradebook.ImportWizard.studentId', + defaultMessage: 'Student ID', + }, + email: { id: 'course.gradebook.ImportWizard.email', defaultMessage: 'Email' }, + studentIdHint: { + id: 'course.gradebook.ImportWizard.studentIdHint', + defaultMessage: + "Matching uses each student's current Student ID. Keep Student IDs up to date in Manage Users.", + }, + downloadTemplate: { + id: 'course.gradebook.ImportWizard.downloadTemplate', + defaultMessage: 'Download template', + }, + upload: { + id: 'course.gradebook.ImportWizard.upload', + defaultMessage: 'Upload filled CSV', + }, + back: { id: 'course.gradebook.ImportWizard.back', defaultMessage: 'Back' }, + next: { id: 'course.gradebook.ImportWizard.next', defaultMessage: 'Next' }, + verify: { + id: 'course.gradebook.ImportWizard.verify', + defaultMessage: 'Verify', + }, + cancel: { + id: 'course.gradebook.ImportWizard.cancel', + defaultMessage: 'Cancel', + }, + continue: { + id: 'course.gradebook.ImportWizard.continue', + defaultMessage: 'Confirm import', + }, + unresolved: { + id: 'course.gradebook.ImportWizard.unresolved', + defaultMessage: 'These identifiers were not found in the course: {ids}', + }, + malformed: { + id: 'course.gradebook.ImportWizard.malformed', + defaultMessage: 'These cells are not valid numbers: {cells}', + }, + committed: { + id: 'course.gradebook.ImportWizard.committed', + defaultMessage: 'Import complete.', + }, + commitError: { + id: 'course.gradebook.ImportWizard.commitError', + defaultMessage: 'Import failed. Nothing was saved.', + }, + previewError: { + id: 'course.gradebook.ImportWizard.previewError', + defaultMessage: 'Could not verify the file. Please try again.', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; + weightedViewEnabled: boolean; + existingExternalTitles: string[]; +} + +let rowId = 0; +const blankComponent = (): ImportComponent & { id: number } => { + rowId += 1; + return { id: rowId, name: '', weightage: 0, maximumGrade: 0 }; +}; + +const ImportExternalAssessmentsWizard: FC = ({ + open, + onClose, + weightedViewEnabled, + existingExternalTitles, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseId } = useParams(); + const [step, setStep] = useState(0); + const [components, setComponents] = useState< + (ImportComponent & { id: number })[] + >([blankComponent()]); + const [mode, setMode] = useState('student_id'); + const [csvData, setCsvData] = useState(''); + const [preview, setPreview] = useState(null); + const [conflictOpen, setConflictOpen] = useState(false); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!open) { + setStep(0); + setComponents([blankComponent()]); + setCsvData(''); + setPreview(null); + setConflictOpen(false); + setBusy(false); + } + }, [open]); + + const existingSet = useMemo( + () => new Set(existingExternalTitles), + [existingExternalTitles], + ); + const isExisting = (name: string): boolean => existingSet.has(name.trim()); + + const updateComponent = ( + i: number, + patch: Partial, + ): void => + setComponents((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c))); + + const defineValid = + components.length > 0 && + components.every((c) => c.name.trim() !== '') && + new Set(components.map((c) => c.name.trim())).size === components.length; + + const runPreview = async (): Promise => { + setBusy(true); + try { + const result = await dispatch( + previewImport({ + components: components.map(({ id: _, ...rest }) => rest), + identifierMode: mode, + csvData, + }), + ); + setPreview(result); + setStep(2); + } catch { + toast.error(t(translations.previewError)); + } finally { + setBusy(false); + } + }; + + const doCommit = async (onConflict: 'keep' | 'replace'): Promise => { + setBusy(true); + try { + await dispatch( + commitImport({ + components: components.map(({ id: _, ...rest }) => rest), + identifierMode: mode, + csvData, + onConflict, + }), + ); + toast.success(t(translations.committed)); + setConflictOpen(false); + onClose(); + } catch { + toast.error(t(translations.commitError)); + } finally { + setBusy(false); + } + }; + + const onConfirm = (): void => { + if (preview && preview.conflicts.length > 0) setConflictOpen(true); + else doCommit('replace'); + }; + + return ( + + {t(translations.title)} + + + + {t(translations.stepDefine)} + + + {t(translations.stepUpload)} + + + {t(translations.stepVerify)} + + + + {step === 0 && ( + <> + {components.map((c, i) => { + const locked = isExisting(c.name); + return ( +
    + + updateComponent(i, { name: e.target.value }) + } + size="small" + value={c.name} + /> + {weightedViewEnabled && ( + + updateComponent(i, { + weightage: Number(e.target.value), + }) + } + size="small" + type="number" + value={c.weightage} + /> + )} + + updateComponent(i, { + maximumGrade: Number(e.target.value), + }) + } + size="small" + type="number" + value={c.maximumGrade} + /> + {locked && ( + + {t(translations.updatesExisting)} + + )} + + setComponents((cs) => cs.filter((_, j) => j !== i)) + } + size="small" + > + + +
    + ); + })} + + +
    + {t(translations.identifierMode)} + v && setMode(v)} + size="small" + value={mode} + > + + {t(translations.studentId)} + + + {t(translations.email)} + + +
    + {mode === 'student_id' && ( + + {t(translations.studentIdHint)}{' '} + + Manage Users + + + )} + + )} + + {step === 1 && ( +
    + + { + const f = (e.target as HTMLInputElement).files?.[0]; + if (f) setCsvData(await readFileText(f)); + }} + type="file" + /> +
    + )} + + {step === 2 && preview && ( + <> + {!preview.ok && preview.unresolved.length > 0 && ( + + {t(translations.unresolved, { + ids: preview.unresolved.join(', '), + })} + + )} + {!preview.ok && preview.malformed.length > 0 && ( + + {t(translations.malformed, { + cells: preview.malformed.join('; '), + })} + + )} + {preview.ok && ( + + + + {t(translations.componentName)} + {components.map((c) => ( + {c.name} + ))} + + + + {preview.sample.map((row) => ( + + {row.studentName} + {components.map((c) => ( + + {row.grades[c.name] ?? '—'} + + ))} + + ))} + +
    + )} + + )} +
    + + + {step > 0 && ( + + )} + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && preview?.ok && ( + + )} + + + setConflictOpen(false)} + onKeepExisting={() => doCommit('keep')} + onReplaceAll={() => doCommit('replace')} + open={conflictOpen} + /> +
    + ); +}; + +export default ImportExternalAssessmentsWizard; diff --git a/client/app/bundles/course/gradebook/components/import/buildTemplate.ts b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts new file mode 100644 index 00000000000..14da3e2d42e --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts @@ -0,0 +1,34 @@ +import type { ImportComponent } from 'types/course/gradebook'; + +const csvCell = (value: string): string => + /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; + +// Header-only template: Identifier + one column per component (dialog order). +export const buildTemplateCsv = (components: ImportComponent[]): string => { + const header = ['Identifier', ...components.map((c) => c.name)] + .map(csvCell) + .join(','); + return `${header}\n`; +}; + +// Triggers a client-side download of the template. +export const downloadTemplate = (components: ImportComponent[]): void => { + const blob = new Blob([buildTemplateCsv(components)], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'external_assessments_template.csv'; + link.click(); + URL.revokeObjectURL(url); +}; + +// Reads an uploaded File to text (raw CSV; the server parses authoritatively). +export const readFileText = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (): void => resolve(String(reader.result)); + reader.onerror = (): void => reject(reader.error); + reader.readAsText(file); + }); diff --git a/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx b/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx new file mode 100644 index 00000000000..58459dce9ac --- /dev/null +++ b/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx @@ -0,0 +1,118 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Switch, + TextField, +} from '@mui/material'; +import type { AssessmentData } from 'types/course/gradebook'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { editExternalAssessment } from '../../operations'; + +const translations = defineMessages({ + title: { id: 'course.gradebook.EditExternalAssessmentPrompt.title', defaultMessage: 'Edit external assessment' }, + nameLabel: { id: 'course.gradebook.EditExternalAssessmentPrompt.nameLabel', defaultMessage: 'Name' }, + maxLabel: { id: 'course.gradebook.EditExternalAssessmentPrompt.maxLabel', defaultMessage: 'Max marks' }, + floorLabel: { id: 'course.gradebook.EditExternalAssessmentPrompt.floorLabel', defaultMessage: 'Floor grades at 0' }, + capLabel: { id: 'course.gradebook.EditExternalAssessmentPrompt.capLabel', defaultMessage: 'Cap grades at max' }, + cancel: { id: 'course.gradebook.EditExternalAssessmentPrompt.cancel', defaultMessage: 'Cancel' }, + save: { id: 'course.gradebook.EditExternalAssessmentPrompt.save', defaultMessage: 'Save' }, + error: { id: 'course.gradebook.EditExternalAssessmentPrompt.error', defaultMessage: 'Could not save the external assessment.' }, +}); + +interface Props { + open: boolean; + assessment: AssessmentData; + onClose: () => void; +} + +const EditExternalAssessmentPrompt: FC = ({ open, assessment, onClose }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [name, setName] = useState(assessment.title); + const [max, setMax] = useState(String(assessment.maxGrade)); + const [floorAtZero, setFloorAtZero] = useState(assessment.floorAtZero ?? true); + const [capAtMaximum, setCapAtMaximum] = useState(assessment.capAtMaximum ?? true); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setName(assessment.title); + setMax(String(assessment.maxGrade)); + setFloorAtZero(assessment.floorAtZero ?? true); + setCapAtMaximum(assessment.capAtMaximum ?? true); + } + }, [open, assessment]); + + const canSave = name.trim() !== '' && max.trim() !== '' && Number(max) >= 0 && !saving; + + const submit = async (): Promise => { + setSaving(true); + try { + await dispatch( + editExternalAssessment(assessment.id, { + title: name.trim(), + maximumGrade: Number(max), + floorAtZero, + capAtMaximum, + }), + ); + onClose(); + } catch { + toast.error(t(translations.error)); + } finally { + setSaving(false); + } + }; + + return ( + + {t(translations.title)} + + setName(e.target.value)} + value={name} + /> + setMax(e.target.value)} + type="number" + value={max} + /> + setFloorAtZero(e.target.checked)} />} + label={t(translations.floorLabel)} + /> + setCapAtMaximum(e.target.checked)} />} + label={t(translations.capLabel)} + /> + + + + + + + ); +}; + +export default EditExternalAssessmentPrompt; diff --git a/client/app/bundles/course/gradebook/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts index d4bb0424f70..4826683c575 100644 --- a/client/app/bundles/course/gradebook/computeWeighted.ts +++ b/client/app/bundles/course/gradebook/computeWeighted.ts @@ -4,6 +4,7 @@ import { StudentData, SubmissionData, TabData, + UpdateWeightsPayload, } from 'types/course/gradebook'; type GradeEntry = Pick; @@ -40,6 +41,19 @@ type GradeLookup = Map; const gradeKey = (studentId: number, assessmentId: number): string => `${studentId}:${assessmentId}`; +// Per-assessment grade bounding (external assessments only). Applied at READ time +// so the toggles stay reversible and the stored grade is never mutated. Native +// assessments leave both flags undefined → passthrough (unchanged behaviour). +export const effectiveGrade = ( + grade: number, + a: Pick, +): number => { + let g = grade; + if (a.floorAtZero && g < 0) g = 0; + if (a.capAtMaximum && g > a.maxGrade) g = a.maxGrade; + return g; +}; + // Index submissions by (student, assessment) once: O(submissions). const buildGradeLookup = (submissions: GradeEntry[]): GradeLookup => { const lookup: GradeLookup = new Map(); @@ -75,7 +89,7 @@ const equalSubtotal = ( if (included.length === 0) return null; const ratios = included.map((a) => { const grade = gradeLookup.get(gradeKey(studentId, a.id)); - return grade != null ? grade / a.maxGrade : 0; + return grade != null ? effectiveGrade(grade, a) / a.maxGrade : 0; }); return ratios.reduce((acc, r) => acc + r, 0) / ratios.length; }; @@ -97,7 +111,8 @@ const customSubtotal = ( if (a.gradebookExcluded) return; const grade = gradeLookup.get(gradeKey(studentId, a.id)); const assessmentWeight = a.gradebookWeight ?? 0; - if (grade != null) numerator += (grade / a.maxGrade) * assessmentWeight; + if (grade != null) + numerator += (effectiveGrade(grade, a) / a.maxGrade) * assessmentWeight; hasContributing = true; }); return hasContributing ? numerator / tabWeight : null; @@ -194,7 +209,7 @@ export const computeStudentBreakdown = ({ const contributions = list.map((a) => { const excluded = !!a.gradebookExcluded; const grade = gradeLookup.get(gradeKey(studentId, a.id)) ?? null; - const ratio = grade != null ? grade / a.maxGrade : 0; + const ratio = grade != null ? effectiveGrade(grade, a) / a.maxGrade : 0; let points: number; let effectiveWeight: number; if (excluded) { @@ -310,3 +325,22 @@ export const resolveTabWeights = ( : tab, ); }; + +// When the table is showing the equal-split default (usingDefaultWeights), setting +// a real weight on one tab would disengage the fallback and collapse every other +// tab to its stored 0. To prevent that, freeze the currently-displayed equal split +// into a real weights-update payload for the populated tabs, so a caller can persist +// it BEFORE applying the new weight. Mode is always 'equal' (the split is an equal +// default); empty tabs carry no weight and are omitted. External tabs keep their +// negative store id, which `Contribution.bulk_update` routes by sign. +export const materializedDefaultWeights = ( + tabs: TabData[], + assessments: Pick[], +): UpdateWeightsPayload['weights'] => + resolveTabWeights(tabs, assessments) + .filter((tab) => (tab.gradebookWeight ?? 0) > 0) + .map((tab) => ({ + tabId: tab.id, + weight: tab.gradebookWeight as number, + weightMode: tab.weightMode ?? 'equal', + })); diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts index ae2f962fbbf..1392dc81f1e 100644 --- a/client/app/bundles/course/gradebook/operations.ts +++ b/client/app/bundles/course/gradebook/operations.ts @@ -1,5 +1,10 @@ import type { Operation } from 'store'; -import type { UpdateWeightsPayload } from 'types/course/gradebook'; +import type { + ImportCommitSummary, + ImportPreviewRequest, + ImportPreviewResult, + UpdateWeightsPayload, +} from 'types/course/gradebook'; import CourseAPI from 'api/course'; @@ -17,4 +22,103 @@ export const updateGradebookWeights = dispatch(actions.updateTabWeights(response.data)); }; +export const createExternalAssessment = + ( + title: string, + maximumGrade: number, + floorAtZero: boolean, + capAtMaximum: boolean, + weight?: number, + ): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.createExternal({ + title, + maximumGrade, + floorAtZero, + capAtMaximum, + ...(weight === undefined ? {} : { weight }), + }); + dispatch(actions.applyCreatedExternal(response.data)); + }; + +export const renameExternalAssessment = + (assessmentId: number, title: string): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.updateExternal(-assessmentId, { + title, + }); + dispatch(actions.updateExternalAssessment(response.data)); + }; + +export const editExternalAssessment = + ( + assessmentId: number, + patch: { + title?: string; + maximumGrade?: number; + floorAtZero?: boolean; + capAtMaximum?: boolean; + weight?: number; + }, + ): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.updateExternal( + -assessmentId, + patch, + ); + dispatch(actions.updateExternalAssessment(response.data)); + }; + +export const deleteExternalAssessment = + (assessmentId: number): Operation => + async (dispatch) => { + await CourseAPI.gradebook.deleteExternal(-assessmentId); + dispatch(actions.deleteExternalAssessment(assessmentId)); + }; + +// Optimistic: apply the new grade immediately, then reconcile with the server. +// On failure, restore the previous value and rethrow so the caller can toast. +export const setExternalGrade = + (assessmentId: number, studentId: number, grade: number | null): Operation => + async (dispatch, getState) => { + const prev = + getState().gradebook.submissions.find( + (s) => s.studentId === studentId && s.assessmentId === assessmentId, + )?.grade ?? null; + dispatch(actions.setExternalGrade({ studentId, assessmentId, grade })); + try { + const response = await CourseAPI.gradebook.setExternalGrade( + -assessmentId, + { + studentId, + grade, + }, + ); + dispatch(actions.setExternalGrade(response.data)); + } catch (error) { + dispatch( + actions.setExternalGrade({ studentId, assessmentId, grade: prev }), + ); + throw error; + } + }; + +export const previewImport = + (payload: ImportPreviewRequest): Operation => + async () => { + const response = await CourseAPI.gradebook.importPreview(payload); + return response.data; + }; + +export const commitImport = + ( + payload: ImportPreviewRequest & { onConflict: 'keep' | 'replace' }, + ): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.importCommit(payload); + const refreshed = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(refreshed.data)); + return 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 index ae7fc55e029..c2276c6ba1a 100644 --- a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -2,7 +2,7 @@ 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 { Button, Tab, Tabs, Typography } from '@mui/material'; import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; @@ -11,9 +11,11 @@ import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; import { useCourseContext } from '../../../container/CourseLoader'; +import AddExternalColumnPrompt from '../../components/AddExternalColumnPrompt'; import GradebookTable from '../../components/GradebookTable'; import GradebookWeightedTable from '../../components/GradebookWeightedTable'; import GradeLinkHint from '../../components/GradeLinkHint'; +import ImportExternalAssessmentsButton from '../../components/import/ImportExternalAssessmentsButton'; import WeightedViewHint from '../../components/WeightedViewHint'; import fetchGradebook from '../../operations'; import { @@ -52,6 +54,10 @@ const translations = defineMessages({ id: 'course.gradebook.GradebookIndex.byWeight', defaultMessage: 'Weighted total', }, + addExternal: { + id: 'course.gradebook.GradebookIndex.addExternal', + defaultMessage: 'Add external assessment', + }, }); const GradebookIndex: FC = () => { @@ -66,6 +72,7 @@ const GradebookIndex: FC = () => { searchParams.get('view') === 'weighted' ? 'weighted' : 'all', ); const [isPending, startTransition] = useTransition(); + const [addOpen, setAddOpen] = useState(false); const assessments = useAppSelector(getAssessments); const categories = useAppSelector(getCategories); @@ -149,6 +156,22 @@ const GradebookIndex: FC = () => { {!isLoading && students.length > 0 && !(weightedViewEnabled && viewMode === 'weighted') && } + {!isLoading && canManageWeights && students.length > 0 && ( +
    + + +
    + )} + setAddOpen(false)} + open={addOpen} + />
    {isPending && (
    diff --git a/client/app/bundles/course/gradebook/selectors.ts b/client/app/bundles/course/gradebook/selectors.ts index 0fb7d1398d5..2776f912113 100644 --- a/client/app/bundles/course/gradebook/selectors.ts +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -26,3 +26,7 @@ export const getWeightedViewEnabled = (state: AppState): boolean => getLocalState(state).weightedViewEnabled; export const getCanManageWeights = (state: AppState): boolean => getLocalState(state).canManageWeights; +export const getExternalAssessments = ( + state: AppState, +): GradebookState['assessments'] => + getLocalState(state).assessments.filter((a) => a.external); diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts index 4777e16b3b6..d72746b192f 100644 --- a/client/app/bundles/course/gradebook/store.ts +++ b/client/app/bundles/course/gradebook/store.ts @@ -1,5 +1,8 @@ import { produce } from 'immer'; import type { + ExternalAssessmentNode, + ExternalAssessmentUpdate, + ExternalGradePayload, GradebookData, UpdateWeightsPayload, } from 'types/course/gradebook'; @@ -14,6 +17,12 @@ import type { const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; const UPDATE_TAB_WEIGHTS = 'course/gradebook/UPDATE_TAB_WEIGHTS'; +const APPLY_CREATED_EXTERNAL = 'course/gradebook/APPLY_CREATED_EXTERNAL'; +const UPDATE_EXTERNAL_ASSESSMENT = + 'course/gradebook/UPDATE_EXTERNAL_ASSESSMENT'; +const DELETE_EXTERNAL_ASSESSMENT = + 'course/gradebook/DELETE_EXTERNAL_ASSESSMENT'; +const SET_EXTERNAL_GRADE = 'course/gradebook/SET_EXTERNAL_GRADE'; interface GradebookState { categories: CategoryData[]; @@ -36,6 +45,23 @@ interface UpdateTabWeightsAction { payload: UpdateWeightsPayload; } +interface ApplyCreatedExternalAction { + type: typeof APPLY_CREATED_EXTERNAL; + payload: ExternalAssessmentNode; +} +interface UpdateExternalAssessmentAction { + type: typeof UPDATE_EXTERNAL_ASSESSMENT; + payload: ExternalAssessmentUpdate; +} +interface DeleteExternalAssessmentAction { + type: typeof DELETE_EXTERNAL_ASSESSMENT; + payload: number; // negative serialized assessment id +} +interface SetExternalGradeAction { + type: typeof SET_EXTERNAL_GRADE; + payload: ExternalGradePayload; +} + const initialState: GradebookState = { categories: [], tabs: [], @@ -50,7 +76,13 @@ const initialState: GradebookState = { const reducer = produce( ( draft: GradebookState, - action: SaveGradebookAction | UpdateTabWeightsAction, + action: + | SaveGradebookAction + | UpdateTabWeightsAction + | ApplyCreatedExternalAction + | UpdateExternalAssessmentAction + | DeleteExternalAssessmentAction + | SetExternalGradeAction, ) => { switch (action.type) { case SAVE_GRADEBOOK: { @@ -99,6 +131,73 @@ const reducer = produce( ); break; } + case APPLY_CREATED_EXTERNAL: { + const { assessment, tab, category } = action.payload; + if (!draft.categories.some((c) => c.id === category.id)) { + draft.categories.push(category); + } + if (!draft.tabs.some((t) => t.id === tab.id)) { + draft.tabs.push(tab); + } + if (!draft.assessments.some((a) => a.id === assessment.id)) { + draft.assessments.push(assessment); + } + break; + } + case UPDATE_EXTERNAL_ASSESSMENT: { + const { assessment, tab } = action.payload; + const a = draft.assessments.find((x) => x.id === assessment.id); + if (a) { + a.title = assessment.title; + a.maxGrade = assessment.maxGrade; + a.floorAtZero = assessment.floorAtZero; + a.capAtMaximum = assessment.capAtMaximum; + } + const t = draft.tabs.find((x) => x.id === tab.id); + if (t) { + t.title = tab.title; + if (tab.gradebookWeight !== undefined) { + t.gradebookWeight = tab.gradebookWeight; + } + } + break; + } + case DELETE_EXTERNAL_ASSESSMENT: { + const id = action.payload; + const removed = draft.assessments.find((a) => a.id === id); + draft.assessments = draft.assessments.filter((a) => a.id !== id); + draft.submissions = draft.submissions.filter( + (s) => s.assessmentId !== id, + ); + if ( + removed && + !draft.assessments.some((a) => a.tabId === removed.tabId) + ) { + const removedTab = draft.tabs.find((t) => t.id === removed.tabId); + draft.tabs = draft.tabs.filter((t) => t.id !== removed.tabId); + // Drop the now-empty synthetic "External Assessments" category so its + // header doesn't linger after the last external is deleted (the + // backend omits it entirely once no externals remain). + if ( + removedTab && + !draft.tabs.some((t) => t.categoryId === removedTab.categoryId) + ) { + draft.categories = draft.categories.filter( + (c) => c.id !== removedTab.categoryId, + ); + } + } + break; + } + case SET_EXTERNAL_GRADE: { + const { studentId, assessmentId, grade } = action.payload; + const existing = draft.submissions.find( + (s) => s.studentId === studentId && s.assessmentId === assessmentId, + ); + if (existing) existing.grade = grade; + else draft.submissions.push({ studentId, assessmentId, grade }); + break; + } default: break; } @@ -117,6 +216,22 @@ export const actions = { type: UPDATE_TAB_WEIGHTS, payload, }), + applyCreatedExternal: ( + payload: ExternalAssessmentNode, + ): ApplyCreatedExternalAction => ({ type: APPLY_CREATED_EXTERNAL, payload }), + updateExternalAssessment: ( + payload: ExternalAssessmentUpdate, + ): UpdateExternalAssessmentAction => ({ + type: UPDATE_EXTERNAL_ASSESSMENT, + payload, + }), + deleteExternalAssessment: (id: number): DeleteExternalAssessmentAction => ({ + type: DELETE_EXTERNAL_ASSESSMENT, + payload: id, + }), + setExternalGrade: ( + payload: ExternalGradePayload, + ): SetExternalGradeAction => ({ type: SET_EXTERNAL_GRADE, payload }), }; export default reducer; diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts index c9009afbfe0..b1833aa03f1 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -18,6 +18,9 @@ export interface AssessmentData { maxGrade: number; gradebookWeight?: number | null; gradebookExcluded?: boolean; + external?: boolean; + floorAtZero?: boolean; + capAtMaximum?: boolean; } export interface StudentData { @@ -32,7 +35,7 @@ export interface StudentData { export interface SubmissionData { studentId: number; assessmentId: number; - submissionId: number; + submissionId?: number; grade: number | null; } @@ -56,3 +59,56 @@ export interface UpdateWeightsPayload { assessmentWeights?: { assessmentId: number; weight: number }[]; }[]; } + +export interface ExternalAssessmentNode { + assessment: AssessmentData; + tab: TabData; + category: CategoryData; +} + +export interface ExternalAssessmentUpdate { + assessment: AssessmentData; + tab: Pick; +} + +export interface ExternalGradePayload { + studentId: number; + assessmentId: number; + grade: number | null; +} + +export type IdentifierMode = 'email' | 'student_id'; + +export interface ImportComponent { + name: string; + weightage: number; + maximumGrade: number; +} + +export interface ImportPreviewRequest { + components: ImportComponent[]; + identifierMode: IdentifierMode; + csvData: string; +} + +export interface ImportConflict { + component: string; + studentName: string; + existingGrade: number; + inFileGrade: number; + identifierMismatch: boolean; +} + +export interface ImportPreviewResult { + ok: boolean; + unresolved: string[]; + malformed: string[]; + sample: { studentName: string; grades: Record }[]; + conflicts: ImportConflict[]; +} + +export interface ImportCommitSummary { + createdComponents: number; + updatedComponents: number; + gradesWritten: number; +} diff --git a/client/locales/en.json b/client/locales/en.json index 1d9bf1cce66..8721bd61b33 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9427,5 +9427,197 @@ }, "course.gradebook.WeightedViewHint.settingsLink": { "defaultMessage": "Gradebook settings" + }, + "course.gradebook.AddExternalColumnPrompt.cancel": { + "defaultMessage": "Cancel" + }, + "course.gradebook.AddExternalColumnPrompt.create": { + "defaultMessage": "Create" + }, + "course.gradebook.AddExternalColumnPrompt.error": { + "defaultMessage": "Could not create the external assessment." + }, + "course.gradebook.AddExternalColumnPrompt.maxLabel": { + "defaultMessage": "Max marks" + }, + "course.gradebook.AddExternalColumnPrompt.nameLabel": { + "defaultMessage": "Name" + }, + "course.gradebook.AddExternalColumnPrompt.success": { + "defaultMessage": "External assessment created." + }, + "course.gradebook.AddExternalColumnPrompt.title": { + "defaultMessage": "Add external assessment" + }, + "course.gradebook.DeleteExternalColumnPrompt.body": { + "defaultMessage": "Delete \"{title}\"? This permanently removes the column and every student grade in it. This cannot be undone." + }, + "course.gradebook.DeleteExternalColumnPrompt.cancel": { + "defaultMessage": "Cancel" + }, + "course.gradebook.DeleteExternalColumnPrompt.confirm": { + "defaultMessage": "Delete" + }, + "course.gradebook.DeleteExternalColumnPrompt.error": { + "defaultMessage": "Could not delete the external assessment." + }, + "course.gradebook.DeleteExternalColumnPrompt.title": { + "defaultMessage": "Delete external assessment" + }, + "course.gradebook.ExternalGradeConflictPrompt.body": { + "defaultMessage": "These students already have a grade for these components. Keep their existing grades, or replace them with the values from your file? New students and blank cells are unaffected." + }, + "course.gradebook.ExternalGradeConflictPrompt.goBack": { + "defaultMessage": "Go Back" + }, + "course.gradebook.ExternalGradeConflictPrompt.keepExisting": { + "defaultMessage": "Keep Existing" + }, + "course.gradebook.ExternalGradeConflictPrompt.replace": { + "defaultMessage": "Replace" + }, + "course.gradebook.ExternalGradeConflictPrompt.title": { + "defaultMessage": "Resolve grade conflicts" + }, + "course.gradebook.ExternalGradeConflictTable.component": { + "defaultMessage": "Component" + }, + "course.gradebook.ExternalGradeConflictTable.existing": { + "defaultMessage": "Existing grade" + }, + "course.gradebook.ExternalGradeConflictTable.inFile": { + "defaultMessage": "In-file grade" + }, + "course.gradebook.ExternalGradeConflictTable.mismatch": { + "defaultMessage": "This identifier now resolves to a different student than the existing grade was imported under." + }, + "course.gradebook.ExternalGradeConflictTable.student": { + "defaultMessage": "Student" + }, + "course.gradebook.GradebookIndex.addExternal": { + "defaultMessage": "Add external assessment" + }, + "course.gradebook.GradebookTable.delete": { + "defaultMessage": "Delete" + }, + "course.gradebook.GradebookTable.externalBadge": { + "defaultMessage": "External" + }, + "course.gradebook.GradebookTable.externalGradeAria": { + "defaultMessage": "{title} grade for {name}" + }, + "course.gradebook.GradebookTable.externalMaxAria": { + "defaultMessage": "{title} max marks" + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "Could not save the grade. Please try again." + }, + "course.gradebook.GradebookTable.manageAria": { + "defaultMessage": "manage {title}" + }, + "course.gradebook.GradebookTable.maxSaveError": { + "defaultMessage": "Could not save the max marks. Please try again." + }, + "course.gradebook.GradebookTable.rename": { + "defaultMessage": "Rename" + }, + "course.gradebook.GradebookWeightedTable.displayMode": { + "defaultMessage": "Display mode" + }, + "course.gradebook.ImportExternalAssessmentsButton.label": { + "defaultMessage": "Import external assessments" + }, + "course.gradebook.ImportWizard.addComponent": { + "defaultMessage": "Add component" + }, + "course.gradebook.ImportWizard.back": { + "defaultMessage": "Back" + }, + "course.gradebook.ImportWizard.cancel": { + "defaultMessage": "Cancel" + }, + "course.gradebook.ImportWizard.commitError": { + "defaultMessage": "Import failed. Nothing was saved." + }, + "course.gradebook.ImportWizard.committed": { + "defaultMessage": "Import complete." + }, + "course.gradebook.ImportWizard.componentName": { + "defaultMessage": "Component name" + }, + "course.gradebook.ImportWizard.continue": { + "defaultMessage": "Confirm import" + }, + "course.gradebook.ImportWizard.downloadTemplate": { + "defaultMessage": "Download template" + }, + "course.gradebook.ImportWizard.email": { + "defaultMessage": "Email" + }, + "course.gradebook.ImportWizard.identifierMode": { + "defaultMessage": "Match students by" + }, + "course.gradebook.ImportWizard.malformed": { + "defaultMessage": "These cells are not valid numbers: {cells}" + }, + "course.gradebook.ImportWizard.maxMarks": { + "defaultMessage": "Max marks" + }, + "course.gradebook.ImportWizard.next": { + "defaultMessage": "Next" + }, + "course.gradebook.ImportWizard.previewError": { + "defaultMessage": "Could not verify the file. Please try again." + }, + "course.gradebook.ImportWizard.stepDefine": { + "defaultMessage": "Define components" + }, + "course.gradebook.ImportWizard.stepUpload": { + "defaultMessage": "Template & upload" + }, + "course.gradebook.ImportWizard.stepVerify": { + "defaultMessage": "Verify" + }, + "course.gradebook.ImportWizard.studentId": { + "defaultMessage": "Student ID" + }, + "course.gradebook.ImportWizard.studentIdHint": { + "defaultMessage": "Matching uses each student's current Student ID. Keep Student IDs up to date in Manage Users." + }, + "course.gradebook.ImportWizard.title": { + "defaultMessage": "Import external assessments" + }, + "course.gradebook.ImportWizard.unresolved": { + "defaultMessage": "These identifiers were not found in the course: {ids}" + }, + "course.gradebook.ImportWizard.updatesExisting": { + "defaultMessage": "Updates existing — managed in the gradebook" + }, + "course.gradebook.ImportWizard.upload": { + "defaultMessage": "Upload filled CSV" + }, + "course.gradebook.ImportWizard.verify": { + "defaultMessage": "Verify" + }, + "course.gradebook.ImportWizard.weightage": { + "defaultMessage": "Weightage" + }, + "course.gradebook.ProjectedTotalHint.policy": { + "defaultMessage": "Totals count ungraded assessments as 0." + }, + "course.gradebook.RenameExternalColumnPrompt.cancel": { + "defaultMessage": "Cancel" + }, + "course.gradebook.RenameExternalColumnPrompt.error": { + "defaultMessage": "Could not rename the external assessment." + }, + "course.gradebook.RenameExternalColumnPrompt.nameLabel": { + "defaultMessage": "Name" + }, + "course.gradebook.RenameExternalColumnPrompt.save": { + "defaultMessage": "Save" + }, + "course.gradebook.RenameExternalColumnPrompt.title": { + "defaultMessage": "Rename external assessment" } } \ No newline at end of file diff --git a/client/locales/ko.json b/client/locales/ko.json index 9f9c0c593cb..42c6ba39f82 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9412,5 +9412,203 @@ }, "course.gradebook.WeightedViewHint.settingsLink": { "defaultMessage": "성적부 설정" + }, + "course.gradebook.AddExternalColumnPrompt.cancel": { + "defaultMessage": "취소" + }, + "course.gradebook.AddExternalColumnPrompt.create": { + "defaultMessage": "만들기" + }, + "course.gradebook.AddExternalColumnPrompt.error": { + "defaultMessage": "외부 평가를 생성할 수 없습니다." + }, + "course.gradebook.AddExternalColumnPrompt.maxLabel": { + "defaultMessage": "최대 점수" + }, + "course.gradebook.AddExternalColumnPrompt.nameLabel": { + "defaultMessage": "이름" + }, + "course.gradebook.AddExternalColumnPrompt.success": { + "defaultMessage": "외부 평가가 생성되었습니다." + }, + "course.gradebook.AddExternalColumnPrompt.title": { + "defaultMessage": "외부 평가 추가" + }, + "course.gradebook.DeleteExternalColumnPrompt.body": { + "defaultMessage": "\"{title}\"을(를) 삭제하시겠습니까? 이 작업은 해당 열과 그 안의 모든 학생 성적을 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다." + }, + "course.gradebook.DeleteExternalColumnPrompt.cancel": { + "defaultMessage": "취소" + }, + "course.gradebook.DeleteExternalColumnPrompt.confirm": { + "defaultMessage": "삭제" + }, + "course.gradebook.DeleteExternalColumnPrompt.error": { + "defaultMessage": "외부 평가를 삭제할 수 없습니다." + }, + "course.gradebook.DeleteExternalColumnPrompt.title": { + "defaultMessage": "외부 평가 삭제" + }, + "course.gradebook.ExternalGradeConflictPrompt.body": { + "defaultMessage": "이 학생들은 이미 해당 구성 요소에 대한 성적이 있습니다. 기존 성적을 유지하시겠습니까, 아니면 파일의 값으로 대체하시겠습니까? 새 학생과 빈 셀은 영향을 받지 않습니다." + }, + "course.gradebook.ExternalGradeConflictPrompt.goBack": { + "defaultMessage": "돌아가기" + }, + "course.gradebook.ExternalGradeConflictPrompt.keepExisting": { + "defaultMessage": "기존 항목 유지" + }, + "course.gradebook.ExternalGradeConflictPrompt.replace": { + "defaultMessage": "대체" + }, + "course.gradebook.ExternalGradeConflictPrompt.title": { + "defaultMessage": "성적 충돌 해결" + }, + "course.gradebook.ExternalGradeConflictTable.component": { + "defaultMessage": "구성 요소" + }, + "course.gradebook.ExternalGradeConflictTable.existing": { + "defaultMessage": "기존 성적" + }, + "course.gradebook.ExternalGradeConflictTable.inFile": { + "defaultMessage": "파일 내 성적" + }, + "course.gradebook.ExternalGradeConflictTable.mismatch": { + "defaultMessage": "이 식별자는 이제 기존 성적을 가져올 때의 학생과 다른 학생으로 확인됩니다." + }, + "course.gradebook.ExternalGradeConflictTable.student": { + "defaultMessage": "학생" + }, + "course.gradebook.GradeLinkHint.hint": { + "defaultMessage": "각 성적은 학생 제출물의 점수 합계입니다. 성적을 클릭하면 해당 제출물을 열고 점수를 조정할 수 있습니다." + }, + "course.gradebook.GradebookIndex.addExternal": { + "defaultMessage": "외부 평가 추가" + }, + "course.gradebook.GradebookIndex.noStudentsHint": { + "defaultMessage": "학생이 강좌에 참여하면 여기에 성적이 표시됩니다." + }, + "course.gradebook.GradebookTable.delete": { + "defaultMessage": "삭제" + }, + "course.gradebook.GradebookTable.externalBadge": { + "defaultMessage": "외부" + }, + "course.gradebook.GradebookTable.externalGradeAria": { + "defaultMessage": "{name}의 {title} 성적" + }, + "course.gradebook.GradebookTable.externalMaxAria": { + "defaultMessage": "{title} 최대 점수" + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "성적을 저장할 수 없습니다. 다시 시도해 주세요." + }, + "course.gradebook.GradebookTable.manageAria": { + "defaultMessage": "{title} 관리" + }, + "course.gradebook.GradebookTable.maxSaveError": { + "defaultMessage": "최대 점수를 저장할 수 없습니다. 다시 시도해 주세요." + }, + "course.gradebook.GradebookTable.rename": { + "defaultMessage": "이름 변경" + }, + "course.gradebook.GradebookWeightedTable.displayMode": { + "defaultMessage": "표시 모드" + }, + "course.gradebook.ImportExternalAssessmentsButton.label": { + "defaultMessage": "외부 평가 가져오기" + }, + "course.gradebook.ImportWizard.addComponent": { + "defaultMessage": "구성 요소 추가" + }, + "course.gradebook.ImportWizard.back": { + "defaultMessage": "뒤로" + }, + "course.gradebook.ImportWizard.cancel": { + "defaultMessage": "취소" + }, + "course.gradebook.ImportWizard.commitError": { + "defaultMessage": "가져오기에 실패했습니다. 아무것도 저장되지 않았습니다." + }, + "course.gradebook.ImportWizard.committed": { + "defaultMessage": "가져오기가 완료되었습니다." + }, + "course.gradebook.ImportWizard.componentName": { + "defaultMessage": "구성 요소 이름" + }, + "course.gradebook.ImportWizard.continue": { + "defaultMessage": "가져오기 확인" + }, + "course.gradebook.ImportWizard.downloadTemplate": { + "defaultMessage": "템플릿 다운로드" + }, + "course.gradebook.ImportWizard.email": { + "defaultMessage": "이메일" + }, + "course.gradebook.ImportWizard.identifierMode": { + "defaultMessage": "학생 매칭 기준" + }, + "course.gradebook.ImportWizard.malformed": { + "defaultMessage": "다음 셀은 올바른 숫자가 아닙니다: {cells}" + }, + "course.gradebook.ImportWizard.maxMarks": { + "defaultMessage": "최대 점수" + }, + "course.gradebook.ImportWizard.next": { + "defaultMessage": "다음" + }, + "course.gradebook.ImportWizard.previewError": { + "defaultMessage": "파일을 확인할 수 없습니다. 다시 시도해 주세요." + }, + "course.gradebook.ImportWizard.stepDefine": { + "defaultMessage": "구성 요소 정의" + }, + "course.gradebook.ImportWizard.stepUpload": { + "defaultMessage": "템플릿 및 업로드" + }, + "course.gradebook.ImportWizard.stepVerify": { + "defaultMessage": "확인" + }, + "course.gradebook.ImportWizard.studentId": { + "defaultMessage": "학생 ID" + }, + "course.gradebook.ImportWizard.studentIdHint": { + "defaultMessage": "매칭에는 각 학생의 현재 학생 ID가 사용됩니다. 사용자 관리에서 학생 ID를 최신 상태로 유지하세요." + }, + "course.gradebook.ImportWizard.title": { + "defaultMessage": "외부 평가 가져오기" + }, + "course.gradebook.ImportWizard.unresolved": { + "defaultMessage": "다음 식별자를 강좌에서 찾을 수 없습니다: {ids}" + }, + "course.gradebook.ImportWizard.updatesExisting": { + "defaultMessage": "기존 항목 업데이트 - 성적부에서 관리됨" + }, + "course.gradebook.ImportWizard.upload": { + "defaultMessage": "작성한 CSV 업로드" + }, + "course.gradebook.ImportWizard.verify": { + "defaultMessage": "확인" + }, + "course.gradebook.ImportWizard.weightage": { + "defaultMessage": "가중치" + }, + "course.gradebook.ProjectedTotalHint.policy": { + "defaultMessage": "총점은 채점되지 않은 평가를 0점으로 계산합니다." + }, + "course.gradebook.RenameExternalColumnPrompt.cancel": { + "defaultMessage": "취소" + }, + "course.gradebook.RenameExternalColumnPrompt.error": { + "defaultMessage": "외부 평가의 이름을 변경할 수 없습니다." + }, + "course.gradebook.RenameExternalColumnPrompt.nameLabel": { + "defaultMessage": "이름" + }, + "course.gradebook.RenameExternalColumnPrompt.save": { + "defaultMessage": "저장" + }, + "course.gradebook.RenameExternalColumnPrompt.title": { + "defaultMessage": "외부 평가 이름 변경" } } diff --git a/client/locales/zh.json b/client/locales/zh.json index 9d5c753e3e3..35fd0bc9ba9 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9406,5 +9406,203 @@ }, "course.gradebook.WeightedViewHint.settingsLink": { "defaultMessage": "成绩册设置" + }, + "course.gradebook.AddExternalColumnPrompt.cancel": { + "defaultMessage": "取消" + }, + "course.gradebook.AddExternalColumnPrompt.create": { + "defaultMessage": "创建" + }, + "course.gradebook.AddExternalColumnPrompt.error": { + "defaultMessage": "无法创建外部评估。" + }, + "course.gradebook.AddExternalColumnPrompt.maxLabel": { + "defaultMessage": "最高分" + }, + "course.gradebook.AddExternalColumnPrompt.nameLabel": { + "defaultMessage": "名称" + }, + "course.gradebook.AddExternalColumnPrompt.success": { + "defaultMessage": "外部评估已创建。" + }, + "course.gradebook.AddExternalColumnPrompt.title": { + "defaultMessage": "添加外部评估" + }, + "course.gradebook.DeleteExternalColumnPrompt.body": { + "defaultMessage": "要删除\"{title}\"吗?这将永久移除该列及其中每位学生的成绩。此操作无法撤销。" + }, + "course.gradebook.DeleteExternalColumnPrompt.cancel": { + "defaultMessage": "取消" + }, + "course.gradebook.DeleteExternalColumnPrompt.confirm": { + "defaultMessage": "删除" + }, + "course.gradebook.DeleteExternalColumnPrompt.error": { + "defaultMessage": "无法删除外部评估。" + }, + "course.gradebook.DeleteExternalColumnPrompt.title": { + "defaultMessage": "删除外部评估" + }, + "course.gradebook.ExternalGradeConflictPrompt.body": { + "defaultMessage": "这些学生在这些组成部分中已有成绩。要保留他们现有的成绩,还是用文件中的值替换?新学生和空白单元格不受影响。" + }, + "course.gradebook.ExternalGradeConflictPrompt.goBack": { + "defaultMessage": "返回" + }, + "course.gradebook.ExternalGradeConflictPrompt.keepExisting": { + "defaultMessage": "保留现有成绩" + }, + "course.gradebook.ExternalGradeConflictPrompt.replace": { + "defaultMessage": "替换" + }, + "course.gradebook.ExternalGradeConflictPrompt.title": { + "defaultMessage": "解决成绩冲突" + }, + "course.gradebook.ExternalGradeConflictTable.component": { + "defaultMessage": "组成部分" + }, + "course.gradebook.ExternalGradeConflictTable.existing": { + "defaultMessage": "现有成绩" + }, + "course.gradebook.ExternalGradeConflictTable.inFile": { + "defaultMessage": "文件中的成绩" + }, + "course.gradebook.ExternalGradeConflictTable.mismatch": { + "defaultMessage": "此标识符现在对应的学生与现有成绩导入时对应的学生不同。" + }, + "course.gradebook.ExternalGradeConflictTable.student": { + "defaultMessage": "学生" + }, + "course.gradebook.GradeLinkHint.hint": { + "defaultMessage": "每个成绩都是学生提交内容中各项分数的总和。点击任一成绩即可打开该提交内容并调整分数。" + }, + "course.gradebook.GradebookIndex.addExternal": { + "defaultMessage": "添加外部评估" + }, + "course.gradebook.GradebookIndex.noStudentsHint": { + "defaultMessage": "学生加入课程后,成绩将显示在这里。" + }, + "course.gradebook.GradebookTable.delete": { + "defaultMessage": "删除" + }, + "course.gradebook.GradebookTable.externalBadge": { + "defaultMessage": "外部" + }, + "course.gradebook.GradebookTable.externalGradeAria": { + "defaultMessage": "{name} 的 {title} 成绩" + }, + "course.gradebook.GradebookTable.externalMaxAria": { + "defaultMessage": "{title} 最高分" + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "无法保存成绩。请重试。" + }, + "course.gradebook.GradebookTable.manageAria": { + "defaultMessage": "管理 {title}" + }, + "course.gradebook.GradebookTable.maxSaveError": { + "defaultMessage": "无法保存最高分。请重试。" + }, + "course.gradebook.GradebookTable.rename": { + "defaultMessage": "重命名" + }, + "course.gradebook.GradebookWeightedTable.displayMode": { + "defaultMessage": "显示模式" + }, + "course.gradebook.ImportExternalAssessmentsButton.label": { + "defaultMessage": "导入外部评估" + }, + "course.gradebook.ImportWizard.addComponent": { + "defaultMessage": "添加组成部分" + }, + "course.gradebook.ImportWizard.back": { + "defaultMessage": "返回" + }, + "course.gradebook.ImportWizard.cancel": { + "defaultMessage": "取消" + }, + "course.gradebook.ImportWizard.commitError": { + "defaultMessage": "导入失败。未保存任何内容。" + }, + "course.gradebook.ImportWizard.committed": { + "defaultMessage": "导入完成。" + }, + "course.gradebook.ImportWizard.componentName": { + "defaultMessage": "组成部分名称" + }, + "course.gradebook.ImportWizard.continue": { + "defaultMessage": "确认导入" + }, + "course.gradebook.ImportWizard.downloadTemplate": { + "defaultMessage": "下载模板" + }, + "course.gradebook.ImportWizard.email": { + "defaultMessage": "电子邮件" + }, + "course.gradebook.ImportWizard.identifierMode": { + "defaultMessage": "匹配学生的依据" + }, + "course.gradebook.ImportWizard.malformed": { + "defaultMessage": "以下单元格不是有效数字:{cells}" + }, + "course.gradebook.ImportWizard.maxMarks": { + "defaultMessage": "最高分" + }, + "course.gradebook.ImportWizard.next": { + "defaultMessage": "下一步" + }, + "course.gradebook.ImportWizard.previewError": { + "defaultMessage": "无法验证文件。请重试。" + }, + "course.gradebook.ImportWizard.stepDefine": { + "defaultMessage": "定义组成部分" + }, + "course.gradebook.ImportWizard.stepUpload": { + "defaultMessage": "模板和上传" + }, + "course.gradebook.ImportWizard.stepVerify": { + "defaultMessage": "验证" + }, + "course.gradebook.ImportWizard.studentId": { + "defaultMessage": "学生 ID" + }, + "course.gradebook.ImportWizard.studentIdHint": { + "defaultMessage": "匹配会使用每位学生当前的学生 ID。请在\"管理用户\"中保持学生 ID 为最新。" + }, + "course.gradebook.ImportWizard.title": { + "defaultMessage": "导入外部评估" + }, + "course.gradebook.ImportWizard.unresolved": { + "defaultMessage": "在课程中找不到以下标识符:{ids}" + }, + "course.gradebook.ImportWizard.updatesExisting": { + "defaultMessage": "更新现有项目 - 在成绩册中管理" + }, + "course.gradebook.ImportWizard.upload": { + "defaultMessage": "上传已填写的 CSV" + }, + "course.gradebook.ImportWizard.verify": { + "defaultMessage": "验证" + }, + "course.gradebook.ImportWizard.weightage": { + "defaultMessage": "权重" + }, + "course.gradebook.ProjectedTotalHint.policy": { + "defaultMessage": "总分会将未评分的评估计为 0。" + }, + "course.gradebook.RenameExternalColumnPrompt.cancel": { + "defaultMessage": "取消" + }, + "course.gradebook.RenameExternalColumnPrompt.error": { + "defaultMessage": "无法重命名外部评估。" + }, + "course.gradebook.RenameExternalColumnPrompt.nameLabel": { + "defaultMessage": "名称" + }, + "course.gradebook.RenameExternalColumnPrompt.save": { + "defaultMessage": "保存" + }, + "course.gradebook.RenameExternalColumnPrompt.title": { + "defaultMessage": "重命名外部评估" } } diff --git a/config/locales/en/activerecord/errors.yml b/config/locales/en/activerecord/errors.yml index 713858a6dcb..c425d123fc5 100644 --- a/config/locales/en/activerecord/errors.yml +++ b/config/locales/en/activerecord/errors.yml @@ -6,6 +6,10 @@ en: attributes: reference_timelines: must_have_at_most_one_default: 'must have at most one default' + course/gradebook/contribution: + attributes: + base: + exactly_one_contributor: "must reference exactly one contributor (a tab or an external assessment)" course/announcement: attributes: end_at: diff --git a/config/routes.rb b/config/routes.rb index 0ff051754ca..bc3518af92f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -502,6 +502,16 @@ resource :gradebook, only: [] do get '/' => 'gradebook#index' patch '/weights' => 'gradebook#update_weights' + resources :external_assessments, only: [:create, :update, :destroy] do + member do + put 'grades' => 'external_assessments#grades' + end + end + resources :external_assessment_imports, only: [:create] do + collection do + post 'preview' + end + end end scope module: :discussion do diff --git a/db/migrate/20260615000000_create_course_external_assessments_and_grades.rb b/db/migrate/20260615000000_create_course_external_assessments_and_grades.rb new file mode 100644 index 00000000000..28a240a4679 --- /dev/null +++ b/db/migrate/20260615000000_create_course_external_assessments_and_grades.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +class CreateCourseExternalAssessmentsAndGrades < ActiveRecord::Migration[7.2] + def change + create_table :course_external_assessments do |t| + t.references :course, null: false, + foreign_key: { to_table: :courses, + name: 'fk_course_external_assessments_course_id' }, + index: { name: 'fk__course_external_assessments_course_id' } + t.string :title, null: false + t.decimal :maximum_grade, precision: 4, scale: 1, null: false + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_external_assessments_creator_id' }, + index: { name: 'fk__course_external_assessments_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_external_assessments_updater_id' }, + index: { name: 'fk__course_external_assessments_updater_id' } + t.timestamps null: false + end + add_index :course_external_assessments, [:course_id, :title], + unique: true, name: 'index_course_external_assessments_on_course_id_and_title' + + create_table :course_external_assessment_grades do |t| + t.references :external_assessment, null: false, + foreign_key: { to_table: :course_external_assessments, + name: 'fk_course_external_assessment_grades_' \ + 'external_assessment_id' }, + index: { name: 'fk__course_external_assessment_grades_external_assessment_id' } + t.references :course_user, null: false, + foreign_key: { to_table: :course_users, + name: 'fk_course_external_assessment_grades_course_user_id' }, + index: { name: 'fk__course_external_assessment_grades_course_user_id' } + t.decimal :grade, precision: 4, scale: 1, null: true + t.string :imported_identifier, null: true + t.references :creator, null: false, + foreign_key: { to_table: :users, name: 'fk_course_external_assessment_grades_creator_id' }, + index: { name: 'fk__course_external_assessment_grades_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, name: 'fk_course_external_assessment_grades_updater_id' }, + index: { name: 'fk__course_external_assessment_grades_updater_id' } + t.timestamps null: false + end + add_index :course_external_assessment_grades, [:external_assessment_id, :course_user_id], + unique: true, name: 'index_course_external_assessment_grades_on_ea_id_and_cu_id' + end +end diff --git a/db/migrate/20260616000000_add_external_assessment_to_gradebook_contributions.rb b/db/migrate/20260616000000_add_external_assessment_to_gradebook_contributions.rb new file mode 100644 index 00000000000..9bfad2e9854 --- /dev/null +++ b/db/migrate/20260616000000_add_external_assessment_to_gradebook_contributions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +class AddExternalAssessmentToGradebookContributions < ActiveRecord::Migration[7.2] + def change + add_reference :course_gradebook_contributions, :external_assessment, null: true, + foreign_key: { to_table: :course_external_assessments, on_delete: :cascade }, + index: { unique: true, + name: 'index_course_gradebook_contributions_on_external_assessment_id' } + + # Exactly one contributor: either a native tab, or an external assessment. + add_check_constraint :course_gradebook_contributions, + '(tab_id IS NOT NULL) <> (external_assessment_id IS NOT NULL)', + name: 'chk_gradebook_contribution_exactly_one_contributor' + end +end diff --git a/db/migrate/20260622000000_add_bounds_to_course_external_assessments.rb b/db/migrate/20260622000000_add_bounds_to_course_external_assessments.rb new file mode 100644 index 00000000000..423a2dfd6c3 --- /dev/null +++ b/db/migrate/20260622000000_add_bounds_to_course_external_assessments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true +class AddBoundsToCourseExternalAssessments < ActiveRecord::Migration[7.2] + def change + add_column :course_external_assessments, :floor_at_zero, :boolean, null: false, default: true + add_column :course_external_assessments, :cap_at_maximum, :boolean, null: false, default: true + end +end diff --git a/db/migrate/20260623000000_change_external_assessment_grade_precision_to_two_decimals.rb b/db/migrate/20260623000000_change_external_assessment_grade_precision_to_two_decimals.rb new file mode 100644 index 00000000000..f6ffa1c9409 --- /dev/null +++ b/db/migrate/20260623000000_change_external_assessment_grade_precision_to_two_decimals.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +# External grades are imported from Canvas and SoftMark, both of which record marks +# to two decimal places. The columns were decimal(4,1), which silently rounds an +# imported 87.25 to 87.3 on store. Widen to decimal(5,2) — same 3 integer digits +# (max 999.99), one extra decimal. Externals only; native grades stay decimal(4,1). +class ChangeExternalAssessmentGradePrecisionToTwoDecimals < ActiveRecord::Migration[7.2] + def up + change_column :course_external_assessment_grades, :grade, + :decimal, precision: 5, scale: 2, null: true + change_column :course_external_assessments, :maximum_grade, + :decimal, precision: 5, scale: 2, null: false + end + + def down + change_column :course_external_assessment_grades, :grade, + :decimal, precision: 4, scale: 1, null: true + change_column :course_external_assessments, :maximum_grade, + :decimal, precision: 4, scale: 1, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index d1a22b40e89..3171a997e12 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_11_000000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_23_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -762,6 +762,38 @@ t.index ["updater_id"], name: "fk__course_experience_points_records_updater_id" end + create_table "course_external_assessment_grades", force: :cascade do |t| + t.bigint "external_assessment_id", null: false + t.bigint "course_user_id", null: false + t.decimal "grade", precision: 5, scale: 2 + t.string "imported_identifier" + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["course_user_id"], name: "fk__course_external_assessment_grades_course_user_id" + t.index ["creator_id"], name: "fk__course_external_assessment_grades_creator_id" + t.index ["external_assessment_id", "course_user_id"], name: "index_course_external_assessment_grades_on_ea_id_and_cu_id", unique: true + t.index ["external_assessment_id"], name: "fk__course_external_assessment_grades_external_assessment_id" + t.index ["updater_id"], name: "fk__course_external_assessment_grades_updater_id" + end + + create_table "course_external_assessments", force: :cascade do |t| + t.bigint "course_id", null: false + t.string "title", null: false + t.decimal "maximum_grade", precision: 5, scale: 2, null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "floor_at_zero", default: true, null: false + t.boolean "cap_at_maximum", default: true, null: false + t.index ["course_id", "title"], name: "index_course_external_assessments_on_course_id_and_title", unique: true + t.index ["course_id"], name: "fk__course_external_assessments_course_id" + t.index ["creator_id"], name: "fk__course_external_assessments_creator_id" + t.index ["updater_id"], name: "fk__course_external_assessments_updater_id" + end + create_table "course_forum_discussion_references", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false @@ -879,10 +911,13 @@ t.bigint "updater_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.bigint "external_assessment_id" t.index ["course_id"], name: "fk__course_gradebook_contributions_course_id" t.index ["creator_id"], name: "fk__course_gradebook_contributions_creator_id" + t.index ["external_assessment_id"], name: "index_course_gradebook_contributions_on_external_assessment_id", unique: true t.index ["tab_id"], name: "index_course_gradebook_contributions_on_tab_id", unique: true t.index ["updater_id"], name: "fk__course_gradebook_contributions_updater_id" + t.check_constraint "(tab_id IS NOT NULL) <> (external_assessment_id IS NOT NULL)", name: "chk_gradebook_contribution_exactly_one_contributor" end create_table "course_group_categories", force: :cascade do |t| @@ -1925,6 +1960,13 @@ add_foreign_key "course_experience_points_records", "users", column: "awarder_id", name: "fk_course_experience_points_records_awarder_id" add_foreign_key "course_experience_points_records", "users", column: "creator_id", name: "fk_course_experience_points_records_creator_id" add_foreign_key "course_experience_points_records", "users", column: "updater_id", name: "fk_course_experience_points_records_updater_id" + add_foreign_key "course_external_assessment_grades", "course_external_assessments", column: "external_assessment_id", name: "fk_course_external_assessment_grades_external_assessment_id" + add_foreign_key "course_external_assessment_grades", "course_users", name: "fk_course_external_assessment_grades_course_user_id" + add_foreign_key "course_external_assessment_grades", "users", column: "creator_id", name: "fk_course_external_assessment_grades_creator_id" + add_foreign_key "course_external_assessment_grades", "users", column: "updater_id", name: "fk_course_external_assessment_grades_updater_id" + add_foreign_key "course_external_assessments", "courses", name: "fk_course_external_assessments_course_id" + add_foreign_key "course_external_assessments", "users", column: "creator_id", name: "fk_course_external_assessments_creator_id" + add_foreign_key "course_external_assessments", "users", column: "updater_id", name: "fk_course_external_assessments_updater_id" add_foreign_key "course_forum_discussion_references", "course_forum_discussions", column: "discussion_id", name: "fk_course_forum_discussion_references_discussion_id" add_foreign_key "course_forum_discussion_references", "course_forum_imports", column: "forum_import_id", name: "fk_course_forum_discussion_references_forum_import_id" add_foreign_key "course_forum_discussion_references", "users", column: "creator_id", name: "fk_course_forum_discussion_references_creator_id" @@ -1948,6 +1990,7 @@ add_foreign_key "course_gradebook_assessment_contributions", "users", column: "creator_id" add_foreign_key "course_gradebook_assessment_contributions", "users", column: "updater_id" add_foreign_key "course_gradebook_contributions", "course_assessment_tabs", column: "tab_id", on_delete: :cascade + add_foreign_key "course_gradebook_contributions", "course_external_assessments", column: "external_assessment_id", on_delete: :cascade add_foreign_key "course_gradebook_contributions", "courses" add_foreign_key "course_gradebook_contributions", "users", column: "creator_id" add_foreign_key "course_gradebook_contributions", "users", column: "updater_id" diff --git a/spec/controllers/course/external_assessment_imports_controller_spec.rb b/spec/controllers/course/external_assessment_imports_controller_spec.rb new file mode 100644 index 00000000000..6020f610926 --- /dev/null +++ b/spec/controllers/course/external_assessment_imports_controller_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::ExternalAssessmentImportsController, type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + let(:ta) { create(:course_teaching_assistant, course: course) } + let!(:alice) { create(:course_student, course: course, external_id: 'A001') } + let!(:bob) { create(:course_student, course: course, external_id: 'A002') } + + let(:components) { [name: 'Midterm', weightage: 30, maximumGrade: 50] } + let(:csv_data) { "Identifier,Midterm\nA001,41\n" } + let(:base_params) do + { course_id: course.id, format: :json, + components: components, identifierMode: 'student_id', csvData: csv_data } + end + + describe '#preview' do + render_views + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'returns ok with a sample and writes nothing' do + expect { post :preview, params: base_params }. + not_to(change { Course::ExternalAssessmentGrade.count }) + data = JSON.parse(response.body) + expect(data['ok']).to be(true) + expect(data['sample'].first['studentName']).to eq(alice.name) + end + + it 'returns ok:false with unresolved identifiers' do + post :preview, params: base_params.merge(csvData: "Identifier,Midterm\nZZZ,1\n") + data = JSON.parse(response.body) + expect(data['ok']).to be(false) + expect(data['unresolved']).to include('ZZZ') + end + + it 'returns 422 on a malformed header' do + post :preview, params: base_params.merge(csvData: "Wrong,Midterm\nA001,1\n") + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns conflicts when a grade already exists' do + # Seed an existing grade for alice + service = Course::Gradebook::ExternalAssessmentImportService.new( + course: course, actor: manager.user, + components: [name: 'Midterm', weightage: 30, maximum_grade: 50], + identifier_mode: 'student_id', csv_data: "Identifier,Midterm\nA001,10\n" + ) + service.commit(on_conflict: 'replace') + + post :preview, params: base_params.merge(csvData: "Identifier,Midterm\nA001,20\n") + data = JSON.parse(response.body) + expect(data['conflicts'].size).to eq(1) + expect(data['conflicts'].first['component']).to eq('Midterm') + end + + it 'returns ok:false with malformed grade cells' do + post :preview, params: base_params.merge(csvData: "Identifier,Midterm\nA001,oops\n") + data = JSON.parse(response.body) + expect(data['ok']).to be(false) + expect(data['malformed']).to be_present + end + + it 'returns 422 on duplicate component names' do + dup_components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }, + { name: 'Midterm', weightage: 20, maximumGrade: 40 }] + post :preview, params: base_params.merge( + components: dup_components, + csvData: "Identifier,Midterm,Midterm\nA001,1,2\n" + ) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'resolves by email when identifierMode is email' do + post :preview, params: base_params.merge( + identifierMode: 'email', + csvData: "Identifier,Midterm\n#{alice.user.email},41\n" + ) + data = JSON.parse(response.body) + expect(data['ok']).to be(true) + expect(data['sample'].first['studentName']).to eq(alice.name) + end + end + + context 'as a teaching assistant' do + before { controller_sign_in(controller, ta.user) } + + it 'is denied' do + expect { post :preview, params: base_params }.to raise_error(CanCan::AccessDenied) + end + end + end + + describe '#create (commit)' do + render_views + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'commits and returns a summary' do + expect { post :create, params: base_params.merge(onConflict: 'replace') }. + to change { Course::ExternalAssessmentGrade.count }.by(1) + data = JSON.parse(response.body) + expect(data['createdComponents']).to eq(1) + expect(data['gradesWritten']).to eq(1) + end + + it 'returns 422 and writes nothing on an unresolved identifier' do + expect do + post :create, params: base_params.merge( + csvData: "Identifier,Midterm\nZZZ,1\n", onConflict: 'replace' + ) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'commits with onConflict keep and returns updatedComponents' do + # Seed first + post :create, params: base_params.merge(onConflict: 'replace') + # Re-import with keep + expect do + post :create, params: base_params.merge(onConflict: 'keep', + csvData: "Identifier,Midterm\nA001,99\n") + end.not_to(change { Course::ExternalAssessmentGrade.count }) + data = JSON.parse(response.body) + expect(data['updatedComponents']).to eq(1) + expect(data['createdComponents']).to eq(0) + end + + it 'returns 422 and writes nothing on malformed grade cells' do + expect do + post :create, params: base_params.merge( + csvData: "Identifier,Midterm\nA001,oops\n", onConflict: 'replace' + ) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as a teaching assistant' do + before { controller_sign_in(controller, ta.user) } + + it 'is denied' do + expect { post :create, params: base_params.merge(onConflict: 'keep') }. + to raise_error(CanCan::AccessDenied) + end + end + end + end +end diff --git a/spec/controllers/course/external_assessments_controller_spec.rb b/spec/controllers/course/external_assessments_controller_spec.rb new file mode 100644 index 00000000000..411895951e7 --- /dev/null +++ b/spec/controllers/course/external_assessments_controller_spec.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::ExternalAssessmentsController, type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + let(:ta) { create(:course_teaching_assistant, course: course) } + + describe '#create' do + render_views + let(:params) do + { course_id: course.id, format: :json, title: 'Final', maximumGrade: 100 } + end + + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'creates an external assessment with a contribution and no tab/category' do + expect do + post :create, params: params + end.to change(Course::ExternalAssessment, :count).by(1). + and change(Course::Gradebook::Contribution, :count).by(1). + and not_change(Course::Assessment::Tab, :count) + expect(response).to be_successful + body = JSON.parse(response.body) + created = Course::ExternalAssessment.last + expect(body['assessment']['id']).to eq(-created.id) + expect(body['assessment']['id']).to be < 0 + expect(body['assessment']['title']).to eq('Final') + expect(body['assessment']['maxGrade']).to eq(100.0) + expect(body['assessment']['external']).to be(true) + expect(body['assessment']['gradebookExcluded']).to be(false) + expect(body['tab']['id']).to eq(body['assessment']['tabId']) + expect(body['tab']['id']).to be < 0 + expect(body['tab']['categoryId']).to eq(Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID) + expect(body['category']['id']).to eq(Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID) + expect(body['category']['title']).to eq(Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE) + end + + it 'serializes the bound flags' do + post :create, as: :json, params: { + course_id: course, title: 'Midterm', maximumGrade: 50 + } + json = JSON.parse(response.body) + expect(json['assessment']).to include('floorAtZero' => true, 'capAtMaximum' => true) + end + + it 'persists explicit bound flags on create' do + post :create, as: :json, params: { + course_id: course, title: 'Bonus', maximumGrade: 10, + floorAtZero: false, capAtMaximum: false + } + json = JSON.parse(response.body) + expect(json['assessment']).to include('floorAtZero' => false, 'capAtMaximum' => false) + end + + it 'persists a weight when weighted view is enabled' do + context = OpenStruct.new(current_course: course, key: Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(context).weighted_view_enabled = true + course.save! + + post :create, as: :json, params: { + course_id: course, title: 'Presentation', maximumGrade: 10, + weight: 25 + } + + created = Course::ExternalAssessment.last + json = JSON.parse(response.body) + expect(created.gradebook_contribution.weight).to eq(25) + expect(json['assessment']['gradebookWeight']).to eq(25.0) + expect(json['tab']['gradebookWeight']).to eq(25.0) + end + + it 'ignores a weight when weighted view is disabled' do + post :create, as: :json, params: { + course_id: course, title: 'Presentation', maximumGrade: 10, + weight: 25 + } + + created = Course::ExternalAssessment.last + json = JSON.parse(response.body) + expect(created.gradebook_contribution.weight).to eq(0) + expect(json['assessment']).not_to have_key('gradebookWeight') + expect(json['tab']).not_to have_key('gradebookWeight') + end + + it 'returns 422 on a blank title' do + post :create, params: params.merge(title: '') + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns 422 on a blank maximumGrade' do + post :create, params: params.merge(maximumGrade: '') + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as a teaching assistant' do + before { controller_sign_in(controller, ta.user) } + + it 'is denied' do + expect { post :create, params: params }.to raise_error(CanCan::AccessDenied) + end + end + end + + describe '#update' do + render_views + let!(:external) { create(:course_external_assessment, course: course, title: 'Mid') } + + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'renames and changes the maximum grade without touching any tab' do + expect do + patch :update, params: { course_id: course.id, id: external.id, format: :json, + title: 'Midterm', maximumGrade: 60 } + end.not_to change(Course::Assessment::Tab, :count) + expect(response).to be_successful + body = JSON.parse(response.body) + expect(body['assessment']['id']).to eq(-external.id) + expect(body['assessment']['title']).to eq('Midterm') + expect(body['assessment']['maxGrade']).to eq(60.0) + expect(body['assessment']['external']).to be(true) + expect(body['assessment']['gradebookExcluded']).to be(false) + expect(body['tab']['id']).to eq(-external.id) + expect(body['tab']['title']).to eq('Midterm') + expect(body['tab']['categoryId']).to eq(Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID) + expect(external.reload.title).to eq('Midterm') + expect(external.maximum_grade).to eq(60) + end + + it 'stores a maximum grade with two decimal places without rounding' do + patch :update, params: { course_id: course.id, id: external.id, format: :json, + maximumGrade: '99.25' } + expect(response).to be_successful + expect(external.reload.maximum_grade).to eq(BigDecimal('99.25')) + end + + it 'returns 404 when the external belongs to another course' do + other_external = create(:course_external_assessment) + patch :update, params: { course_id: course.id, id: other_external.id, format: :json, title: 'X' } + expect(response).to have_http_status(:not_found) + end + + it 'returns 422 on a blank title' do + patch :update, params: { course_id: course.id, id: external.id, format: :json, + title: '' } + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'updates a bound flag' do + external = create(:course_external_assessment, + course: course, title: 'Quiz', maximum_grade: 20) + patch :update, as: :json, params: { + course_id: course, id: external.id, capAtMaximum: false + } + expect(external.reload.cap_at_maximum).to be(false) + expect(external.floor_at_zero).to be(true) + end + + context 'when weighted view is enabled' do + let!(:weighted_external) do + Course::ExternalAssessment.create_for_course!( + course: course, title: 'Final', maximum_grade: 100, weight: 10 + ) + end + + before do + context = OpenStruct.new(current_course: course, key: Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(context).weighted_view_enabled = true + course.save! + end + + it 'updates the contribution weight and echoes it back' do + patch :update, as: :json, params: { + course_id: course, id: weighted_external.id, weight: 40 + } + expect(response).to be_successful + expect(weighted_external.gradebook_contribution.reload.weight).to eq(40) + body = JSON.parse(response.body) + expect(body['assessment']['gradebookWeight']).to eq(40.0) + expect(body['tab']['gradebookWeight']).to eq(40.0) + end + end + + it 'ignores a weight when weighted view is disabled' do + weighted_external = Course::ExternalAssessment.create_for_course!( + course: course, title: 'Final', maximum_grade: 100, weight: 10 + ) + patch :update, as: :json, params: { + course_id: course, id: weighted_external.id, weight: 40 + } + expect(weighted_external.gradebook_contribution.reload.weight).to eq(10) + body = JSON.parse(response.body) + expect(body['assessment']).not_to have_key('gradebookWeight') + expect(body['tab']).not_to have_key('gradebookWeight') + end + end + + context 'as a teaching assistant' do + before { controller_sign_in(controller, ta.user) } + + it 'is denied' do + expect do + patch :update, params: { course_id: course.id, id: external.id, format: :json, title: 'X' } + end.to raise_error(CanCan::AccessDenied) + end + end + end + + describe '#destroy' do + let!(:external) { create(:course_external_assessment, course: course) } + + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'deletes the external and cascades grades' do + create(:course_external_assessment_grade, external_assessment: external) + expect do + delete :destroy, params: { course_id: course.id, id: external.id, format: :json } + end.to change { Course::ExternalAssessment.count }.by(-1). + and change { Course::ExternalAssessmentGrade.count }.by(-1) + expect(response).to be_successful + end + + it 'returns 404 when the external belongs to another course' do + other_external = create(:course_external_assessment) + expect do + delete :destroy, params: { course_id: course.id, id: other_external.id, format: :json } + end.not_to(change { Course::ExternalAssessment.count }) + expect(response).to have_http_status(:not_found) + end + end + + context 'as a teaching assistant' do + before { controller_sign_in(controller, ta.user) } + + it 'is denied' do + expect { delete :destroy, params: { course_id: course.id, id: external.id, format: :json } }. + to raise_error(CanCan::AccessDenied) + end + end + end + + describe '#grades' do + render_views + let!(:external) { create(:course_external_assessment, course: course) } + let(:gb_student) { create(:course_student, course: course) } + + context 'as a teaching assistant (grading-capable staff)' do + before { controller_sign_in(controller, ta.user) } + + # The gradebook frontend keys students by user_id (json.studentId == course_user.user_id), + # so #grades must resolve the course_user from the `studentId` param, not a course_user PK. + it 'inserts a grade for a student who has none' do + expect do + put :grades, params: { course_id: course.id, id: external.id, format: :json, + studentId: gb_student.user_id, grade: 88 } + end.to change { Course::ExternalAssessmentGrade.count }.by(1) + expect(response).to be_successful + data = JSON.parse(response.body) + expect(data['studentId']).to eq(gb_student.user_id) + expect(data['assessmentId']).to eq(-external.id) + expect(data['grade']).to eq(88.0) + expect(Course::ExternalAssessmentGrade.last.course_user).to eq(gb_student) + end + + it 'updates an existing grade in place (no duplicate row)' do + grade = create(:course_external_assessment_grade, + external_assessment: external, course_user: gb_student, grade: 10) + expect do + put :grades, params: { course_id: course.id, id: external.id, format: :json, + studentId: gb_student.user_id, grade: 20 } + end.not_to(change { Course::ExternalAssessmentGrade.count }) + expect(grade.reload.grade).to eq(20) + end + + it 'stores a grade with two decimal places without rounding' do + put :grades, params: { course_id: course.id, id: external.id, format: :json, + studentId: gb_student.user_id, grade: '87.25' } + expect(response).to be_successful + expect(Course::ExternalAssessmentGrade.last.grade).to eq(BigDecimal('87.25')) + end + + it 'clears a grade to null (ungraded) when grade is blank' do + grade = create(:course_external_assessment_grade, + external_assessment: external, course_user: gb_student, grade: 10) + put :grades, params: { course_id: course.id, id: external.id, format: :json, + studentId: gb_student.user_id, grade: '' } + expect(grade.reload.grade).to be_nil + end + + it 'returns 404 when the student does not belong to the course' do + other_student = create(:course_student) + put :grades, params: { course_id: course.id, id: external.id, format: :json, + studentId: other_student.user_id, grade: 50 } + expect(response).to have_http_status(:not_found) + end + end + + context 'as a student' do + let(:viewer) { create(:course_student, course: course) } + before { controller_sign_in(controller, viewer.user) } + + it 'is denied' do + expect do + put :grades, params: { course_id: course.id, id: external.id, format: :json, + studentId: gb_student.user_id, grade: 5 } + end.to raise_error(CanCan::AccessDenied) + end + end + end + end +end diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb index 59397f4120d..74d54d758f7 100644 --- a/spec/controllers/course/gradebook_controller_spec.rb +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -199,6 +199,114 @@ expect(sub['grade']).to be_nil end end + + context 'when the course has an external assessment' do + render_views + let(:ta) { create(:course_teaching_assistant, course: course) } + let(:gb_student) { create(:course_student, course: course) } + let!(:external) do + create(:course_external_assessment, course: course, title: 'Midterm', maximum_grade: 50) + end + let!(:external_grade) do + create(:course_external_assessment_grade, + external_assessment: external, course_user: gb_student, grade: 41) + end + before { controller_sign_in(controller, ta.user) } + + it 'merges the external into assessments with a negative id and external flag' do + subject + data = JSON.parse(response.body) + ext_row = data['assessments'].find { |a| a['id'] == -external.id } + expect(ext_row).to be_present + expect(ext_row['title']).to eq('Midterm') + expect(ext_row['external']).to be(true) + expect(ext_row['maxGrade']).to eq(50.0) + expect(ext_row['tabId']).to eq(external.synthetic_tab_id) + end + + it 'merges the external grade into submissions with a negative assessmentId' do + subject + data = JSON.parse(response.body) + sub = data['submissions'].find { |s| s['assessmentId'] == -external.id } + expect(sub).to be_present + expect(sub['studentId']).to eq(gb_student.user_id) + expect(sub['grade']).to eq(41.0) + end + + it 'emits a synthetic External Assessments category' do + subject + data = JSON.parse(response.body) + cat = data['categories'].find { |c| c['id'] == Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID } + expect(cat).to be_present + expect(cat['title']).to eq('External Assessments') + end + + it 'emits a synthetic tab with negative id under the synthetic category' do + subject + data = JSON.parse(response.body) + tab = data['tabs'].find { |t| t['id'] == external.synthetic_tab_id } + expect(tab).to be_present + expect(tab['categoryId']).to eq(Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID) + end + + it 'creates no real tab or category for the external' do + tab_count_before = Course::Assessment::Tab.count + cat_count_before = Course::Assessment::Category.count + subject + expect(Course::Assessment::Tab.count).to eq(tab_count_before) + expect(Course::Assessment::Category.count).to eq(cat_count_before) + expect(Course::Assessment::Category.where(title: 'External Assessments')).to be_empty + end + end + end + + describe 'GET #index with externals' do + render_views + let!(:course) { create(:course) } + let!(:external) do + Course::ExternalAssessment.create_for_course!(course: course, title: 'Midterm', + maximum_grade: 50.0, weight: 40) + end + let(:ta) { create(:course_teaching_assistant, course: course) } + + before do + ctx = Struct.new(:current_course, :key).new(course, Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(ctx).weighted_view_enabled = true + course.save! + controller_sign_in(controller, ta.user) + end + + subject(:body) do + get(:index, params: { course_id: course }, format: :json) + JSON.parse(response.body) + end + + it 'emits a synthetic External Assessments category' do + cat = body['categories'].find { |c| c['id'] == Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID } + expect(cat['title']).to eq('External Assessments') + end + + it 'emits one synthetic tab per external carrying its weight' do + tab = body['tabs'].find { |t| t['id'] == -external.id } + expect(tab['categoryId']).to eq(Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID) + expect(tab['gradebookWeight']).to eq(40.0) + expect(tab['weightMode']).to eq('equal') + end + + it 'emits the external as a negative-id leaf under its synthetic tab' do + leaf = body['assessments'].find { |a| a['id'] == -external.id } + expect(leaf['external']).to be(true) + expect(leaf['tabId']).to eq(-external.id) + end + + it 'creates no real tab or category for the external' do + tab_count_before = Course::Assessment::Tab.count + cat_count_before = Course::Assessment::Category.count + body + expect(Course::Assessment::Tab.count).to eq(tab_count_before) + expect(Course::Assessment::Category.count).to eq(cat_count_before) + expect(Course::Assessment::Category.where(title: 'External Assessments')).to be_empty + end end describe 'PATCH update_weights' do @@ -310,13 +418,13 @@ def weight_for(tab) it 'persists custom mode + assessment weights and echoes them back' do post :update_weights, as: :json, params: { course_id: course.id, - weights: [{ + weights: [ tabId: tab.id, weight: '50', weightMode: 'custom', assessmentWeights: [ { assessmentId: a1.id, weight: '30' }, { assessmentId: a2.id, weight: '20' } ] - }] + ] } expect(response).to have_http_status(:ok) body = JSON.parse(response.body) @@ -332,10 +440,10 @@ def weight_for(tab) it 'returns 422 when custom weights do not sum to the tab total' do post :update_weights, as: :json, params: { course_id: course.id, - weights: [{ + weights: [ tabId: tab.id, weight: '50', weightMode: 'custom', - assessmentWeights: [{ assessmentId: a1.id, weight: '10' }] - }] + assessmentWeights: [assessmentId: a1.id, weight: '10'] + ] } expect(response).to have_http_status(:unprocessable_entity) end @@ -343,10 +451,10 @@ def weight_for(tab) it 'persists and echoes per-assessment exclusion in equal mode' do post :update_weights, as: :json, params: { course_id: course.id, - weights: [{ + weights: [ tabId: tab.id, weight: '50', weightMode: 'equal', excludedAssessmentIds: [a1.id] - }] + ] } expect(response).to have_http_status(:ok) expect(a1.reload.gradebook_assessment_contribution.excluded).to eq(true) diff --git a/spec/factories/course_external_assessments.rb b/spec/factories/course_external_assessments.rb new file mode 100644 index 00000000000..5aa07599bad --- /dev/null +++ b/spec/factories/course_external_assessments.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_external_assessment, class: Course::ExternalAssessment do + course + sequence(:title) { |n| "External #{n}" } + maximum_grade { 100.0 } + end + + factory :course_external_assessment_grade, class: Course::ExternalAssessmentGrade do + external_assessment { association(:course_external_assessment) } + course_user { association(:course_user) } + grade { 50.0 } + end +end diff --git a/spec/factories/course_gradebook_contributions.rb b/spec/factories/course_gradebook_contributions.rb index 606994f4aa2..94ff1573050 100644 --- a/spec/factories/course_gradebook_contributions.rb +++ b/spec/factories/course_gradebook_contributions.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true FactoryBot.define do - factory :course_gradebook_contribution, class: Course::Gradebook::Contribution.name do - association :tab, factory: :course_assessment_tab - course { tab.category.course } + factory :course_gradebook_contribution, class: Course::Gradebook::Contribution do + course + tab { association(:course_assessment_tab, course: course) } weight { 0 } weight_mode { :equal } + keep_highest { 0 } end end diff --git a/spec/models/course/external_assessment_grade_spec.rb b/spec/models/course/external_assessment_grade_spec.rb new file mode 100644 index 00000000000..6f0acb69c79 --- /dev/null +++ b/spec/models/course/external_assessment_grade_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::ExternalAssessmentGrade, type: :model do + let(:instance) { Instance.default } + + with_tenant(:instance) do + describe 'validations' do + subject { build(:course_external_assessment_grade) } + + it { is_expected.to be_valid } + + it 'allows a null grade (ungraded)' do + subject.grade = nil + expect(subject).to be_valid + end + + it 'allows a grade greater than the maximum (no ceiling, bonus-consistent)' do + subject.external_assessment.maximum_grade = 10 + subject.grade = 15 + expect(subject).to be_valid + end + + it 'allows a negative grade (penalties; floor applied via floor_at_zero, not validation)' do + subject.grade = -5 + expect(subject).to be_valid + end + + it 'enforces one grade per (external_assessment, course_user)' do + existing = create(:course_external_assessment_grade) + duplicate = build(:course_external_assessment_grade, + external_assessment: existing.external_assessment, + course_user: existing.course_user) + expect(duplicate).not_to be_valid + end + + it 'allows the same course_user under a different external_assessment' do + existing = create(:course_external_assessment_grade) + other = build(:course_external_assessment_grade, course_user: existing.course_user) + expect(other).to be_valid + end + + it 'allows the same external_assessment for a different course_user' do + existing = create(:course_external_assessment_grade) + other = build(:course_external_assessment_grade, + external_assessment: existing.external_assessment) + expect(other).to be_valid + end + + it 'requires a course_user' do + subject.course_user = nil + expect(subject).not_to be_valid + end + + it 'rejects a non-numeric grade string' do + subject.grade = 'abc' + expect(subject).not_to be_valid + end + + it 'requires an external_assessment' do + subject.external_assessment = nil + expect(subject).not_to be_valid + end + end + + describe 'determinacy — grade binds to course_user, not the identifier string' do + it 'does not move an existing grade when the student external_id changes after import' do + grade = create(:course_external_assessment_grade, imported_identifier: 'A0001X', grade: 5) + course_user = grade.course_user + + course_user.update!(external_id: 'A9999Z') + + expect(grade.reload.course_user_id).to eq(course_user.id) + expect(grade.grade).to eq(5) + end + end + end +end diff --git a/spec/models/course/external_assessment_spec.rb b/spec/models/course/external_assessment_spec.rb new file mode 100644 index 00000000000..aebd75e4b36 --- /dev/null +++ b/spec/models/course/external_assessment_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::ExternalAssessment, type: :model do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + + describe 'associations' do + it { is_expected.to belong_to(:course) } + it { is_expected.to have_one(:gradebook_contribution).dependent(:destroy) } + it { is_expected.to have_many(:external_assessment_grades).dependent(:delete_all) } + end + + describe 'dependent destroy' do + it 'destroys the gradebook contribution and grades when destroyed' do + external = described_class.create_for_course!(course: course, title: 'Final', maximum_grade: 80.0) + create(:course_external_assessment_grade, external_assessment: external) + expect do + external.destroy + end.to change(Course::Gradebook::Contribution, :count).by(-1). + and change(Course::ExternalAssessmentGrade, :count).by(-1) + end + end + + describe 'validations' do + subject { build(:course_external_assessment, course: course) } + it { is_expected.to validate_presence_of(:title) } + it { is_expected.to validate_length_of(:title).is_at_most(255) } + it { is_expected.to validate_presence_of(:maximum_grade) } + + it 'enforces course-scoped unique titles' do + create(:course_external_assessment, course: course, title: 'Midterm') + dup = build(:course_external_assessment, course: course, title: 'Midterm') + expect(dup).not_to be_valid + expect(dup.errors[:title]).to include(I18n.t('errors.messages.taken')) + end + + it 'allows the same title in different courses' do + create(:course_external_assessment, course: course, title: 'Midterm') + other = build(:course_external_assessment, course: create(:course), title: 'Midterm') + expect(other).to be_valid + end + + it 'rejects a negative maximum_grade' do + subject.maximum_grade = -1 + expect(subject).not_to be_valid + expect(subject.errors[:maximum_grade]).to be_present + end + + it 'accepts a zero maximum_grade' do + subject.maximum_grade = 0 + expect(subject).to be_valid + end + end + + describe '.create_for_course!' do + it 'creates the external and its contribution row' do + external = nil + expect do + external = described_class.create_for_course!(course: course, title: 'Final', + maximum_grade: 80.0, weight: 30) + end.to change(Course::Gradebook::Contribution, :count).by(1) + expect(external.course).to eq(course) + expect(external.gradebook_contribution.weight).to eq(30) + end + + it 'does not create any assessment tab or category' do + course # ensure course (and its default tab/category) is created before measuring + expect do + described_class.create_for_course!(course: course, title: 'Final', maximum_grade: 80.0) + end.to not_change(Course::Assessment::Tab, :count).and not_change(Course::Assessment::Category, :count) + end + + it 'raises on duplicate title within the course' do + described_class.create_for_course!(course: course, title: 'Final', maximum_grade: 80.0) + expect do + described_class.create_for_course!(course: course, title: 'Final', maximum_grade: 80.0) + end.to raise_error(ActiveRecord::RecordInvalid) + end + + it 'rolls back the external when contribution creation fails' do + allow(Course::Gradebook::Contribution).to receive(:create!). + and_raise(ActiveRecord::RecordInvalid.new(Course::Gradebook::Contribution.new)) + expect do + expect do + described_class.create_for_course!(course: course, title: 'Final', maximum_grade: 80.0) + end.to raise_error(ActiveRecord::RecordInvalid) + end.to not_change(described_class, :count) + end + end + + describe '#synthetic_tab_id' do + it 'returns the negative of the record id' do + external = create(:course_external_assessment, course: course) + expect(external.synthetic_tab_id).to eq(-external.id) + end + end + + describe '.for_course' do + it 'returns only externals in the course' do + mine = create(:course_external_assessment, course: course) + create(:course_external_assessment, course: create(:course)) + expect(described_class.for_course(course)).to contain_exactly(mine) + end + end + + describe 'grade-bounding defaults' do + it 'defaults floor_at_zero and cap_at_maximum to true' do + external = Course::ExternalAssessment.create_for_course!( + course: course, title: 'Midterm', maximum_grade: 50 + ) + expect(external.floor_at_zero).to be(true) + expect(external.cap_at_maximum).to be(true) + end + + it 'honours explicit bound flags' do + external = Course::ExternalAssessment.create_for_course!( + course: course, title: 'Bonus', maximum_grade: 10, + floor_at_zero: false, cap_at_maximum: false + ) + expect(external.floor_at_zero).to be(false) + expect(external.cap_at_maximum).to be(false) + end + end + end +end diff --git a/spec/services/course/gradebook/external_assessment_import_service_spec.rb b/spec/services/course/gradebook/external_assessment_import_service_spec.rb new file mode 100644 index 00000000000..b55eeb9be14 --- /dev/null +++ b/spec/services/course/gradebook/external_assessment_import_service_spec.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Gradebook::ExternalAssessmentImportService, type: :service do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:actor) { create(:course_manager, course: course).user } + let!(:alice) { create(:course_student, course: course, external_id: 'A001') } + let!(:bob) { create(:course_student, course: course, external_id: 'A002') } + + def service(csv_data:, components:, identifier_mode: 'student_id') + described_class.new( + course: course, actor: actor, components: components, + identifier_mode: identifier_mode, csv_data: csv_data + ) + end + + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + describe '#preview' do + it 'writes nothing (dry-run)' do + csv = "Identifier,Midterm\nA001,41\nA002,37\n" + expect { service(csv_data: csv, components: components).preview }. + not_to(change { Course::ExternalAssessmentGrade.count }) + end + + it 'returns ok with the first 5 resolved rows (student names)' do + csv = "Identifier,Midterm\nA001,41\nA002,37\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:unresolved]).to be_empty + expect(result[:sample].size).to eq(2) + expect(result[:sample].map { |r| r[:studentName] }).to include(alice.name, bob.name) + expect(result[:sample].first[:grades]['Midterm']).to eq(41.0) + end + + it 'resolves by email when in email mode' do + csv = "Identifier,Midterm\n#{alice.user.email},41\n" + result = service(csv_data: csv, components: components, identifier_mode: 'email').preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:studentName]).to eq(alice.name) + end + + it 'fails the whole batch on any unresolved identifier' do + csv = "Identifier,Midterm\nA001,41\nZZZZ,37\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(false) + expect(result[:unresolved]).to include('ZZZZ') + end + + it 'flags a malformed (non-numeric) cell' do + csv = "Identifier,Midterm\nA001,oops\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(false) + expect(result[:malformed]).to be_present + end + + it 'rejects an in-file duplicate component name' do + dup = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Midterm', weightage: 20, maximum_grade: 40 }] + csv = "Identifier,Midterm,Midterm\nA001,1,2\n" + expect { service(csv_data: csv, components: dup).preview }. + to raise_error(described_class::ImportError) + end + + it 'raises ImportError on wrong CSV header' do + csv = "Wrong,Midterm\nA001,41\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) + end + + it 'returns ok with empty sample when CSV has no data rows' do + csv = "Identifier,Midterm\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:sample]).to be_empty + expect(result[:conflicts]).to be_empty + end + + it 'resolves by email case-insensitively' do + csv = "Identifier,Midterm\n#{alice.user.email.upcase},41\n" + result = service(csv_data: csv, components: components, identifier_mode: 'email').preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:studentName]).to eq(alice.name) + end + + it 'deduplicates unresolved identifiers' do + csv = "Identifier,Midterm\nZZZZ,1\nZZZZ,2\n" + result = service(csv_data: csv, components: components).preview + expect(result[:unresolved].count('ZZZZ')).to eq(1) + end + + it 'treats a blank cell as ungraded in the sample' do + csv = "Identifier,Midterm\nA001,\n" + result = service(csv_data: csv, components: components).preview + expect(result[:sample].first[:grades]['Midterm']).to be_nil + end + end + + describe '#commit (fresh import)' do + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + it 'creates the external in the External Assessments category with the typed weight' do + csv = "Identifier,Midterm\nA001,41\nA002,37\n" + summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') + external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + expect(external).to be_present + expect(external.maximum_grade).to eq(50) + expect(external.gradebook_contribution.weight).to eq(30) + expect(summary[:createdComponents]).to eq(1) + expect(summary[:gradesWritten]).to eq(2) + end + + it 'writes one grade row per resolved student bound to course_user' do + csv = "Identifier,Midterm\nA001,41\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') + grade = external.external_assessment_grades.find_by!(course_user: alice) + expect(grade.course_user_id).to eq(alice.id) + expect(grade.grade).to eq(41) + expect(grade.imported_identifier).to eq('A001') + end + + it 'skips a blank cell on a fresh import (no grade row created)' do + csv = "Identifier,Midterm\nA001,\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + # After fix: blank cell on fresh import does NOT create a grade row (filter_map skips nil) + external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + expect(external.external_assessment_grades.count).to eq(0) + end + + it 'accepts a grade greater than the max (no ceiling)' do + csv = "Identifier,Midterm\nA001,60\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') + expect(external.external_assessment_grades.find_by!(course_user: alice).grade).to eq(60) + end + + it 'creates multiple components as separate externals' do + comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 50, maximum_grade: 100 }] + csv = "Identifier,Midterm,Final\nA001,40,80\n" + service(csv_data: csv, components: comps).commit(on_conflict: 'replace') + expect(Course::ExternalAssessment.for_course(course).pluck(:title)).to contain_exactly('Midterm', 'Final') + expect(Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm'). + external_assessment_grades.count).to eq(1) + expect(Course::ExternalAssessment.for_course(course).find_by!(title: 'Final'). + external_assessment_grades.count).to eq(1) + end + + it 'writes nothing when an identifier does not resolve' do + csv = "Identifier,Midterm\nA001,41\nZZZ,9\n" + expect do + expect do + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + end.to raise_error(described_class::ImportError) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + end + end + + describe '#commit (upsert into existing component)' do + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + def seed_initial! + csv = "Identifier,Midterm\nA001,10\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + end + + it 'updates grades into the same component (no second tab)' do + external = seed_initial! + csv = "Identifier,Midterm\nA001,20\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + expect(Course::ExternalAssessment.for_course(course).where(title: 'Midterm').count).to eq(1) + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(20) + end + + it "keeps existing grades when on_conflict is 'keep'" do + external = seed_initial! + csv = "Identifier,Midterm\nA001,99\n" + service(csv_data: csv, components: components).commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) + end + + it 'inserts a grade for a brand-new student regardless of on_conflict' do + external = seed_initial! + csv = "Identifier,Midterm\nA002,55\n" + service(csv_data: csv, components: components).commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: bob).grade).to eq(55) + end + + it 'skips a blank cell on upsert (existing grade unchanged)' do + external = seed_initial! + csv = "Identifier,Midterm\nA001,\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) + end + + it 'never changes the external max or contribution weight on upsert' do + external = seed_initial! + csv = "Identifier,Midterm\nA001,20\n" + comps = [name: 'Midterm', weightage: 99, maximum_grade: 999] + service(csv_data: csv, components: comps).commit(on_conflict: 'replace') + expect(external.reload.maximum_grade).to eq(50) + expect(external.gradebook_contribution.reload.weight).to eq(30) + end + + it 'lists conflicts only for existing non-blank grade rows' do + seed_initial! + csv = "Identifier,Midterm\nA001,20\nA002,33\n" + result = service(csv_data: csv, components: components).preview + expect(result[:conflicts].map { |c| c[:studentName] }).to contain_exactly(alice.name) + conflict = result[:conflicts].first + expect(conflict[:existingGrade]).to eq(10.0) + expect(conflict[:inFileGrade]).to eq(20.0) + end + + it 'flags identifierMismatch when a row resolves to a student imported under a different id' do + seed_initial! # alice imported under 'A001' + bob.update!(external_id: 'A777') + alice.update!(external_id: 'A002') # alice now owns A002 (formerly bob's) + csv = "Identifier,Midterm\nA002,20\n" # A002 now → alice, but her grade was imported as 'A001' + result = service(csv_data: csv, components: components).preview + mismatch = result[:conflicts].find { |c| c[:studentName] == alice.name } + expect(mismatch[:identifierMismatch]).to be(true) + end + + it 'returns updatedComponents: 1 after an upsert' do + seed_initial! + csv = "Identifier,Midterm\nA001,20\n" + summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') + expect(summary[:updatedComponents]).to eq(1) + expect(summary[:createdComponents]).to eq(0) + end + + it 'updates a nil existing grade even when on_conflict is keep' do + external = seed_initial! + # Manually clear the grade to nil (simulates a partial import that wrote the row but not the value) + external.external_assessment_grades.find_by(course_user: alice).update_column(:grade, nil) + csv = "Identifier,Midterm\nA001,50\n" + service(csv_data: csv, components: components).commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(50) + end + + it 'detects conflicts across multiple components' do + comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 50, maximum_grade: 100 }] + # Seed both components + seed_csv = "Identifier,Midterm,Final\nA001,10,80\n" + service(csv_data: seed_csv, components: comps).commit(on_conflict: 'replace') + # Re-import with different values + csv = "Identifier,Midterm,Final\nA001,20,90\n" + result = service(csv_data: csv, components: comps).preview + expect(result[:conflicts].map { |c| c[:component] }).to contain_exactly('Midterm', 'Final') + end + end + + describe 'determinacy' do + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + it 'does not move a grade when the student external_id changes after import' do + csv = "Identifier,Midterm\nA001,41\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + grade = Course::ExternalAssessmentGrade.last + alice.update!(external_id: 'CHANGED') + expect(grade.reload.course_user_id).to eq(alice.id) + expect(grade.grade).to eq(41) + end + end + end +end From 73ebbcb294c677358b306ee7d2e837b4be71e2cb Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 26 Jun 2026 11:53:55 +0800 Subject: [PATCH 4/4] feat(gradebook): external-ID import, manage panel, and drag-and-drop reorder --- .../course/external_assessments_controller.rb | 12 + .../course/gradebook_controller.rb | 2 +- app/models/course/external_assessment.rb | 35 +- .../external_assessment_import_service.rb | 8 +- client/app/api/course/Gradebook.ts | 7 + .../AddExternalColumnPrompt.test.tsx | 195 ++++++ .../DeleteExternalColumnPrompt.test.tsx | 101 +++ .../EditExternalAssessmentPrompt.test.tsx | 315 ++++++++- .../__tests__/GradebookIndex.test.tsx | 14 +- .../ImportExternalAssessmentsWizard.test.tsx | 153 ++++- .../ManageExternalAssessmentsButton.test.tsx | 20 + .../ManageExternalAssessmentsPanel.test.tsx | 452 +++++++++++++ .../gradebook/__tests__/buildTemplate.test.ts | 57 +- .../gradebook/__tests__/operations.test.ts | 614 ++++++++++++++++++ .../course/gradebook/__tests__/store.test.ts | 182 +++++- .../components/AddExternalColumnPrompt.tsx | 95 ++- .../components/DeleteExternalColumnPrompt.tsx | 7 +- .../components/GradebookColumnTree.tsx | 60 +- .../gradebook/components/GradebookTable.tsx | 124 ++-- .../components/GradebookWeightedTable.tsx | 61 ++ .../components/RenameExternalColumnPrompt.tsx | 100 --- .../ImportExternalAssessmentsButton.tsx | 44 -- .../ImportExternalAssessmentsWizard.tsx | 211 ++++-- .../components/import/buildTemplate.ts | 21 +- .../manage/EditExternalAssessmentPrompt.tsx | 134 +++- .../ManageExternalAssessmentsButton.tsx | 30 + .../manage/ManageExternalAssessmentsPanel.tsx | 358 ++++++++++ .../app/bundles/course/gradebook/constants.ts | 4 + .../bundles/course/gradebook/operations.ts | 39 ++ .../gradebook/pages/GradebookIndex/index.tsx | 31 +- client/app/bundles/course/gradebook/store.ts | 32 + .../ManageUsersTable/__test__/index.test.tsx | 28 +- .../tables/ManageUsersTable/index.tsx | 15 +- client/app/types/course/gradebook.ts | 6 + client/locales/en.json | 215 ++++-- client/locales/ko.json | 107 ++- client/locales/zh.json | 109 +++- config/routes.rb | 3 + ...position_to_course_external_assessments.rb | 23 + db/schema.rb | 3 +- ...rnal_assessment_imports_controller_spec.rb | 20 +- .../external_assessments_controller_spec.rb | 23 + .../models/course/external_assessment_spec.rb | 33 + ...external_assessment_import_service_spec.rb | 78 ++- 44 files changed, 3609 insertions(+), 572 deletions(-) create mode 100644 client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/DeleteExternalColumnPrompt.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/operations.test.ts delete mode 100644 client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx delete mode 100644 client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx create mode 100644 client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx create mode 100644 client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx create mode 100644 db/migrate/20260624000000_add_position_to_course_external_assessments.rb diff --git a/app/controllers/course/external_assessments_controller.rb b/app/controllers/course/external_assessments_controller.rb index 141983de0e1..881f8a7dc87 100644 --- a/app/controllers/course/external_assessments_controller.rb +++ b/app/controllers/course/external_assessments_controller.rb @@ -34,6 +34,14 @@ def destroy head :ok end + def reorder + authorize! :manage_gradebook_weights, current_course + Course::ExternalAssessment.reorder!(course: current_course, ordered_ids: reorder_params) + head :ok + rescue ArgumentError + head :unprocessable_entity + end + def grades authorize! :grade, @external_assessment # The gradebook keys students by user_id (see index/update_grade jbuilders), so the @@ -104,4 +112,8 @@ def grade_params def normalized_grade(value) value.blank? ? nil : value end + + def reorder_params + params.require(:orderedIds).map(&:to_i) + end end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb index a7fb12c6c0b..2d1b7e761a5 100644 --- a/app/controllers/course/gradebook_controller.rb +++ b/app/controllers/course/gradebook_controller.rb @@ -85,7 +85,7 @@ def fetch_categories_and_tabs end def load_externals - @external_assessments = Course::ExternalAssessment.for_course(current_course). + @external_assessments = Course::ExternalAssessment.for_course(current_course).order(:position). includes(:gradebook_contribution, external_assessment_grades: :course_user).to_a @external_grades = @external_assessments.flat_map(&:external_assessment_grades) @external_contributions = @external_assessments. diff --git a/app/models/course/external_assessment.rb b/app/models/course/external_assessment.rb index 84aa12273fc..69eec6ebac8 100644 --- a/app/models/course/external_assessment.rb +++ b/app/models/course/external_assessment.rb @@ -20,11 +20,37 @@ class Course::ExternalAssessment < ApplicationRecord belongs_to :course, inverse_of: :external_assessments has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution', inverse_of: :external_assessment, dependent: :destroy + # delete_all (not destroy): grades carry no destroy callbacks, so destroying the + # parent would otherwise fire one SELECT+DELETE per grade (N+1). delete_all removes + # them in a single statement. Rails must issue it — the DB FK has no ON DELETE CASCADE. has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade', - inverse_of: :external_assessment, dependent: :destroy + inverse_of: :external_assessment, dependent: :delete_all scope :for_course, ->(course) { where(course_id: course.id) } + before_create :assign_default_position + + # Next free position for the course (positions are 0-based, per course, not + # required to be unique). Drives append-at-end for both manual add and import. + def self.next_position(course) + (for_course(course).maximum(:position) || -1) + 1 + end + + # Rewrites positions to match ordered_ids (the canonical gradebook order). The + # id set must match the course's externals exactly, else the order would be + # corrupted by a stale/partial payload. + def self.reorder!(course:, ordered_ids:) + scope = for_course(course) + raise ArgumentError, 'ordered_ids must match the course externals' unless + ordered_ids.map(&:to_i).sort == scope.pluck(:id).sort + + transaction do + ordered_ids.each_with_index do |id, index| + scope.where(id: id).update_all(position: index) + end + end + end + # The negative serialized id used by the synthetic tab AND the leaf assessment. def synthetic_tab_id -id @@ -44,4 +70,11 @@ def self.create_for_course!(course:, title:, maximum_grade:, weight: 0, external end end + + + private + + def assign_default_position + self.position ||= self.class.next_position(course) + end end diff --git a/app/services/course/gradebook/external_assessment_import_service.rb b/app/services/course/gradebook/external_assessment_import_service.rb index 65128f89df9..95089ee8f12 100644 --- a/app/services/course/gradebook/external_assessment_import_service.rb +++ b/app/services/course/gradebook/external_assessment_import_service.rb @@ -76,12 +76,16 @@ def guard_no_duplicate_components! end def guard_header!(headers) - expected = ['Identifier'] + @components.map { |c| c[:name] } + expected = [identifier_header] + @components.map { |c| c[:name] } return if headers == expected raise ImportError, { message: 'bad_header', expected: expected, got: headers } end + def identifier_header + (@identifier_mode == 'email') ? 'Email' : 'External ID' + end + def roster_lookup @roster_lookup ||= if @identifier_mode == 'email' @@ -100,7 +104,7 @@ def resolve(table) unresolved = [] malformed = [] table.each_with_index do |row, idx| - identifier = row['Identifier'].to_s.strip + identifier = row[identifier_header].to_s.strip course_user = roster_lookup[lookup_key(identifier)] if course_user.nil? unresolved << identifier diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts index 85b6a84a2c7..7ee76d814b7 100644 --- a/client/app/api/course/Gradebook.ts +++ b/client/app/api/course/Gradebook.ts @@ -59,6 +59,13 @@ export default class GradebookAPI extends BaseCourseAPI { return this.client.delete(`${this.#urlPrefix}/external_assessments/${id}`); } + reorderExternals(payload: { orderedIds: number[] }): APIResponse { + return this.client.put( + `${this.#urlPrefix}/external_assessments/reorder`, + payload, + ); + } + setExternalGrade( id: number, payload: { studentId: number; grade: number | null }, diff --git a/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx new file mode 100644 index 00000000000..cd8fd577fed --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx @@ -0,0 +1,195 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import AddExternalColumnPrompt from '../components/AddExternalColumnPrompt'; +import { createExternalAssessment } from '../operations'; + +jest.mock('../operations', () => ({ + createExternalAssessment: jest.fn( + () => (): Promise => Promise.resolve(), + ), +})); + +afterEach(() => { + jest.clearAllMocks(); +}); + +it('submits with both bound flags on by default', async () => { + render(); + // Wait for the Dialog to render (i18n loading) + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + true, + true, + undefined, + ), + ); +}); + +it('submits floorAtZero false when the floor toggle is switched off', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Floor grades at 0' })); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + false, + true, + undefined, + ), + ); +}); + +it('submits capAtMaximum false when the cap toggle is switched off', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + true, + false, + undefined, + ), + ); +}); + +it('disables Create when the name is blank', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); +}); + +it('disables Create when max marks is blank', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); +}); + +it('disables Create when max marks is negative', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '-5' }, + }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); +}); + +it('closes the dialog after a successful create', async () => { + const onClose = jest.fn(); + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); +}); + +it('keeps the dialog open when create fails', async () => { + (createExternalAssessment as jest.Mock).mockReturnValueOnce(() => + Promise.reject(new Error('boom')), + ); + const onClose = jest.fn(); + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => expect(createExternalAssessment).toHaveBeenCalled()); + expect(onClose).not.toHaveBeenCalled(); +}); + +it('explains the floor and cap toggles, stressing the grade is unchanged', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + expect( + screen.getByLabelText( + /Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + /Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); +}); + +it('passes the typed weightage when weighted view is on', async () => { + render( + , + ); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.change(screen.getByLabelText('Weightage'), { + target: { value: '20' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + true, + true, + 20, + ), + ); +}); + +it('hides the weightage field when weighted view is off', async () => { + render( + , + ); + await waitFor(() => screen.getByLabelText('Name')); + expect(screen.queryByLabelText('Weightage')).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/DeleteExternalColumnPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/DeleteExternalColumnPrompt.test.tsx new file mode 100644 index 00000000000..68cd9858899 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/DeleteExternalColumnPrompt.test.tsx @@ -0,0 +1,101 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import DeleteExternalColumnPrompt from '../components/DeleteExternalColumnPrompt'; +import { deleteExternalAssessment } from '../operations'; + +jest.mock('../operations', () => ({ + __esModule: true, + ...jest.requireActual('../operations'), + deleteExternalAssessment: jest.fn(), +})); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + +const mockDelete = deleteExternalAssessment as jest.Mock; +const mockToast = toast as jest.Mocked; + +const baseProps = { + open: true, + assessmentId: -1, + title: 'Quiz 2', + onClose: jest.fn(), +}; + +afterEach(() => jest.clearAllMocks()); + +it('renders nothing when closed', () => { + render(); + + expect( + screen.queryByRole('button', { name: 'Delete' }), + ).not.toBeInTheDocument(); +}); + +it('shows a loading spinner on the delete button while the deletion is in flight', async () => { + let resolveDelete: () => void = () => {}; + mockDelete.mockReturnValue( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); + + // While the request is pending, the delete button shows its loading state. + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + + resolveDelete(); + await waitFor(() => expect(baseProps.onClose).toHaveBeenCalled()); +}); + +it('dispatches the delete operation with the assessment id on confirm', async () => { + mockDelete.mockReturnValue(() => Promise.resolve()); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); + + await waitFor(() => + expect(mockDelete).toHaveBeenCalledWith(baseProps.assessmentId), + ); + await waitFor(() => expect(baseProps.onClose).toHaveBeenCalled()); +}); + +it('toasts an error and keeps the dialog open when the delete fails', async () => { + mockDelete.mockReturnValue(() => Promise.reject(new Error('boom'))); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalled()); + expect(baseProps.onClose).not.toHaveBeenCalled(); + // finally{} resets saving → confirm button leaves its loading state. + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), + ); +}); + +it('closes without deleting when Cancel is clicked', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + + expect(baseProps.onClose).toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); +}); + +it('names the assessment being deleted in the confirmation body', async () => { + render(); + + expect(await screen.findByText(/Delete "Quiz 2"\?/)).toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx index 131e1facaf2..1b76a774c0e 100644 --- a/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx @@ -1,28 +1,307 @@ +import userEvent from '@testing-library/user-event'; import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + import EditExternalAssessmentPrompt from '../components/manage/EditExternalAssessmentPrompt'; import { editExternalAssessment } from '../operations'; jest.mock('../operations', () => ({ - editExternalAssessment: jest.fn(() => () => Promise.resolve()), + editExternalAssessment: jest.fn(() => (): Promise => Promise.resolve()), })); +jest.mock('lib/hooks/toast', () => ({ error: jest.fn() })); const assessment = { - id: -3, title: 'Quiz', tabId: -3, maxGrade: 20, external: true, - floorAtZero: true, capAtMaximum: true, + id: -3, + title: 'Quiz', + tabId: -3, + maxGrade: 20, + external: true, + floorAtZero: true, + capAtMaximum: true, }; -it( - 'saves edited name, max and bound flags', - async () => { - render(); - fireEvent.change(await screen.findByLabelText('Name'), { target: { value: 'Quiz 1' } }); - fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - await waitFor(() => - expect(editExternalAssessment).toHaveBeenCalledWith(-3, { - title: 'Quiz 1', maximumGrade: 20, floorAtZero: true, capAtMaximum: false, - }), - ); - }, - 10000, -); +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('saves the edited name and a toggled cap flag', async () => { + render( + , + ); + const name = await screen.findByLabelText('Name'); + await userEvent.clear(name); + await userEvent.type(name, 'Quiz 1'); + fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz 1', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: false, + }), + ); +}, 10000); + +it('trims surrounding whitespace from the saved name', async () => { + render( + , + ); + const name = await screen.findByLabelText('Name'); + await userEvent.clear(name); + await userEvent.type(name, ' Quiz 2 '); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz 2', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: true, + }), + ); +}); + +it('saves a toggled floor flag', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('checkbox', { name: 'Floor grades at 0' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 20, + floorAtZero: false, + capAtMaximum: true, + }), + ); +}); + +it('saves an edited max marks value', async () => { + render( + , + ); + fireEvent.change(await screen.findByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 50, + floorAtZero: true, + capAtMaximum: true, + }), + ); +}); + +it('defaults floor and cap switches to checked when the assessment omits them', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + expect( + screen.getByRole('checkbox', { name: 'Floor grades at 0' }), + ).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: 'Cap grades at max' }), + ).toBeChecked(); +}); + +it('explains the floor and cap toggles, explaining the grade is unchanged', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + expect( + screen.getByLabelText( + /Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + /Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); +}); + +it('shows a weightage field seeded from the current weight and includes it when saving (weighted view on)', async () => { + render( + , + ); + const weight = await screen.findByLabelText('Weightage'); + expect(weight).toHaveValue(30); + fireEvent.change(weight, { target: { value: '45' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: true, + weight: 45, + }), + ); +}); + +it('defaults weightage to 0 when no currentWeight is provided (weighted view on)', async () => { + render( + , + ); + const weight = await screen.findByLabelText('Weightage'); + expect(weight).toHaveValue(0); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: true, + weight: 0, + }), + ); +}); + +it('omits the weightage field when weighted view is off', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + expect(screen.queryByLabelText('Weightage')).not.toBeInTheDocument(); +}); + +it('disables Save when the name is blank', async () => { + render( + , + ); + const name = await screen.findByLabelText('Name'); + await userEvent.clear(name); + await userEvent.type(name, ' '); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +it('disables Save when max marks is blank', async () => { + render( + , + ); + fireEvent.change(await screen.findByLabelText('Max marks'), { + target: { value: '' }, + }); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +it('disables Save when max marks is negative', async () => { + render( + , + ); + fireEvent.change(await screen.findByLabelText('Max marks'), { + target: { value: '-5' }, + }); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +it('calls onClose when Cancel is clicked', async () => { + const onClose = jest.fn(); + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); +}); + +it('closes the dialog after a successful save', async () => { + const onClose = jest.fn(); + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); +}); + +it('shows an error toast and keeps the dialog open when saving fails', async () => { + (editExternalAssessment as jest.Mock).mockImplementationOnce( + () => (): Promise => Promise.reject(new Error('boom')), + ); + const onClose = jest.fn(); + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(onClose).not.toHaveBeenCalled(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index c9f1d80b490..3baf0b609fd 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -223,13 +223,17 @@ describe('GradebookIndex', () => { expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); }); - it('renders the import external assessments button when manager and students exist', async () => { + it('shows the manage button and not the old import/add buttons', async () => { render(, { state: populatedStateManagerWeightedOff }); expect( - await screen.findByRole('button', { - name: /import external assessments/i, - }), - ).toBeInTheDocument(); + await screen.findByRole('button', { name: 'Manage external assessments' }), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Import external assessments' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add external assessment' }), + ).not.toBeInTheDocument(); }); describe('weighted-view discoverability hint', () => { diff --git a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx index dee3b631260..dd96bf41a91 100644 --- a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx @@ -1,5 +1,4 @@ import userEvent from '@testing-library/user-event'; -import { store as appStore } from 'store'; import { render, screen, waitFor } from 'test-utils'; import CourseAPI from 'api/course'; @@ -9,18 +8,35 @@ import ImportExternalAssessmentsWizard from '../components/import/ImportExternal jest.mock('api/course'); jest.mock('lib/components/wrappers/I18nProvider'); +const defaultProps = { + existingAssessments: [], + onClose: jest.fn(), + weightedViewEnabled: true, +}; + const renderWizard = (): void => { render( , - { store: appStore }, ); }; +const studentsState = (students: object[]): object => ({ + gradebook: { + categories: [], tabs: [], submissions: [], assessments: [], + gamificationEnabled: false, weightedViewEnabled: false, canManageWeights: true, + students, + }, +}); + +const fillOneComponent = async (): Promise => { + await userEvent.type(screen.getByLabelText('Component name'), 'Midterm'); +}; + const file = (text: string): File => new File([text], 'marks.csv', { type: 'text/csv' }); @@ -114,12 +130,11 @@ describe('ImportExternalAssessmentsWizard', () => { it('hides weightage field when weightedViewEnabled is false', async () => { render( , - { store: appStore }, ); await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); expect(screen.queryByLabelText(/weightage/i)).not.toBeInTheDocument(); @@ -131,6 +146,19 @@ describe('ImportExternalAssessmentsWizard', () => { expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); }); + it('labels the identifier toggle "External ID"', () => { + render( + , + ); + expect(screen.getByRole('radio', { name: 'External ID' })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: 'Student ID' })).not.toBeInTheDocument(); + }); + it('commits with keep when Keep Existing is clicked on the conflict prompt', async () => { (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ data: { @@ -188,6 +216,53 @@ describe('ImportExternalAssessmentsWizard', () => { ); }); + it('blocks Next in External ID mode while a student has no External ID', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice Lim', email: 'a@x.com', externalId: null, level: 0, totalXp: 0 }, + { id: 2, name: 'Bob Tan', email: 'b@x.com', externalId: 'E2', level: 0, totalXp: 0 }, + ]), + }); + await fillOneComponent(); + expect(screen.getByText(/Alice Lim has no External ID/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + }); + + it('enables Next once matching by Email instead', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice Lim', email: 'a@x.com', externalId: null, level: 0, totalXp: 0 }, + ]), + }); + await fillOneComponent(); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); + }); + + it('lists the exact required headers on the upload step', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice', email: 'a@x.com', externalId: 'E1', level: 0, totalXp: 0 }, + ]), + }); + await userEvent.type(screen.getByLabelText('Component name'), 'Midterm'); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + expect( + screen.getByText(/Your CSV needs these column headers: External ID, Midterm/), + ).toBeInTheDocument(); + }); + + it('summarises the count when several students lack an External ID', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice Lim', email: 'a@x.com', externalId: null, level: 0, totalXp: 0 }, + { id: 2, name: 'Bob Tan', email: 'b@x.com', externalId: '', level: 0, totalXp: 0 }, + ]), + }); + await fillOneComponent(); + expect(screen.getByText(/2 students have no External ID, including Alice Lim/)).toBeInTheDocument(); + }); + it('does not show Confirm import button when preview has errors', async () => { (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ data: { @@ -249,12 +324,11 @@ describe('ImportExternalAssessmentsWizard', () => { }); render( , - { store: appStore }, ); // Step 1: 'Midterm' matches existing → max/weightage locked; just Next. await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); @@ -278,4 +352,69 @@ describe('ImportExternalAssessmentsWizard', () => { ).toBe('replace'), ); }); + + it('renders existing external chips in the define step', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Midterm' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Finals' })).toBeInTheDocument(); + }); + + it('clicking an existing chip inserts a locked row pre-filled with correct max and weight', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Midterm' })); + + // The chip-inserted row's name field is read-only (disabled input) + const nameInput = screen.getByDisplayValue('Midterm'); + expect(nameInput).toBeDisabled(); + + // Max and weight are pre-filled with the existing values + expect(screen.getByDisplayValue('50')).toBeDisabled(); + expect(screen.getByDisplayValue('30')).toBeDisabled(); + + // "Updates existing" label is shown + expect(screen.getByText(/updates existing/i)).toBeInTheDocument(); + }); + + it('hides a chip once the corresponding external has been added to the component list', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Midterm' })); + // After clicking, chip disappears (already in the list) + expect(screen.queryByRole('button', { name: 'Midterm' })).not.toBeInTheDocument(); + }); + + it('does not render the From existing section when there are no existing externals', () => { + render( + , + ); + expect(screen.queryByText(/from existing/i)).not.toBeInTheDocument(); + }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx new file mode 100644 index 00000000000..fc1b01e7752 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx @@ -0,0 +1,20 @@ +import { fireEvent, render, screen } from 'test-utils'; +import ManageExternalAssessmentsButton from '../components/manage/ManageExternalAssessmentsButton'; + +const state = { + gradebook: { + categories: [], tabs: [], students: [], submissions: [], assessments: [], + gamificationEnabled: false, weightedViewEnabled: false, canManageWeights: true, + }, +}; + +it('opens the panel on click', async () => { + render(, { state }); + fireEvent.click(await screen.findByRole('button', { name: 'Manage external assessments' })); + expect(await screen.findByText('External assessments')).toBeVisible(); +}); + +it('does not open the external assessments panel by default', () => { + render(, { state }); + expect(screen.queryByText('External assessments')).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx new file mode 100644 index 00000000000..73639d95921 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx @@ -0,0 +1,452 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from 'test-utils'; + +import ManageExternalAssessmentsPanel from '../components/manage/ManageExternalAssessmentsPanel'; +import type { DropResult } from '@hello-pangea/dnd'; +import type { AppDispatch } from 'store'; +import { + moveItem, + handleDragEnd, +} from '../components/manage/ManageExternalAssessmentsPanel'; +import { reorderExternalAssessments } from '../operations'; + +const dropResult = (from: number, to: number | null): DropResult => + ({ + draggableId: 'x', + type: 'DEFAULT', + mode: 'FLUID', + reason: 'DROP', + combine: null, + source: { index: from, droppableId: 'external-assessments' }, + destination: + to === null ? null : { index: to, droppableId: 'external-assessments' }, + }) as DropResult; + +jest.mock('../components/import/ImportExternalAssessmentsWizard', () => ({ + __esModule: true, + default: ({ + existingAssessments, + onClose, + open, + }: { + existingAssessments: { name: string }[]; + onClose: () => void; + open: boolean; + }): JSX.Element | null => + open ? ( +
    +

    Import external assessments

    + {existingAssessments.map((assessment) => ( + + ))} + + +
    + ) : null, +})); + +jest.mock('../operations', () => ({ + __esModule: true, + ...jest.requireActual('../operations'), + reorderExternalAssessments: jest.fn(() => () => Promise.resolve()), +})); + +const preloadedState = { + gradebook: { + categories: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + tabs: [ + { id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }, + { id: -2, title: 'Final', categoryId: -1, gradebookWeight: 50 }, + ], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + { + id: -2, + title: 'Final', + tabId: -2, + maxGrade: 100, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + { id: 7, title: 'Native quiz', tabId: 3, maxGrade: 10 }, + ], + }, +}; + +const externalWith = (overrides: { + floorAtZero: boolean; + capAtMaximum: boolean; +}): typeof preloadedState => ({ + gradebook: { + ...preloadedState.gradebook, + tabs: [{ id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + ...overrides, + }, + ], + }, +}); + +describe('handleDragEnd', () => { + beforeEach(() => { + (reorderExternalAssessments as jest.Mock).mockClear(); + }); + + it('does nothing when the row is dropped outside the list', () => { + const dispatch = jest.fn() as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(0, null), dispatch, jest.fn()); + expect(reorderExternalAssessments).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('does nothing when the row is dropped back in its original position', () => { + const dispatch = jest.fn() as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(1, 1), dispatch, jest.fn()); + expect(reorderExternalAssessments).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches the reordered ids on a valid move', () => { + const dispatch = jest.fn(() => Promise.resolve()) as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(2, 0), dispatch, jest.fn()); + expect(reorderExternalAssessments).toHaveBeenCalledWith([-3, -1, -2]); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('invokes the error callback when the reorder dispatch rejects', async () => { + const onError = jest.fn(); + const dispatch = jest.fn(() => + Promise.reject(new Error('nope')), + ) as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(0, 2), dispatch, onError); + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + }); +}); + +it('shows an empty state when there are no externals', async () => { + render(, { + state: { gradebook: { ...preloadedState.gradebook, assessments: [] } }, + }); + expect(await screen.findByText('No external assessments yet')).toBeVisible(); + expect( + screen.getByText( + 'Add one manually, or import a CSV of grades earned outside Coursemology.', + ), + ).toBeVisible(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); +}); + +it('lists only external assessments', async () => { + render(, { + state: preloadedState, + }); + expect(await screen.findByText('Name')).toBeVisible(); + expect(await screen.findByText('Midterm')).toBeVisible(); + expect(screen.queryByText('Native quiz')).not.toBeInTheDocument(); +}); + +it('opens the add dialog', async () => { + render(, { + state: preloadedState, + }); + await userEvent.click(await screen.findByRole('button', { name: 'Add' })); + await waitFor(() => + expect(screen.getByText('Add external assessment')).toBeVisible(), + ); +}); + +it('opens the edit prompt for a row', async () => { + render(, { + state: preloadedState, + }); + await userEvent.click( + await screen.findByRole('button', { name: 'edit Midterm' }), + ); + await waitFor(() => + expect(screen.getByText('Edit external assessment')).toBeVisible(), + ); +}); + +it('opens the delete prompt for a row', async () => { + render(, { + state: preloadedState, + }); + await userEvent.click( + await screen.findByRole('button', { name: 'delete Midterm' }), + ); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); +}); + +it('invokes onClose when the close button is clicked', async () => { + const onClose = jest.fn(); + render(, { + state: preloadedState, + }); + await userEvent.click(await screen.findByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(1); +}); + +it('opens the edit dialog for a row', async () => { + render(, { + state: preloadedState, + }); + + await userEvent.click(await screen.findByLabelText('edit Midterm')); + expect(await screen.findByText('Edit external assessment')).toBeVisible(); +}); + +it('opens the delete confirmation for a row', async () => { + render(, { + state: preloadedState, + }); + + await userEvent.click(await screen.findByLabelText('delete Midterm')); + expect(await screen.findByText('Delete external assessment')).toBeVisible(); + // the confirmation body names the assessment being deleted + expect( + screen.getByText( + /Delete "Midterm"\? This permanently removes the column and every student grade in it\. This cannot be undone\./, + ), + ).toBeVisible(); +}); + +it('hides weights when weighted view is disabled', async () => { + render(, { + state: preloadedState, + }); + + await screen.findByText('Midterm'); + expect(screen.queryByText('Weight')).not.toBeInTheDocument(); + expect(screen.queryByText('25')).not.toBeInTheDocument(); +}); + +it('shows external assessment weights when weighted view is enabled', async () => { + render(, { + state: { + gradebook: { + ...preloadedState.gradebook, + weightedViewEnabled: true, + }, + }, + }); + + expect(await screen.findByText('Weight')).toBeVisible(); + expect(screen.getByText('25')).toBeVisible(); +}); + +it('renders bounds chips per the floor/cap flags', async () => { + const stateWithMixedBounds = { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: false, + }, + ], + }, + }; + render(, { + state: stateWithMixedBounds, + }); + + expect(await screen.findByText('≥ 0')).toBeVisible(); + expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); +}); + +it('shows both bounds chips by default when flags are absent', async () => { + const stateWithDefaultBounds = { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { id: -1, title: 'Midterm', tabId: -1, maxGrade: 50, external: true }, + ], + }, + }; + render(, { + state: stateWithDefaultBounds, + }); + + expect(await screen.findByText('≥ 0')).toBeVisible(); + expect(screen.getByText('≤ max')).toBeVisible(); +}); + +it('shows the ≥ 0 and ≤ max chips for a floored and capped external', async () => { + render(, { + state: externalWith({ floorAtZero: true, capAtMaximum: true }), + }); + + expect(await screen.findByText('≥ 0')).toBeVisible(); + expect(screen.getByText('≤ max')).toBeVisible(); + expect(screen.getByText('50')).toBeVisible(); +}); + +it('hides both bound chips when an external is neither floored nor capped', async () => { + render(, { + state: externalWith({ floorAtZero: false, capAtMaximum: false }), + }); + + await screen.findByText('Midterm'); + expect(screen.queryByText('≥ 0')).not.toBeInTheDocument(); + expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); +}); + +it('passes existing external names as chips to the import wizard', async () => { + const stateWithExternal = { + gradebook: { + categories: [], + tabs: [{ id: 1, title: 'External', categoryId: 0, gradebookWeight: 30 }], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: 1, + maxGrade: 50, + gradebookWeight: 30, + external: true, + }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }; + render(, { + state: stateWithExternal, + }); + + // Open the import wizard + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + + // The "Midterm" chip must appear in the define step + expect( + within(await screen.findByTestId('import-wizard')).getByText('Midterm'), + ).toBeInTheDocument(); +}); + +it('hides the external assessments panel while importing and reopens it on cancel', async () => { + render(, { + state: preloadedState, + }); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); + + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + + expect(await screen.findByText('Import external assessments')).toBeVisible(); + await waitFor(() => + expect(screen.queryByText('External assessments')).not.toBeInTheDocument(), + ); + + await userEvent.click( + within(screen.getByTestId('import-wizard')).getByText('Cancel'), + ); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); +}); + +it('reopens the external assessments panel after a successful import', async () => { + render(, { + state: preloadedState, + }); + + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + expect(await screen.findByText('Import external assessments')).toBeVisible(); + await userEvent.click( + within(screen.getByTestId('import-wizard')).getByText('Confirm import'), + ); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); +}); + +it('renders a drag handle per external assessment', async () => { + const page = render( + , + { + state: preloadedState, + }, + ); + expect(await page.findByLabelText('reorder Midterm')).toBeVisible(); + expect(await page.findByLabelText('reorder Final')).toBeVisible(); +}); + +it('hides the drag handle when there is only one external assessment', async () => { + const page = render( + , + { + state: { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + ], + }, + }, + }, + ); + // The row still renders, with its grade in place... + expect(await page.findByText('Midterm')).toBeVisible(); + expect(page.getByText('50')).toBeVisible(); + // ...but the lone row has no reorder handle, so it cannot be dragged. + expect(page.queryByLabelText('reorder Midterm')).not.toBeInTheDocument(); +}); + +describe('moveItem', () => { + it('moves an item from one index to another, preserving the rest', () => { + expect(moveItem([-1, -2, -3], 2, 0)).toEqual([-3, -1, -2]); + expect(moveItem([-1, -2, -3], 0, 2)).toEqual([-2, -3, -1]); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts index a0283fdfb98..c05c7d0796f 100644 --- a/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts @@ -1,45 +1,70 @@ -import { buildTemplateCsv } from '../components/import/buildTemplate'; +import { buildTemplateCsv, identifierHeader } from '../components/import/buildTemplate'; + +describe('identifierHeader', () => { + it('maps mode to the concrete header', () => { + expect(identifierHeader('student_id')).toBe('External ID'); + expect(identifierHeader('email')).toBe('Email'); + }); +}); describe('buildTemplateCsv', () => { - it('builds a header of Identifier + component names in order', () => { - const csv = buildTemplateCsv([ - { name: 'Midterm', weightage: 30, maximumGrade: 50 }, - { name: 'Final', weightage: 50, maximumGrade: 100 }, - ]); - expect(csv.split('\n')[0]).toBe('Identifier,Midterm,Final'); + const components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }]; + + it('uses the External ID header in student_id mode', () => { + expect(buildTemplateCsv(components, 'student_id')).toBe('External ID,Midterm\n'); + }); + + it('uses the Email header in email mode', () => { + expect(buildTemplateCsv(components, 'email')).toBe('Email,Midterm\n'); }); it('quotes a component name containing a comma', () => { const csv = buildTemplateCsv([ { name: 'Lab, week 1', weightage: 10, maximumGrade: 20 }, - ]); - expect(csv.split('\n')[0]).toBe('Identifier,"Lab, week 1"'); + ], 'student_id'); + expect(csv.split('\n')[0]).toBe('External ID,"Lab, week 1"'); }); - it('returns "Identifier\\n" for empty components array', () => { - expect(buildTemplateCsv([])).toBe('Identifier\n'); + it('returns "External ID\\n" for empty components array in student_id mode', () => { + expect(buildTemplateCsv([], 'student_id')).toBe('External ID\n'); }); it('quotes a component name containing a double-quote', () => { const csv = buildTemplateCsv([ { name: 'My "Best" Quiz', weightage: 10, maximumGrade: 20 }, - ]); - expect(csv.split('\n')[0]).toBe('Identifier,"My ""Best"" Quiz"'); + ], 'student_id'); + expect(csv.split('\n')[0]).toBe('External ID,"My ""Best"" Quiz"'); }); it('quotes a component name containing a newline', () => { const csv = buildTemplateCsv([ { name: 'Lab\nWeek1', weightage: 10, maximumGrade: 20 }, - ]); + ], 'student_id'); // The quoted cell spans two lines; verify the full header row content. - expect(csv.startsWith('Identifier,"Lab\nWeek1"')).toBe(true); + expect(csv.startsWith('External ID,"Lab\nWeek1"')).toBe(true); }); it('always ends with exactly one newline', () => { const csv = buildTemplateCsv([ { name: 'A', weightage: 0, maximumGrade: 100 }, - ]); + ], 'student_id'); expect(csv.endsWith('\n')).toBe(true); expect(csv.split('\n')).toHaveLength(2); // header line + empty string after trailing \n }); + + it('emits one column per component, in input order', () => { + const csv = buildTemplateCsv( + [ + { name: 'Midterm', weightage: 30, maximumGrade: 50 }, + { name: 'Final', weightage: 50, maximumGrade: 100 }, + { name: 'Lab', weightage: 20, maximumGrade: 20 }, + ], + 'external_id', + ); + expect(csv).toBe('External ID,Midterm,Final,Lab\n'); + }); + + it('returns "Email\\n" for empty components array in email mode', () => { + expect(buildTemplateCsv([], 'email')).toBe('Email\n'); + }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/operations.test.ts b/client/app/bundles/course/gradebook/__tests__/operations.test.ts new file mode 100644 index 00000000000..4c8cb32bbb0 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/operations.test.ts @@ -0,0 +1,614 @@ +import type { AppDispatch } from 'store'; + +import CourseAPI from 'api/course'; + +import fetchGradebook, { + commitImport, + createExternalAssessment, + deleteExternalAssessment, + editExternalAssessment, + previewImport, + renameExternalAssessment, + reorderExternalAssessments, + setExternalGrade, + updateGradebookWeights, +} from '../operations'; + +const REORDER = 'course/gradebook/REORDER_EXTERNAL_ASSESSMENTS'; + +const externals = [ + { id: -1, title: 'A', tabId: -1, maxGrade: 10, external: true }, + { id: -2, title: 'B', tabId: -2, maxGrade: 10, external: true }, +]; + +// Minimal thunk harness: record plain actions, stub getState. +const harness = (): { + dispatched: { type: string; payload: unknown }[]; + dispatch: AppDispatch; + getState: () => never; +} => { + const dispatched: { type: string; payload: unknown }[] = []; + return { + dispatched, + // The operation only dispatches plain { type, payload } actions, which we + // record; cast to AppDispatch so it satisfies the thunk's dispatch param. + dispatch: ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch, + getState: () => ({ gradebook: { assessments: externals } }) as never, + }; +}; + +describe('reorderExternalAssessments', () => { + afterEach(() => jest.restoreAllMocks()); + + it('applies optimistically, then reverts and rethrows when the PUT fails', async () => { + const { dispatched, dispatch, getState } = harness(); + jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockRejectedValue(new Error('network')); + + await expect( + reorderExternalAssessments([-2, -1])(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const reorders = dispatched.filter((a) => a.type === REORDER); + expect(reorders[0].payload).toEqual([-2, -1]); // optimistic apply + expect(reorders[reorders.length - 1].payload).toEqual([-1, -2]); // rolled back to original + }); + + it('does not roll back on success', async () => { + const { dispatched, dispatch, getState } = harness(); + jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockResolvedValue({ data: undefined } as never); + + await reorderExternalAssessments([-2, -1])(dispatch, getState, {}); + + const reorders = dispatched.filter((a) => a.type === REORDER); + expect(reorders).toHaveLength(1); + expect(reorders[0].payload).toEqual([-2, -1]); + }); + + it('calls the API with positive external ids (negated store ids)', async () => { + const { dispatch, getState } = harness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockResolvedValue({ data: undefined } as never); + + await reorderExternalAssessments([-2, -1])(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ orderedIds: [2, 1] }); + }); + + it('rolls back only external assessments in their stored order', async () => { + const dispatched: { type: string; payload: unknown }[] = []; + const dispatch = ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch; + const getState = (() => ({ + gradebook: { + assessments: [ + { id: 5, external: false }, + ...externals, // ids -1, -2 + ], + }, + })) as unknown as () => never; + jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockRejectedValue(new Error('network')); + + await expect( + reorderExternalAssessments([-2, -1])(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const reorders = dispatched.filter((a) => a.type === REORDER); + expect(reorders[reorders.length - 1].payload).toEqual([-1, -2]); + }); +}); + +describe('setExternalGrade', () => { + afterEach(() => jest.restoreAllMocks()); + + const gradeHarness = ( + submissions: { + studentId: number; + assessmentId: number; + grade: number | null; + }[], + ): { + dispatched: { type: string; payload: unknown }[]; + dispatch: AppDispatch; + getState: () => never; + } => { + const dispatched: { type: string; payload: unknown }[] = []; + return { + dispatched, + dispatch: ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch, + getState: () => ({ gradebook: { submissions } }) as never, + }; + }; + + const SET = 'course/gradebook/SET_EXTERNAL_GRADE'; + + it('applies optimistically, calls the API with the negated id, then reconciles', async () => { + const { dispatched, dispatch, getState } = gradeHarness([ + { studentId: 7, assessmentId: -1, grade: 3 }, + ]); + const spy = jest + .spyOn(CourseAPI.gradebook, 'setExternalGrade') + .mockResolvedValue({ + data: { studentId: 7, assessmentId: -1, grade: 9 }, + } as never); + + await setExternalGrade(-1, 7, 9)(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith(1, { studentId: 7, grade: 9 }); + const sets = dispatched.filter((a) => a.type === SET); + expect(sets[0].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 9, + }); // optimistic + expect(sets[sets.length - 1].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 9, + }); // reconciled from response.data + }); + + it('restores the prior grade and rethrows when the PUT fails', async () => { + const { dispatched, dispatch, getState } = gradeHarness([ + { studentId: 7, assessmentId: -1, grade: 3 }, + ]); + jest + .spyOn(CourseAPI.gradebook, 'setExternalGrade') + .mockRejectedValue(new Error('network')); + + await expect( + setExternalGrade(-1, 7, 9)(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const sets = dispatched.filter((a) => a.type === SET); + expect(sets[0].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 9, + }); // optimistic + expect(sets[sets.length - 1].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 3, + }); // rolled back to prev + }); + + it('rolls back to null when no prior submission exists', async () => { + const { dispatched, dispatch, getState } = gradeHarness([]); + jest + .spyOn(CourseAPI.gradebook, 'setExternalGrade') + .mockRejectedValue(new Error('network')); + + await expect( + setExternalGrade(-1, 7, 9)(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const sets = dispatched.filter((a) => a.type === SET); + expect(sets[sets.length - 1].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: null, + }); + }); +}); + +describe('createExternalAssessment', () => { + afterEach(() => jest.restoreAllMocks()); + + const createHarness = (): { + dispatch: AppDispatch; + getState: () => never; + } => ({ + dispatch: ((a: unknown) => a) as unknown as AppDispatch, + getState: (() => ({})) as unknown as () => never, + }); + + it('omits weight from the payload when undefined', async () => { + const { dispatch, getState } = createHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue({ data: {} } as never); + + await createExternalAssessment( + 'A', + 10, + true, + false, + )(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ + title: 'A', + maximumGrade: 10, + floorAtZero: true, + capAtMaximum: false, + }); + }); + + it('includes weight in the payload when provided', async () => { + const { dispatch, getState } = createHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue({ data: {} } as never); + + await createExternalAssessment( + 'A', + 10, + true, + false, + 2, + )(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ + title: 'A', + maximumGrade: 10, + floorAtZero: true, + capAtMaximum: false, + weight: 2, + }); + }); +}); + +describe('id-negating thunks', () => { + afterEach(() => jest.restoreAllMocks()); + + const noopHarness = (): { dispatch: AppDispatch; getState: () => never } => ({ + dispatch: ((a: unknown) => a) as unknown as AppDispatch, + getState: (() => ({})) as unknown as () => never, + }); + + it('renameExternalAssessment negates the id for the API call', async () => { + const { dispatch, getState } = noopHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateExternal') + .mockResolvedValue({ data: {} } as never); + + await renameExternalAssessment(-3, 'New')(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith(3, { title: 'New' }); + }); + + it('editExternalAssessment negates the id and forwards the patch', async () => { + const { dispatch, getState } = noopHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateExternal') + .mockResolvedValue({ data: {} } as never); + + await editExternalAssessment(-3, { maximumGrade: 20 })( + dispatch, + getState, + {}, + ); + + expect(spy).toHaveBeenCalledWith(3, { maximumGrade: 20 }); + }); + + it('deleteExternalAssessment negates the id for the API but dispatches the original id', async () => { + const dispatched: { type: string; payload: unknown }[] = []; + const dispatch = ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch; + const spy = jest + .spyOn(CourseAPI.gradebook, 'deleteExternal') + .mockResolvedValue({ data: undefined } as never); + + await deleteExternalAssessment(-3)(dispatch, (() => ({})) as never, {}); + + expect(spy).toHaveBeenCalledWith(3); + expect(dispatched).toContainEqual({ + type: 'course/gradebook/DELETE_EXTERNAL_ASSESSMENT', + payload: -3, + }); + }); +}); + +describe('commitImport', () => { + afterEach(() => jest.restoreAllMocks()); + + it('commits, refreshes the gradebook, and returns the commit summary', async () => { + const dispatched: { type: string; payload: unknown }[] = []; + const dispatch = ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch; + jest + .spyOn(CourseAPI.gradebook, 'importCommit') + .mockResolvedValue({ data: { inserted: 2 } } as never); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: { refreshed: true } } as never); + + const summary = await commitImport({ + components: [], + onConflict: 'replace', + } as never)( + dispatch, + (() => ({})) as never, + {}, + ); + + expect(summary).toEqual({ inserted: 2 }); + expect(dispatched).toContainEqual({ + type: 'course/gradebook/SAVE_GRADEBOOK', + payload: { refreshed: true }, + }); + }); +}); + +describe('simple pass-through thunks', () => { + afterEach(() => jest.restoreAllMocks()); + + const passHarness = (): { + dispatched: { type: string; payload: unknown }[]; + dispatch: AppDispatch; + getState: () => never; + } => { + const dispatched: { type: string; payload: unknown }[] = []; + return { + dispatched, + dispatch: ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch, + getState: (() => ({})) as unknown as () => never, + }; + }; + + it('fetchGradebook dispatches saveGradebook with the index response', async () => { + const { dispatched, dispatch, getState } = passHarness(); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: { loaded: true } } as never); + + await fetchGradebook()(dispatch, getState, {}); + + expect(dispatched).toContainEqual({ + type: 'course/gradebook/SAVE_GRADEBOOK', + payload: { loaded: true }, + }); + }); + + it('updateGradebookWeights wraps weights and dispatches updateTabWeights', async () => { + const { dispatched, dispatch, getState } = passHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + + await updateGradebookWeights([])(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ weights: [] }); + expect(dispatched).toContainEqual({ + type: 'course/gradebook/UPDATE_TAB_WEIGHTS', + payload: { weights: [] }, + }); + }); + + it('previewImport returns the API data without dispatching', async () => { + const { dispatched, dispatch, getState } = passHarness(); + jest + .spyOn(CourseAPI.gradebook, 'importPreview') + .mockResolvedValue({ data: { conflicts: [] } } as never); + + const result = await previewImport({} as never)(dispatch, getState, {}); + + expect(result).toEqual({ conflicts: [] }); + expect(dispatched).toHaveLength(0); + }); +}); + +// Thunk-aware harness: executes nested thunks (createExternalAssessment dispatches +// the materialize thunk, which dispatches updateGradebookWeights) and records plain +// actions. getState returns the supplied gradebook slice. +const thunkHarness = ( + gradebook: unknown, +): { dispatch: AppDispatch; getState: () => never } => { + const getState = (() => ({ gradebook })) as unknown as () => never; + const dispatch = ((a: unknown) => + typeof a === 'function' + ? (a as (d: unknown, g: unknown, e: unknown) => unknown)( + dispatch, + getState, + {}, + ) + : a) as unknown as AppDispatch; + return { dispatch, getState }; +}; + +const defaultGradebook = { + tabs: [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: 2, title: 'T2', categoryId: 1 }, + ], + assessments: [ + { id: 10, title: 'a', tabId: 1, maxGrade: 10 }, + { id: 11, title: 'b', tabId: 2, maxGrade: 10 }, + ], +}; + +const createdNode = { + data: { + assessment: { id: -1, title: 'X', tabId: -1, maxGrade: 10, external: true }, + tab: { id: -1, title: 'X', categoryId: -1, gradebookWeight: 20 }, + category: { id: -1, title: 'External Assessments' }, + }, +}; + +describe('createExternalAssessment — default-weight materialization', () => { + afterEach(() => jest.restoreAllMocks()); + + it('persists the equal split before creating when a non-zero weight is set', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const createExternal = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue(createdNode as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await createExternalAssessment('X', 10, true, true, 20)(dispatch, getState, {}); + + expect(updateWeights).toHaveBeenCalledWith({ + weights: [ + { tabId: 1, weight: 50, weightMode: 'equal' }, + { tabId: 2, weight: 50, weightMode: 'equal' }, + ], + }); + expect(updateWeights.mock.invocationCallOrder[0]).toBeLessThan( + createExternal.mock.invocationCallOrder[0], + ); + }); + + it('does not materialize when the new weight is zero', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue(createdNode as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await createExternalAssessment('X', 10, true, true, 0)(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + }); + + it('does not materialize when weights are already configured', async () => { + const configured = { + tabs: [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 70 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 30 }, + ], + assessments: defaultGradebook.assessments, + }; + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const createExternal = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue(createdNode as never); + const { dispatch, getState } = thunkHarness(configured); + + await createExternalAssessment('X', 10, true, true, 20)(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + expect(createExternal).toHaveBeenCalled(); + }); +}); + +describe('editExternalAssessment — default-weight materialization', () => { + afterEach(() => jest.restoreAllMocks()); + + it('persists the equal split before updating when a non-zero weight is set', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const updateExternal = jest + .spyOn(CourseAPI.gradebook, 'updateExternal') + .mockResolvedValue({ + data: { + assessment: { id: -1, title: 'X', tabId: -1, maxGrade: 10, external: true }, + tab: { id: -1, title: 'X', categoryId: -1, gradebookWeight: 40 }, + }, + } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await editExternalAssessment(-1, { weight: 40 })(dispatch, getState, {}); + + expect(updateWeights).toHaveBeenCalled(); + expect(updateWeights.mock.invocationCallOrder[0]).toBeLessThan( + updateExternal.mock.invocationCallOrder[0], + ); + }); + + it('does not materialize when the edit carries no weight', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + jest.spyOn(CourseAPI.gradebook, 'updateExternal').mockResolvedValue({ + data: { + assessment: { id: -1, title: 'X', tabId: -1, maxGrade: 10, external: true }, + tab: { id: -1, title: 'X', categoryId: -1 }, + }, + } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await editExternalAssessment(-1, { title: 'X' })(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + }); +}); + +describe('commitImport — default-weight materialization', () => { + afterEach(() => jest.restoreAllMocks()); + + const fullGradebook = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }; + + const importPayload = (weightage: number) => ({ + components: [{ name: 'Q', weightage, maximumGrade: 10 }], + identifierMode: 'email' as const, + csvData: 'x', + onConflict: 'keep' as const, + }); + + it('persists the equal split before committing when any component is weighted', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const importCommit = jest + .spyOn(CourseAPI.gradebook, 'importCommit') + .mockResolvedValue({ + data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 0 }, + } as never); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: fullGradebook } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await commitImport(importPayload(20))(dispatch, getState, {}); + + expect(updateWeights).toHaveBeenCalled(); + expect(updateWeights.mock.invocationCallOrder[0]).toBeLessThan( + importCommit.mock.invocationCallOrder[0], + ); + }); + + it('does not materialize when no imported component is weighted', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + jest.spyOn(CourseAPI.gradebook, 'importCommit').mockResolvedValue({ + data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 0 }, + } as never); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: fullGradebook } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await commitImport(importPayload(0))(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/store.test.ts b/client/app/bundles/course/gradebook/__tests__/store.test.ts index d4211d00014..ad8b591e424 100644 --- a/client/app/bundles/course/gradebook/__tests__/store.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/store.test.ts @@ -1,5 +1,7 @@ import reducer, { actions } from '../store'; +const EXTERNAL_ASSESSMENTS = 'External Assessments'; + const baseState = { categories: [], tabs: [ @@ -17,6 +19,45 @@ const baseState = { canManageWeights: false, }; +describe('SAVE_GRADEBOOK reducer', () => { + it('returns the initial state for an unknown action', () => { + const next = reducer(undefined, { type: 'unknown' } as never); + expect(next).toEqual({ + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }); + }); + + it('hydrates every field from the payload', () => { + const next = reducer( + undefined, + actions.saveGradebook({ + categories: [{ id: 1, title: 'C' }], + tabs: [{ id: 10, title: 'T', categoryId: 1, gradebookWeight: 50 }], + assessments: [{ id: 100, title: 'A', tabId: 10, maxGrade: 100 }], + students: [], + submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: true, + weightedViewEnabled: true, + canManageWeights: true, + }), + ); + expect(next.categories).toHaveLength(1); + expect(next.tabs[0].gradebookWeight).toBe(50); + expect(next.assessments[0].id).toBe(100); + expect(next.submissions[0].grade).toBe(8); + expect(next.gamificationEnabled).toBe(true); + expect(next.weightedViewEnabled).toBe(true); + expect(next.canManageWeights).toBe(true); + }); +}); + describe('UPDATE_TAB_WEIGHTS reducer', () => { it('updates gradebookWeight and weightMode for the matching tab', () => { const next = reducer( @@ -30,7 +71,7 @@ describe('UPDATE_TAB_WEIGHTS reducer', () => { expect(next.tabs.find((t) => t.id === 2)?.gradebookWeight).toBe(50); }); - it('does not set any excluded field', () => { + it('does not set any excluded field in tabs', () => { const next = reducer( baseState, actions.updateTabWeights({ @@ -150,6 +191,40 @@ describe('UPDATE_TAB_WEIGHTS reducer', () => { false, ); }); + + it('clears assessment exclusion when excludedAssessmentIds is omitted', () => { + const seeded = { + ...baseState, + assessments: [ + { + id: 101, + title: 'A1', + tabId: 1, + maxGrade: 100, + gradebookExcluded: true, + }, + { + id: 102, + title: 'A2', + tabId: 1, + maxGrade: 100, + gradebookExcluded: true, + }, + ], + }; + const next = reducer( + seeded, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 50, weightMode: 'equal' }], + }), + ); + expect(next.assessments.find((a) => a.id === 101)?.gradebookExcluded).toBe( + false, + ); + expect(next.assessments.find((a) => a.id === 102)?.gradebookExcluded).toBe( + false, + ); + }); }); describe('external assessment reducers', () => { @@ -177,23 +252,20 @@ describe('external assessment reducers', () => { external: true, }, tab: { id: 200, title: 'Midterm', categoryId: 2 }, - category: { id: 2, title: 'External Assessments' }, + category: { id: 2, title: EXTERNAL_ASSESSMENTS }, }), ); expect(next.categories.find((c) => c.id === 2)?.title).toBe( - 'External Assessments', + EXTERNAL_ASSESSMENTS, ); expect(next.tabs.find((t) => t.id === 200)?.title).toBe('Midterm'); expect(next.assessments.find((a) => a.id === -5)?.external).toBe(true); }); - it('applyCreatedExternal does not duplicate an existing category/tab', () => { + it('applyCreatedExternal does not duplicate existing category, tab, or assessment', () => { const seeded = { ...state, - categories: [ - ...state.categories, - { id: 2, title: 'External Assessments' }, - ], + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], }; const next = reducer( @@ -207,11 +279,12 @@ describe('external assessment reducers', () => { external: true, }, tab: { id: 200, title: 'Midterm', categoryId: 2 }, - category: { id: 2, title: 'External Assessments' }, + category: { id: 2, title: EXTERNAL_ASSESSMENTS }, }), ); expect(next.categories.filter((c) => c.id === 2)).toHaveLength(1); expect(next.tabs.filter((t) => t.id === 200)).toHaveLength(1); + expect(next.assessments.filter((a) => a.id === -6)).toHaveLength(1); }); it('updateExternalAssessment changes title and maxGrade and syncs tab title', () => { @@ -243,6 +316,49 @@ describe('external assessment reducers', () => { expect(next.tabs.find((t) => t.id === 200)?.title).toBe('Midterm Exam'); }); + it('updateExternalAssessment writes tab.gradebookWeight when provided and preserves it when omitted', () => { + const seeded = { + ...state, + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [ + ...state.tabs, + { id: 200, title: 'Midterm', categoryId: 2, gradebookWeight: 40 }, + ], + }; + const withWeight = reducer( + seeded, + actions.updateExternalAssessment({ + assessment: { + id: -5, + title: 'M', + tabId: 200, + maxGrade: 50, + external: true, + }, + tab: { id: 200, title: 'M', categoryId: 2, gradebookWeight: 75 }, + }), + ); + expect(withWeight.tabs.find((t) => t.id === 200)?.gradebookWeight).toBe(75); + + const noWeight = reducer( + seeded, + actions.updateExternalAssessment({ + assessment: { + id: -5, + title: 'M', + tabId: 200, + maxGrade: 50, + external: true, + }, + tab: { id: 200, title: 'M', categoryId: 2 }, + }), + ); + expect(noWeight.tabs.find((t) => t.id === 200)?.gradebookWeight).toBe(40); + }); + it('deleteExternalAssessment removes the assessment and its now-empty tab', () => { const seeded = { ...state, @@ -265,10 +381,7 @@ describe('external assessment reducers', () => { it('deleteExternalAssessment drops the synthetic category once its last external is gone', () => { const seeded = { ...state, - categories: [ - ...state.categories, - { id: 2, title: 'External Assessments' }, - ], + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], assessments: [ ...state.assessments, { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, @@ -283,10 +396,7 @@ describe('external assessment reducers', () => { it('deleteExternalAssessment keeps the category while other externals remain', () => { const seeded = { ...state, - categories: [ - ...state.categories, - { id: 2, title: 'External Assessments' }, - ], + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], assessments: [ ...state.assessments, { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, @@ -342,7 +452,7 @@ describe('external assessment reducers', () => { }); it('UPDATE_EXTERNAL_ASSESSMENT copies bound flags', () => { - const seed = (overrides = {}) => + const seed = (overrides = {}): ReturnType => reducer( undefined, actions.saveGradebook({ @@ -387,4 +497,40 @@ describe('external assessment reducers', () => { expect(a.floorAtZero).toBe(false); expect(a.capAtMaximum).toBe(false); }); + + it('REORDER_EXTERNAL_ASSESSMENTS permutes external assessments and their synthetic tabs', () => { + const externalState = { + ...baseState, + tabs: [ + { id: -1, title: 'Quiz', categoryId: -1 }, + { id: -2, title: 'Exam', categoryId: -1 }, + ], + assessments: [ + { id: -1, title: 'Quiz', tabId: -1, maxGrade: 10, external: true }, + { id: -2, title: 'Exam', tabId: -2, maxGrade: 20, external: true }, + ], + }; + const next = reducer( + externalState, + actions.reorderExternalAssessments([-2, -1]), + ); + expect(next.assessments.map((a) => a.id)).toEqual([-2, -1]); + expect(next.tabs.map((t) => t.id)).toEqual([-2, -1]); + }); + + it('deleteExternalAssessment is a no-op for an unknown id', () => { + const seeded = { + ...state, + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-999)); + expect(next.assessments.find((a) => a.id === -5)).toBeDefined(); + expect(next.tabs.find((t) => t.id === 200)).toBeDefined(); + expect(next.categories.find((c) => c.id === 2)).toBeDefined(); + }); }); diff --git a/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx index 12cf4cc1107..f872da82f88 100644 --- a/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx +++ b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx @@ -1,12 +1,16 @@ import { FC, useState } from 'react'; import { defineMessages } from 'react-intl'; +import InfoOutlined from '@mui/icons-material/InfoOutlined'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle, + FormControlLabel, + Switch, TextField, + Tooltip, } from '@mui/material'; import { useAppDispatch } from 'lib/hooks/store'; @@ -28,6 +32,28 @@ const translations = defineMessages({ id: 'course.gradebook.AddExternalColumnPrompt.maxLabel', defaultMessage: 'Max marks', }, + weightLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.weightLabel', + defaultMessage: 'Weightage', + }, + floorLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.floorLabel', + defaultMessage: 'Floor grades at 0', + }, + capLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.capLabel', + defaultMessage: 'Cap grades at max', + }, + floorHint: { + id: 'course.gradebook.AddExternalColumnPrompt.floorHint', + defaultMessage: + 'Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged.', + }, + capHint: { + id: 'course.gradebook.AddExternalColumnPrompt.capHint', + defaultMessage: + 'Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged.', + }, cancel: { id: 'course.gradebook.AddExternalColumnPrompt.cancel', defaultMessage: 'Cancel', @@ -48,19 +74,30 @@ const translations = defineMessages({ interface Props { open: boolean; + weightedViewEnabled?: boolean; onClose: () => void; } -const AddExternalColumnPrompt: FC = ({ open, onClose }) => { +const AddExternalColumnPrompt: FC = ({ + open, + weightedViewEnabled = false, + onClose, +}) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const [name, setName] = useState(''); const [max, setMax] = useState(''); + const [floorAtZero, setFloorAtZero] = useState(true); + const [capAtMaximum, setCapAtMaximum] = useState(true); const [saving, setSaving] = useState(false); + const [weight, setWeight] = useState('0'); const reset = (): void => { setName(''); setMax(''); + setFloorAtZero(true); + setCapAtMaximum(true); + setWeight('0'); }; const canSave = @@ -69,7 +106,15 @@ const AddExternalColumnPrompt: FC = ({ open, onClose }) => { const submit = async (): Promise => { setSaving(true); try { - await dispatch(createExternalAssessment(name.trim(), Number(max))); + await dispatch( + createExternalAssessment( + name.trim(), + Number(max), + floorAtZero, + capAtMaximum, + weightedViewEnabled ? Number(weight) : undefined, + ), + ); toast.success(t(translations.success)); reset(); onClose(); @@ -100,6 +145,52 @@ const AddExternalColumnPrompt: FC = ({ open, onClose }) => { type="number" value={max} /> + {weightedViewEnabled && ( + setWeight(e.target.value)} + type="number" + value={weight} + /> + )} +
    + setFloorAtZero(e.target.checked)} + /> + } + label={t(translations.floorLabel)} + /> + + + +
    +
    + setCapAtMaximum(e.target.checked)} + /> + } + label={t(translations.capLabel)} + /> + + + +
    - + ); diff --git a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx index e2b5d526485..e42d629dfc5 100644 --- a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -11,6 +11,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import tableTranslations from 'lib/translations/table'; import { + EXTERNAL_CATEGORY_ID, GAMIFICATION_COL_IDS, type GamificationColId, STUDENT_INFO_COL_IDS, @@ -105,6 +106,21 @@ const GradebookColumnTree = ({ return map; }, [tabs, tabAsnIds]); + const renderLeaf = (id: string, indentLevel: number): JSX.Element | null => { + const asnId = parseAssessmentColumnId(id); + const asn = asnId !== null ? asnById.get(asnId) : undefined; + if (!asn) return null; + return ( + setVisible(id, e.target.checked)} + /> + ); + }; + 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)} - /> - ); - })} - - ); - })} + {cat.id === EXTERNAL_CATEGORY_ID + ? catIds.map((id) => renderLeaf(id, 2)) + : thisCatTabs.map((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + return ( + + {tabIds.map((id) => renderLeaf(id, 3))} + + ); + })} ); })} diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx index 3190c6273bd..c30b362ea2e 100644 --- a/client/app/bundles/course/gradebook/components/GradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -1,4 +1,5 @@ import { + cloneElement, forwardRef, useCallback, useLayoutEffect, @@ -7,13 +8,10 @@ import { useState, } from 'react'; import { defineMessages } from 'react-intl'; -import { MoreVert } from '@mui/icons-material'; import { Checkbox, Chip, - IconButton, - Menu, - MenuItem, + CircularProgress, Paper, type SxProps, Table, @@ -48,7 +46,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import tableTranslations from 'lib/translations/table'; import { GAMIFICATION_COL_IDS } from '../constants'; -import { setExternalGrade, updateExternalMaxGrade } from '../operations'; +import { setExternalGrade } from '../operations'; import type { AssessmentData, CategoryData, @@ -61,9 +59,7 @@ import { buildAssessmentColumnId, parseAssessmentColumnId, } from './buildAssessmentColumnIds'; -import DeleteExternalColumnPrompt from './DeleteExternalColumnPrompt'; import GradebookColumnTree from './GradebookColumnTree'; -import RenameExternalColumnPrompt from './RenameExternalColumnPrompt'; const COL_WIDTHS = { name: 160, @@ -131,7 +127,7 @@ const translations = defineMessages({ }, gradeSaveError: { id: 'course.gradebook.GradebookTable.gradeSaveError', - defaultMessage: 'Could not save the grade. Please try again.', + defaultMessage: 'Could not save the {title} grade for {name}. Please try again.', }, externalMaxAria: { id: 'course.gradebook.GradebookTable.externalMaxAria', @@ -141,18 +137,6 @@ const translations = defineMessages({ id: 'course.gradebook.GradebookTable.maxSaveError', defaultMessage: 'Could not save the max marks. Please try again.', }, - rename: { - id: 'course.gradebook.GradebookTable.rename', - defaultMessage: 'Rename', - }, - deleteAction: { - id: 'course.gradebook.GradebookTable.delete', - defaultMessage: 'Delete', - }, - manageAria: { - id: 'course.gradebook.GradebookTable.manageAria', - defaultMessage: 'manage {title}', - }, }); const HeaderLabel = forwardRef< @@ -240,6 +224,7 @@ const ExternalGradeCell = ({ const [localValue, setLocalValue] = useState( value, ); + const [saving, setSaving] = useState(false); const commit = async (): Promise => { setEditing(false); @@ -249,11 +234,14 @@ const ExternalGradeCell = ({ if (next === (localValue ?? null)) return; const prev = localValue; setLocalValue(next); + setSaving(true); try { await dispatch(setExternalGrade(assessmentId, studentId, next)); } catch { setLocalValue(prev); - toast.error(t(translations.gradeSaveError)); + toast.error(t(translations.gradeSaveError, { name: studentName, title })); + } finally { + setSaving(false); } }; @@ -288,10 +276,20 @@ const ExternalGradeCell = ({ setEditing(true); }} role="button" - style={{ cursor: 'pointer', display: 'inline-block', minWidth: 24 }} + // Fill the cell so the whole editable column band is the click target, + // not just the digits at the right edge. + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + width: '100%', + }} tabIndex={0} > - {localValue == null ? '—' : localValue} + {saving && } + {localValue == null ? '—' : localValue} ); }; @@ -378,6 +376,8 @@ interface GradebookTableProps { courseTitle: string; courseId: number; gamificationEnabled: boolean; + /** Optional action rendered in the toolbar, left of the column picker. */ + toolbarAction?: JSX.Element; } const GradebookTable = ({ @@ -389,18 +389,10 @@ const GradebookTable = ({ courseTitle, courseId, gamificationEnabled, + toolbarAction, }: GradebookTableProps): JSX.Element => { const { t } = useTranslation(); - const [menuAnchor, setMenuAnchor] = useState(null); - const [menuAsnId, setMenuAsnId] = useState(null); - const [renameOpen, setRenameOpen] = useState(false); - const [deleteOpen, setDeleteOpen] = useState(false); - const menuAsn = - menuAsnId !== null - ? assessments.find((a) => a.id === menuAsnId) - : undefined; - const submissionsByStudent = useMemo(() => { const map = new Map(); submissions.forEach((s) => { @@ -544,7 +536,7 @@ const GradebookTable = ({ return grade; }, csvDownloadable: true, - defaultVisible: false, + defaultVisible: asn.external ?? false, }); }); return cols; @@ -662,16 +654,24 @@ const GradebookTable = ({ return t(translations.exportButton); }, [selectedCount, rows.length, t]); - const toolbarWithLabel = toolbar?.columnPicker + const toolbarWithLabel = toolbar ? { - ...toolbar, + ...toolbar, + buttons: toolbarAction + ? [ + ...(toolbar.buttons ?? []), + cloneElement(toolbarAction, { key: 'toolbar-action' }), + ] + : toolbar.buttons, + ...(toolbar.columnPicker && { columnPicker: { ...toolbar.columnPicker, directExportLabel, directExportTooltip: selectedCount === 0 ? t(translations.exportAllTooltip) : undefined, }, - } + }), + } : toolbar; const totalWidth = useMemo( @@ -803,7 +803,7 @@ const GradebookTable = ({ const isExternalCol = asnId !== null && assessments.find((a) => a.id === asnId)?.external === - true; + true; const labelNode = ( @@ -859,18 +859,6 @@ const GradebookTable = ({ size="small" sx={{ ml: 0.5 }} /> - { - setMenuAnchor(e.currentTarget); - setMenuAsnId(asnId); - }} - size="small" - > - - ) : ( sortedLabel @@ -1049,44 +1037,6 @@ const GradebookTable = ({ {pagination && }
    - setMenuAnchor(null)} - open={Boolean(menuAnchor)} - > - { - setMenuAnchor(null); - setRenameOpen(true); - }} - > - {t(translations.rename)} - - { - setMenuAnchor(null); - setDeleteOpen(true); - }} - > - {t(translations.deleteAction)} - - - {menuAsn && ( - setRenameOpen(false)} - open={renameOpen} - /> - )} - {menuAsn && ( - setDeleteOpen(false)} - open={deleteOpen} - title={menuAsn.title} - /> - )}
    ); }; diff --git a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx index 671a6c7fdbe..73074702475 100644 --- a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx @@ -44,6 +44,7 @@ import type { AssessmentContribution, WeightedRow } from '../computeWeighted'; import { computeStudentBreakdown, computeWeightedRows, + effectiveGrade, resolveTabWeights, usingDefaultWeights, } from '../computeWeighted'; @@ -164,6 +165,18 @@ const translations = defineMessages({ id: 'course.gradebook.GradebookWeightedTable.total', defaultMessage: 'Total', }, + gradeCapped: { + id: 'course.gradebook.GradebookWeightedTable.gradeCapped', + defaultMessage: 'Capped at {value}', + }, + gradeFloored: { + id: 'course.gradebook.GradebookWeightedTable.gradeFloored', + defaultMessage: 'Floored to {value}', + }, + gradeCountsAs: { + id: 'course.gradebook.GradebookWeightedTable.gradeCountsAs', + defaultMessage: 'Counts as {value}', + }, }); type DisplayMode = 'points' | 'percent'; @@ -299,6 +312,11 @@ const GradebookWeightedTable = ({ return next; }); + const assessmentsById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + const breakdownsByStudent = useMemo( () => new Map( @@ -943,6 +961,24 @@ const GradebookWeightedTable = ({ a.grade === null ? `—/${a.maxGrade}` : `${a.grade}/${a.maxGrade}`; + const assessmentData = assessmentsById.get( + a.assessmentId, + ); + const eff = + a.grade != null && assessmentData != null + ? effectiveGrade(a.grade, assessmentData) + : null; + const wasCapped = + eff != null && + a.grade != null && + a.grade > a.maxGrade && + eff !== a.grade; + const wasFloored = + eff != null && + a.grade != null && + a.grade < 0 && + eff !== a.grade; + const clamped = wasCapped || wasFloored; return ( {`${gradeText} · ${isExcluded ? t(translations.excluded) : weightText}`} + {clamped && ( + + + + )} {/* One empty cell per visible identity column so diff --git a/client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx deleted file mode 100644 index d5876fe2f63..00000000000 --- a/client/app/bundles/course/gradebook/components/RenameExternalColumnPrompt.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { FC, useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { - Button, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - TextField, -} from '@mui/material'; - -import { useAppDispatch } from 'lib/hooks/store'; -import toast from 'lib/hooks/toast'; -import useTranslation from 'lib/hooks/useTranslation'; - -import { renameExternalAssessment } from '../operations'; - -const translations = defineMessages({ - title: { - id: 'course.gradebook.RenameExternalColumnPrompt.title', - defaultMessage: 'Rename external assessment', - }, - nameLabel: { - id: 'course.gradebook.RenameExternalColumnPrompt.nameLabel', - defaultMessage: 'Name', - }, - cancel: { - id: 'course.gradebook.RenameExternalColumnPrompt.cancel', - defaultMessage: 'Cancel', - }, - save: { - id: 'course.gradebook.RenameExternalColumnPrompt.save', - defaultMessage: 'Save', - }, - error: { - id: 'course.gradebook.RenameExternalColumnPrompt.error', - defaultMessage: 'Could not rename the external assessment.', - }, -}); - -interface Props { - open: boolean; - assessmentId: number; - currentTitle: string; - onClose: () => void; -} - -const RenameExternalColumnPrompt: FC = ({ - open, - assessmentId, - currentTitle, - onClose, -}) => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const [name, setName] = useState(currentTitle); - const [saving, setSaving] = useState(false); - - const submit = async (): Promise => { - setSaving(true); - try { - await dispatch(renameExternalAssessment(assessmentId, name.trim())); - onClose(); - } catch { - toast.error(t(translations.error)); - } finally { - setSaving(false); - } - }; - - return ( - - {t(translations.title)} - - setName(e.target.value)} - value={name} - /> - - - - - - - ); -}; - -export default RenameExternalColumnPrompt; diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx deleted file mode 100644 index 279b239603f..00000000000 --- a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsButton.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { FC, useMemo, useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { Button } from '@mui/material'; - -import { useAppSelector } from 'lib/hooks/store'; -import useTranslation from 'lib/hooks/useTranslation'; - -import { getAssessments, getWeightedViewEnabled } from '../../selectors'; - -import ImportExternalAssessmentsWizard from './ImportExternalAssessmentsWizard'; - -const translations = defineMessages({ - importButton: { - id: 'course.gradebook.ImportExternalAssessmentsButton.label', - defaultMessage: 'Import external assessments', - }, -}); - -const ImportExternalAssessmentsButton: FC = () => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); - const assessments = useAppSelector(getAssessments); - const existingExternalTitles = useMemo( - () => assessments.filter((a) => a.external).map((a) => a.title), - [assessments], - ); - - return ( - <> - - setOpen(false)} - open={open} - weightedViewEnabled={weightedViewEnabled} - /> - - ); -}; - -export default ImportExternalAssessmentsButton; diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx index 032caf39d1a..cd5fd46f885 100644 --- a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx +++ b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx @@ -1,10 +1,12 @@ import { FC, useEffect, useMemo, useState } from 'react'; import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; +import Dropzone from 'react-dropzone'; import { Add, Delete } from '@mui/icons-material'; import { Alert, Button, + Chip, Dialog, DialogActions, DialogContent, @@ -20,22 +22,26 @@ import { TableHead, TableRow, TextField, - ToggleButton, - ToggleButtonGroup, + Typography, } from '@mui/material'; +import { FilePreview } from 'lib/components/form/fields/SingleFileInput'; +import SegmentedSwitch from 'lib/components/core/buttons/SegmentedSwitch'; import type { + ExistingExternalAssessment, IdentifierMode, ImportComponent, ImportPreviewResult, } from 'types/course/gradebook'; -import { useAppDispatch } from 'lib/hooks/store'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; +import { getStudents } from '../../selectors'; + import { commitImport, previewImport } from '../../operations'; -import { downloadTemplate, readFileText } from './buildTemplate'; +import { downloadTemplate, identifierHeader, readFileText } from './buildTemplate'; import ExternalGradeConflictPrompt from './ExternalGradeConflictPrompt'; const translations = defineMessages({ @@ -75,19 +81,26 @@ const translations = defineMessages({ id: 'course.gradebook.ImportWizard.updatesExisting', defaultMessage: 'Updates existing — managed in the gradebook', }, + fromExisting: { + id: 'course.gradebook.ImportWizard.fromExisting', + defaultMessage: 'From existing', + }, identifierMode: { id: 'course.gradebook.ImportWizard.identifierMode', defaultMessage: 'Match students by', }, - studentId: { - id: 'course.gradebook.ImportWizard.studentId', - defaultMessage: 'Student ID', + externalId: { + id: 'course.gradebook.ImportWizard.externalId', + defaultMessage: 'External ID', }, email: { id: 'course.gradebook.ImportWizard.email', defaultMessage: 'Email' }, - studentIdHint: { - id: 'course.gradebook.ImportWizard.studentIdHint', - defaultMessage: - "Matching uses each student's current Student ID. Keep Student IDs up to date in Manage Users.", + requiredHeaders: { + id: 'course.gradebook.ImportWizard.requiredHeaders', + defaultMessage: 'Your CSV needs these column headers: {headers}', + }, + dropzone: { + id: 'course.gradebook.ImportWizard.dropzone', + defaultMessage: 'Drag a CSV here, or click to choose a file', }, downloadTemplate: { id: 'course.gradebook.ImportWizard.downloadTemplate', @@ -131,13 +144,23 @@ const translations = defineMessages({ id: 'course.gradebook.ImportWizard.previewError', defaultMessage: 'Could not verify the file. Please try again.', }, + externalIdHint: { + id: 'course.gradebook.ImportWizard.externalIdHint', + defaultMessage: + "Matching uses each student's External ID. Keep External IDs up to date in Manage Users.", + }, + externalIdBlocked: { + id: 'course.gradebook.ImportWizard.externalIdBlocked', + defaultMessage: + '{count, plural, one {{name} has no External ID.} other {# students have no External ID, including {name}.}} Importing by External ID needs every student to have one, so this import is blocked until they are all filled in. In Manage Users, sort by the External ID column to group the blank ones together and fill them in, then come back here. Prefer to match by Email instead? Switch the toggle above.', + }, }); interface Props { open: boolean; onClose: () => void; weightedViewEnabled: boolean; - existingExternalTitles: string[]; + existingAssessments: ExistingExternalAssessment[]; } let rowId = 0; @@ -150,25 +173,35 @@ const ImportExternalAssessmentsWizard: FC = ({ open, onClose, weightedViewEnabled, - existingExternalTitles, + existingAssessments, }) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const { courseId } = useParams(); + const { courseId: courseIdParam } = useParams(); + const courseId = courseIdParam ?? ''; const [step, setStep] = useState(0); const [components, setComponents] = useState< (ImportComponent & { id: number })[] >([blankComponent()]); const [mode, setMode] = useState('student_id'); + const [file, setFile] = useState(null); const [csvData, setCsvData] = useState(''); const [preview, setPreview] = useState(null); const [conflictOpen, setConflictOpen] = useState(false); const [busy, setBusy] = useState(false); + const students = useAppSelector(getStudents); + const missingStudents = useMemo( + () => students.filter((s) => s.externalId == null || s.externalId === ''), + [students], + ); + const identifierReady = mode === 'email' || missingStudents.length === 0; + useEffect(() => { if (!open) { setStep(0); setComponents([blankComponent()]); + setFile(null); setCsvData(''); setPreview(null); setConflictOpen(false); @@ -176,11 +209,21 @@ const ImportExternalAssessmentsWizard: FC = ({ } }, [open]); - const existingSet = useMemo( - () => new Set(existingExternalTitles), - [existingExternalTitles], + const existingMap = useMemo( + () => new Map(existingAssessments.map((a) => [a.name, a])), + [existingAssessments], + ); + const isExisting = (name: string): boolean => existingMap.has(name.trim()); + + const addedNames = useMemo( + () => new Set(components.map((c) => c.name.trim())), + [components], + ); + + const availableChips = useMemo( + () => existingAssessments.filter((a) => !addedNames.has(a.name)), + [existingAssessments, addedNames], ); - const isExisting = (name: string): boolean => existingSet.has(name.trim()); const updateComponent = ( i: number, @@ -188,6 +231,14 @@ const ImportExternalAssessmentsWizard: FC = ({ ): void => setComponents((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c))); + const insertFromExisting = (a: ExistingExternalAssessment): void => { + rowId += 1; + setComponents((cs) => [ + ...cs, + { id: rowId, name: a.name, weightage: a.weightage, maximumGrade: a.maximumGrade }, + ]); + }; + const defineValid = components.length > 0 && components.every((c) => c.name.trim() !== '') && @@ -256,11 +307,31 @@ const ImportExternalAssessmentsWizard: FC = ({ {step === 0 && ( <> + {availableChips.length > 0 && ( +
    + + {t(translations.fromExisting)} + +
    + {availableChips.map((a) => ( + insertFromExisting(a)} + variant="outlined" + /> + ))} +
    +
    + )} + {components.map((c, i) => { const locked = isExisting(c.name); + const existing = locked ? existingMap.get(c.name.trim()) : undefined; return (
    @@ -281,7 +352,7 @@ const ImportExternalAssessmentsWizard: FC = ({ } size="small" type="number" - value={c.weightage} + value={locked && existing ? existing.weightage : c.weightage} /> )} = ({ } size="small" type="number" - value={c.maximumGrade} + value={locked && existing ? existing.maximumGrade : c.maximumGrade} /> {locked && ( @@ -322,28 +393,43 @@ const ImportExternalAssessmentsWizard: FC = ({ {t(translations.addComponent)} -
    - {t(translations.identifierMode)} - v && setMode(v)} - size="small" +
    + + {t(translations.identifierMode)} + + - - {t(translations.studentId)} - - - {t(translations.email)} - - + />
    + {mode === 'student_id' && ( - - {t(translations.studentIdHint)}{' '} - - Manage Users - + + {identifierReady + ? t(translations.externalIdHint, { + link: (chunks) => ( + + {chunks} + + ), + }) + : t(translations.externalIdBlocked, { + name: missingStudents[0]?.name ?? '', + count: missingStudents.length, + link: (chunks) => ( + + {chunks} + + ), + })} )} @@ -351,20 +437,49 @@ const ImportExternalAssessmentsWizard: FC = ({ {step === 1 && (
    + + {t(translations.requiredHeaders, { + headers: [ + identifierHeader(mode), + ...components.map((c) => c.name), + ].join(', '), + })} + - { - const f = (e.target as HTMLInputElement).files?.[0]; - if (f) setCsvData(await readFileText(f)); + { + const f = files[0]; + if (f) { + setFile(f); + setCsvData(await readFileText(f)); + } }} - type="file" - /> + > + {({ getRootProps, getInputProps }) => ( +
    + + {file ? ( + + ) : ( +
    {t(translations.dropzone)}
    + )} +
    + )} +
    )} @@ -422,7 +537,7 @@ const ImportExternalAssessmentsWizard: FC = ({ )} {step === 0 && ( + setOpen(false)} open={open} /> + + ); +}; + +export default ManageExternalAssessmentsButton; diff --git a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx new file mode 100644 index 00000000000..2d043c6223b --- /dev/null +++ b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx @@ -0,0 +1,358 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + DragDropContext, + Draggable, + Droppable, + DropResult, +} from '@hello-pangea/dnd'; +import { Add, Delete, DragIndicator, Edit, Upload } from '@mui/icons-material'; +import { + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + Typography, +} from '@mui/material'; +import type { + AssessmentData, + ExistingExternalAssessment, +} from 'types/course/gradebook'; +import type { AppDispatch } from 'store'; + +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { reorderExternalAssessments } from '../../operations'; +import { + getExternalAssessments, + getTabs, + getWeightedViewEnabled, +} from '../../selectors'; +import AddExternalColumnPrompt from '../AddExternalColumnPrompt'; +import DeleteExternalColumnPrompt from '../DeleteExternalColumnPrompt'; +import ImportExternalAssessmentsWizard from '../import/ImportExternalAssessmentsWizard'; + +import EditExternalAssessmentPrompt from './EditExternalAssessmentPrompt'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ManageExternalPanel.title', + defaultMessage: 'External assessments', + }, + add: { + id: 'course.gradebook.ManageExternalPanel.add', + defaultMessage: 'Add', + }, + import: { + id: 'course.gradebook.ManageExternalPanel.import', + defaultMessage: 'Import CSV', + }, + name: { + id: 'course.gradebook.ManageExternalPanel.name', + defaultMessage: 'Name', + }, + max: { + id: 'course.gradebook.ManageExternalPanel.max', + defaultMessage: 'Max', + }, + weight: { + id: 'course.gradebook.ManageExternalPanel.weight', + defaultMessage: 'Weight', + }, + bounds: { + id: 'course.gradebook.ManageExternalPanel.bounds', + defaultMessage: 'Bounds', + }, + floored: { + id: 'course.gradebook.ManageExternalPanel.floored', + defaultMessage: '≥ 0', + }, + capped: { + id: 'course.gradebook.ManageExternalPanel.capped', + defaultMessage: '≤ max', + }, + actions: { + id: 'course.gradebook.ManageExternalPanel.actions', + defaultMessage: 'Actions', + }, + empty: { + id: 'course.gradebook.ManageExternalPanel.empty', + defaultMessage: 'No external assessments yet', + }, + emptyHint: { + id: 'course.gradebook.ManageExternalPanel.emptyHint', + defaultMessage: + 'Add one manually, or import a CSV of grades earned outside Coursemology.', + }, + close: { + id: 'course.gradebook.ManageExternalPanel.close', + defaultMessage: 'Close', + }, + reorderError: { + id: 'course.gradebook.ManageExternalPanel.reorderError', + defaultMessage: 'Could not save the new order. Please try again.', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; +} + +// Returns a new array with the item at `from` moved to `to`. +export const moveItem = (ids: number[], from: number, to: number): number[] => { + const next = [...ids]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +}; + +// Builds the new external order from a drag result and persists it. +// Exported so the no-op / dispatch / failure branches stay unit-testable +// without driving the drag-and-drop library in jsdom. +export const handleDragEnd = ( + externalIds: number[], + result: DropResult, + dispatch: AppDispatch, + onError: () => void, +): void => { + if (!result.destination || result.destination.index === result.source.index) { + return; + } + const order = moveItem( + externalIds, + result.source.index, + result.destination.index, + ); + dispatch(reorderExternalAssessments(order)).catch(onError); +}; + +const ManageExternalAssessmentsPanel: FC = ({ open, onClose }) => { + const { t } = useTranslation(); + const externals = useAppSelector(getExternalAssessments); + const tabs = useAppSelector(getTabs); + const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); + const dispatch = useAppDispatch(); + const [addOpen, setAddOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const tabWeights = Object.fromEntries( + tabs.map((tab) => [tab.id, tab.gradebookWeight ?? 0]), + ); + const existingAssessments: ExistingExternalAssessment[] = externals.map( + (a) => ({ + name: a.title, + maximumGrade: a.maxGrade, + weightage: tabWeights[a.tabId] ?? 0, + }), + ); + + const onDragEnd = (result: DropResult): void => + handleDragEnd( + externals.map((a) => a.id), + result, + dispatch, + () => toast.error(t(translations.reorderError)), + ); + + const gridCols = weightedViewEnabled + ? '2.5rem minmax(10rem,1fr) 5rem 5rem 9.5rem 6rem' + : '2.5rem minmax(10rem,1fr) 5rem 9.5rem 6rem'; + + // A lone assessment has nothing to reorder against, so dragging is disabled + // and the handle is hidden. Its grid column is kept (empty) so the rest of + // the row stays in identical placement. + const reorderable = externals.length > 1; + + return ( + <> + + {t(translations.title)} + + + + + + + {externals.length === 0 ? ( +
    + + {t(translations.empty)} + + + {t(translations.emptyHint)} + +
    + ) : ( + <> +
    + + {t(translations.name)} + {t(translations.max)} + {weightedViewEnabled && {t(translations.weight)}} + {t(translations.bounds)} + {t(translations.actions)} +
    + + + + {(dropProvided) => ( +
    + {externals.map((a, index) => ( + + {(dragProvided, { isDragging }) => ( +
    + {reorderable ? ( + + + + ) : ( + + )} + + {a.title} + + {a.maxGrade} + {weightedViewEnabled && ( + {tabWeights[a.tabId] ?? 0} + )} + + + {(a.floorAtZero ?? true) && ( + + )} + {(a.capAtMaximum ?? true) && ( + + )} + + + + setEditing(a)} + size="small" + > + + + setDeleting(a)} + size="small" + > + + + +
    + )} +
    + ))} + {dropProvided.placeholder} +
    + )} +
    +
    + + )} +
    + + + + + setAddOpen(false)} + open={addOpen} + weightedViewEnabled={weightedViewEnabled} + /> + {editing && ( + setEditing(null)} + open={Boolean(editing)} + weightedViewEnabled={weightedViewEnabled} + /> + )} + {deleting && ( + setDeleting(null)} + open={Boolean(deleting)} + title={deleting.title} + /> + )} +
    + setImportOpen(false)} + open={open && importOpen} + weightedViewEnabled={weightedViewEnabled} + /> + + ); +}; + +export default ManageExternalAssessmentsPanel; diff --git a/client/app/bundles/course/gradebook/constants.ts b/client/app/bundles/course/gradebook/constants.ts index 4c61d2723fb..c5e07ff06f1 100644 --- a/client/app/bundles/course/gradebook/constants.ts +++ b/client/app/bundles/course/gradebook/constants.ts @@ -3,3 +3,7 @@ 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]; + +/** Synthetic category id for external assessments (mirrors backend + * Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID). */ +export const EXTERNAL_CATEGORY_ID = -1; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts index 1392dc81f1e..a0c7b6b24c0 100644 --- a/client/app/bundles/course/gradebook/operations.ts +++ b/client/app/bundles/course/gradebook/operations.ts @@ -9,6 +9,7 @@ import type { import CourseAPI from 'api/course'; import { actions } from './store'; +import { materializedDefaultWeights, usingDefaultWeights } from './computeWeighted'; const fetchGradebook = (): Operation => async (dispatch) => { const response = await CourseAPI.gradebook.index(); @@ -22,6 +23,18 @@ export const updateGradebookWeights = dispatch(actions.updateTabWeights(response.data)); }; +// When the weighted view is showing the equal-split default (no weights stored), +// persist that visible split for the existing tabs. Call this BEFORE a mutation that +// will set a real weight, so the other tabs keep their on-screen weights instead of +// snapping to their stored 0 once the fallback disengages. No-op when weights are +// already configured. Idempotent / best-effort: it only makes the visible state +// durable, so it needs no rollback if the following mutation fails. +const materializeDefaultWeights = (): Operation => async (dispatch, getState) => { + const { tabs, assessments } = getState().gradebook; + if (!usingDefaultWeights(tabs, assessments)) return; + await dispatch(updateGradebookWeights(materializedDefaultWeights(tabs, assessments))); +}; + export const createExternalAssessment = ( title: string, @@ -31,6 +44,7 @@ export const createExternalAssessment = weight?: number, ): Operation => async (dispatch) => { + if (weight) await dispatch(materializeDefaultWeights()); const response = await CourseAPI.gradebook.createExternal({ title, maximumGrade, @@ -62,6 +76,7 @@ export const editExternalAssessment = }, ): Operation => async (dispatch) => { + if (patch.weight) await dispatch(materializeDefaultWeights()); const response = await CourseAPI.gradebook.updateExternal( -assessmentId, patch, @@ -76,6 +91,27 @@ export const deleteExternalAssessment = dispatch(actions.deleteExternalAssessment(assessmentId)); }; +// Optimistic: apply the new external order immediately, persist via one PUT. +// On failure, restore the previous order and rethrow so the caller can toast. +// `orderedAssessmentIds` are the negative serialized ids (store ids); the API +// wants the positive external ids, so we negate them. +export const reorderExternalAssessments = + (orderedAssessmentIds: number[]): Operation => + async (dispatch, getState) => { + const prevOrder = getState() + .gradebook.assessments.filter((a) => a.external) + .map((a) => a.id); + dispatch(actions.reorderExternalAssessments(orderedAssessmentIds)); + try { + await CourseAPI.gradebook.reorderExternals({ + orderedIds: orderedAssessmentIds.map((id) => -id), + }); + } catch (error) { + dispatch(actions.reorderExternalAssessments(prevOrder)); + throw error; + } + }; + // Optimistic: apply the new grade immediately, then reconcile with the server. // On failure, restore the previous value and rethrow so the caller can toast. export const setExternalGrade = @@ -115,6 +151,9 @@ export const commitImport = payload: ImportPreviewRequest & { onConflict: 'keep' | 'replace' }, ): Operation => async (dispatch) => { + if (payload.components.some((c) => c.weightage > 0)) { + await dispatch(materializeDefaultWeights()); + } const response = await CourseAPI.gradebook.importCommit(payload); const refreshed = await CourseAPI.gradebook.index(); dispatch(actions.saveGradebook(refreshed.data)); diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx index c2276c6ba1a..403f0c7a58a 100644 --- a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -2,7 +2,7 @@ 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 { Button, Tab, Tabs, Typography } from '@mui/material'; +import { Tab, Tabs, Typography } from '@mui/material'; import Page from 'lib/components/core/layouts/Page'; import LoadingIndicator from 'lib/components/core/LoadingIndicator'; @@ -11,12 +11,11 @@ import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; import { useCourseContext } from '../../../container/CourseLoader'; -import AddExternalColumnPrompt from '../../components/AddExternalColumnPrompt'; import GradebookTable from '../../components/GradebookTable'; import GradebookWeightedTable from '../../components/GradebookWeightedTable'; import GradeLinkHint from '../../components/GradeLinkHint'; -import ImportExternalAssessmentsButton from '../../components/import/ImportExternalAssessmentsButton'; import WeightedViewHint from '../../components/WeightedViewHint'; +import ManageExternalAssessmentsButton from '../../components/manage/ManageExternalAssessmentsButton'; import fetchGradebook from '../../operations'; import { getAssessments, @@ -54,10 +53,6 @@ const translations = defineMessages({ id: 'course.gradebook.GradebookIndex.byWeight', defaultMessage: 'Weighted total', }, - addExternal: { - id: 'course.gradebook.GradebookIndex.addExternal', - defaultMessage: 'Add external assessment', - }, }); const GradebookIndex: FC = () => { @@ -72,7 +67,6 @@ const GradebookIndex: FC = () => { searchParams.get('view') === 'weighted' ? 'weighted' : 'all', ); const [isPending, startTransition] = useTransition(); - const [addOpen, setAddOpen] = useState(false); const assessments = useAppSelector(getAssessments); const categories = useAppSelector(getCategories); @@ -137,6 +131,11 @@ const GradebookIndex: FC = () => { {!isLoading && canManageWeights && !weightedViewEnabled && ( )} + {!isLoading && canManageWeights && students.length > 0 && ( +
    + +
    + )} {weightedViewEnabled && !isLoading && students.length > 0 && ( { {!isLoading && students.length > 0 && !(weightedViewEnabled && viewMode === 'weighted') && } - {!isLoading && canManageWeights && students.length > 0 && ( -
    - - -
    - )} - setAddOpen(false)} - open={addOpen} - />
    {isPending && (
    diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts index d72746b192f..644519a9c1e 100644 --- a/client/app/bundles/course/gradebook/store.ts +++ b/client/app/bundles/course/gradebook/store.ts @@ -22,6 +22,8 @@ const UPDATE_EXTERNAL_ASSESSMENT = 'course/gradebook/UPDATE_EXTERNAL_ASSESSMENT'; const DELETE_EXTERNAL_ASSESSMENT = 'course/gradebook/DELETE_EXTERNAL_ASSESSMENT'; +const REORDER_EXTERNAL_ASSESSMENTS = + 'course/gradebook/REORDER_EXTERNAL_ASSESSMENTS'; const SET_EXTERNAL_GRADE = 'course/gradebook/SET_EXTERNAL_GRADE'; interface GradebookState { @@ -57,6 +59,10 @@ interface DeleteExternalAssessmentAction { type: typeof DELETE_EXTERNAL_ASSESSMENT; payload: number; // negative serialized assessment id } +interface ReorderExternalAssessmentsAction { + type: typeof REORDER_EXTERNAL_ASSESSMENTS; + payload: number[]; // negative serialized assessment ids, new order +} interface SetExternalGradeAction { type: typeof SET_EXTERNAL_GRADE; payload: ExternalGradePayload; @@ -82,6 +88,7 @@ const reducer = produce( | ApplyCreatedExternalAction | UpdateExternalAssessmentAction | DeleteExternalAssessmentAction + | ReorderExternalAssessmentsAction | SetExternalGradeAction, ) => { switch (action.type) { @@ -189,6 +196,25 @@ const reducer = produce( } break; } + case REORDER_EXTERNAL_ASSESSMENTS: { + const rank = new Map(action.payload.map((id, i) => [id, i])); + const externalsSorted = draft.assessments + .filter((a) => a.external) + .sort((x, y) => (rank.get(x.id) ?? 0) - (rank.get(y.id) ?? 0)); + let ai = 0; + draft.assessments = draft.assessments.map((a) => + a.external ? externalsSorted[ai++] : a, + ); + const tabRank = new Map(externalsSorted.map((a, i) => [a.tabId, i])); + const tabsSorted = draft.tabs + .filter((t) => tabRank.has(t.id)) + .sort((x, y) => (tabRank.get(x.id) ?? 0) - (tabRank.get(y.id) ?? 0)); + let ti = 0; + draft.tabs = draft.tabs.map((t) => + tabRank.has(t.id) ? tabsSorted[ti++] : t, + ); + break; + } case SET_EXTERNAL_GRADE: { const { studentId, assessmentId, grade } = action.payload; const existing = draft.submissions.find( @@ -229,6 +255,12 @@ export const actions = { type: DELETE_EXTERNAL_ASSESSMENT, payload: id, }), + reorderExternalAssessments: ( + payload: number[], + ): ReorderExternalAssessmentsAction => ({ + type: REORDER_EXTERNAL_ASSESSMENTS, + payload, + }), setExternalGrade: ( payload: ExternalGradePayload, ): SetExternalGradeAction => ({ type: SET_EXTERNAL_GRADE, payload }), 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 index fdbaedfc26c..0984ac6c527 100644 --- 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 @@ -1,5 +1,5 @@ import userEvent from '@testing-library/user-event'; -import { render, screen, waitForElementToBeRemoved } from 'test-utils'; +import { render, screen, waitFor, waitForElementToBeRemoved, within } from 'test-utils'; import { CourseUserMiniEntity } from 'types/course/courseUsers'; import ManageUsersTable from '../index'; @@ -96,4 +96,30 @@ describe('', () => { expect(screen.getByText('Bob Tan')).toBeInTheDocument(); }); }); + + describe('sort by External ID', () => { + it( + 'clusters blank External IDs together when the column is sorted', + async () => { + const user = userEvent.setup(); + const users: CourseUserMiniEntity[] = [ + { ...baseUser, id: 1, name: 'Alice Lim', externalId: 'B-1' }, + { ...baseUser, id: 2, name: 'Bob Tan', externalId: null }, + { ...baseUser, id: 3, name: 'Cara Ng', externalId: 'A-1' }, + ]; + render(); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + const extIdHeader = screen.getByRole('columnheader', { name: /external id/i }); + await user.click(within(extIdHeader).getByRole('button')); + + await waitFor(() => { + const rows = screen.getAllByRole('row').slice(1); // drop the header row + const order = rows.map((r) => within(r).getByText(/Lim|Tan|Ng/).textContent); + expect(order[0]).toContain('Bob Tan'); // blank External ID sorts first + }); + }, + 10000, + ); + }); }); 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 76f30227a4f..ec1559934e0 100644 --- a/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx @@ -70,10 +70,23 @@ const ManageUsersTable = (props: ManageUsersTableProps): JSX.Element => { { of: 'externalId', title: t(tableTranslations.externalId), - sortable: false, + sortable: true, searchable: true, cell: (user) => , csvDownloadable: true, + sortProps: { + // Empties first so all blank External IDs cluster onto page 1 for a + // single fix pass; non-empty values sort lexicographically after. + // descFirst: false ensures the first click sorts ascending (empties first). + descFirst: false, + sort: (a, b) => { + const av = a.externalId ?? ''; + const bv = b.externalId ?? ''; + if (!av && bv) return -1; + if (av && !bv) return 1; + return av.localeCompare(bv); + }, + }, }, { of: 'phantom', diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts index b1833aa03f1..5c027cefde1 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -85,6 +85,12 @@ export interface ImportComponent { maximumGrade: number; } +export interface ExistingExternalAssessment { + name: string; + maximumGrade: number; + weightage: number; +} + export interface ImportPreviewRequest { components: ImportComponent[]; identifierMode: IdentifierMode; diff --git a/client/locales/en.json b/client/locales/en.json index 8721bd61b33..68980c199b0 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9431,12 +9431,24 @@ "course.gradebook.AddExternalColumnPrompt.cancel": { "defaultMessage": "Cancel" }, + "course.gradebook.AddExternalColumnPrompt.capHint": { + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged." + }, + "course.gradebook.AddExternalColumnPrompt.capLabel": { + "defaultMessage": "Cap grades at max" + }, "course.gradebook.AddExternalColumnPrompt.create": { "defaultMessage": "Create" }, "course.gradebook.AddExternalColumnPrompt.error": { "defaultMessage": "Could not create the external assessment." }, + "course.gradebook.AddExternalColumnPrompt.floorHint": { + "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged." + }, + "course.gradebook.AddExternalColumnPrompt.floorLabel": { + "defaultMessage": "Floor grades at 0" + }, "course.gradebook.AddExternalColumnPrompt.maxLabel": { "defaultMessage": "Max marks" }, @@ -9449,6 +9461,9 @@ "course.gradebook.AddExternalColumnPrompt.title": { "defaultMessage": "Add external assessment" }, + "course.gradebook.AddExternalColumnPrompt.weightLabel": { + "defaultMessage": "Weightage" + }, "course.gradebook.DeleteExternalColumnPrompt.body": { "defaultMessage": "Delete \"{title}\"? This permanently removes the column and every student grade in it. This cannot be undone." }, @@ -9464,8 +9479,44 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "Delete external assessment" }, + "course.gradebook.EditExternalAssessmentPrompt.cancel": { + "defaultMessage": "Cancel" + }, + "course.gradebook.EditExternalAssessmentPrompt.capHint": { + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged." + }, + "course.gradebook.EditExternalAssessmentPrompt.capLabel": { + "defaultMessage": "Cap grades at max" + }, + "course.gradebook.EditExternalAssessmentPrompt.error": { + "defaultMessage": "Could not save the external assessment." + }, + "course.gradebook.EditExternalAssessmentPrompt.floorHint": { + "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged." + }, + "course.gradebook.EditExternalAssessmentPrompt.floorLabel": { + "defaultMessage": "Floor grades at 0" + }, + "course.gradebook.EditExternalAssessmentPrompt.maxLabel": { + "defaultMessage": "Max marks" + }, + "course.gradebook.EditExternalAssessmentPrompt.nameLabel": { + "defaultMessage": "Name" + }, + "course.gradebook.EditExternalAssessmentPrompt.save": { + "defaultMessage": "Save" + }, + "course.gradebook.EditExternalAssessmentPrompt.title": { + "defaultMessage": "Edit external assessment" + }, + "course.gradebook.EditExternalAssessmentPrompt.weightLabel": { + "defaultMessage": "Weightage" + }, "course.gradebook.ExternalGradeConflictPrompt.body": { - "defaultMessage": "These students already have a grade for these components. Keep their existing grades, or replace them with the values from your file? New students and blank cells are unaffected." + "defaultMessage": "Some students already have grades for these components that differ from the values in your file. Replace will overwrite the existing grades with the values from your file. Keep Existing will leave the existing grades unchanged." + }, + "course.gradebook.ExternalGradeConflictPrompt.changesSummary": { + "defaultMessage": "{changed} of {total} rows have changes" }, "course.gradebook.ExternalGradeConflictPrompt.goBack": { "defaultMessage": "Go Back" @@ -9479,26 +9530,14 @@ "course.gradebook.ExternalGradeConflictPrompt.title": { "defaultMessage": "Resolve grade conflicts" }, - "course.gradebook.ExternalGradeConflictTable.component": { - "defaultMessage": "Component" - }, - "course.gradebook.ExternalGradeConflictTable.existing": { - "defaultMessage": "Existing grade" - }, - "course.gradebook.ExternalGradeConflictTable.inFile": { - "defaultMessage": "In-file grade" - }, "course.gradebook.ExternalGradeConflictTable.mismatch": { "defaultMessage": "This identifier now resolves to a different student than the existing grade was imported under." }, - "course.gradebook.ExternalGradeConflictTable.student": { - "defaultMessage": "Student" - }, - "course.gradebook.GradebookIndex.addExternal": { - "defaultMessage": "Add external assessment" + "course.gradebook.ExternalGradeConflictTable.name": { + "defaultMessage": "Name" }, - "course.gradebook.GradebookTable.delete": { - "defaultMessage": "Delete" + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "Accept" }, "course.gradebook.GradebookTable.externalBadge": { "defaultMessage": "External" @@ -9506,26 +9545,35 @@ "course.gradebook.GradebookTable.externalGradeAria": { "defaultMessage": "{title} grade for {name}" }, - "course.gradebook.GradebookTable.externalMaxAria": { - "defaultMessage": "{title} max marks" + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "This grade is below 0." }, - "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "Could not save the grade. Please try again." + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "This grade is below 0; it is floored to 0 in the weighted total." }, - "course.gradebook.GradebookTable.manageAria": { - "defaultMessage": "manage {title}" + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "This grade exceeds the maximum of {max}." }, - "course.gradebook.GradebookTable.maxSaveError": { - "defaultMessage": "Could not save the max marks. Please try again." + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "This grade exceeds the maximum of {max}; its contribution to the weighted total is capped at {max}." }, - "course.gradebook.GradebookTable.rename": { - "defaultMessage": "Rename" + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "Could not save the {title} grade for {name}. Please try again." + }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "Revert" }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "Display mode" }, - "course.gradebook.ImportExternalAssessmentsButton.label": { - "defaultMessage": "Import external assessments" + "course.gradebook.GradebookWeightedTable.gradeCapped": { + "defaultMessage": "Capped at {value}" + }, + "course.gradebook.GradebookWeightedTable.gradeCountsAs": { + "defaultMessage": "Counts as {value}" + }, + "course.gradebook.GradebookWeightedTable.gradeFloored": { + "defaultMessage": "Floored to {value}" }, "course.gradebook.ImportWizard.addComponent": { "defaultMessage": "Add component" @@ -9551,14 +9599,32 @@ "course.gradebook.ImportWizard.downloadTemplate": { "defaultMessage": "Download template" }, + "course.gradebook.ImportWizard.dropzone": { + "defaultMessage": "Drag a CSV here, or click to choose a file" + }, "course.gradebook.ImportWizard.email": { "defaultMessage": "Email" }, + "course.gradebook.ImportWizard.externalId": { + "defaultMessage": "External ID" + }, + "course.gradebook.ImportWizard.externalIdBlocked": { + "defaultMessage": "{count, plural, one {{name} has no External ID} other {{name} and # other students have no External ID}}. Add the missing IDs in Manage Users to import by External ID." + }, + "course.gradebook.ImportWizard.externalIdHint": { + "defaultMessage": "Matching uses each student's External ID. Keep External IDs up to date in Manage Users." + }, + "course.gradebook.ImportWizard.fromExisting": { + "defaultMessage": "From existing" + }, "course.gradebook.ImportWizard.identifierMode": { "defaultMessage": "Match students by" }, "course.gradebook.ImportWizard.malformed": { - "defaultMessage": "These cells are not valid numbers: {cells}" + "defaultMessage": "These cells do not contain valid numbers:" + }, + "course.gradebook.ImportWizard.malformedMore": { + "defaultMessage": "and {count} more" }, "course.gradebook.ImportWizard.maxMarks": { "defaultMessage": "Max marks" @@ -9566,9 +9632,27 @@ "course.gradebook.ImportWizard.next": { "defaultMessage": "Next" }, + "course.gradebook.ImportWizard.outOfRangeSubtitle": { + "defaultMessage": "Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. If these out-of-range grades are intentional, continue." + }, + "course.gradebook.ImportWizard.outOfRangeTitle": { + "defaultMessage": "Some grades are outside their valid range." + }, + "course.gradebook.ImportWizard.outOfRangeWeightedSubtitle": { + "defaultMessage": "Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. Out-of-range grades are only floored or capped in the weighted total. If these out-of-range grades are intentional, continue." + }, "course.gradebook.ImportWizard.previewError": { "defaultMessage": "Could not verify the file. Please try again." }, + "course.gradebook.ImportWizard.previewFewRows": { + "defaultMessage": "Previewing all {totalRows} rows. Check that this preview matches your CSV before continuing." + }, + "course.gradebook.ImportWizard.previewRows": { + "defaultMessage": "Previewing the first 5 of {totalRows} rows. Check that this preview matches your CSV before continuing." + }, + "course.gradebook.ImportWizard.requiredHeaders": { + "defaultMessage": "Your CSV needs these column headers: {headers}" + }, "course.gradebook.ImportWizard.stepDefine": { "defaultMessage": "Define components" }, @@ -9578,17 +9662,14 @@ "course.gradebook.ImportWizard.stepVerify": { "defaultMessage": "Verify" }, - "course.gradebook.ImportWizard.studentId": { - "defaultMessage": "Student ID" - }, - "course.gradebook.ImportWizard.studentIdHint": { - "defaultMessage": "Matching uses each student's current Student ID. Keep Student IDs up to date in Manage Users." - }, "course.gradebook.ImportWizard.title": { "defaultMessage": "Import external assessments" }, - "course.gradebook.ImportWizard.unresolved": { - "defaultMessage": "These identifiers were not found in the course: {ids}" + "course.gradebook.ImportWizard.unresolvedEmail": { + "defaultMessage": "{count, plural, one {This email address was not found in the course: {ids}} other {These email addresses were not found in the course: {ids}}}" + }, + "course.gradebook.ImportWizard.unresolvedExternalId": { + "defaultMessage": "{count, plural, one {This external ID was not found in the course: {ids}} other {These external IDs were not found in the course: {ids}}}" }, "course.gradebook.ImportWizard.updatesExisting": { "defaultMessage": "Updates existing — managed in the gradebook" @@ -9602,22 +9683,58 @@ "course.gradebook.ImportWizard.weightage": { "defaultMessage": "Weightage" }, - "course.gradebook.ProjectedTotalHint.policy": { - "defaultMessage": "Totals count ungraded assessments as 0." + "course.gradebook.ImportWizard.willChangeExisting": { + "defaultMessage": "{count, plural, one {# row contains} other {# rows contain}} changes to existing grades. After checking this preview, click Confirm import to review these conflicts before anything is imported." }, - "course.gradebook.RenameExternalColumnPrompt.cancel": { - "defaultMessage": "Cancel" + "course.gradebook.ManageExternalAssessmentsButton.label": { + "defaultMessage": "Manage external assessments" }, - "course.gradebook.RenameExternalColumnPrompt.error": { - "defaultMessage": "Could not rename the external assessment." + "course.gradebook.ManageExternalPanel.actions": { + "defaultMessage": "Actions" + }, + "course.gradebook.ManageExternalPanel.add": { + "defaultMessage": "Add" + }, + "course.gradebook.ManageExternalPanel.bounds": { + "defaultMessage": "Bounds" + }, + "course.gradebook.ManageExternalPanel.capped": { + "defaultMessage": "≤ max" + }, + "course.gradebook.ManageExternalPanel.close": { + "defaultMessage": "Close" + }, + "course.gradebook.ManageExternalPanel.empty": { + "defaultMessage": "No external assessments yet" + }, + "course.gradebook.ManageExternalPanel.emptyHint": { + "defaultMessage": "Add one manually, or import a CSV of grades earned outside Coursemology." + }, + "course.gradebook.ManageExternalPanel.floored": { + "defaultMessage": "≥ 0" }, - "course.gradebook.RenameExternalColumnPrompt.nameLabel": { + "course.gradebook.ManageExternalPanel.import": { + "defaultMessage": "Import CSV" + }, + "course.gradebook.ManageExternalPanel.max": { + "defaultMessage": "Max" + }, + "course.gradebook.ManageExternalPanel.name": { "defaultMessage": "Name" }, - "course.gradebook.RenameExternalColumnPrompt.save": { - "defaultMessage": "Save" + "course.gradebook.ManageExternalPanel.title": { + "defaultMessage": "External assessments" + }, + "course.gradebook.ManageExternalPanel.weight": { + "defaultMessage": "Weight" }, - "course.gradebook.RenameExternalColumnPrompt.title": { - "defaultMessage": "Rename external assessment" + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range. Review before exporting." + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range and {gradeCount, plural, one {is} other {are}} being capped or floored in the weighted total. Review before exporting." + }, + "course.gradebook.ProjectedTotalHint.policy": { + "defaultMessage": "Totals count ungraded assessments as 0." } } \ No newline at end of file diff --git a/client/locales/ko.json b/client/locales/ko.json index 42c6ba39f82..70dc7b6777c 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9416,12 +9416,18 @@ "course.gradebook.AddExternalColumnPrompt.cancel": { "defaultMessage": "취소" }, + "course.gradebook.AddExternalColumnPrompt.capHint": { + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The grade you see is unchanged." + }, "course.gradebook.AddExternalColumnPrompt.create": { "defaultMessage": "만들기" }, "course.gradebook.AddExternalColumnPrompt.error": { "defaultMessage": "외부 평가를 생성할 수 없습니다." }, + "course.gradebook.AddExternalColumnPrompt.floorHint": { + "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The grade you see is unchanged." + }, "course.gradebook.AddExternalColumnPrompt.maxLabel": { "defaultMessage": "최대 점수" }, @@ -9434,6 +9440,9 @@ "course.gradebook.AddExternalColumnPrompt.title": { "defaultMessage": "외부 평가 추가" }, + "course.gradebook.AddExternalColumnPrompt.weightLabel": { + "defaultMessage": "가중치" + }, "course.gradebook.DeleteExternalColumnPrompt.body": { "defaultMessage": "\"{title}\"을(를) 삭제하시겠습니까? 이 작업은 해당 열과 그 안의 모든 학생 성적을 영구적으로 제거합니다. 이 작업은 되돌릴 수 없습니다." }, @@ -9449,8 +9458,20 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "외부 평가 삭제" }, + "course.gradebook.EditExternalAssessmentPrompt.capHint": { + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The grade you see is unchanged." + }, + "course.gradebook.EditExternalAssessmentPrompt.floorHint": { + "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The grade you see is unchanged." + }, + "course.gradebook.EditExternalAssessmentPrompt.weightLabel": { + "defaultMessage": "가중치" + }, "course.gradebook.ExternalGradeConflictPrompt.body": { - "defaultMessage": "이 학생들은 이미 해당 구성 요소에 대한 성적이 있습니다. 기존 성적을 유지하시겠습니까, 아니면 파일의 값으로 대체하시겠습니까? 새 학생과 빈 셀은 영향을 받지 않습니다." + "defaultMessage": "일부 학생은 이 구성 요소에 이미 성적이 있으며, 파일의 값과 다릅니다. '바꾸기'를 선택하면 파일의 값으로 기존 성적을 덮어씁니다. '기존 성적 유지'를 선택하면 기존 성적이 변경되지 않습니다." + }, + "course.gradebook.ExternalGradeConflictPrompt.changesSummary": { + "defaultMessage": "{total}개 행 중 {changed}개에 변경 사항이 있습니다" }, "course.gradebook.ExternalGradeConflictPrompt.goBack": { "defaultMessage": "돌아가기" @@ -9464,20 +9485,11 @@ "course.gradebook.ExternalGradeConflictPrompt.title": { "defaultMessage": "성적 충돌 해결" }, - "course.gradebook.ExternalGradeConflictTable.component": { - "defaultMessage": "구성 요소" - }, - "course.gradebook.ExternalGradeConflictTable.existing": { - "defaultMessage": "기존 성적" - }, - "course.gradebook.ExternalGradeConflictTable.inFile": { - "defaultMessage": "파일 내 성적" - }, "course.gradebook.ExternalGradeConflictTable.mismatch": { "defaultMessage": "이 식별자는 이제 기존 성적을 가져올 때의 학생과 다른 학생으로 확인됩니다." }, - "course.gradebook.ExternalGradeConflictTable.student": { - "defaultMessage": "학생" + "course.gradebook.ExternalGradeConflictTable.name": { + "defaultMessage": "이름" }, "course.gradebook.GradeLinkHint.hint": { "defaultMessage": "각 성적은 학생 제출물의 점수 합계입니다. 성적을 클릭하면 해당 제출물을 열고 점수를 조정할 수 있습니다." @@ -9488,8 +9500,8 @@ "course.gradebook.GradebookIndex.noStudentsHint": { "defaultMessage": "학생이 강좌에 참여하면 여기에 성적이 표시됩니다." }, - "course.gradebook.GradebookTable.delete": { - "defaultMessage": "삭제" + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "Accept" }, "course.gradebook.GradebookTable.externalBadge": { "defaultMessage": "외부" @@ -9497,21 +9509,27 @@ "course.gradebook.GradebookTable.externalGradeAria": { "defaultMessage": "{name}의 {title} 성적" }, - "course.gradebook.GradebookTable.externalMaxAria": { - "defaultMessage": "{title} 최대 점수" + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "This grade is below 0." }, - "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "성적을 저장할 수 없습니다. 다시 시도해 주세요." + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "This grade is below 0; it is floored to 0 in the weighted total." }, - "course.gradebook.GradebookTable.manageAria": { - "defaultMessage": "{title} 관리" + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "This grade exceeds the maximum of {max}." }, - "course.gradebook.GradebookTable.maxSaveError": { - "defaultMessage": "최대 점수를 저장할 수 없습니다. 다시 시도해 주세요." + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "This grade exceeds the maximum of {max}; its contribution to the weighted total is capped at {max}." + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "성적을 저장할 수 없습니다. 다시 시도해 주세요." }, "course.gradebook.GradebookTable.rename": { "defaultMessage": "이름 변경" }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "Revert" + }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "표시 모드" }, @@ -9545,11 +9563,23 @@ "course.gradebook.ImportWizard.email": { "defaultMessage": "이메일" }, + "course.gradebook.ImportWizard.externalId": { + "defaultMessage": "외부 ID" + }, + "course.gradebook.ImportWizard.externalIdBlocked": { + "defaultMessage": "{count, plural, =0 {{name}에게 외부 ID가 없습니다} one {{name} 외 학생 1명에게 외부 ID가 없습니다} other {{name} 외 학생 #명에게 외부 ID가 없습니다}}. 외부 ID로 가져오려면 사용자 관리에서 누락된 ID를 추가하세요." + }, + "course.gradebook.ImportWizard.externalIdHint": { + "defaultMessage": "각 학생의 외부 ID를 사용하여 일치 여부를 확인합니다. 사용자 관리에서 외부 ID를 최신 상태로 유지하세요." + }, "course.gradebook.ImportWizard.identifierMode": { "defaultMessage": "학생 매칭 기준" }, "course.gradebook.ImportWizard.malformed": { - "defaultMessage": "다음 셀은 올바른 숫자가 아닙니다: {cells}" + "defaultMessage": "이 셀에는 유효한 숫자가 포함되어 있지 않습니다:" + }, + "course.gradebook.ImportWizard.malformedMore": { + "defaultMessage": "외 {count}개 더" }, "course.gradebook.ImportWizard.maxMarks": { "defaultMessage": "최대 점수" @@ -9557,9 +9587,24 @@ "course.gradebook.ImportWizard.next": { "defaultMessage": "다음" }, + "course.gradebook.ImportWizard.outOfRangeSubtitle": { + "defaultMessage": "성적은 입력한 값 그대로 가져옵니다. 이는 경고일 뿐이며, 평가 설정에서 이러한 경고를 끌 수 있습니다. 범위를 벗어난 성적이 의도한 값이라면 계속 진행하세요." + }, + "course.gradebook.ImportWizard.outOfRangeTitle": { + "defaultMessage": "일부 성적이 유효 범위를 벗어났습니다." + }, + "course.gradebook.ImportWizard.outOfRangeWeightedSubtitle": { + "defaultMessage": "성적은 입력한 값 그대로 가져옵니다. 이는 경고일 뿐이며, 평가 설정에서 이러한 경고를 끌 수 있습니다. 범위를 벗어난 성적은 평가별 설정에 따라 가중 총점에서만 하한 또는 상한이 적용되며, 이 설정은 평가를 편집할 때 변경할 수 있습니다. 범위를 벗어난 성적이 의도한 값이라면 계속 진행하세요." + }, "course.gradebook.ImportWizard.previewError": { "defaultMessage": "파일을 확인할 수 없습니다. 다시 시도해 주세요." }, + "course.gradebook.ImportWizard.previewFewRows": { + "defaultMessage": "전체 {totalRows}개 행을 미리보고 있습니다. 계속하기 전에 이 미리보기가 CSV와 일치하는지 확인하세요." + }, + "course.gradebook.ImportWizard.previewRows": { + "defaultMessage": "총 {totalRows}개 행 중 처음 5개 행을 미리보고 있습니다. 계속하기 전에 이 미리보기가 CSV와 일치하는지 확인하세요." + }, "course.gradebook.ImportWizard.stepDefine": { "defaultMessage": "구성 요소 정의" }, @@ -9578,8 +9623,11 @@ "course.gradebook.ImportWizard.title": { "defaultMessage": "외부 평가 가져오기" }, - "course.gradebook.ImportWizard.unresolved": { - "defaultMessage": "다음 식별자를 강좌에서 찾을 수 없습니다: {ids}" + "course.gradebook.ImportWizard.unresolvedEmail": { + "defaultMessage": "{count, plural, one {이 이메일 주소를 강좌에서 찾을 수 없습니다: {ids}} other {이 이메일 주소들을 강좌에서 찾을 수 없습니다: {ids}}}" + }, + "course.gradebook.ImportWizard.unresolvedExternalId": { + "defaultMessage": "{count, plural, one {이 외부 ID를 강좌에서 찾을 수 없습니다: {ids}} other {이 외부 ID들을 강좌에서 찾을 수 없습니다: {ids}}}" }, "course.gradebook.ImportWizard.updatesExisting": { "defaultMessage": "기존 항목 업데이트 - 성적부에서 관리됨" @@ -9593,6 +9641,15 @@ "course.gradebook.ImportWizard.weightage": { "defaultMessage": "가중치" }, + "course.gradebook.ImportWizard.willChangeExisting": { + "defaultMessage": "{count, plural, one {#개 행에} other {#개 행에}} 기존 성적에 대한 변경 사항이 있습니다. 이 미리보기가 CSV와 일치하는지 확인한 후, 가져오기 확인을 클릭하여 가져오기 전에 이러한 충돌을 검토하세요." + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "외부 평가 {assessmentNames}의 {gradeCount, plural, one {#개 성적} other {#개 성적}}이 범위를 벗어났습니다. 내보내기 전에 검토하세요." + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "외부 평가 {assessmentNames}의 {gradeCount, plural, one {#개 성적} other {#개 성적}}이 범위를 벗어났으며, 가중 총점에서 상한 또는 하한이 적용됩니다. 내보내기 전에 검토하세요." + }, "course.gradebook.ProjectedTotalHint.policy": { "defaultMessage": "총점은 채점되지 않은 평가를 0점으로 계산합니다." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 35fd0bc9ba9..2d2d29bbd39 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9410,12 +9410,18 @@ "course.gradebook.AddExternalColumnPrompt.cancel": { "defaultMessage": "取消" }, + "course.gradebook.AddExternalColumnPrompt.capHint": { + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The grade you see is unchanged." + }, "course.gradebook.AddExternalColumnPrompt.create": { "defaultMessage": "创建" }, "course.gradebook.AddExternalColumnPrompt.error": { "defaultMessage": "无法创建外部评估。" }, + "course.gradebook.AddExternalColumnPrompt.floorHint": { + "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The grade you see is unchanged." + }, "course.gradebook.AddExternalColumnPrompt.maxLabel": { "defaultMessage": "最高分" }, @@ -9428,6 +9434,9 @@ "course.gradebook.AddExternalColumnPrompt.title": { "defaultMessage": "添加外部评估" }, + "course.gradebook.AddExternalColumnPrompt.weightLabel": { + "defaultMessage": "权重" + }, "course.gradebook.DeleteExternalColumnPrompt.body": { "defaultMessage": "要删除\"{title}\"吗?这将永久移除该列及其中每位学生的成绩。此操作无法撤销。" }, @@ -9443,8 +9452,20 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "删除外部评估" }, + "course.gradebook.EditExternalAssessmentPrompt.capHint": { + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The grade you see is unchanged." + }, + "course.gradebook.EditExternalAssessmentPrompt.floorHint": { + "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The grade you see is unchanged." + }, + "course.gradebook.EditExternalAssessmentPrompt.weightLabel": { + "defaultMessage": "权重" + }, "course.gradebook.ExternalGradeConflictPrompt.body": { - "defaultMessage": "这些学生在这些组成部分中已有成绩。要保留他们现有的成绩,还是用文件中的值替换?新学生和空白单元格不受影响。" + "defaultMessage": "部分学生在这些组件中已有成绩,且与文件中的值不同。选择“替换”将使用文件中的值覆盖现有成绩。选择“保留现有成绩”将保持现有成绩不变。" + }, + "course.gradebook.ExternalGradeConflictPrompt.changesSummary": { + "defaultMessage": "{changed}/{total} 行有更改" }, "course.gradebook.ExternalGradeConflictPrompt.goBack": { "defaultMessage": "返回" @@ -9458,20 +9479,11 @@ "course.gradebook.ExternalGradeConflictPrompt.title": { "defaultMessage": "解决成绩冲突" }, - "course.gradebook.ExternalGradeConflictTable.component": { - "defaultMessage": "组成部分" - }, - "course.gradebook.ExternalGradeConflictTable.existing": { - "defaultMessage": "现有成绩" - }, - "course.gradebook.ExternalGradeConflictTable.inFile": { - "defaultMessage": "文件中的成绩" - }, "course.gradebook.ExternalGradeConflictTable.mismatch": { "defaultMessage": "此标识符现在对应的学生与现有成绩导入时对应的学生不同。" }, - "course.gradebook.ExternalGradeConflictTable.student": { - "defaultMessage": "学生" + "course.gradebook.ExternalGradeConflictTable.name": { + "defaultMessage": "姓名" }, "course.gradebook.GradeLinkHint.hint": { "defaultMessage": "每个成绩都是学生提交内容中各项分数的总和。点击任一成绩即可打开该提交内容并调整分数。" @@ -9482,8 +9494,8 @@ "course.gradebook.GradebookIndex.noStudentsHint": { "defaultMessage": "学生加入课程后,成绩将显示在这里。" }, - "course.gradebook.GradebookTable.delete": { - "defaultMessage": "删除" + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "Accept" }, "course.gradebook.GradebookTable.externalBadge": { "defaultMessage": "外部" @@ -9491,21 +9503,27 @@ "course.gradebook.GradebookTable.externalGradeAria": { "defaultMessage": "{name} 的 {title} 成绩" }, - "course.gradebook.GradebookTable.externalMaxAria": { - "defaultMessage": "{title} 最高分" + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "This grade is below 0." }, - "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "无法保存成绩。请重试。" + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "This grade is below 0; it is floored to 0 in the weighted total." }, - "course.gradebook.GradebookTable.manageAria": { - "defaultMessage": "管理 {title}" + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "This grade exceeds the maximum of {max}." }, - "course.gradebook.GradebookTable.maxSaveError": { - "defaultMessage": "无法保存最高分。请重试。" + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "This grade exceeds the maximum of {max}; its contribution to the weighted total is capped at {max}." + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "无法保存成绩。请重试。" }, "course.gradebook.GradebookTable.rename": { "defaultMessage": "重命名" }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "Revert" + }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "显示模式" }, @@ -9539,11 +9557,23 @@ "course.gradebook.ImportWizard.email": { "defaultMessage": "电子邮件" }, + "course.gradebook.ImportWizard.externalId": { + "defaultMessage": "外部编号" + }, + "course.gradebook.ImportWizard.externalIdBlocked": { + "defaultMessage": "{count, plural, =0 {{name} 没有外部编号} one {{name} 和另外 1 名学生没有外部编号} other {{name} 和另外 # 名学生没有外部编号}}。请在 管理用户 中添加缺少的编号,以通过外部编号导入。" + }, + "course.gradebook.ImportWizard.externalIdHint": { + "defaultMessage": "匹配时将使用每位学生的外部编号。请在 管理用户 中保持外部编号为最新。" + }, "course.gradebook.ImportWizard.identifierMode": { - "defaultMessage": "匹配学生的依据" + "defaultMessage": "使用以下标识匹配学生成绩" }, "course.gradebook.ImportWizard.malformed": { - "defaultMessage": "以下单元格不是有效数字:{cells}" + "defaultMessage": "这些单元格不包含有效数字:" + }, + "course.gradebook.ImportWizard.malformedMore": { + "defaultMessage": "以及其他 {count} 个" }, "course.gradebook.ImportWizard.maxMarks": { "defaultMessage": "最高分" @@ -9551,9 +9581,24 @@ "course.gradebook.ImportWizard.next": { "defaultMessage": "下一步" }, + "course.gradebook.ImportWizard.outOfRangeSubtitle": { + "defaultMessage": "成绩将完全按照输入内容导入。如果这些超出范围的成绩正是您要导入的内容,请继续。" + }, + "course.gradebook.ImportWizard.outOfRangeTitle": { + "defaultMessage": "部分成绩超出了其有效范围。" + }, + "course.gradebook.ImportWizard.outOfRangeWeightedSubtitle": { + "defaultMessage": "成绩将完全按照输入内容导入。只有在加权总分中才会按下限或上限限制,这是每项评估的设置,可在编辑评估时更改。如果这些超出范围的成绩正是您要导入的内容,请继续。" + }, "course.gradebook.ImportWizard.previewError": { "defaultMessage": "无法验证文件。请重试。" }, + "course.gradebook.ImportWizard.previewFewRows": { + "defaultMessage": "正在预览全部 {totalRows} 行。请检查此预览是否与您的 CSV 一致,然后再继续。" + }, + "course.gradebook.ImportWizard.previewRows": { + "defaultMessage": "正在预览 {totalRows} 行中的前 5 行。请检查此预览是否与您的 CSV 一致,然后再继续。" + }, "course.gradebook.ImportWizard.stepDefine": { "defaultMessage": "定义组成部分" }, @@ -9572,8 +9617,11 @@ "course.gradebook.ImportWizard.title": { "defaultMessage": "导入外部评估" }, - "course.gradebook.ImportWizard.unresolved": { - "defaultMessage": "在课程中找不到以下标识符:{ids}" + "course.gradebook.ImportWizard.unresolvedEmail": { + "defaultMessage": "{count, plural, one {在课程中找不到此电子邮件地址:{ids}} other {在课程中找不到这些电子邮件地址:{ids}}}" + }, + "course.gradebook.ImportWizard.unresolvedExternalId": { + "defaultMessage": "{count, plural, one {在课程中找不到此外部编号:{ids}} other {在课程中找不到这些外部编号:{ids}}}" }, "course.gradebook.ImportWizard.updatesExisting": { "defaultMessage": "更新现有项目 - 在成绩册中管理" @@ -9587,6 +9635,15 @@ "course.gradebook.ImportWizard.weightage": { "defaultMessage": "权重" }, + "course.gradebook.ImportWizard.willChangeExisting": { + "defaultMessage": "{count, plural, one {# 行包含} other {# 行包含}}对现有成绩的更改。请先检查此预览是否与您的 CSV 一致,然后点击“确认导入”以在导入前查看这些冲突。" + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "外部评估 {assessmentNames} 中有 {gradeCount, plural, one {# 个成绩} other {# 个成绩}}超出了范围。请在导出前检查。" + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "外部评估 {assessmentNames} 中有 {gradeCount, plural, one {# 个成绩} other {# 个成绩}}超出了范围,并且会在加权总分中应用上限或下限限制。请在导出前检查。" + }, "course.gradebook.ProjectedTotalHint.policy": { "defaultMessage": "总分会将未评分的评估计为 0。" }, diff --git a/config/routes.rb b/config/routes.rb index bc3518af92f..a088e71faa3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -506,6 +506,9 @@ member do put 'grades' => 'external_assessments#grades' end + collection do + put 'reorder' => 'external_assessments#reorder' + end end resources :external_assessment_imports, only: [:create] do collection do diff --git a/db/migrate/20260624000000_add_position_to_course_external_assessments.rb b/db/migrate/20260624000000_add_position_to_course_external_assessments.rb new file mode 100644 index 00000000000..2ae4828ac77 --- /dev/null +++ b/db/migrate/20260624000000_add_position_to_course_external_assessments.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +class AddPositionToCourseExternalAssessments < ActiveRecord::Migration[7.2] + def up + add_column :course_external_assessments, :position, :integer + + # Backfill existing rows deterministically: per course, 0-based by id. + execute <<~SQL.squish + UPDATE course_external_assessments AS e + SET position = sub.rn - 1 + FROM ( + SELECT id, ROW_NUMBER() OVER (PARTITION BY course_id ORDER BY id) AS rn + FROM course_external_assessments + ) AS sub + WHERE e.id = sub.id + SQL + + change_column_null :course_external_assessments, :position, false + end + + def down + remove_column :course_external_assessments, :position + end +end \ No newline at end of file diff --git a/db/schema.rb b/db/schema.rb index 3171a997e12..057b553e580 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_06_23_000000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_24_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -788,6 +788,7 @@ t.datetime "updated_at", null: false t.boolean "floor_at_zero", default: true, null: false t.boolean "cap_at_maximum", default: true, null: false + t.integer "position", null: false t.index ["course_id", "title"], name: "index_course_external_assessments_on_course_id_and_title", unique: true t.index ["course_id"], name: "fk__course_external_assessments_course_id" t.index ["creator_id"], name: "fk__course_external_assessments_creator_id" diff --git a/spec/controllers/course/external_assessment_imports_controller_spec.rb b/spec/controllers/course/external_assessment_imports_controller_spec.rb index 6020f610926..529a7bc16ea 100644 --- a/spec/controllers/course/external_assessment_imports_controller_spec.rb +++ b/spec/controllers/course/external_assessment_imports_controller_spec.rb @@ -12,7 +12,7 @@ let!(:bob) { create(:course_student, course: course, external_id: 'A002') } let(:components) { [name: 'Midterm', weightage: 30, maximumGrade: 50] } - let(:csv_data) { "Identifier,Midterm\nA001,41\n" } + let(:csv_data) { "External ID,Midterm\nA001,41\n" } let(:base_params) do { course_id: course.id, format: :json, components: components, identifierMode: 'student_id', csvData: csv_data } @@ -32,7 +32,7 @@ end it 'returns ok:false with unresolved identifiers' do - post :preview, params: base_params.merge(csvData: "Identifier,Midterm\nZZZ,1\n") + post :preview, params: base_params.merge(csvData: "External ID,Midterm\nZZZ,1\n") data = JSON.parse(response.body) expect(data['ok']).to be(false) expect(data['unresolved']).to include('ZZZ') @@ -48,18 +48,18 @@ service = Course::Gradebook::ExternalAssessmentImportService.new( course: course, actor: manager.user, components: [name: 'Midterm', weightage: 30, maximum_grade: 50], - identifier_mode: 'student_id', csv_data: "Identifier,Midterm\nA001,10\n" + identifier_mode: 'student_id', csv_data: "External ID,Midterm\nA001,10\n" ) service.commit(on_conflict: 'replace') - post :preview, params: base_params.merge(csvData: "Identifier,Midterm\nA001,20\n") + post :preview, params: base_params.merge(csvData: "External ID,Midterm\nA001,20\n") data = JSON.parse(response.body) expect(data['conflicts'].size).to eq(1) expect(data['conflicts'].first['component']).to eq('Midterm') end it 'returns ok:false with malformed grade cells' do - post :preview, params: base_params.merge(csvData: "Identifier,Midterm\nA001,oops\n") + post :preview, params: base_params.merge(csvData: "External ID,Midterm\nA001,oops\n") data = JSON.parse(response.body) expect(data['ok']).to be(false) expect(data['malformed']).to be_present @@ -70,7 +70,7 @@ { name: 'Midterm', weightage: 20, maximumGrade: 40 }] post :preview, params: base_params.merge( components: dup_components, - csvData: "Identifier,Midterm,Midterm\nA001,1,2\n" + csvData: "External ID,Midterm,Midterm\nA001,1,2\n" ) expect(response).to have_http_status(:unprocessable_entity) end @@ -78,7 +78,7 @@ it 'resolves by email when identifierMode is email' do post :preview, params: base_params.merge( identifierMode: 'email', - csvData: "Identifier,Midterm\n#{alice.user.email},41\n" + csvData: "Email,Midterm\n#{alice.user.email},41\n" ) data = JSON.parse(response.body) expect(data['ok']).to be(true) @@ -111,7 +111,7 @@ it 'returns 422 and writes nothing on an unresolved identifier' do expect do post :create, params: base_params.merge( - csvData: "Identifier,Midterm\nZZZ,1\n", onConflict: 'replace' + csvData: "External ID,Midterm\nZZZ,1\n", onConflict: 'replace' ) end.not_to(change { Course::ExternalAssessmentGrade.count }) expect(response).to have_http_status(:unprocessable_entity) @@ -123,7 +123,7 @@ # Re-import with keep expect do post :create, params: base_params.merge(onConflict: 'keep', - csvData: "Identifier,Midterm\nA001,99\n") + csvData: "External ID,Midterm\nA001,99\n") end.not_to(change { Course::ExternalAssessmentGrade.count }) data = JSON.parse(response.body) expect(data['updatedComponents']).to eq(1) @@ -133,7 +133,7 @@ it 'returns 422 and writes nothing on malformed grade cells' do expect do post :create, params: base_params.merge( - csvData: "Identifier,Midterm\nA001,oops\n", onConflict: 'replace' + csvData: "External ID,Midterm\nA001,oops\n", onConflict: 'replace' ) end.not_to(change { Course::ExternalAssessmentGrade.count }) expect(response).to have_http_status(:unprocessable_entity) diff --git a/spec/controllers/course/external_assessments_controller_spec.rb b/spec/controllers/course/external_assessments_controller_spec.rb index 411895951e7..36fddb31b6d 100644 --- a/spec/controllers/course/external_assessments_controller_spec.rb +++ b/spec/controllers/course/external_assessments_controller_spec.rb @@ -314,5 +314,28 @@ end end end + + describe '#reorder' do + let!(:a) { create(:course_external_assessment, course: course) } + let!(:b) { create(:course_external_assessment, course: course) } + let!(:c) { create(:course_external_assessment, course: course) } + + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'rewrites positions to the given order' do + put :reorder, params: { course_id: course.id, format: :json, + orderedIds: [c.id, a.id, b.id] } + expect(response).to have_http_status(:ok) + expect([a.reload.position, b.reload.position, c.reload.position]).to eq([1, 2, 0]) + end + + it 'rejects a payload whose id set does not match' do + put :reorder, params: { course_id: course.id, format: :json, + orderedIds: [a.id, b.id] } + expect(response).to have_http_status(:unprocessable_entity) + end + end + end end end diff --git a/spec/models/course/external_assessment_spec.rb b/spec/models/course/external_assessment_spec.rb index aebd75e4b36..1574aa60338 100644 --- a/spec/models/course/external_assessment_spec.rb +++ b/spec/models/course/external_assessment_spec.rb @@ -124,5 +124,38 @@ expect(external.cap_at_maximum).to be(false) end end + + describe 'positioning' do + let(:course) { create(:course) } + + it 'appends new assessments at the end of the course' do + first = create(:course_external_assessment, course: course) + second = create(:course_external_assessment, course: course) + expect([first.reload.position, second.reload.position]).to eq([0, 1]) + end + + it 'scopes positions per course' do + other = create(:course) + a = create(:course_external_assessment, course: course) + b = create(:course_external_assessment, course: other) + expect([a.reload.position, b.reload.position]).to eq([0, 0]) + end + + describe '.reorder!' do + it 'rewrites positions to the given order' do + a = create(:course_external_assessment, course: course) + b = create(:course_external_assessment, course: course) + c = create(:course_external_assessment, course: course) + described_class.reorder!(course: course, ordered_ids: [c.id, a.id, b.id]) + expect([a.reload.position, b.reload.position, c.reload.position]).to eq([1, 2, 0]) + end + + it 'raises when the id set does not match the course externals' do + a = create(:course_external_assessment, course: course) + expect { described_class.reorder!(course: course, ordered_ids: [a.id, a.id + 999]) }. + to raise_error(ArgumentError) + end + end + end end end diff --git a/spec/services/course/gradebook/external_assessment_import_service_spec.rb b/spec/services/course/gradebook/external_assessment_import_service_spec.rb index b55eeb9be14..6594dc4557c 100644 --- a/spec/services/course/gradebook/external_assessment_import_service_spec.rb +++ b/spec/services/course/gradebook/external_assessment_import_service_spec.rb @@ -21,13 +21,13 @@ def service(csv_data:, components:, identifier_mode: 'student_id') describe '#preview' do it 'writes nothing (dry-run)' do - csv = "Identifier,Midterm\nA001,41\nA002,37\n" + csv = "External ID,Midterm\nA001,41\nA002,37\n" expect { service(csv_data: csv, components: components).preview }. not_to(change { Course::ExternalAssessmentGrade.count }) end it 'returns ok with the first 5 resolved rows (student names)' do - csv = "Identifier,Midterm\nA001,41\nA002,37\n" + csv = "External ID,Midterm\nA001,41\nA002,37\n" result = service(csv_data: csv, components: components).preview expect(result[:ok]).to be(true) expect(result[:unresolved]).to be_empty @@ -37,21 +37,21 @@ def service(csv_data:, components:, identifier_mode: 'student_id') end it 'resolves by email when in email mode' do - csv = "Identifier,Midterm\n#{alice.user.email},41\n" + csv = "Email,Midterm\n#{alice.user.email},41\n" result = service(csv_data: csv, components: components, identifier_mode: 'email').preview expect(result[:ok]).to be(true) expect(result[:sample].first[:studentName]).to eq(alice.name) end it 'fails the whole batch on any unresolved identifier' do - csv = "Identifier,Midterm\nA001,41\nZZZZ,37\n" + csv = "External ID,Midterm\nA001,41\nZZZZ,37\n" result = service(csv_data: csv, components: components).preview expect(result[:ok]).to be(false) expect(result[:unresolved]).to include('ZZZZ') end it 'flags a malformed (non-numeric) cell' do - csv = "Identifier,Midterm\nA001,oops\n" + csv = "External ID,Midterm\nA001,oops\n" result = service(csv_data: csv, components: components).preview expect(result[:ok]).to be(false) expect(result[:malformed]).to be_present @@ -60,7 +60,7 @@ def service(csv_data:, components:, identifier_mode: 'student_id') it 'rejects an in-file duplicate component name' do dup = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, { name: 'Midterm', weightage: 20, maximum_grade: 40 }] - csv = "Identifier,Midterm,Midterm\nA001,1,2\n" + csv = "External ID,Midterm,Midterm\nA001,1,2\n" expect { service(csv_data: csv, components: dup).preview }. to raise_error(described_class::ImportError) end @@ -72,7 +72,7 @@ def service(csv_data:, components:, identifier_mode: 'student_id') end it 'returns ok with empty sample when CSV has no data rows' do - csv = "Identifier,Midterm\n" + csv = "External ID,Midterm\n" result = service(csv_data: csv, components: components).preview expect(result[:ok]).to be(true) expect(result[:sample]).to be_empty @@ -80,30 +80,50 @@ def service(csv_data:, components:, identifier_mode: 'student_id') end it 'resolves by email case-insensitively' do - csv = "Identifier,Midterm\n#{alice.user.email.upcase},41\n" + csv = "Email,Midterm\n#{alice.user.email.upcase},41\n" result = service(csv_data: csv, components: components, identifier_mode: 'email').preview expect(result[:ok]).to be(true) expect(result[:sample].first[:studentName]).to eq(alice.name) end it 'deduplicates unresolved identifiers' do - csv = "Identifier,Midterm\nZZZZ,1\nZZZZ,2\n" + csv = "External ID,Midterm\nZZZZ,1\nZZZZ,2\n" result = service(csv_data: csv, components: components).preview expect(result[:unresolved].count('ZZZZ')).to eq(1) end it 'treats a blank cell as ungraded in the sample' do - csv = "Identifier,Midterm\nA001,\n" + csv = "External ID,Midterm\nA001,\n" result = service(csv_data: csv, components: components).preview expect(result[:sample].first[:grades]['Midterm']).to be_nil end + + it 'accepts the External ID header in student_id mode' do + csv = "External ID,Midterm\nA001,41\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:studentName]).to eq(alice.name) + end + + it 'accepts the Email header in email mode' do + csv = "Email,Midterm\n#{alice.user.email},41\n" + result = service(csv_data: csv, components: components, identifier_mode: 'email').preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:studentName]).to eq(alice.name) + end + + it 'rejects the legacy Identifier header' do + csv = "Identifier,Midterm\nA001,41\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) + end end describe '#commit (fresh import)' do let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } it 'creates the external in the External Assessments category with the typed weight' do - csv = "Identifier,Midterm\nA001,41\nA002,37\n" + csv = "External ID,Midterm\nA001,41\nA002,37\n" summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') expect(external).to be_present @@ -114,7 +134,7 @@ def service(csv_data:, components:, identifier_mode: 'student_id') end it 'writes one grade row per resolved student bound to course_user' do - csv = "Identifier,Midterm\nA001,41\n" + csv = "External ID,Midterm\nA001,41\n" service(csv_data: csv, components: components).commit(on_conflict: 'replace') external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') grade = external.external_assessment_grades.find_by!(course_user: alice) @@ -124,7 +144,7 @@ def service(csv_data:, components:, identifier_mode: 'student_id') end it 'skips a blank cell on a fresh import (no grade row created)' do - csv = "Identifier,Midterm\nA001,\n" + csv = "External ID,Midterm\nA001,\n" service(csv_data: csv, components: components).commit(on_conflict: 'replace') # After fix: blank cell on fresh import does NOT create a grade row (filter_map skips nil) external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') @@ -132,7 +152,7 @@ def service(csv_data:, components:, identifier_mode: 'student_id') end it 'accepts a grade greater than the max (no ceiling)' do - csv = "Identifier,Midterm\nA001,60\n" + csv = "External ID,Midterm\nA001,60\n" service(csv_data: csv, components: components).commit(on_conflict: 'replace') external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') expect(external.external_assessment_grades.find_by!(course_user: alice).grade).to eq(60) @@ -141,7 +161,7 @@ def service(csv_data:, components:, identifier_mode: 'student_id') it 'creates multiple components as separate externals' do comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, { name: 'Final', weightage: 50, maximum_grade: 100 }] - csv = "Identifier,Midterm,Final\nA001,40,80\n" + csv = "External ID,Midterm,Final\nA001,40,80\n" service(csv_data: csv, components: comps).commit(on_conflict: 'replace') expect(Course::ExternalAssessment.for_course(course).pluck(:title)).to contain_exactly('Midterm', 'Final') expect(Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm'). @@ -151,7 +171,7 @@ def service(csv_data:, components:, identifier_mode: 'student_id') end it 'writes nothing when an identifier does not resolve' do - csv = "Identifier,Midterm\nA001,41\nZZZ,9\n" + csv = "External ID,Midterm\nA001,41\nZZZ,9\n" expect do expect do service(csv_data: csv, components: components).commit(on_conflict: 'replace') @@ -164,14 +184,14 @@ def service(csv_data:, components:, identifier_mode: 'student_id') let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } def seed_initial! - csv = "Identifier,Midterm\nA001,10\n" + csv = "External ID,Midterm\nA001,10\n" service(csv_data: csv, components: components).commit(on_conflict: 'replace') Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') end it 'updates grades into the same component (no second tab)' do external = seed_initial! - csv = "Identifier,Midterm\nA001,20\n" + csv = "External ID,Midterm\nA001,20\n" service(csv_data: csv, components: components).commit(on_conflict: 'replace') expect(Course::ExternalAssessment.for_course(course).where(title: 'Midterm').count).to eq(1) expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(20) @@ -179,28 +199,28 @@ def seed_initial! it "keeps existing grades when on_conflict is 'keep'" do external = seed_initial! - csv = "Identifier,Midterm\nA001,99\n" + csv = "External ID,Midterm\nA001,99\n" service(csv_data: csv, components: components).commit(on_conflict: 'keep') expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) end it 'inserts a grade for a brand-new student regardless of on_conflict' do external = seed_initial! - csv = "Identifier,Midterm\nA002,55\n" + csv = "External ID,Midterm\nA002,55\n" service(csv_data: csv, components: components).commit(on_conflict: 'keep') expect(external.external_assessment_grades.find_by(course_user: bob).grade).to eq(55) end it 'skips a blank cell on upsert (existing grade unchanged)' do external = seed_initial! - csv = "Identifier,Midterm\nA001,\n" + csv = "External ID,Midterm\nA001,\n" service(csv_data: csv, components: components).commit(on_conflict: 'replace') expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) end it 'never changes the external max or contribution weight on upsert' do external = seed_initial! - csv = "Identifier,Midterm\nA001,20\n" + csv = "External ID,Midterm\nA001,20\n" comps = [name: 'Midterm', weightage: 99, maximum_grade: 999] service(csv_data: csv, components: comps).commit(on_conflict: 'replace') expect(external.reload.maximum_grade).to eq(50) @@ -209,7 +229,7 @@ def seed_initial! it 'lists conflicts only for existing non-blank grade rows' do seed_initial! - csv = "Identifier,Midterm\nA001,20\nA002,33\n" + csv = "External ID,Midterm\nA001,20\nA002,33\n" result = service(csv_data: csv, components: components).preview expect(result[:conflicts].map { |c| c[:studentName] }).to contain_exactly(alice.name) conflict = result[:conflicts].first @@ -221,7 +241,7 @@ def seed_initial! seed_initial! # alice imported under 'A001' bob.update!(external_id: 'A777') alice.update!(external_id: 'A002') # alice now owns A002 (formerly bob's) - csv = "Identifier,Midterm\nA002,20\n" # A002 now → alice, but her grade was imported as 'A001' + csv = "External ID,Midterm\nA002,20\n" # A002 now -> alice, but her grade was imported as 'A001' result = service(csv_data: csv, components: components).preview mismatch = result[:conflicts].find { |c| c[:studentName] == alice.name } expect(mismatch[:identifierMismatch]).to be(true) @@ -229,7 +249,7 @@ def seed_initial! it 'returns updatedComponents: 1 after an upsert' do seed_initial! - csv = "Identifier,Midterm\nA001,20\n" + csv = "External ID,Midterm\nA001,20\n" summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') expect(summary[:updatedComponents]).to eq(1) expect(summary[:createdComponents]).to eq(0) @@ -239,7 +259,7 @@ def seed_initial! external = seed_initial! # Manually clear the grade to nil (simulates a partial import that wrote the row but not the value) external.external_assessment_grades.find_by(course_user: alice).update_column(:grade, nil) - csv = "Identifier,Midterm\nA001,50\n" + csv = "External ID,Midterm\nA001,50\n" service(csv_data: csv, components: components).commit(on_conflict: 'keep') expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(50) end @@ -248,10 +268,10 @@ def seed_initial! comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, { name: 'Final', weightage: 50, maximum_grade: 100 }] # Seed both components - seed_csv = "Identifier,Midterm,Final\nA001,10,80\n" + seed_csv = "External ID,Midterm,Final\nA001,10,80\n" service(csv_data: seed_csv, components: comps).commit(on_conflict: 'replace') # Re-import with different values - csv = "Identifier,Midterm,Final\nA001,20,90\n" + csv = "External ID,Midterm,Final\nA001,20,90\n" result = service(csv_data: csv, components: comps).preview expect(result[:conflicts].map { |c| c[:component] }).to contain_exactly('Midterm', 'Final') end @@ -261,7 +281,7 @@ def seed_initial! let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } it 'does not move a grade when the student external_id changes after import' do - csv = "Identifier,Midterm\nA001,41\n" + csv = "External ID,Midterm\nA001,41\n" service(csv_data: csv, components: components).commit(on_conflict: 'replace') grade = Course::ExternalAssessmentGrade.last alice.update!(external_id: 'CHANGED')