diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb index 774a8fa36e1..a54d4dae4fe 100644 --- a/app/controllers/components/course/gradebook_component.rb +++ b/app/controllers/components/course/gradebook_component.rb @@ -7,6 +7,12 @@ def self.display_name end def sidebar_items + main_sidebar_items + settings_sidebar_items + end + + private + + def main_sidebar_items return [] unless can?(:read_gradebook, current_course) [ @@ -19,4 +25,17 @@ def sidebar_items } ] end + + def settings_sidebar_items + return [] unless can?(:manage_gradebook_settings, current_course) + + [ + { + key: self.class.key, + type: :settings, + weight: 14, + path: course_admin_gradebook_path(current_course) + } + ] + end end diff --git a/app/controllers/course/admin/gradebook_settings_controller.rb b/app/controllers/course/admin/gradebook_settings_controller.rb new file mode 100644 index 00000000000..6e6f3313ed5 --- /dev/null +++ b/app/controllers/course/admin/gradebook_settings_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Course::Admin::GradebookSettingsController < Course::Admin::Controller + def edit + respond_to(&:json) + end + + def update + if @settings.update(gradebook_settings_params) && current_course.save + render 'edit' + else + render json: { errors: @settings.errors }, status: :bad_request + end + end + + private + + def gradebook_settings_params + params.require(:settings_gradebook_component).permit(:weighted_view_enabled) + end + + def component + current_component_host[:course_gradebook_component] + end + + def authorize_admin + authorize! :manage_gradebook_settings, current_course + end +end diff --git a/app/controllers/course/external_assessments_controller.rb b/app/controllers/course/external_assessments_controller.rb new file mode 100644 index 00000000000..cf2c02fdadb --- /dev/null +++ b/app/controllers/course/external_assessments_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +class Course::ExternalAssessmentsController < Course::ComponentController + before_action :load_external_assessment, only: [:grades] + + def grades + authorize! :manage_gradebook_weights, current_course + # 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 load_external_assessment + @external_assessment = Course::ExternalAssessment.for_course(current_course).find(params[:id]) + rescue ActiveRecord::RecordNotFound + head :not_found + 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 4201a2f5f2c..515f6e35fe1 100644 --- a/app/controllers/course/gradebook_controller.rb +++ b/app/controllers/course/gradebook_controller.rb @@ -6,25 +6,76 @@ class Course::GradebookController < Course::ComponentController def index respond_to do |format| format.json do + @weighted_view_enabled = @settings.weighted_view_enabled @published_assessments = fetch_published_assessments @categories, @tabs = fetch_categories_and_tabs @students = fetch_students assessment_ids = @published_assessments.pluck(:id) + load_weighted_view_contributions(assessment_ids) if @weighted_view_enabled @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 ) + load_externals end end end + def update_weights + authorize! :manage_gradebook_weights, current_course + updates = update_weights_params[:weights].map { |entry| parse_weight_entry(entry) } + Course::Gradebook::Contribution.bulk_update(course: current_course, updates: updates) + render json: { weights: serialize_weight_updates(updates) } + rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + private def authorize_read_gradebook! authorize! :read_gradebook, current_course end + def load_weighted_view_contributions(assessment_ids) + @tab_contributions = Course::Gradebook::Contribution. + where(tab_id: @tabs.map(&:id)).index_by(&:tab_id) + @assessment_contributions = Course::Gradebook::AssessmentContribution. + where(assessment_id: assessment_ids).index_by(&:assessment_id) + end + + def parse_weight_entry(entry) + { + tab_id: entry[:tabId].to_i, + weight: entry[:weight].to_f, + weight_mode: entry[:weightMode] || 'equal', + excluded_assessment_ids: (entry[:excludedAssessmentIds] || []).map(&:to_i), + assessment_weights: (entry[:assessmentWeights] || []).map do |aw| + { assessment_id: aw[:assessmentId].to_i, weight: aw[:weight].to_f } + end + } + end + + def update_weights_params + params.permit( + weights: [:tabId, :weight, :weightMode, + excludedAssessmentIds: [], assessmentWeights: [:assessmentId, :weight]] + ) + end + + def serialize_weight_updates(updates) + updates.map do |u| + entry = { tabId: u[:tab_id], weight: u[:weight], weightMode: u[:weight_mode].to_s, + excludedAssessmentIds: u[:excluded_assessment_ids] } + if u[:weight_mode].to_s == 'custom' + entry[:assessmentWeights] = u[:assessment_weights].map do |aw| + { assessmentId: aw[:assessment_id], weight: aw[:weight] } + end + end + entry + end + end + def component current_component_host[:course_gradebook_component] end @@ -34,6 +85,15 @@ 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.course_users.students.without_phantom_users. calculated(:experience_points).includes(user: :emails).to_a diff --git a/app/models/components/course/gradebook_ability_component.rb b/app/models/components/course/gradebook_ability_component.rb index d5b9862f299..6b2221947de 100644 --- a/app/models/components/course/gradebook_ability_component.rb +++ b/app/models/components/course/gradebook_ability_component.rb @@ -3,7 +3,9 @@ module Course::GradebookAbilityComponent include AbilityHost::Component def define_permissions - can :read_gradebook, Course, id: course.id if course_user&.staff? + can :read_gradebook, Course, id: course.id if course_user&.manager_or_owner? + can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner? + can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner? super end end diff --git a/app/models/course.rb b/app/models/course.rb index 8a402ce88c8..b96153d668b 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -53,6 +53,10 @@ class Course < ApplicationRecord dependent: :destroy, inverse_of: :course has_many :assessment_tabs, source: :tabs, through: :assessment_categories 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.rb b/app/models/course/assessment.rb index aec93f9de08..7bde6a0d4bc 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -79,6 +79,9 @@ class Course::Assessment < ApplicationRecord inverse_of: :assessment, dependent: :destroy has_one :plagiarism_check, class_name: 'Course::Assessment::PlagiarismCheck', inverse_of: :assessment, dependent: :destroy, autosave: true + has_one :gradebook_assessment_contribution, + class_name: 'Course::Gradebook::AssessmentContribution', + dependent: :destroy, inverse_of: :assessment has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', inverse_of: :assessment, dependent: :destroy has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy diff --git a/app/models/course/assessment/tab.rb b/app/models/course/assessment/tab.rb index bb88b5287f2..c66eb85378b 100644 --- a/app/models/course/assessment/tab.rb +++ b/app/models/course/assessment/tab.rb @@ -8,6 +8,9 @@ class Course::Assessment::Tab < ApplicationRecord belongs_to :category, class_name: 'Course::Assessment::Category', inverse_of: :tabs 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..4da42d82d02 --- /dev/null +++ b/app/models/course/external_assessment.rb @@ -0,0 +1,49 @@ +# 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: :delete_all + + 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. + # rubocop:disable Metrics/ParameterLists -- factory mirrors the model's columns; named kwargs are clearer than a struct + 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 + # rubocop:enable Metrics/ParameterLists +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.rb b/app/models/course/gradebook.rb new file mode 100644 index 00000000000..ef1d5b3ac92 --- /dev/null +++ b/app/models/course/gradebook.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Course::Gradebook + def self.table_name_prefix + "#{Course.table_name.singularize}_gradebook_" + end +end diff --git a/app/models/course/gradebook/assessment_contribution.rb b/app/models/course/gradebook/assessment_contribution.rb new file mode 100644 index 00000000000..320a157b587 --- /dev/null +++ b/app/models/course/gradebook/assessment_contribution.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +class Course::Gradebook::AssessmentContribution < ApplicationRecord + belongs_to :assessment, class_name: 'Course::Assessment', + inverse_of: :gradebook_assessment_contribution + + validates :creator, presence: true + validates :updater, presence: true + validates :weight, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true + validates :excluded, inclusion: { in: [true, false] } + validates :assessment_id, uniqueness: true +end diff --git a/app/models/course/gradebook/contribution.rb b/app/models/course/gradebook/contribution.rb new file mode 100644 index 00000000000..0ae5cc78f29 --- /dev/null +++ b/app/models/course/gradebook/contribution.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true +class Course::Gradebook::Contribution < ApplicationRecord + # `prefix: true` keeps Rails from generating a bare `equal?` predicate that would + # override Ruby's Object#equal? (identity, arity 1). Helpers become + # `weight_mode_equal?` etc.; the `weight_mode` reader still returns 'equal'/'custom'. + enum :weight_mode, { equal: 0, custom: 1 }, prefix: true + + 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: { 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. + # Consumes the identical `updates` payload the controller parses today. + # Raises ActiveRecord::RecordNotFound if any tab_id/assessment_id is unknown, and + # ActiveRecord::RecordInvalid if validation fails or, for custom tabs, the included + # assessment weights do not sum (at 2dp) to the tab total; the transaction rolls back. + # + # @param course [Course] + # @param updates [Array] each { tab_id:, weight:, weight_mode:, + # 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 + 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: tab_updates.map { |e| e[:tab_id] }). + includes(:assessments).index_by(&:id) + + 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 + def self.apply_entry(course, tabs_by_id, entry) + tab = tabs_by_id[entry[:tab_id]] + mode = (entry[:weight_mode] || 'equal').to_s + + contribution = find_or_initialize_by(tab_id: tab.id) + contribution.course = course + contribution.assign_attributes(weight: entry[:weight], weight_mode: mode) + contribution.save! + + excluded_ids = entry[:excluded_assessment_ids] || [] + apply_assessment_exclusions(tab, excluded_ids) + + if mode == 'custom' + apply_custom_assessment_weights(tab, entry, excluded_ids.to_set) + else + clear_assessment_weights(tab) + end + 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) + end + private_class_method :assessment_contribution_for + + # @api private + # Membership applies in both modes: excluded ids -> true, the rest of the tab -> false. + def self.apply_assessment_exclusions(tab, excluded_ids) + excluded_set = excluded_ids.to_set + tab.assessments.each do |assessment| + ac = assessment_contribution_for(assessment) + ac.excluded = excluded_set.include?(assessment.id) + ac.save! + end + end + private_class_method :apply_assessment_exclusions + + # @api private + def self.clear_assessment_weights(tab) + tab.assessments.each do |assessment| + ac = assessment_contribution_for(assessment) + ac.weight = nil + ac.save! + end + end + private_class_method :clear_assessment_weights + + # @api private + def self.apply_custom_assessment_weights(tab, entry, excluded_ids) + assessments_by_id = tab.assessments.index_by(&:id) + included_sum = 0 + included_any = false + (entry[:assessment_weights] || []).each do |aw| + assessment = assessments_by_id[aw[:assessment_id]] + raise ActiveRecord::RecordNotFound if assessment.nil? + + ac = assessment_contribution_for(assessment) + ac.weight = aw[:weight] + ac.save! + next if excluded_ids.include?(aw[:assessment_id]) + + included_sum += aw[:weight] + included_any = true + end + validate_custom_assessment_weights_sum!(tab, entry, included_sum, included_any) + end + private_class_method :apply_custom_assessment_weights + + def self.validate_custom_assessment_weights_sum!(tab, entry, included_sum, included_any) + return unless included_any + return unless (included_sum * 100).round != (entry[:weight] * 100).round + + tab.errors.add(:base, :custom_weights_mismatch) + raise ActiveRecord::RecordInvalid, tab + end + + private + + def exactly_one_contributor + return if [tab_id, external_assessment_id].compact.size == 1 + + errors.add(:base, :exactly_one_contributor) + end + + def course_matches_contributor + return if course.nil? + + 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/settings/gradebook_component.rb b/app/models/course/settings/gradebook_component.rb new file mode 100644 index 00000000000..0f788086061 --- /dev/null +++ b/app/models/course/settings/gradebook_component.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +class Course::Settings::GradebookComponent < Course::Settings::Component + # Returns whether weighted view is enabled (disabled by default). + # + # @return [Boolean] Setting on whether weighted view is enabled. + def weighted_view_enabled + ActiveRecord::Type::Boolean.new.cast(settings.weighted_view_enabled) || false + end + + # Enable or disable the weighted view. + # + # @param [Boolean|Integer|String] value Setting on whether weighted view is enabled. + def weighted_view_enabled=(value) + settings.weighted_view_enabled = ActiveRecord::Type::Boolean.new.cast(value) + end +end diff --git a/app/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/views/course/admin/gradebook_settings/edit.json.jbuilder b/app/views/course/admin/gradebook_settings/edit.json.jbuilder new file mode 100644 index 00000000000..24c730f6bcb --- /dev/null +++ b/app/views/course/admin/gradebook_settings/edit.json.jbuilder @@ -0,0 +1,2 @@ +# frozen_string_literal: true +json.weightedViewEnabled @settings.weighted_view_enabled diff --git a/app/views/course/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 d2180773a40..8cab4699a09 100644 --- a/app/views/course/gradebook/index.json.jbuilder +++ b/app/views/course/gradebook/index.json.jbuilder @@ -1,20 +1,73 @@ # frozen_string_literal: true -json.categories @categories do |cat| - json.id cat.id - json.title cat.title +json.weightedViewEnabled @weighted_view_enabled +json.canManageWeights can?(:manage_gradebook_weights, current_course) + +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 +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 +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 json.students @students do |course_user| @@ -26,11 +79,21 @@ json.students @students do |course_user| 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 +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/Admin/Gradebook.ts b/client/app/api/course/Admin/Gradebook.ts new file mode 100644 index 00000000000..287e4b0c79d --- /dev/null +++ b/client/app/api/course/Admin/Gradebook.ts @@ -0,0 +1,23 @@ +import { AxiosResponse } from 'axios'; +import { + GradebookSettingsData, + GradebookSettingsPostData, +} from 'types/course/admin/gradebook'; + +import BaseAdminAPI from './Base'; + +export default class GradebookAdminAPI extends BaseAdminAPI { + override get urlPrefix(): string { + return `${super.urlPrefix}/gradebook`; + } + + index(): Promise> { + return this.client.get(this.urlPrefix); + } + + update( + data: GradebookSettingsPostData, + ): Promise> { + return this.client.patch(this.urlPrefix, data); + } +} diff --git a/client/app/api/course/Admin/index.ts b/client/app/api/course/Admin/index.ts index 966a1d3b05f..fcd4097b26d 100644 --- a/client/app/api/course/Admin/index.ts +++ b/client/app/api/course/Admin/index.ts @@ -6,6 +6,7 @@ import CommentsAdminAPI from './Comments'; import ComponentsAdminAPI from './Components'; import CourseAdminAPI from './Course'; import ForumsAdminAPI from './Forums'; +import GradebookAdminAPI from './Gradebook'; import LeaderboardAdminAPI from './Leaderboard'; import LessonPlanSettingsAPI from './LessonPlan'; import MaterialsAdminAPI from './Materials'; @@ -28,6 +29,7 @@ const AdminAPI = { lessonPlan: new LessonPlanSettingsAPI(), materials: new MaterialsAdminAPI(), forums: new ForumsAdminAPI(), + gradebook: new GradebookAdminAPI(), videos: new VideosAdminAPI(), notifications: new NotificationsSettingsAPI(), codaveri: new CodaveriAdminAPI(), diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts index e00c94a64c3..4fe79086b99 100644 --- a/client/app/api/course/Gradebook.ts +++ b/client/app/api/course/Gradebook.ts @@ -1,4 +1,8 @@ -import { GradebookData } from 'types/course/gradebook'; +import { + ExternalGradePayload, + GradebookData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; import { APIResponse } from 'api/types'; @@ -12,4 +16,20 @@ export default class GradebookAPI extends BaseCourseAPI { index(): APIResponse { return this.client.get(this.#urlPrefix); } + + updateWeights( + payload: UpdateWeightsPayload, + ): APIResponse { + return this.client.patch(`${this.#urlPrefix}/weights`, payload); + } + + setExternalGrade( + id: number, + payload: { studentId: number; grade: number | null }, + ): APIResponse { + return this.client.put( + `${this.#urlPrefix}/external_assessments/${id}/grades`, + payload, + ); + } } diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx new file mode 100644 index 00000000000..2a0c5363c40 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/GradebookSettingsForm.tsx @@ -0,0 +1,59 @@ +import { forwardRef } from 'react'; +import { Controller } from 'react-hook-form'; +import { Typography } from '@mui/material'; +import { GradebookSettingsData } from 'types/course/admin/gradebook'; + +import Section from 'lib/components/core/layouts/Section'; +import FormCheckboxField from 'lib/components/form/fields/CheckboxField'; +import Form, { FormRef } from 'lib/components/form/Form'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from './translations'; + +interface GradebookSettingsFormProps { + data: GradebookSettingsData; + onSubmit: (data: GradebookSettingsData) => void; + disabled?: boolean; +} + +const GradebookSettingsForm = forwardRef< + FormRef, + GradebookSettingsFormProps +>((props, ref): JSX.Element => { + const { t } = useTranslation(); + + return ( +
+ {(control): JSX.Element => ( +
+ ( + + )} + /> + + + {t(translations.weightedViewEnabledHint)} + +
+ )} +
+ ); +}); + +GradebookSettingsForm.displayName = 'GradebookSettingsForm'; + +export default GradebookSettingsForm; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx new file mode 100644 index 00000000000..0a0c95db7e0 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx @@ -0,0 +1,142 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import GradebookSettings from '../index'; + +const mock = createMockAdapter(CourseAPI.admin.gradebook.client); + +describe('', () => { + it('shows the loading indicator before settings load', () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + + render(); + + expect( + screen.queryByRole('checkbox', { name: /enable weighted grade view/i }), + ).not.toBeInTheDocument(); + }); + + it('renders the toggle unchecked when weightedViewEnabled is false', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + expect(checkbox).not.toBeChecked(); + }); + + it('renders the toggle checked when weightedViewEnabled is true', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: true }); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + expect(checkbox).toBeChecked(); + }); + + it('PATCHes when toggle is checked and form submitted', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + mock + .onPatch(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: true }); + + const spy = jest.spyOn(CourseAPI.admin.gradebook, 'update'); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + settings_gradebook_component: { weighted_view_enabled: true }, + }); + }); + }); + + it('shows a success toast after a successful save', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + mock + .onPatch(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: true }); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + expect( + await screen.findByText('Your changes have been saved.'), + ).toBeVisible(); + }); + + it('PATCHes false when toggle is unchecked and form submitted', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: true }); + mock + .onPatch(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + + const spy = jest.spyOn(CourseAPI.admin.gradebook, 'update'); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + settings_gradebook_component: { weighted_view_enabled: false }, + }); + }); + }); + + it('does not toast success when the save fails', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + mock + .onPatch(`/courses/${global.courseId}/admin/gradebook`) + .reply(422, { errors: { weightedViewEnabled: 'is invalid' } }); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect( + screen.queryByText('Your changes have been saved.'), + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx b/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx new file mode 100644 index 00000000000..122c063b90b --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/index.tsx @@ -0,0 +1,49 @@ +import { ComponentRef, useRef, useState } from 'react'; +import { GradebookSettingsData } from 'types/course/admin/gradebook'; + +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Preload from 'lib/components/wrappers/Preload'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; +import translations from 'lib/translations/form'; + +import { useItemsReloader } from '../../components/SettingsNavigation'; + +import GradebookSettingsForm from './GradebookSettingsForm'; +import { fetchGradebookSettings, updateGradebookSettings } from './operations'; + +const GradebookSettings = (): JSX.Element => { + const reloadItems = useItemsReloader(); + const { t } = useTranslation(); + const formRef = useRef>(null); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = (data: GradebookSettingsData): void => { + setSubmitting(true); + + updateGradebookSettings(data) + .then((newData) => { + if (!newData) return; + formRef.current?.resetTo?.(newData); + reloadItems(); + toast.success(t(translations.changesSaved)); + }) + .catch(formRef.current?.receiveErrors) + .finally(() => setSubmitting(false)); + }; + + return ( + } while={fetchGradebookSettings}> + {(data): JSX.Element => ( + + )} + + ); +}; + +export default GradebookSettings; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts b/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts new file mode 100644 index 00000000000..0d19aebc9da --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/operations.ts @@ -0,0 +1,32 @@ +import { AxiosError } from 'axios'; +import { + GradebookSettingsData, + GradebookSettingsPostData, +} from 'types/course/admin/gradebook'; + +import CourseAPI from 'api/course'; + +type Data = Promise; + +export const fetchGradebookSettings = async (): Data => { + const response = await CourseAPI.admin.gradebook.index(); + return response.data; +}; + +export const updateGradebookSettings = async ( + data: GradebookSettingsData, +): Data => { + const adaptedData: GradebookSettingsPostData = { + settings_gradebook_component: { + weighted_view_enabled: data.weightedViewEnabled, + }, + }; + + try { + const response = await CourseAPI.admin.gradebook.update(adaptedData); + return response.data; + } catch (error) { + if (error instanceof AxiosError) throw error.response?.data?.errors; + throw error; + } +}; diff --git a/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts b/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts new file mode 100644 index 00000000000..b1c71a60558 --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/translations.ts @@ -0,0 +1,17 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + gradebookSettings: { + id: 'course.admin.GradebookSettings.gradebookSettings', + defaultMessage: 'Gradebook settings', + }, + weightedViewEnabled: { + id: 'course.admin.GradebookSettings.weightedViewEnabled', + defaultMessage: 'Enable weighted grade view', + }, + weightedViewEnabledHint: { + id: 'course.admin.GradebookSettings.weightedViewEnabledHint', + defaultMessage: + 'Enables a "Weighted total" view in the gradebook where staff can configure per-tab weights and see a weighted Total column.', + }, +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx new file mode 100644 index 00000000000..2f1f87a0f6c --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx @@ -0,0 +1,546 @@ +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import ConfigureWeightsPrompt from '../components/ConfigureWeightsPrompt'; +import * as operations from '../operations'; + +// 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'); +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + +jest + .spyOn(operations, 'updateGradebookWeights') + .mockReturnValue(async () => {}); + +const A1 = 'Assignment 1'; +const A2 = 'Assignment 2'; +const ASSIGN_A1 = 'Assignments: Assignment 1'; +const INCLUDE_A1 = 'Include Assignment 1 in grade'; + +const categories = [{ id: 1, title: 'Missions' }]; +const tabs = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 }, + { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 }, +]; +// Differing max grades to prove equal mode is 1/n (NOT proportional to max grade). +const assessments = [ + { id: 101, title: A1, tabId: 10, maxGrade: 100 }, + { id: 102, title: A2, tabId: 10, maxGrade: 50 }, +]; + +const setup = (overrides = {}): ReturnType => + render( + , + ); + +const modeGroup = (tabTitle: string): HTMLElement => + screen.getByRole('radiogroup', { name: `${tabTitle} weight mode` }); + +describe('', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renders one tab total input per tab grouped by category', () => { + setup(); + expect(screen.getByText('Missions')).toBeInTheDocument(); + expect(screen.getByLabelText('Assignments')).toHaveValue(50); + expect(screen.getByLabelText('Optional')).toHaveValue(50); + }); + + it('shows Total: 100% with no warning when sum = 100', () => { + setup(); + expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); + expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument(); + }); + + it('shows warning when sum != 100', () => { + setup(); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '30' }, + }); + expect(screen.getByText(/Total:\s*80%/)).toBeInTheDocument(); + expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument(); + }); + + it('shows inline error for tab total > 100', () => { + setup(); + fireEvent.change(screen.getByLabelText('Assignments'), { + target: { value: '101' }, + }); + expect(screen.getByText(/must be at most 100/i)).toBeInTheDocument(); + }); + + it('shows inline error for negative tab total', () => { + setup(); + fireEvent.change(screen.getByLabelText('Optional'), { + target: { value: '-1' }, + }); + expect(screen.getByText(/must be at least 0/i)).toBeInTheDocument(); + }); + + it('accepts a 2dp tab total without error', () => { + setup(); + fireEvent.change(screen.getByLabelText('Assignments'), { + target: { value: '49.55' }, + }); + expect(screen.queryByText(/decimal places/i)).not.toBeInTheDocument(); + }); + + it('auto-rounds to 2 decimal places on blur', () => { + setup(); + const input = screen.getByLabelText('Assignments'); + fireEvent.change(input, { target: { value: '49.555' } }); + fireEvent.blur(input); + expect(input).toHaveValue(49.56); + expect(screen.queryByText(/decimal places/i)).not.toBeInTheDocument(); + }); + + it('defaults every tab to Equal mode', () => { + setup(); + expect( + within(modeGroup('Assignments')).getByRole('radio', { name: /equal/i }), + ).toHaveAttribute('aria-checked', 'true'); + }); + + it('equal mode preview shows tabTotal / n per assessment (ignores max grade)', () => { + setup(); + const expandBtns = screen.getAllByRole('button', { name: '' }); + fireEvent.click(expandBtns[0]); // expand Assignments (weight 50, n=2) + // 1/n => 25.00 each; proportional-to-maxGrade would be 33.33 / 16.67. + expect(screen.getAllByText('25.00% of grade')).toHaveLength(2); + }); + + it('equal mode preview rebalances % of grade across remaining included assessments', async () => { + setup(); + fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); // expand Assignments (50, n=2) + expect(screen.getAllByText('25.00% of grade')).toHaveLength(2); + // Exclude Assignment 2 -> the single remaining assessment now carries the full 50. + fireEvent.click( + await screen.findByRole('checkbox', { + name: 'Include Assignment 2 in grade', + }), + ); + expect(screen.getByText('50.00% of grade')).toBeInTheDocument(); + expect(screen.queryByText('25.00% of grade')).not.toBeInTheDocument(); + }); + + it('switching to Custom reveals per-assessment inputs seeded to sum the tab total', () => { + setup(); + fireEvent.click( + within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), + ); + expect(screen.getByLabelText(ASSIGN_A1)).toHaveValue(25); + expect(screen.getByLabelText('Assignments: Assignment 2')).toHaveValue(25); + }); + + it('switching to Custom preserves existing non-zero assessment weights instead of reseeding', () => { + setup({ + assessments: [ + { id: 101, title: A1, tabId: 10, maxGrade: 100, gradebookWeight: 40 }, + { id: 102, title: A2, tabId: 10, maxGrade: 50, gradebookWeight: 10 }, + ], + }); + fireEvent.click( + within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), + ); + // allZero === false -> seeds left untouched (NOT redistributed to 25/25). + expect(screen.getByLabelText(ASSIGN_A1)).toHaveValue(40); + expect(screen.getByLabelText('Assignments: Assignment 2')).toHaveValue(10); + }); + + it('shows an inline error Alert and disables Save when a custom tab is unbalanced', () => { + setup(); + fireEvent.click( + within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), + ); + fireEvent.change(screen.getByLabelText(ASSIGN_A1), { + target: { value: '10' }, // 10 + 25 = 35 != 50 + }); + expect(screen.getByText(/must sum to its tab total/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('shows the running assessment-weight sum against the tab total', () => { + setup(); + fireEvent.click( + within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), + ); + fireEvent.change(screen.getByLabelText(ASSIGN_A1), { + target: { value: '10' }, // 10 + 25 = 35 of 50 + }); + expect( + screen.getByText('Assessment weights: 35.00 / 50.00'), + ).toBeInTheDocument(); + }); + + it('Save sends weightMode for equal tabs and assessmentWeights for custom tabs', async () => { + setup(); + fireEvent.click( + within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), + ); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => { + expect(operations.updateGradebookWeights).toHaveBeenCalledWith([ + { + tabId: 10, + weight: 50, + weightMode: 'custom', + excludedAssessmentIds: [], + assessmentWeights: [ + { assessmentId: 101, weight: 25 }, + { assessmentId: 102, weight: 25 }, + ], + }, + { + tabId: 11, + weight: 50, + weightMode: 'equal', + excludedAssessmentIds: [], + }, + ]); + }); + }); + + it('calls onClose after a successful save', async () => { + const onClose = jest.fn(); + setup({ onClose }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); + + it('shows an error toast and keeps the dialog open when save fails', async () => { + (operations.updateGradebookWeights as jest.Mock).mockReturnValueOnce( + async () => { + throw new Error('boom'); + }, + ); + const onClose = jest.fn(); + setup({ onClose }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'Failed to save weights. Please try again.', + ), + ); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('seeds odd splits so they still sum exactly to the tab total', () => { + setup({ + tabs: [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 }, + ], + assessments: [ + { id: 101, title: 'A1', tabId: 10, maxGrade: 100 }, + { id: 102, title: 'A2', tabId: 10, maxGrade: 100 }, + { id: 103, title: 'A3', tabId: 10, maxGrade: 100 }, + ], + }); + fireEvent.click( + within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), + ); + expect(screen.getByLabelText('Assignments: A1')).toHaveValue(16.67); + expect(screen.getByLabelText('Assignments: A2')).toHaveValue(16.67); + expect(screen.getByLabelText('Assignments: A3')).toHaveValue(16.66); + expect( + screen.queryByText(/must sum to its tab total/i), + ).not.toBeInTheDocument(); + }); + + it('Cancel does not dispatch', () => { + setup(); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(operations.updateGradebookWeights).not.toHaveBeenCalled(); + }); + + it('disables the mode toggle + expand for tabs with no assessments', () => { + setup(); + const expandBtns = screen.getAllByRole('button', { name: '' }); + expect(expandBtns[1]).toBeDisabled(); + expect( + within(modeGroup('Optional')).getByRole('radio', { name: /custom/i }), + ).toBeDisabled(); + }); +}); + +describe('per-assessment exclusion', () => { + beforeEach(() => jest.clearAllMocks()); + + it('renders an include checkbox per assessment (expanded), checked by default', async () => { + setup(); + // expand the Assignments tab to reveal its assessments + fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); // first expand caret + const cb = await screen.findByRole('checkbox', { + name: INCLUDE_A1, + }); + expect(cb).toBeChecked(); + }); + + it('sends excludedAssessmentIds for unchecked assessments on save', async () => { + const onClose = jest.fn(); + setup({ onClose }); + fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); + fireEvent.click( + await screen.findByRole('checkbox', { + name: INCLUDE_A1, + }), + ); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(operations.updateGradebookWeights).toHaveBeenCalled(), + ); + const arg = (operations.updateGradebookWeights as jest.Mock).mock + .calls[0][0]; + const tab10 = arg.find((e: { tabId: number }) => e.tabId === 10); + expect(tab10.excludedAssessmentIds).toEqual([101]); + }); + + it('warns when every assessment in a tab is excluded', async () => { + setup(); + fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); + fireEvent.click( + await screen.findByRole('checkbox', { + name: INCLUDE_A1, + }), + ); + fireEvent.click( + await screen.findByRole('checkbox', { + name: 'Include Assignment 2 in grade', + }), + ); + expect( + screen.getByText(/contributes nothing to the total/i), + ).toBeInTheDocument(); + }); + + it('shows excluded count on the tab header when some assessments start excluded', () => { + setup({ + assessments: [ + { + id: 101, + title: A1, + tabId: 10, + maxGrade: 100, + gradebookExcluded: true, + }, + { id: 102, title: A2, tabId: 10, maxGrade: 50 }, + ], + }); + // Badge is in the header row — no expand needed + expect(screen.getByText('1 excluded')).toBeInTheDocument(); + }); + + it('does not show excluded count when no assessments are excluded', () => { + setup(); + expect(screen.queryByText(/excluded/i)).not.toBeInTheDocument(); + }); + + it('updates the excluded count when user toggles a checkbox', async () => { + setup(); + // Expand and exclude one assessment + fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); + fireEvent.click( + await screen.findByRole('checkbox', { + name: INCLUDE_A1, + }), + ); + expect(screen.getByText('1 excluded')).toBeInTheDocument(); + // Re-include it — count should disappear + fireEvent.click(screen.getByRole('checkbox', { name: INCLUDE_A1 })); + expect(screen.queryByText(/excluded/)).not.toBeInTheDocument(); + }); + + it('labels the chip "All N excluded" when every assessment is excluded', () => { + setup({ + assessments: [ + { + id: 101, + title: A1, + tabId: 10, + maxGrade: 100, + gradebookExcluded: true, + }, + { + id: 102, + title: A2, + tabId: 10, + maxGrade: 50, + gradebookExcluded: true, + }, + ], + }); + expect(screen.getByText('All 2 excluded')).toBeInTheDocument(); + }); + + it('shows 0 in the weight field and disables it when all assessments are excluded', () => { + setup({ + assessments: [ + { + id: 101, + title: A1, + tabId: 10, + maxGrade: 100, + gradebookExcluded: true, + }, + { + id: 102, + title: A2, + tabId: 10, + maxGrade: 50, + gradebookExcluded: true, + }, + ], + }); + const field = screen.getByLabelText('Assignments'); + expect(field).toHaveValue(0); + expect(field).toBeDisabled(); + }); + + it('drops an all-excluded tab from the Total and restores it on re-include', async () => { + setup({ + assessments: [ + { + id: 101, + title: A1, + tabId: 10, + maxGrade: 100, + gradebookExcluded: true, + }, + { + id: 102, + title: A2, + tabId: 10, + maxGrade: 50, + gradebookExcluded: true, + }, + { id: 201, title: 'Optional 1', tabId: 11, maxGrade: 100 }, + ], + }); + // Assignments (50) is all-excluded -> only Optional (50) counts toward Total. + expect(screen.getByText(/Total:\s*50%/)).toBeInTheDocument(); + // Re-include one assessment -> Assignments contributes its 50 again. + fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); + fireEvent.click( + await screen.findByRole('checkbox', { + name: INCLUDE_A1, + }), + ); + expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); + }); + + it('still persists the retained tab weight when all-excluded (display 0 only)', async () => { + setup({ + assessments: [ + { + id: 101, + title: A1, + tabId: 10, + maxGrade: 100, + gradebookExcluded: true, + }, + { + id: 102, + title: A2, + tabId: 10, + maxGrade: 50, + gradebookExcluded: true, + }, + ], + tabs: [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 }, + ], + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(operations.updateGradebookWeights).toHaveBeenCalled(), + ); + const arg = (operations.updateGradebookWeights as jest.Mock).mock + .calls[0][0]; + expect(arg[0]).toMatchObject({ + tabId: 10, + weight: 50, + excludedAssessmentIds: [101, 102], + }); + }); + + it('seeds checkboxes from gradebookExcluded and restores weight on re-include', async () => { + setup({ + assessments: [ + { + id: 101, + title: A1, + tabId: 10, + maxGrade: 100, + gradebookWeight: 50, + gradebookExcluded: true, + }, + { + id: 102, + title: A2, + tabId: 10, + maxGrade: 50, + gradebookWeight: 0, + }, + ], + tabs: [ + { + id: 10, + title: 'Assignments', + categoryId: 1, + gradebookWeight: 50, + weightMode: 'custom', + }, + ], + }); + fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); + const cb = await screen.findByRole('checkbox', { + name: INCLUDE_A1, + }); + expect(cb).not.toBeChecked(); + // re-include -> its retained weight (50) is still in the input + fireEvent.click(cb); + expect(screen.getByLabelText(ASSIGN_A1)).toHaveValue(50); + }); + + describe('default weights when unconfigured', () => { + const zeroTabs = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 0 }, + { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 0 }, + ]; + const bothPopulated = [ + { id: 101, title: 'Graded item', tabId: 10, maxGrade: 100 }, + { id: 201, title: 'Bonus item', tabId: 11, maxGrade: 100 }, + ]; + + it('pre-fills an equal split summing to 100 and shows the defaults hint', () => { + setup({ tabs: zeroTabs, assessments: bothPopulated }); + expect(screen.getByText(/no weights set yet/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Assignments')).toHaveValue(50); + expect(screen.getByLabelText('Optional')).toHaveValue(50); + expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); + }); + + it('gives empty tabs 0% and the full default to the populated tab', () => { + // Only tab 10 has an assessment (shared fixture), so it absorbs all 100. + setup({ tabs: zeroTabs }); + expect(screen.getByLabelText('Assignments')).toHaveValue(100); + expect(screen.getByLabelText('Optional')).toHaveValue(0); + }); + + it('does not show the defaults hint once a weight is configured', () => { + setup(); // shared fixture tabs carry 50/50 + expect(screen.queryByText(/no weights set yet/i)).not.toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index 25b66fd3ed3..c9f01f0dd02 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from 'test-utils'; +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; import toast from 'lib/hooks/toast'; @@ -32,6 +32,8 @@ const emptyState = { students: [], submissions: [], gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, }, }; @@ -43,6 +45,8 @@ const noStudentsState = { students: [], submissions: [], gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, }, }; @@ -65,6 +69,8 @@ const populatedState = { { studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 }, ], gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, }, }; @@ -75,6 +81,39 @@ const populatedStateWithGamification = { }, }; +const populatedStateWithWeightedView = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: false, + }, +}; + +const populatedStateWithWeightedViewAndGamification = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + gamificationEnabled: true, + canManageWeights: false, + }, +}; + +const populatedStateManagerWeightedOff = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: false, + canManageWeights: true, + }, +}; + +const populatedStateManagerWeightedOn = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: true, + }, +}; + beforeEach(() => { jest.clearAllMocks(); mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); @@ -146,4 +185,66 @@ describe('GradebookIndex', () => { ), ).toBeInTheDocument(); }); + + it('does not render view toggle when weightedViewEnabled is false', async () => { + render(, { state: populatedState }); + // Wait for loading to finish + await screen.findByRole('button', { name: /export/i }); + expect(screen.queryByText(/weighted total/i)).not.toBeInTheDocument(); + }); + + it('renders view toggle when weightedViewEnabled is true', async () => { + render(, { state: populatedStateWithWeightedView }); + expect(await screen.findByText(/all assessments/i)).toBeInTheDocument(); + expect(await screen.findByText(/weighted total/i)).toBeInTheDocument(); + }); + + it('switches to Weighted total view on toggle click', async () => { + render(, { state: populatedStateWithWeightedView }); + const byWeightButton = await screen.findByText(/weighted total/i); + fireEvent.click(byWeightButton); + expect( + await screen.findByTestId('gradebook-weighted-table'), + ).toBeInTheDocument(); + }); + + it('weighted view does not expose gamification columns in picker', async () => { + render(, { + state: populatedStateWithWeightedViewAndGamification, + }); + const byWeightButton = await screen.findByText(/weighted total/i); + fireEvent.click(byWeightButton); + await screen.findByTestId('gradebook-weighted-table'); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); + }); + + describe('weighted-view discoverability hint', () => { + it('shows the hint to managers when the weighted view is off', async () => { + render(, { state: populatedStateManagerWeightedOff }); + expect( + await screen.findByRole('link', { name: /gradebook settings/i }), + ).toBeInTheDocument(); + }); + + it('does not show the hint once the weighted view is enabled', async () => { + render(, { state: populatedStateManagerWeightedOn }); + await screen.findByText(/weighted total/i); // wait for data to load + expect( + screen.queryByRole('link', { name: /gradebook settings/i }), + ).not.toBeInTheDocument(); + }); + + it('does not show the hint to staff who cannot manage weights', async () => { + render(, { state: populatedState }); + await screen.findByRole('button', { name: /export/i }); // wait for load + expect( + screen.queryByRole('link', { name: /gradebook settings/i }), + ).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/ProjectedTotalHint.test.tsx b/client/app/bundles/course/gradebook/__tests__/ProjectedTotalHint.test.tsx new file mode 100644 index 00000000000..45250fe1ba1 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ProjectedTotalHint.test.tsx @@ -0,0 +1,54 @@ +import { store as appStore } from 'store'; +import { fireEvent, render, screen } from 'test-utils'; + +import ProjectedTotalHint, { + PROJECTED_TOTAL_POLICY_HINT_KEY, +} from '../components/ProjectedTotalHint'; + +// 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 USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:${PROJECTED_TOTAL_POLICY_HINT_KEY}`; + +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +const renderHint = (): void => { + render(, { state: userState }); +}; + +beforeEach(() => localStorage.clear()); + +describe('ProjectedTotalHint', () => { + it('teaches the projected-total policy on first view', () => { + renderHint(); + expect(screen.getByText(/ungraded assessments as 0/i)).toBeInTheDocument(); + }); + + it('hides and persists dismissal when the close button is clicked', () => { + renderHint(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + + expect( + screen.queryByText(/ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); + + it('does not render when already dismissed', () => { + localStorage.setItem(STORAGE_KEY, 'true'); + renderHint(); + expect( + screen.queryByText(/ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx new file mode 100644 index 00000000000..1a1138fa75c --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx @@ -0,0 +1,161 @@ +import { render, screen, within } from 'test-utils'; + +import WeightedGradebookColumnTree from '../components/WeightedGradebookColumnTree'; + +// 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 STUDENT_INFO = 'Student info'; + +const baseContext = { + isVisible: (): boolean => false, + setVisible: (): void => {}, + setManyVisible: (): void => {}, +}; + +describe('WeightedGradebookColumnTree', () => { + it('renders Student info group with a locked Name and a toggleable Email', () => { + render(); + expect(screen.getByText(STUDENT_INFO)).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Always included')).toBeInTheDocument(); + expect(screen.getByText('Email')).toBeInTheDocument(); + }); + + it('renders Name as a checked, disabled checkbox that cannot be toggled', () => { + const setVisible = jest.fn(); + render( + , + ); + const nameRow = screen.getByText('Name').closest('label')!; + const nameCheckbox = within(nameRow).getByRole('checkbox'); + expect(nameCheckbox).toBeChecked(); + expect(nameCheckbox).toBeDisabled(); + nameCheckbox.click(); + expect(setVisible).not.toHaveBeenCalled(); + }); + + it('renders Email and External ID checked when isVisible returns true', () => { + render( + true} + />, + ); + const emailRow = screen.getByText('Email').closest('label')!; + expect(within(emailRow).getByRole('checkbox')).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: /external id/i }), + ).toBeChecked(); + }); + + it('calls setVisible with false when a visible Email checkbox is unchecked', () => { + const setVisible = jest.fn(); + render( + true} + setVisible={setVisible} + />, + ); + const emailRow = screen.getByText('Email').closest('label')!; + within(emailRow).getByRole('checkbox').click(); + expect(setVisible).toHaveBeenCalledWith('email', false); + }); + + it('does not render Gamification group', () => { + render(); + expect(screen.queryByText('Gamification')).not.toBeInTheDocument(); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + expect(screen.queryByText('Total XP')).not.toBeInTheDocument(); + }); + + it('calls setVisible when Email is toggled', async () => { + const setVisible = jest.fn(); + render( + , + ); + const emailRow = screen.getByText('Email').closest('label')!; + within(emailRow).getByRole('checkbox').click(); + expect(setVisible).toHaveBeenCalledWith('email', true); + }); + + it('renders an External ID checkbox in the Student info group', () => { + render(); + expect( + screen.getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + + it('calls setVisible with externalId when the External ID checkbox is toggled', () => { + const setVisible = jest.fn(); + render( + , + ); + screen.getByRole('checkbox', { name: /external id/i }).click(); + expect(setVisible).toHaveBeenCalledWith('externalId', true); + }); + + it('renders the Student info parent checkbox unchecked when no children are visible', () => { + render(); + const groupRow = screen.getByText(STUDENT_INFO).closest('label')!; + expect(within(groupRow).getByRole('checkbox')).not.toBeChecked(); + }); + + it('renders the Student info parent checkbox checked when all children are visible', () => { + render( + true} + />, + ); + const groupRow = screen.getByText(STUDENT_INFO).closest('label')!; + expect(within(groupRow).getByRole('checkbox')).toBeChecked(); + }); + + it('shows the Student info parent checkbox as indeterminate when only some children are visible', () => { + render( + id === 'email'} + />, + ); + expect( + screen.getByRole('checkbox', { name: /student info/i }), + ).toHaveAttribute('data-indeterminate', 'true'); + }); + + it('calls setManyVisible to bulk-show all Student info columns when the parent is checked', () => { + const setManyVisible = jest.fn(); + render( + , + ); + const groupRow = screen.getByText(STUDENT_INFO).closest('label')!; + within(groupRow).getByRole('checkbox').click(); + expect(setManyVisible).toHaveBeenCalledWith( + ['name', 'email', 'externalId'], + true, + ); + }); + + it('calls setManyVisible to bulk-hide all Student info columns when the parent is unchecked', () => { + const setManyVisible = jest.fn(); + render( + true} + setManyVisible={setManyVisible} + />, + ); + const groupRow = screen.getByText(STUDENT_INFO).closest('label')!; + within(groupRow).getByRole('checkbox').click(); + expect(setManyVisible).toHaveBeenCalledWith( + ['name', 'email', 'externalId'], + false, + ); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx new file mode 100644 index 00000000000..bc97ab584a8 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx @@ -0,0 +1,1333 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; + +import WeightedGradebookTable from '../components/WeightedGradebookTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +// 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 USER_ID = 42; +const WEIGHTED_STORAGE_KEY = `${USER_ID}:gradebook_weighted_columns_1`; +const EXTERNAL_ID = 'External ID'; + +// Mirrors the component's data-testid for a breakdown row: +// `breakdown-row-${studentId}-${tabId}-${assessmentId}`. +const breakdownRowId = ( + studentId: number, + tabId: number, + assessmentId: number, +): string => `breakdown-row-${studentId}-${tabId}-${assessmentId}`; +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +// --------------------------------------------------------------------------- +// Minimal shared fixtures +// --------------------------------------------------------------------------- +const makeCategory = (id: number, title: string): CategoryData => ({ + id, + title, +}); + +const makeTab = ( + id: number, + title: string, + categoryId: number, + gradebookWeight = 50, +): TabData => ({ id, title, categoryId, gradebookWeight }); + +const makeAssessment = ( + id: number, + title: string, + tabId: number, + maxGrade: number, +): AssessmentData => ({ id, title, tabId, maxGrade }); + +const makeStudent = (id: number, name: string): StudentData => ({ + id, + name, + email: `${name.toLowerCase()}@example.com`, + externalId: null, + level: 1, + totalXp: 0, +}); + +const makeSub = ( + studentId: number, + assessmentId: number, + grade: number | null, +): SubmissionData => ({ submissionId: 0, studentId, assessmentId, grade }); + +interface RenderWeightedOptions { + categories?: CategoryData[]; + tabs?: TabData[]; + assessments?: AssessmentData[]; + students?: StudentData[]; + submissions?: SubmissionData[]; + canManageWeights?: boolean; + courseTitle?: string; + courseId?: number; +} + +const renderWeighted = ( + opts: RenderWeightedOptions = {}, +): ReturnType => { + const cats = opts.categories ?? [makeCategory(1, 'Cat A')]; + const tabs = opts.tabs ?? [makeTab(10, 'Tab 1', 1, 100)]; + const assessments = opts.assessments ?? [ + makeAssessment(100, 'Quiz 1', 10, 150), + ]; + const students = opts.students ?? [makeStudent(1, 'Alice')]; + const submissions = opts.submissions ?? []; + return render( + , + { state: userState }, + ); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('WeightedGradebookTable', () => { + beforeEach(() => localStorage.clear()); + + // 1. Row 1: category cells with colSpan + it('renders category cells in row 1 with colSpan equal to number of tabs in that category', () => { + const cats = [makeCategory(1, 'Cat A'), makeCategory(2, 'Cat B')]; + const tabs = [ + makeTab(10, 'Tab 1', 1, 50), + makeTab(11, 'Tab 2', 1, 50), + makeTab(20, 'Tab 3', 2, 0), + ]; + const assessments = [ + makeAssessment(100, 'Q1', 10, 10), + makeAssessment(101, 'Q2', 11, 10), + makeAssessment(102, 'Q3', 20, 10), + ]; + renderWeighted({ categories: cats, tabs, assessments }); + const thead = document.querySelector('thead')!; + const rows = thead.querySelectorAll('tr'); + const row1Cells = rows[0].querySelectorAll('th'); + const catACell = Array.from(row1Cells).find( + (c) => c.textContent === 'Cat A', + ); + const catBCell = Array.from(row1Cells).find( + (c) => c.textContent === 'Cat B', + ); + expect(catACell).toBeTruthy(); + expect(catBCell).toBeTruthy(); + expect( + catACell!.getAttribute('colspan') ?? catACell!.colSpan.toString(), + ).toBe('2'); + expect( + catBCell!.getAttribute('colspan') ?? catBCell!.colSpan.toString(), + ).toBe('1'); + }); + + // 1b. Category not in tabs → header absent + it('does not render a category header for categories with no tabs', () => { + renderWeighted({ + categories: [makeCategory(1, 'Active'), makeCategory(2, 'Ghost')], + tabs: [makeTab(10, 'Tab 1', 1, 100)], // only category 1 has a tab + }); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.queryByText('Ghost')).not.toBeInTheDocument(); + }); + + // 2. Row 2: tab title cells + it('renders tab title cells in row 2', () => { + renderWeighted({ + tabs: [makeTab(10, 'Homework', 1, 60), makeTab(11, 'Exams', 1, 40)], + }); + const thead = document.querySelector('thead')!; + const row2 = thead.querySelectorAll('tr')[1]; + expect( + within(row2 as HTMLElement).getByText('Homework'), + ).toBeInTheDocument(); + expect(within(row2 as HTMLElement).getByText('Exams')).toBeInTheDocument(); + }); + + // 3. Weight subheader shows "/N" (points-out-of) per tab by default + it('shows "/N" points subheader for each tab in row 3 by default', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 70)], + }); + expect(screen.getByText('/30')).toBeInTheDocument(); + expect(screen.getByText('/70')).toBeInTheDocument(); + }); + + // 4a. Total column shows "/100" when sum = 100 (points default) + it('shows "/100" in total column header when weights sum to 100', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 40)], + }); + expect(screen.getByText('/100')).toBeInTheDocument(); + }); + + // 4b. Total column shows just "/N" on one line when sum ≠ 100 + it('shows "/N" when weight sum ≠ 100 in total header', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], + }); + expect(screen.getByText('/60')).toBeInTheDocument(); + }); + + // 4c. Hovering the warning-coloured total reveals the full message via tooltip + it('total tooltip explains the unbalanced sum on hover', async () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 20)], + }); + await userEvent.hover(screen.getByText('/80')); + await waitFor(() => + expect( + screen.getByText('Weights do not sum to 100. Total may be inaccurate.'), + ).toBeInTheDocument(), + ); + }); + + // 4b. Total column shows just "/N" on one line when sum ≠ 100 + it('shows "N% total" when sum ≠ 100 in total header', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], + }); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + const thead = document.querySelector('thead')!; + const row3 = thead.querySelectorAll('tr')[2] as HTMLElement; + expect(within(row3).getByText('60% total')).toBeInTheDocument(); + }); + + // 4d. Percent mode, weights = 100 → total header shows exact "100% total" + it('total column header shows "100% total" in percent mode when sum = 100', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 40)], + }); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + const thead = document.querySelector('thead')!; + const row3 = thead.querySelectorAll('tr')[2] as HTMLElement; + expect(within(row3).getByText('100% total')).toBeInTheDocument(); + }); + + // 5. Cell renders subtotal × weight as points (not percentage); non-integer → 2dp + it('renders cell as subtotal × weight in points (not a percentage); 2dp when non-integer', () => { + // grade=130, maxGrade=150 → subtotal=130/150≈0.8667; weight=100 + // points = 0.8667 × 100 = 86.67 (non-integer → 2dp) + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 150)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 130)], + }); + expect(screen.getAllByText('86.67').length).toBeGreaterThanOrEqual(1); + }); + + // 5b. Column precision: 1dp — all values shown at 1dp when any value needs 1dp + it('shows 1dp for all values in a column when any value needs 1dp but none needs 2dp', () => { + // grade=15, maxGrade=40, weight=100 → 37.5 (needs 1dp) + // grade=20, maxGrade=40, weight=100 → 50.0 (integer alone, but column needs 1dp) + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 40)], + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + submissions: [makeSub(1, 100, 15), makeSub(2, 100, 20)], + }); + // Tab cell + total cell both show the same value for single-tab setup + expect(screen.getAllByText('37.5').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('50.0').length).toBeGreaterThanOrEqual(1); + // Confirm the old 2dp format is NOT shown + expect(screen.queryByText('37.50')).not.toBeInTheDocument(); + expect(screen.queryByText('50.00')).not.toBeInTheDocument(); + }); + + // 5c. Column precision: 2dp forces all values to 2dp, even whole numbers + it('shows 2dp for ALL values in a column when any value needs 2dp', () => { + // Alice: grade=130, maxGrade=150, weight=100 → 86.67 (needs 2dp) + // Bob: grade=150, maxGrade=150, weight=100 → 100 (integer, but column needs 2dp) + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 150)], + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + submissions: [makeSub(1, 100, 130), makeSub(2, 100, 150)], + }); + expect(screen.getAllByText('86.67').length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('100.00').length).toBeGreaterThanOrEqual(1); + // Confirm the integer format is NOT shown in any table cell + expect( + screen.queryAllByRole('cell').some((c) => c.textContent === '100'), + ).toBe(false); + }); + + // 5d. Different columns can have independent precision + it('applies column precision independently per tab column', () => { + // Tab 1 weight=100: Alice → 86.67 (2dp); Tab 2 weight=40: Alice → 36 (integer) + renderWeighted({ + categories: [makeCategory(1, 'Cat A')], + tabs: [makeTab(10, 'Tab 1', 1, 100), makeTab(20, 'Tab 2', 1, 40)], + assessments: [ + makeAssessment(100, 'Q1', 10, 150), + makeAssessment(200, 'Q2', 20, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 130), makeSub(1, 200, 90)], + }); + // Tab 1: 86.67 (2dp); Tab 2: 36 (integer) + expect(screen.getByText('86.67')).toBeInTheDocument(); + expect(screen.getByText('36')).toBeInTheDocument(); + }); + + // 5e. Total column precision is independent of tab column precision + it('total column uses its own column precision', () => { + // Tab 1 weight=100: Alice=86.67, Bob=100.00; total=86.67 and 100.00 + // Both totals have same precision as tab (2dp), but they're independently computed + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 150)], + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + submissions: [makeSub(1, 100, 130), makeSub(2, 100, 150)], + }); + // Total for Alice = 86.67 (needs 2dp), so all totals show 2dp + expect(screen.getAllByText('86.67').length).toBeGreaterThanOrEqual(2); // tab cell + total cell + expect(screen.getAllByText('100.00').length).toBeGreaterThanOrEqual(2); + }); + + // 6a. Tab with no assessments → cell shows "—" + it('shows "—" for a tab with no assessments', () => { + renderWeighted({ + tabs: [makeTab(10, 'Empty Tab', 1, 100)], + assessments: [], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + const aliceRow = screen.getByText('Alice').closest('tr')!; + const cells = within(aliceRow).getAllByRole('cell'); + // The tab cell (between Name and Total) renders "—" for an empty tab. + expect(cells[cells.length - 2]).toHaveTextContent('—'); + }); + + // 6b. Total cell shows "—" when every tab subtotal is null (no assessments at all) + it('shows "—" in the total cell when the row has no contributing tabs', () => { + renderWeighted({ + tabs: [makeTab(10, 'Empty Tab', 1, 100)], + assessments: [], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + const aliceRow = screen.getByText('Alice').closest('tr')!; + const cells = within(aliceRow).getAllByRole('cell'); + expect(cells[cells.length - 1]).toHaveTextContent('—'); + }); + + // 7. Student with no graded submissions → cell shows 0 (ungraded count as 0) + it('shows 0 when student has no graded submissions in a tab', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 10)], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(1); + }); + + // 7b. Total cell shows 0 when all assessments are ungraded + it('shows 0 in both the tab cell and the total cell when all assessments are ungraded', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 10)], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + // tab cell = 0, total cell = 0 → at least 2 zeros + expect(screen.getAllByText('0').length).toBeGreaterThanOrEqual(2); + }); + + // 8. Total equals the sum of the row's cells + it('total cell equals the sum of per-tab point cells', () => { + // tab10 equal-weight: (80/100 + 50/50) / 2 = 0.9 → points = 60*0.9 = 54 + // tab20 equal-weight: 90/100 = 0.9 → points = 40*0.9 = 36 + // total = 54 + 36 = 90 + renderWeighted({ + categories: [makeCategory(1, 'Cat A')], + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(20, 'Tab 2', 1, 40)], + assessments: [ + makeAssessment(1, 'A1', 10, 100), + makeAssessment(2, 'A2', 10, 50), + makeAssessment(3, 'A3', 20, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 1, 80), makeSub(1, 2, 50), makeSub(1, 3, 90)], + }); + expect(screen.getByText('54')).toBeInTheDocument(); + expect(screen.getByText('36')).toBeInTheDocument(); + expect(screen.getByText('90')).toBeInTheDocument(); + }); + + // 9. Ungraded assessments always count as 0 + it('counts ungraded assessments as 0 in the subtotal', () => { + // Q1 graded (40/50), Q2 ungraded → (40/50 + 0) / 2 = 0.4; weight=100 → 40 pts + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + makeAssessment(100, 'Q1', 10, 50), + makeAssessment(101, 'Q2', 10, 50), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 40)], + }); + expect(screen.getAllByText('40').length).toBeGreaterThanOrEqual(1); + }); + + // 10. No weights configured but tabs have assessments → equal-split default + // banner (with Configure CTA for managers), not the bare empty state. + it('shows the default-weights banner when no weights are configured', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0), makeTab(11, 'Tab 2', 1, 0)], + assessments: [ + makeAssessment(100, 'Quiz 1', 10, 150), + makeAssessment(101, 'Quiz 2', 11, 100), + ], + }); + expect(screen.getByText(/showing default weights/i)).toBeInTheDocument(); + expect(screen.getByText(/set your own/i)).toBeInTheDocument(); + }); + + // 10a. Default split feeds the totals: two tabs → 50/100 each, summing to 100. + it('applies an equal split (sums to 100) when no weights are configured', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0), makeTab(11, 'Tab 2', 1, 0)], + assessments: [ + makeAssessment(100, 'Quiz 1', 10, 100), + makeAssessment(101, 'Quiz 2', 11, 100), + ], + }); + // Total subheader reads "/100" (points), with no "does not sum" warning. + expect(screen.getByText('/100')).toBeInTheDocument(); + expect(screen.queryByText(/does not sum to 100/i)).not.toBeInTheDocument(); + }); + + // 10b. No weights configured + canManageWeights=false → no-access default copy + it('shows the no-access default-weights banner when canManageWeights is false', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0)], + canManageWeights: false, + }); + expect( + screen.getByText(/until weights are configured/i), + ).toBeInTheDocument(); + }); + + // 10d. Degenerate case: weights 0 AND no assessments to default → bare empty state + it('shows the bare empty-state banner when no tab has any assessment', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0)], + assessments: [], + }); + expect(screen.getByText(/no weights configured/i)).toBeInTheDocument(); + }); + + // 10c. At least one non-zero weight → banner absent + it('does not show empty-state banner when at least one tab has a non-zero weight', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 50)], + }); + expect( + screen.queryByText(/no weights configured/i), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/no tab weights have been configured yet/i), + ).not.toBeInTheDocument(); + }); + + // 11. canManageWeights === false → no "Configure Weights" button + it('does not show Configure Weights button when canManageWeights is false', () => { + renderWeighted({ canManageWeights: false }); + expect( + screen.queryByRole('button', { name: /configure weights/i }), + ).not.toBeInTheDocument(); + }); + + // 12. canManageWeights === true → "Configure Weights" button present + it('shows Configure Weights button when canManageWeights is true', () => { + renderWeighted({ canManageWeights: true }); + expect( + screen.getByRole('button', { name: /configure weights/i }), + ).toBeInTheDocument(); + }); + + // 13. Search bar is rendered + it('renders a search bar', () => { + renderWeighted(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + // 14. Typing in the search bar filters student rows + it('filters student rows when typing a name in the search bar', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + + await user.type(screen.getByRole('textbox'), 'Alice'); + + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + // 14b. Typing an external ID filters student rows (externalId column is searchable) + it('filters student rows when typing an external ID in the search bar', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [ + { ...makeStudent(1, 'Alice'), externalId: 'EXT-001' }, + { ...makeStudent(2, 'Bob'), externalId: 'EXT-002' }, + ], + }); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + + await user.type(screen.getByRole('textbox'), 'EXT-001'); + + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + // 14c. Typing an email filters student rows when Email column is visible + it('filters student rows when typing an email in the search bar', async () => { + const user = userEvent.setup(); + + localStorage.setItem(WEIGHTED_STORAGE_KEY, JSON.stringify({ email: true })); + + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('Bob')).toBeInTheDocument(); + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + + await user.type(screen.getByRole('textbox'), 'alice@example.com'); + + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + // 15. Pagination controls appear when there are more students than the page size + it('shows pagination controls when students exceed the default page size', () => { + const manyStudents = Array.from({ length: 101 }, (_, i) => + makeStudent(i + 1, `Student ${i + 1}`), + ); + renderWeighted({ students: manyStudents }); + expect(screen.getByText('1-100 / 101')).toBeInTheDocument(); + }); + + // 16. Row selection checkboxes are rendered for each student + it('renders checkboxes for row selection', () => { + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + // One "select all" header checkbox + one per student row + expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(3); + }); + + describe('column picker', () => { + it('shows Select Columns and Export buttons', () => { + renderWeighted(); + expect( + screen.getByRole('button', { name: /select columns/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + + it('shows "Export all rows" when no rows are selected', () => { + renderWeighted(); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(); + }); + + it('shows "Export 1 row" when one row is selected', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + const checkboxes = screen.getAllByRole('checkbox'); + // checkboxes[0] is header "select all"; [1] is the first row + 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', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + const checkboxes = screen.getAllByRole('checkbox'); + // checkboxes[0] is the header "select all" checkbox + await user.click(checkboxes[0]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(), + ); + expect( + screen.queryByRole('button', { name: /export 2 rows/i }), + ).not.toBeInTheDocument(); + }); + + it('shows export tooltip when no rows are selected', async () => { + renderWeighted(); + await userEvent.hover( + screen.getByRole('button', { name: /export all rows/i }), + ); + await waitFor(() => + expect( + screen.getByText('No rows selected - all rows will be exported.'), + ).toBeInTheDocument(), + ); + }); + + it('hides the export tooltip when at least one row is selected', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); + const exportBtn = await screen.findByRole('button', { + name: /export 1 row/i, + }); + await userEvent.hover(exportBtn); + await waitFor(() => + expect( + screen.queryByText('No rows selected - all rows will be exported.'), + ).not.toBeInTheDocument(), + ); + }); + + it('lists Email in the picker dialog (no gamification columns)', async () => { + const user = userEvent.setup(); + renderWeighted(); + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Email')).toBeInTheDocument(); + expect( + within(dialog).queryByText('Gamification'), + ).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); + }); + + it('puts the select-all checkbox in an indeterminate state on a partial selection', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], + }); + const checkboxes = screen.getAllByRole('checkbox'); + await user.click(checkboxes[1]); // select one of two rows + await waitFor(() => + // MUI marks the indeterminate checkbox input with data-indeterminate="true" + expect(checkboxes[0]).toHaveAttribute('data-indeterminate', 'true'), + ); + }); + }); + + describe('truncation tooltips', () => { + it('shows a tooltip with the student name on hover', async () => { + const user = userEvent.setup(); + renderWeighted({ students: [makeStudent(1, 'Alice')] }); + await user.hover(screen.getByText('Alice')); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Alice'); + }); + + it('shows a tooltip with the tab title on hover of the tab header', async () => { + const user = userEvent.setup(); + renderWeighted({ tabs: [makeTab(10, 'Recitation Quizzes', 1, 100)] }); + await user.hover(screen.getByText('Recitation Quizzes')); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'Recitation Quizzes', + ); + }); + + it('shows a tooltip with the email on hover when the Email column is shown', async () => { + const user = userEvent.setup(); + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ email: true }), + ); + const { email } = makeStudent(1, 'Alice'); + renderWeighted({ students: [makeStudent(1, 'Alice')] }); + await user.hover(screen.getByText(email)); + expect(await screen.findByRole('tooltip')).toHaveTextContent(email); + }); + + it('shows a tooltip with the assessment title on hover of a breakdown row title', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice')], + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Mission 1', 10, 10)], + submissions: [makeSub(1, 100, 5)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + await user.hover(await screen.findByText('Mission 1')); + expect(await screen.findByRole('tooltip')).toHaveTextContent('Mission 1'); + }); + }); + + describe('name column auto-expands with row breakdown', () => { + // colgroup order: checkbox(0) | name(1) | [email] | [externalId] | tabs | total + const nameCol = (): HTMLElement => + document.querySelectorAll('colgroup col')[1] as HTMLElement; + + it('keeps the Name column collapsed while all rows are collapsed', () => { + renderWeighted({ students: [makeStudent(1, 'Alice')] }); + expect(nameCol().style.width).toBe('150px'); + }); + + it('widens the Name column when a row is expanded and restores it on collapse', async () => { + const user = userEvent.setup(); + renderWeighted({ + students: [makeStudent(1, 'Alice')], + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Mission 1', 10, 10)], + submissions: [makeSub(1, 100, 5)], + }); + expect(nameCol().style.width).toBe('150px'); + + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + expect(nameCol().style.width).toBe('260px'); + + await user.click(screen.getByRole('button', { name: /collapse Alice/i })); + expect(nameCol().style.width).toBe('150px'); + }); + }); + + describe('row-expand breakdown', () => { + const expandable = { + tabs: [makeTab(10, 'Missions', 1, 60), makeTab(20, 'Quizzes', 1, 40)], + assessments: [ + makeAssessment(1, 'Mission 1', 10, 100), + makeAssessment(2, 'Mission 2', 10, 50), + makeAssessment(3, 'Quiz 1', 20, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [ + makeSub(1, 1, 80), // 0.8 + makeSub(1, 2, 50), // 1.0 + makeSub(1, 3, 90), // 0.9 + ], + }; + + it('renders an expand control on each student row', () => { + renderWeighted(expandable); + expect( + screen.getByRole('button', { name: /expand Alice/i }), + ).toBeInTheDocument(); + }); + + it('does not show assessment breakdown until expanded', () => { + renderWeighted(expandable); + expect(screen.queryByText(/Mission 1/)).not.toBeInTheDocument(); + }); + + it('shows per-assessment grade and points after expanding, and hides on collapse', async () => { + const user = userEvent.setup(); + renderWeighted(expandable); + const toggle = screen.getByRole('button', { name: /expand Alice/i }); + await user.click(toggle); + // grade/max shown in the muted "raw · weightage" subtitle + expect(await screen.findByText(/Mission 1/)).toBeInTheDocument(); + expect(screen.getByText(/80\/100 ·/)).toBeInTheDocument(); + // points contribution: Mission 1 = (0.8/2)*60 = 24 + const detail = screen.getByTestId(breakdownRowId(1, 10, 1)); + expect(within(detail).getByText('24')).toBeInTheDocument(); + // collapse + await user.click(screen.getByRole('button', { name: /collapse Alice/i })); + await waitFor(() => + expect(screen.queryByText(/Mission 1/)).not.toBeInTheDocument(), + ); + }); + + it('shows only the assessment name in the breakdown, without the tab prefix', async () => { + const user = userEvent.setup(); + renderWeighted(expandable); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + const detail = await screen.findByTestId(breakdownRowId(1, 10, 1)); + // Assessment name shown verbatim on its own line — not tab-prefixed as + // "Missions · Mission 1". (The "·" still appears legitimately in the muted + // "raw · weightage" subtitle below the title.) + expect(within(detail).getByText('Mission 1')).toBeInTheDocument(); + expect( + within(detail).queryByText(/Missions · Mission 1/), + ).not.toBeInTheDocument(); + }); + + it('confines title + raw·weightage to the Name cell with empty identity cells (no merged span)', async () => { + const user = userEvent.setup(); + // Surface two identity columns (Email, External ID) so we can assert the + // breakdown row leaves them as discrete empty cells rather than spanning. + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ email: true, externalId: true }), + ); + renderWeighted(expandable); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + const detail = await screen.findByTestId(breakdownRowId(1, 10, 1)); + + // No merged cell: the breakdown row no longer spans the identity columns, + // so it freezes the same checkbox | Name region as the student rows above + // instead of over-freezing across the identity area. + expect(detail.querySelectorAll('td[colspan]')).toHaveLength(0); + + // Title and the muted "raw · weightage" subtitle live in the SAME (Name) + // cell, confined to the Name column. + const titleCell = within(detail).getByText('Mission 1').closest('td')!; + expect( + within(titleCell).getByText(/80\/100 · 30% of grade/), + ).toBeInTheDocument(); + + // Each visible identity column gets its own empty cell so the grid lines + // stay aligned with the rows above. Cells: + // checkbox | Name | Email(empty) | External ID(empty) | Missions(24) | Quizzes | Total + const cells = within(detail).getAllByRole('cell'); + expect(cells).toHaveLength(7); + expect(cells[2]).toBeEmptyDOMElement(); + expect(cells[3]).toBeEmptyDOMElement(); + }); + + it('renders each assessment as a grade/maxGrade percentage in percent mode', async () => { + const user = userEvent.setup(); + renderWeighted(expandable); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // Mission 1: 80/100 → 80% ; Mission 2: 50/50 → 100% ; Quiz 1: 90/100 → 90% + expect( + within(screen.getByTestId(breakdownRowId(1, 10, 1))).getByText('80%'), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId(breakdownRowId(1, 10, 2))).getByText('100%'), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId(breakdownRowId(1, 20, 3))).getByText('90%'), + ).toBeInTheDocument(); + }); + + it('renders — for an ungraded assessment in percent mode', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Missions', 1, 100)], + assessments: [makeAssessment(1, 'Mission 1', 10, 100)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 1, null)], + }); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + expect( + within(screen.getByTestId(breakdownRowId(1, 10, 1))).getByText('—'), + ).toBeInTheDocument(); + }); + + it('breakdown points for a tab sum to the tab cell shown on the main row', async () => { + const user = userEvent.setup(); + renderWeighted(expandable); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // Missions cell (main row) = 24 + 30 = 54 + expect(screen.getByText('54')).toBeInTheDocument(); + // and both contributions are present in the detail + expect( + within(screen.getByTestId(breakdownRowId(1, 10, 1))).getByText('24'), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId(breakdownRowId(1, 10, 2))).getByText('30'), + ).toBeInTheDocument(); + }); + + it('shows each assessment\'s effective weightage as "% of grade" (equal mode)', async () => { + const user = userEvent.setup(); + renderWeighted(expandable); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // Missions weight 60 split across 2 assessments → 30% each; + // Quizzes weight 40 with a single assessment → 40%. + expect( + within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( + /30% of grade/, + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId(breakdownRowId(1, 10, 2))).getByText( + /30% of grade/, + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId(breakdownRowId(1, 20, 3))).getByText( + /40% of grade/, + ), + ).toBeInTheDocument(); + }); + + it('shows effective weightage as "% of grade" regardless of the points/percentage lens', async () => { + const user = userEvent.setup(); + renderWeighted(expandable); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // The weightage label never follows the lens: still "30% of grade". + expect( + within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( + /30% of grade/, + ), + ).toBeInTheDocument(); + }); + + it('marks an excluded assessment in the breakdown with "Excluded" text and a — contribution cell', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Missions', 1, 60)], + assessments: [ + makeAssessment(1, 'Mission 1', 10, 100), + { + ...makeAssessment(2, 'Mission 2', 10, 100), + gradebookExcluded: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 1, 80), makeSub(1, 2, 100)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + const detail = await screen.findByTestId(breakdownRowId(1, 10, 2)); + // "Excluded" label present instead of "… · X% of grade" weightage text + expect(within(detail).getByText(/Excluded/i)).toBeInTheDocument(); + // Contribution cell shows — (dash), not a numeric value + expect(within(detail).getByText('—')).toBeInTheDocument(); + expect(within(detail).queryByText(/^[\d.]+$/)).not.toBeInTheDocument(); + }); + + it('renders 0 (not —) for an ungraded-but-counted assessment, distinguishing it from excluded', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Missions', 1, 60)], + assessments: [makeAssessment(1, 'Mission 1', 10, 100)], + students: [makeStudent(1, 'Alice')], + submissions: [], // ungraded — no submission + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + const detail = await screen.findByTestId(breakdownRowId(1, 10, 1)); + // Ungraded counted as 0 in points mode + expect(within(detail).getByText('0')).toBeInTheDocument(); + // No "Excluded" text + expect(within(detail).queryByText(/Excluded/i)).not.toBeInTheDocument(); + }); + + it("uses the assessment's own weight for effective weightage in custom mode", async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [ + { + id: 10, + title: 'Missions', + categoryId: 1, + gradebookWeight: 60, + weightMode: 'custom', + }, + ], + assessments: [ + { + id: 1, + title: 'Mission 1', + tabId: 10, + maxGrade: 100, + gradebookWeight: 25, + }, + { + id: 2, + title: 'Mission 2', + tabId: 10, + maxGrade: 50, + gradebookWeight: 35, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 1, 80), makeSub(1, 2, 50)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + expect( + within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( + /25% of grade/, + ), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId(breakdownRowId(1, 10, 2))).getByText( + /35% of grade/, + ), + ).toBeInTheDocument(); + }); + + it('rounds a fractional effective weightage to 2dp in the breakdown', async () => { + const user = userEvent.setup(); + // Tab weight 100 split equally across 3 assessments → 33.333…% → "33.33% of grade" + renderWeighted({ + tabs: [makeTab(10, 'Missions', 1, 100)], + assessments: [ + makeAssessment(1, 'M1', 10, 100), + makeAssessment(2, 'M2', 10, 100), + makeAssessment(3, 'M3', 10, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 1, 50)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + expect( + within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( + /33\.33% of grade/, + ), + ).toBeInTheDocument(); + }); + }); + + describe('display mode toggle — values', () => { + // weight 100, one assessment max 100, grade 80 → subtotal 0.8 + // points cell = 80 ; percent cell = 80% + const singleTab = { + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 100)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 80)], + }; + + it('shows points (no % suffix) by default', () => { + renderWeighted(singleTab); + expect(screen.getAllByText('80').length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText('80%')).not.toBeInTheDocument(); + }); + + it('shows percentage with % suffix after switching to Percentage', async () => { + const user = userEvent.setup(); + renderWeighted(singleTab); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + await waitFor(() => + expect(screen.getAllByText('80%').length).toBeGreaterThanOrEqual(1), + ); + }); + + it('normalizes the total in percent mode when weights do not sum to 100', async () => { + const user = userEvent.setup(); + // weight 50, max 100, grade 80 → subtotal 0.8 + // points total = 40 ; percent total = 40 / 50 * 100 = 80% + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 50)], + assessments: [makeAssessment(100, 'Q1', 10, 100)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 80)], + }); + expect(screen.getAllByText('40').length).toBeGreaterThanOrEqual(1); // points default + await user.click(screen.getByRole('radio', { name: /percentage/i })); + await waitFor(() => + expect(screen.getAllByText('80%').length).toBeGreaterThanOrEqual(1), + ); + }); + + it('shows the Points and Percentage explanatory tooltips on hover', async () => { + renderWeighted(); + await userEvent.hover(screen.getByRole('radio', { name: /points/i })); + await waitFor(() => + expect( + screen.getByText(/columns add up to the projected total/i), + ).toBeInTheDocument(), + ); + await userEvent.hover(screen.getByRole('radio', { name: /percentage/i })); + await waitFor(() => + expect( + screen.getByText(/what fraction of each tab the student earned/i), + ).toBeInTheDocument(), + ); + }); + }); + + describe('display mode toggle — control', () => { + it('renders Points and Percentage toggle buttons with Points pressed by default', () => { + renderWeighted(); + const points = screen.getByRole('radio', { name: /points/i }); + const percent = screen.getByRole('radio', { name: /percentage/i }); + expect(points).toBeInTheDocument(); + expect(percent).toBeInTheDocument(); + expect(points).toHaveAttribute('aria-checked', 'true'); + expect(percent).toHaveAttribute('aria-checked', 'false'); + }); + }); + + describe('identity columns rendering', () => { + it('hides Email and External ID by default', () => { + renderWeighted(); + expect(screen.queryByText('Email')).not.toBeInTheDocument(); + expect(screen.queryByText(EXTERNAL_ID)).not.toBeInTheDocument(); + expect(screen.queryByText('alice@example.com')).not.toBeInTheDocument(); + }); + + it('shows the Email column header and value when Email is enabled via storage', async () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ email: true }), + ); + renderWeighted({ students: [makeStudent(1, 'Alice')] }); + // header cell + const thead = document.querySelector('thead')!; + expect( + within(thead as HTMLElement).getByText('Email'), + ).toBeInTheDocument(); + // body value + expect(screen.getByText('alice@example.com')).toBeInTheDocument(); + }); + + it('does not render Level or Total XP columns even if storage enables them', () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ level: true, totalXp: true }), + ); + renderWeighted(); + const thead = document.querySelector('thead')!; + expect( + within(thead as HTMLElement).queryByText('Level'), + ).not.toBeInTheDocument(); + expect( + within(thead as HTMLElement).queryByText('Total XP'), + ).not.toBeInTheDocument(); + }); + + describe('external ID column', () => { + const studentsWithExtId: StudentData[] = [ + { ...makeStudent(1, 'Alice'), externalId: 'EXT-001' }, + { ...makeStudent(2, 'Bob'), externalId: null }, + ]; + + it('shows External ID column by default when a student has one', async () => { + renderWeighted({ students: studentsWithExtId }); + await screen.findByText('Alice'); + const thead = document.querySelector('thead')!; + expect( + within(thead as HTMLElement).getByText(EXTERNAL_ID), + ).toBeInTheDocument(); + expect(screen.getByText('EXT-001')).toBeInTheDocument(); + // Bob has no external ID → his External ID cell renders empty, not "null". + const bobRow = screen.getByText('Bob').closest('tr')!; + expect(within(bobRow).queryByText('null')).not.toBeInTheDocument(); + }); + + it('hides External ID column by default when no student has one', async () => { + renderWeighted(); + await screen.findByText('Alice'); + expect(screen.queryByText(EXTERNAL_ID)).not.toBeInTheDocument(); + }); + + it('treats a blank external ID as absent and hides the column by default', async () => { + renderWeighted({ + students: [{ ...makeStudent(1, 'Alice'), externalId: '' }], + }); + await screen.findByText('Alice'); + expect(screen.queryByText(EXTERNAL_ID)).not.toBeInTheDocument(); + }); + + it('shows External ID when enabled via localStorage even with no external IDs', async () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ externalId: true }), + ); + renderWeighted(); + await screen.findByText('Alice'); + const thead = document.querySelector('thead')!; + expect( + within(thead as HTMLElement).getByText(EXTERNAL_ID), + ).toBeInTheDocument(); + }); + + it('offers External ID checkbox in picker regardless of whether students have one', async () => { + const user = userEvent.setup(); + renderWeighted(); + await user.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + }); + }); + + describe('projected-total policy', () => { + it('shows a shortened "Total" header without the inline policy sentence', () => { + renderWeighted(); + const thead = document.querySelector('thead')!; + expect( + within(thead as HTMLElement).getByText('Total'), + ).toBeInTheDocument(); + // The policy is no longer crammed into the header label itself. + expect( + within(thead as HTMLElement).queryByText(/ungraded assessments/i), + ).not.toBeInTheDocument(); + }); + + it('exposes the projected-total policy via an ⓘ control on the header', () => { + renderWeighted(); + expect( + screen.getByRole('button', { + name: /ungraded assessments as 0/i, + }), + ).toBeInTheDocument(); + }); + + it('reveals the projected-total policy text in the header ⓘ tooltip on hover', async () => { + renderWeighted(); + await userEvent.hover( + screen.getByRole('button', { name: /ungraded assessments as 0/i }), + ); + await waitFor(() => + expect( + screen.getAllByText(/totals count ungraded assessments as 0/i).length, + ).toBeGreaterThanOrEqual(1), + ); + }); + + it('shows a one-time projected-total policy banner that can be dismissed', async () => { + const user = userEvent.setup(); + renderWeighted(); + expect( + screen.getByText(/totals count ungraded assessments as 0/i), + ).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /close/i })); + expect( + screen.queryByText(/totals count ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + }); + + it('does not show the policy banner once it has been dismissed', () => { + localStorage.setItem( + `${USER_ID}:gradebook_projected_total_policy_hint`, + 'true', + ); + renderWeighted(); + expect( + screen.queryByText(/totals count ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + // The ⓘ on the header still carries the policy after dismissal. + expect( + screen.getByRole('button', { + name: /ungraded assessments as 0/i, + }), + ).toBeInTheDocument(); + }); + }); + + describe('all-excluded tab (effective weight 0)', () => { + const excluded = (a: AssessmentData): AssessmentData => ({ + ...a, + gradebookExcluded: true, + }); + + // Assignments (30) is fully excluded → contributes nothing; Quizzes (70) is live. + const oneTabAllExcluded = { + tabs: [makeTab(10, 'Assignments', 1, 30), makeTab(11, 'Quizzes', 1, 70)], + assessments: [ + excluded(makeAssessment(100, 'A1', 10, 100)), + excluded(makeAssessment(101, 'A2', 10, 100)), + makeAssessment(200, 'Q1', 11, 100), + ], + }; + + const row3 = (): HTMLElement => + document.querySelector('thead')!.querySelectorAll('tr')[2] as HTMLElement; + + it('row 3 reads "Excluded" instead of /N for an all-excluded tab (points)', () => { + renderWeighted(oneTabAllExcluded); + expect(within(row3()).getByText('Excluded')).toBeInTheDocument(); + // The dead tab's stored weight is never shown as a "/30" subheader. + expect(screen.queryByText('/30')).not.toBeInTheDocument(); + }); + + it('row 3 reads "Excluded" instead of "% of grade" for an all-excluded tab (percent)', async () => { + const user = userEvent.setup(); + renderWeighted(oneTabAllExcluded); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + expect(within(row3()).getByText('Excluded')).toBeInTheDocument(); + expect(screen.queryByText('30% of grade')).not.toBeInTheDocument(); + }); + + it('drops the all-excluded tab from the projected-total weight (header)', () => { + renderWeighted(oneTabAllExcluded); + // Live weight is only Quizzes (70), so the total header shows /70, not /100. + expect(screen.queryByText('/100')).not.toBeInTheDocument(); + const cells = row3().querySelectorAll('th'); + expect(cells[cells.length - 1]).toHaveTextContent('/70'); + }); + + it('normalizes the percent-mode total over live weight only', async () => { + const user = userEvent.setup(); + renderWeighted({ + ...oneTabAllExcluded, + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 200, 90)], // 0.9 on the only live tab (Quizzes/70) + }); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + // Total points = 0.9×70 = 63. Normalized over live weight (70): 63/70 = 90%. + // The buggy denominator (100) would render 63%. + const aliceRow = screen.getByText('Alice').closest('tr')!; + const cells = within(aliceRow).getAllByRole('cell'); + expect(cells[cells.length - 1]).toHaveTextContent('90%'); + }); + + it('renders "—" for the total in percent mode when all live weight is excluded', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Assignments', 1, 30)], + assessments: [ + excluded(makeAssessment(100, 'A1', 10, 100)), + excluded(makeAssessment(101, 'A2', 10, 100)), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 80)], + }); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + const aliceRow = screen.getByText('Alice').closest('tr')!; + const cells = within(aliceRow).getAllByRole('cell'); + expect(cells[cells.length - 1]).toHaveTextContent('—'); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedViewHint.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedViewHint.test.tsx new file mode 100644 index 00000000000..2ba648e4c1d --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/WeightedViewHint.test.tsx @@ -0,0 +1,54 @@ +import { store as appStore } from 'store'; +import { fireEvent, render, screen } from 'test-utils'; + +import WeightedViewHint, { + WEIGHTED_VIEW_HINT_KEY, +} from '../components/WeightedViewHint'; + +// 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 USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:${WEIGHTED_VIEW_HINT_KEY}`; + +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +const renderHint = (): void => { + render(, { state: userState }); +}; + +beforeEach(() => localStorage.clear()); + +describe('WeightedViewHint', () => { + it('renders the capability hint with a link to gradebook settings', () => { + renderHint(); + // Copy names the capability (weighted total grade), not the mechanism. + expect(screen.getByText(/weighted total/i)).toBeInTheDocument(); + + const link = screen.getByRole('link', { name: /gradebook settings/i }); + expect(link).toHaveAttribute('href', '/courses/7/admin/gradebook'); + }); + + it('hides and persists dismissal when the close button is clicked', () => { + renderHint(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + + expect(screen.queryByText(/weighted total/i)).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); + + it('does not render when already dismissed', () => { + localStorage.setItem(STORAGE_KEY, 'true'); + renderHint(); + expect(screen.queryByText(/weighted total/i)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/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__/computeWeighted.test.ts b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts new file mode 100644 index 00000000000..f8714088784 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts @@ -0,0 +1,797 @@ +// client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts +import { + computeStudentBreakdown, + computeStudentTotal, + computeTabSubtotal, + computeWeightedRows, + resolveTabWeights, + sumWeights, + usingDefaultWeights, +} from '../computeWeighted'; + +const assessments = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A' }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B' }, + { id: 3, tabId: 20, maxGrade: 100, title: 'C' }, +]; + +const subs = ( + entries: { studentId: number; assessmentId: number; grade: number | null }[], +): { studentId: number; assessmentId: number; grade: number | null }[] => + entries; + +describe('computeTabSubtotal — equal mode (default)', () => { + it('returns null when tab has no assessments', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 999, title: 'X', categoryId: 0 }, + assessments, + submissions: [], + }), + ).toBeNull(); + }); + + it('returns 0 when student has no graded submissions (ungraded count as 0)', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: [], + }), + ).toBe(0); + }); + + it('average of (grade/maxGrade) ratios with ungraded assessments counted as 0', () => { + // Assessment 1 graded (80/100=0.8), assessment 2 ungraded (0) + // sub = (0.8 + 0) / 2 = 0.4 + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + // assessment 2 ungraded + ]), + }), + ).toBeCloseTo(0.4); + }); + + it('average of all ratios when fully graded', () => { + // Assessment 1: 80/100=0.8, assessment 2: 50/50=1.0 + // sub = (0.8 + 1.0) / 2 = 0.9 + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + ]), + }), + ).toBeCloseTo(0.9); + }); +}); + +describe('computeTabSubtotal — custom mode', () => { + const customTab = { + id: 10, + title: 'M', + categoryId: 0, + gradebookWeight: 100, + weightMode: 'custom' as const, + }; + const customAssessments = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A', gradebookWeight: 30 }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B', gradebookWeight: 70 }, + ]; + + it('computes weighted sum over tab weight when fully graded', () => { + // sub = (80/100 * 30 + 50/50 * 70) / 100 = (24 + 70) / 100 = 0.94 + expect( + computeTabSubtotal({ + studentId: 1, + tab: customTab, + assessments: customAssessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + ]), + }), + ).toBeCloseTo(0.94); + }); + + it('treats ungraded as zero (only graded weight contributes to numerator)', () => { + // Only assessment 1 graded: sub = (80/100 * 30 + 0 * 70) / 100 = 24/100 = 0.24 + expect( + computeTabSubtotal({ + studentId: 1, + tab: customTab, + assessments: customAssessments, + submissions: subs([{ studentId: 1, assessmentId: 1, grade: 80 }]), + }), + ).toBeCloseTo(0.24); + }); + + it('returns 0 when no graded assessments', () => { + // sub = (0 + 0) / 100 = 0 + expect( + computeTabSubtotal({ + studentId: 1, + tab: customTab, + assessments: customAssessments, + submissions: [], + }), + ).toBe(0); + }); + + it('returns null when tab gradebookWeight is 0 (divide-by-zero guard)', () => { + expect( + computeTabSubtotal({ + studentId: 1, + tab: { ...customTab, gradebookWeight: 0 }, + assessments: customAssessments, + submissions: subs([{ studentId: 1, assessmentId: 1, grade: 80 }]), + }), + ).toBeNull(); + }); + + it('returns null when every assessment in a custom tab is excluded', () => { + const allExcluded = [ + { + id: 1, + tabId: 10, + maxGrade: 100, + title: 'A', + gradebookWeight: 30, + gradebookExcluded: true, + }, + { + id: 2, + tabId: 10, + maxGrade: 100, + title: 'B', + gradebookWeight: 20, + gradebookExcluded: true, + }, + ]; + expect( + computeTabSubtotal({ + studentId: 1, + tab: { + id: 10, + title: 'M', + categoryId: 0, + weightMode: 'custom', + gradebookWeight: 50, + }, + assessments: allExcluded, + submissions: [{ studentId: 1, assessmentId: 1, grade: 90 }], + }), + ).toBeNull(); + }); + + it('treats a custom assessment with no gradebookWeight as weight 0', () => { + // a1 weight 30 graded 100/100 -> 30; a2 has NO weight -> 0; sub = 30/100 = 0.3 + const mixed = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A', gradebookWeight: 30 }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B' }, + ]; + expect( + computeTabSubtotal({ + studentId: 1, + tab: customTab, + assessments: mixed, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 100 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + ]), + }), + ).toBeCloseTo(0.3); + }); +}); + +describe('computeStudentTotal', () => { + const tabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 40 }, + ]; + + it('returns additive sum of weight × subtotal (equal-weight count-based)', () => { + // tab10 equal subtotal = (80/100 + 50/50) / 2 = (0.8 + 1.0) / 2 = 0.9 + // tab20 equal subtotal = 90/100 = 0.9 + // total = 60*0.9 + 40*0.9 = 54 + 36 = 90 + const total = computeStudentTotal({ + studentId: 1, + tabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + }); + expect(total).toBeCloseTo(60 * 0.9 + 40 * 0.9); + }); + + it('weight-0 tab contributes 0 to the sum', () => { + // tab20 weight=0 → 0; tab10 subtotal = (80/100 + 50/50) / 2 = 0.9 + const total = computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 100 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 0 }, + ], + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + }); + expect(total).toBeCloseTo(100 * 0.9); + }); + + it('returns 0 when tab weight is 0 and no graded submissions', () => { + expect( + computeStudentTotal({ + studentId: 1, + tabs: [{ id: 10, title: 'M', categoryId: 0, gradebookWeight: 0 }], + assessments, + submissions: [], + }), + ).toBe(0); + }); + + it('is additive (not normalized) when weights do not sum to 100', () => { + // total = 60*0.9 + 30*0.9 = 54 + 27 = 81 (NOT divided by 90) + const total = computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 30 }, + ], + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + }); + expect(total).toBeCloseTo(60 * 0.9 + 30 * 0.9); + }); + + it('bonus: weights summing past 100 yield a total > 100 for a perfect student', () => { + // perfect student: all grades = maxGrade → each ratio = 1.0 → subtotal = 1.0 + // total = 60*1 + 50*1 = 110 + const bonusTabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 50 }, + ]; + const total = computeStudentTotal({ + studentId: 1, + tabs: bonusTabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 100 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 100 }, + ]), + }); + expect(total).toBeCloseTo(110); + expect(total!).toBeGreaterThan(100); + }); + + it('ungraded tab contributes 0 to the total', () => { + // tab10 subtotal = (80/100 + 50/50) / 2 = 0.9; tab20 no submissions → subtotal = 0 + // total = 60*0.9 + 40*0 = 54 + const total = computeStudentTotal({ + studentId: 1, + tabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + ]), + }); + expect(total).toBeCloseTo(60 * 0.9); + }); + + it('returns null when every tab is empty (no contributing subtotal)', () => { + expect( + computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 77, title: 'X', categoryId: 0, gradebookWeight: 60 }, + { id: 88, title: 'Y', categoryId: 0, gradebookWeight: 40 }, + ], + assessments, // none belong to tabs 77/88 + submissions: [], + }), + ).toBeNull(); + }); + + it('a negative tab weight subtracts from the additive total (no flooring)', () => { + // tab10 subtotal 0.9 * 100 = 90 ; tab20 subtotal 0.9 * -20 = -18 ; total = 72 + const total = computeStudentTotal({ + studentId: 1, + tabs: [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 100 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: -20 }, + ], + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + }); + expect(total).toBeCloseTo(100 * 0.9 + -20 * 0.9); + }); +}); + +describe('sumWeights', () => { + it('returns the sum of all tab weights', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 60 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 40 }, + ]; + expect(sumWeights(tabs)).toBe(100); + }); + + it('includes all tabs regardless of weight value', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 60 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 0 }, + ]; + expect(sumWeights(tabs)).toBe(60); + }); + + it('handles tabs with no gradebookWeight (treats as 0)', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 40 }, + { id: 2, title: 'T2', categoryId: 1 }, + ]; + expect(sumWeights(tabs)).toBe(40); + }); +}); + +describe('resolveTabWeights — equal-split default when unconfigured', () => { + const twoTabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 0 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 0 }, + ]; + // assessments fixture (top of file) covers tabs 10 and 20. + + it('returns tabs unchanged when any tab already carries a weight', () => { + const configured = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 0 }, + ]; + expect(resolveTabWeights(configured, assessments)).toBe(configured); + }); + + it('splits 100 equally across non-empty tabs when every weight is 0', () => { + const resolved = resolveTabWeights(twoTabs, assessments); + expect(resolved.map((t) => t.gradebookWeight)).toEqual([50, 50]); + expect(sumWeights(resolved)).toBe(100); + }); + + it('last non-empty tab absorbs the rounding remainder so it sums to exactly 100', () => { + const threeTabs = [ + { id: 10, title: 'A', categoryId: 0, gradebookWeight: 0 }, + { id: 20, title: 'B', categoryId: 0, gradebookWeight: 0 }, + { id: 30, title: 'C', categoryId: 0, gradebookWeight: 0 }, + ]; + const threeAssessments = [ + { id: 1, tabId: 10, maxGrade: 10, title: 'a' }, + { id: 2, tabId: 20, maxGrade: 10, title: 'b' }, + { id: 3, tabId: 30, maxGrade: 10, title: 'c' }, + ]; + const resolved = resolveTabWeights(threeTabs, threeAssessments); + expect(resolved.map((t) => t.gradebookWeight)).toEqual([ + 33.33, 33.33, 33.34, + ]); + expect(sumWeights(resolved)).toBe(100); + }); + + it('gives empty tabs (no assessments) 0% and shares 100 among the rest', () => { + const withEmpty = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 0 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 0 }, + { id: 99, title: 'Empty', categoryId: 0, gradebookWeight: 0 }, + ]; + const resolved = resolveTabWeights(withEmpty, assessments); + expect(resolved.find((t) => t.id === 99)!.gradebookWeight).toBe(0); + expect(sumWeights(resolved)).toBe(100); + }); + + it('defaults the weight mode to equal on resolved tabs', () => { + const resolved = resolveTabWeights(twoTabs, assessments); + expect(resolved.every((t) => t.weightMode === 'equal')).toBe(true); + }); + + it('returns tabs unchanged when no tab has any assessment (nothing to weight)', () => { + const emptyTabs = [ + { id: 77, title: 'X', categoryId: 0, gradebookWeight: 0 }, + ]; + expect(resolveTabWeights(emptyTabs, assessments)).toBe(emptyTabs); + }); + + it('gives a lone non-empty tab the full 100', () => { + const oneTab = [{ id: 10, title: 'M', categoryId: 0, gradebookWeight: 0 }]; + const resolved = resolveTabWeights(oneTab, assessments); + expect(resolved[0].gradebookWeight).toBe(100); + expect(sumWeights(resolved)).toBe(100); + }); + + it('preserves an already-set weightMode while injecting the default weight', () => { + const customModeTabs = [ + { + id: 10, + title: 'M', + categoryId: 0, + gradebookWeight: 0, + weightMode: 'custom' as const, + }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 0 }, + ]; + const resolved = resolveTabWeights(customModeTabs, assessments); + expect(resolved.find((t) => t.id === 10)!.weightMode).toBe('custom'); + expect(resolved.find((t) => t.id === 20)!.weightMode).toBe('equal'); + }); +}); + +describe('usingDefaultWeights', () => { + it('is true when no weight is configured and a non-empty tab exists', () => { + const tabs = [{ id: 10, title: 'M', categoryId: 0, gradebookWeight: 0 }]; + expect(usingDefaultWeights(tabs, assessments)).toBe(true); + }); + + it('is false once any tab carries a weight', () => { + const tabs = [{ id: 10, title: 'M', categoryId: 0, gradebookWeight: 50 }]; + expect(usingDefaultWeights(tabs, assessments)).toBe(false); + }); + + it('is false when every tab is empty (no default would apply)', () => { + const tabs = [{ id: 77, title: 'X', categoryId: 0, gradebookWeight: 0 }]; + expect(usingDefaultWeights(tabs, assessments)).toBe(false); + }); +}); + +describe('computeWeightedRows', () => { + const rowTabs = [ + { id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }, + { id: 20, title: 'T', categoryId: 0, gradebookWeight: 40 }, + ]; + const rowStudents = [ + { + id: 1, + name: 'Alice', + email: 'alice@e.com', + externalId: null, + level: 1, + totalXp: 0, + }, + { + id: 2, + name: 'Bob', + email: 'bob@e.com', + externalId: null, + level: 1, + totalXp: 0, + }, + ]; + const rowSubmissions = subs([ + // Alice: full data + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + // Bob: only tab10 graded + { studentId: 2, assessmentId: 1, grade: 100 }, + { studentId: 2, assessmentId: 2, grade: 50 }, + ]); + + it('returns one row per student carrying studentId, name and email', () => { + const rows = computeWeightedRows({ + students: rowStudents, + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + }); + expect(rows).toHaveLength(2); + expect(rows[0]).toMatchObject({ + studentId: 1, + name: 'Alice', + email: 'alice@e.com', + }); + expect(rows[1]).toMatchObject({ + studentId: 2, + name: 'Bob', + email: 'bob@e.com', + }); + }); + + it('produces subtotals and total identical to the per-student helpers', () => { + const rows = computeWeightedRows({ + students: rowStudents, + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + }); + rowStudents.forEach((student, i) => { + rowTabs.forEach((tab, j) => { + expect(rows[i].subtotals[j]).toEqual( + computeTabSubtotal({ + studentId: student.id, + tab, + assessments, + submissions: rowSubmissions, + }), + ); + }); + expect(rows[i].total).toEqual( + computeStudentTotal({ + studentId: student.id, + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + }), + ); + }); + }); + + it('computes the known additive total for a fully-graded student (equal-weight)', () => { + // Alice tab10 = (80/100 + 50/50) / 2 = (0.8 + 1.0) / 2 = 0.9 + // Alice tab20 = 90/100 = 0.9 + // total = 60*0.9 + 40*0.9 = 90 + const rows = computeWeightedRows({ + students: [rowStudents[0]], + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + }); + expect(rows[0].subtotals[0]).toBeCloseTo(0.9); + expect(rows[0].subtotals[1]).toBeCloseTo(0.9); + expect(rows[0].total).toBeCloseTo(60 * 0.9 + 40 * 0.9); + }); + + it('a tab with no graded submissions yields a 0 subtotal (ungraded count as 0)', () => { + // Bob tab10: (100/100 + 50/50) / 2 = 1.0; tab20: no submissions → 0 + const rows = computeWeightedRows({ + students: [rowStudents[1]], + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + }); + expect(rows[0].subtotals[0]).toBeCloseTo(1); + expect(rows[0].subtotals[1]).toBe(0); + expect(rows[0].total).toBeCloseTo(60 * 1 + 40 * 0); + }); + + it('returns an empty array when there are no students', () => { + expect( + computeWeightedRows({ + students: [], + tabs: rowTabs, + assessments, + submissions: rowSubmissions, + }), + ).toEqual([]); + }); +}); + +describe('computeWeightedRows — identity passthrough', () => { + it('carries name, email and externalId from each student onto the row', () => { + const students = [ + { + id: 1, + name: 'Alice', + email: 'a@x.com', + externalId: 'EXT-1', + level: 5, + totalXp: 1234, + }, + ]; + const tabs = [ + { id: 10, title: 'Tab 1', categoryId: 1, gradebookWeight: 100 }, + ]; + const localAssessments = [ + { id: 100, title: 'Q1', tabId: 10, maxGrade: 10 }, + ]; + const submissions = [{ studentId: 1, assessmentId: 100, grade: 8 }]; + + const rows = computeWeightedRows({ + students, + tabs, + assessments: localAssessments, + submissions, + }); + + expect(rows[0].name).toBe('Alice'); + expect(rows[0].email).toBe('a@x.com'); + expect(rows[0].externalId).toBe('EXT-1'); + }); +}); + +describe('computeStudentBreakdown', () => { + const tabs = [ + { id: 10, title: 'Tab 1', categoryId: 1, gradebookWeight: 60 }, + { id: 20, title: 'Tab 2', categoryId: 1, gradebookWeight: 40 }, + ]; + + it('equal mode: per-assessment points sum to the tab cell (subtotal × weight)', () => { + // Tab 10 (weight 60), equal: A(80/100=0.8), B(50/50=1.0); n=2 + // A points = (0.8/2)*60 = 24 ; B points = (1.0/2)*60 = 30 ; Σ = 54 + const breakdown = computeStudentBreakdown({ + studentId: 1, + tabs, + assessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + { studentId: 1, assessmentId: 3, grade: 90 }, + ]), + }); + const tab10 = breakdown.find((b) => b.tabId === 10)!; + const a = tab10.assessments.find((x) => x.assessmentId === 1)!; + const b = tab10.assessments.find((x) => x.assessmentId === 2)!; + expect(a.points).toBeCloseTo(24); + expect(b.points).toBeCloseTo(30); + expect(a.points + b.points).toBeCloseTo(54); // = tab cell + }); + + it('carries grade and maxGrade per assessment; ungraded contributes 0 points', () => { + // Tab 10: A graded 80/100, B ungraded; n=2 → A=(0.8/2)*60=24, B=0 + const breakdown = computeStudentBreakdown({ + studentId: 1, + tabs, + assessments, + submissions: subs([{ studentId: 1, assessmentId: 1, grade: 80 }]), + }); + const tab10 = breakdown.find((b) => b.tabId === 10)!; + const b = tab10.assessments.find((x) => x.assessmentId === 2)!; + expect(b.grade).toBeNull(); + expect(b.maxGrade).toBe(50); + expect(b.points).toBe(0); + }); + + it('custom mode: per-assessment points = ratio × assessmentWeight, summing to the cell', () => { + // Tab 10 custom, weight 60: A weight 40 (80/100=0.8 → 32), B weight 20 (50/50=1 → 20) + // subtotal = (0.8*40 + 1*20)/60 = 52/60 ; cell = subtotal*60 = 52 ; Σpoints = 32 + 20 = 52 + const customTabs = [ + { + id: 10, + title: 'Tab 1', + categoryId: 1, + gradebookWeight: 60, + weightMode: 'custom' as const, + }, + ]; + const customAssessments = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A', gradebookWeight: 40 }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B', gradebookWeight: 20 }, + ]; + const breakdown = computeStudentBreakdown({ + studentId: 1, + tabs: customTabs, + assessments: customAssessments, + submissions: subs([ + { studentId: 1, assessmentId: 1, grade: 80 }, + { studentId: 1, assessmentId: 2, grade: 50 }, + ]), + }); + const tab10 = breakdown[0]; + const a = tab10.assessments.find((x) => x.assessmentId === 1)!; + const b = tab10.assessments.find((x) => x.assessmentId === 2)!; + expect(a.points).toBeCloseTo(32); + expect(b.points).toBeCloseTo(20); + expect(a.points + b.points).toBeCloseTo(52); // = tab cell + }); + + it('returns an empty assessment list for a tab with no assessments', () => { + const breakdown = computeStudentBreakdown({ + studentId: 1, + tabs: [{ id: 999, title: 'Empty', categoryId: 1, gradebookWeight: 50 }], + assessments, + submissions: [], + }); + expect(breakdown[0].assessments).toEqual([]); + }); +}); + +describe('exclusion — equal mode', () => { + it('averages over included assessments only (excluded dropped from numerator and count)', () => { + // a1 80/100=0.8 included, a2 excluded -> subtotal = 0.8 / 1 = 0.8 + const withExcluded = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A' }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B', gradebookExcluded: true }, + ]; + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments: withExcluded, + submissions: [{ studentId: 1, assessmentId: 1, grade: 80 }], + }), + ).toBeCloseTo(0.8); + }); + + it('returns null when every assessment in the tab is excluded', () => { + const allExcluded = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A', gradebookExcluded: true }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B', gradebookExcluded: true }, + ]; + expect( + computeTabSubtotal({ + studentId: 1, + tab: { id: 10, title: 'M', categoryId: 0 }, + assessments: allExcluded, + submissions: [{ studentId: 1, assessmentId: 1, grade: 80 }], + }), + ).toBeNull(); + }); +}); + +describe('exclusion — custom mode', () => { + it('drops excluded assessments from the numerator', () => { + // tab weight 30; a1 weight 30 graded 90/100=0.9 -> 0.9*30=27; a2 excluded. + // subtotal = 27 / 30 = 0.9 + const customAssessments = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A', gradebookWeight: 30 }, + { + id: 2, + tabId: 10, + maxGrade: 100, + title: 'B', + gradebookWeight: 20, + gradebookExcluded: true, + }, + ]; + expect( + computeTabSubtotal({ + studentId: 1, + tab: { + id: 10, + title: 'M', + categoryId: 0, + weightMode: 'custom', + gradebookWeight: 30, + }, + assessments: customAssessments, + submissions: [ + { studentId: 1, assessmentId: 1, grade: 90 }, + { studentId: 1, assessmentId: 2, grade: 100 }, + ], + }), + ).toBeCloseTo(0.9); + }); +}); + +describe('breakdown — exclusion', () => { + const bdAssessments = [ + { id: 1, tabId: 10, maxGrade: 100, title: 'A' }, + { id: 2, tabId: 10, maxGrade: 50, title: 'B', gradebookExcluded: true }, + ]; + + it('flags excluded assessments and gives them zero points/effectiveWeight', () => { + const [tab] = computeStudentBreakdown({ + studentId: 1, + tabs: [{ id: 10, title: 'M', categoryId: 0, gradebookWeight: 60 }], + assessments: bdAssessments, + submissions: [{ studentId: 1, assessmentId: 1, grade: 100 }], + }); + const a = tab.assessments.find((x) => x.assessmentId === 1)!; + const b = tab.assessments.find((x) => x.assessmentId === 2)!; + expect(b.excluded).toBe(true); + expect(b.points).toBe(0); + expect(b.effectiveWeight).toBe(0); + // equal effectiveWeight uses included count (1), so a gets the full 60 + expect(a.excluded).toBe(false); + expect(a.effectiveWeight).toBeCloseTo(60); + expect(a.points).toBeCloseTo(60); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/store.test.ts b/client/app/bundles/course/gradebook/__tests__/store.test.ts new file mode 100644 index 00000000000..d56d6d1ff36 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/store.test.ts @@ -0,0 +1,205 @@ +import reducer, { actions } from '../store'; + +const baseState = { + categories: [], + tabs: [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 50 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 50 }, + ], + assessments: [ + { id: 101, title: 'A1', tabId: 1, maxGrade: 100 }, + { id: 102, title: 'A2', tabId: 1, maxGrade: 100 }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, +}; + +describe('UPDATE_TAB_WEIGHTS reducer', () => { + it('updates gradebookWeight and weightMode for the matching tab', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 80, weightMode: 'equal' }], + }), + ); + expect(next.tabs.find((t) => t.id === 1)?.gradebookWeight).toBe(80); + expect(next.tabs.find((t) => t.id === 1)?.weightMode).toBe('equal'); + expect(next.tabs.find((t) => t.id === 2)?.gradebookWeight).toBe(50); + }); + + it('does not set any excluded field', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 0, weightMode: 'equal' }], + }), + ); + const tab = next.tabs.find((t) => t.id === 1)!; + expect(tab).not.toHaveProperty('gradebookExcluded'); + }); + + it('updates multiple tabs in one action', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [ + { tabId: 1, weight: 30, weightMode: 'equal' }, + { tabId: 2, weight: 70, weightMode: 'custom' }, + ], + }), + ); + expect(next.tabs.find((t) => t.id === 1)?.gradebookWeight).toBe(30); + expect(next.tabs.find((t) => t.id === 2)?.gradebookWeight).toBe(70); + expect(next.tabs.find((t) => t.id === 2)?.weightMode).toBe('custom'); + }); + + it('applies per-assessment weights for custom mode', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [ + { + tabId: 1, + weight: 100, + weightMode: 'custom', + assessmentWeights: [ + { assessmentId: 101, weight: 30 }, + { assessmentId: 102, weight: 70 }, + ], + }, + ], + }), + ); + expect(next.assessments.find((a) => a.id === 101)?.gradebookWeight).toBe( + 30, + ); + expect(next.assessments.find((a) => a.id === 102)?.gradebookWeight).toBe( + 70, + ); + }); + + it('clears per-assessment weights for equal mode', () => { + const stateWithWeights = { + ...baseState, + assessments: [ + { id: 101, title: 'A1', tabId: 1, maxGrade: 100, gradebookWeight: 30 }, + { id: 102, title: 'A2', tabId: 1, maxGrade: 100, gradebookWeight: 70 }, + ], + }; + const next = reducer( + stateWithWeights, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 100, weightMode: 'equal' }], + }), + ); + expect( + next.assessments.find((a) => a.id === 101)?.gradebookWeight, + ).toBeNull(); + expect( + next.assessments.find((a) => a.id === 102)?.gradebookWeight, + ).toBeNull(); + }); + + it('applies per-assessment exclusion flips from the payload', () => { + const start = reducer( + undefined, + actions.saveGradebook({ + categories: [{ id: 1, title: 'C' }], + tabs: [ + { + id: 10, + title: 'T', + categoryId: 1, + gradebookWeight: 50, + weightMode: 'equal', + }, + ], + assessments: [ + { id: 101, title: 'A', tabId: 10, maxGrade: 100 }, + { id: 102, title: 'B', tabId: 10, maxGrade: 100 }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }), + ); + + const next = reducer( + start, + actions.updateTabWeights({ + weights: [ + { + tabId: 10, + weight: 50, + weightMode: 'equal', + excludedAssessmentIds: [101], + }, + ], + }), + ); + + expect(next.assessments.find((a) => a.id === 101)!.gradebookExcluded).toBe( + true, + ); + expect(next.assessments.find((a) => a.id === 102)!.gradebookExcluded).toBe( + false, + ); + }); +}); + +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('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(); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx new file mode 100644 index 00000000000..8937cd5388f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx @@ -0,0 +1,600 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { + Alert, + Checkbox, + Collapse, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; +import type { + AssessmentData, + CategoryData, + TabData, +} from 'types/course/gradebook'; + +import SegmentedSwitch from 'lib/components/core/buttons/SegmentedSwitch'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; +import formTranslations from 'lib/translations/form'; + +import { resolveTabWeights, usingDefaultWeights } from '../computeWeighted'; +import { updateGradebookWeights } from '../operations'; + +const translations = defineMessages({ + promptTitle: { + id: 'course.gradebook.ConfigureWeightsPrompt.promptTitle', + defaultMessage: 'Configure contributions', + }, + descriptionIntro: { + id: 'course.gradebook.ConfigureWeightsPrompt.descriptionIntro', + defaultMessage: + "Control how tabs and assessments count toward each student's total grade.", + }, + descriptionWeights: { + id: 'course.gradebook.ConfigureWeightsPrompt.descriptionWeights', + defaultMessage: + "Set each tab's weight - how much it contributes to the total (weights should sum to 100).", + }, + descriptionExclusion: { + id: 'course.gradebook.ConfigureWeightsPrompt.descriptionExclusion', + defaultMessage: + 'Expand a tab to include or exclude individual assessments from grading.', + }, + descriptionModes: { + id: 'course.gradebook.ConfigureWeightsPrompt.descriptionModes', + defaultMessage: + "Choose Equal (all assessments share the tab's weight) or Custom (set each assessment's share).", + }, + descriptionDrop: { + id: 'course.gradebook.ConfigureWeightsPrompt.descriptionDrop', + defaultMessage: + "In Equal mode, optionally drop each student's N lowest-scoring assessments before averaging.", + }, + total: { + id: 'course.gradebook.ConfigureWeightsPrompt.total', + defaultMessage: 'Total: {sum}%', + }, + weightsDoNotSum: { + id: 'course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum', + defaultMessage: + 'Weights do not sum to 100. Saving is allowed; Total may be inaccurate.', + }, + valueTooLow: { + id: 'course.gradebook.ConfigureWeightsPrompt.valueTooLow', + defaultMessage: 'Value must be at least 0', + }, + valueTooHigh: { + id: 'course.gradebook.ConfigureWeightsPrompt.valueTooHigh', + defaultMessage: 'Value must be at most 100', + }, + saveError: { + id: 'course.gradebook.ConfigureWeightsPrompt.saveError', + defaultMessage: 'Failed to save weights. Please try again.', + }, + ofGrade: { + id: 'course.gradebook.ConfigureWeightsPrompt.ofGrade', + defaultMessage: '{pct}% of grade', + }, + equalMode: { + id: 'course.gradebook.ConfigureWeightsPrompt.equalMode', + defaultMessage: 'Equal', + }, + customMode: { + id: 'course.gradebook.ConfigureWeightsPrompt.customMode', + defaultMessage: 'Custom', + }, + modeAria: { + id: 'course.gradebook.ConfigureWeightsPrompt.modeAria', + defaultMessage: '{tab} weight mode', + }, + customSum: { + id: 'course.gradebook.ConfigureWeightsPrompt.customSum', + defaultMessage: 'Assessment weights: {sum} / {total}', + }, + unbalanced: { + id: 'course.gradebook.ConfigureWeightsPrompt.unbalanced', + defaultMessage: + 'Assessment weights for "{tab}" must sum to its tab total before saving.', + }, + includeAssessment: { + id: 'course.gradebook.ConfigureWeightsPrompt.includeAssessment', + defaultMessage: 'Include {assessment} in grade', + }, + excluded: { + id: 'course.gradebook.ConfigureWeightsPrompt.excluded', + defaultMessage: 'Excluded', + }, + allExcluded: { + id: 'course.gradebook.ConfigureWeightsPrompt.allExcluded', + defaultMessage: + 'All assessments in "{tab}" are excluded - it contributes nothing to the total.', + }, + excludedCount: { + id: 'course.gradebook.ConfigureWeightsPrompt.excludedCount', + defaultMessage: '{n} excluded', + }, + allExcludedCount: { + id: 'course.gradebook.ConfigureWeightsPrompt.allExcludedCount', + defaultMessage: 'All {n} excluded', + }, + defaultsHint: { + id: 'course.gradebook.ConfigureWeightsPrompt.defaultsHint', + defaultMessage: + 'No weights set yet - these are suggested defaults with every tab counting equally. Save to confirm, or adjust below.', + }, +}); + +type WeightMode = 'equal' | 'custom'; + +const r2 = (n: number): number => Math.round(n * 100) / 100; +const cents = (n: number): number => Math.round(n * 100); +// Distribute a tab total across assessment ids at 2dp; the last id absorbs the rounding +// remainder so the seeded values sum back exactly to total. +const distributeEqual = ( + total: number, + ids: number[], +): Record => { + const result: Record = {}; + const n = ids.length; + if (n === 0) return result; + const base = r2(total / n); + ids.forEach((id, i) => { + result[id] = i === n - 1 ? r2(total - base * (n - 1)) : base; + }); + return result; +}; + +interface Props { + open: boolean; + onClose: () => void; + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; +} + +const ConfigureWeightsPrompt: FC = ({ + open, + onClose, + categories, + tabs, + assessments, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + // Pre-fill from the same equal-split default the table shows when no weights + // are configured, so opening the dialog confirms what is already on screen + // rather than presenting a blank 0%. + const resolvedTabs = resolveTabWeights(tabs, assessments); + const showingDefaults = usingDefaultWeights(tabs, assessments); + + const validate = (value: number): string | null => { + if (Number.isNaN(value)) return t(translations.valueTooLow); + if (value < 0) return t(translations.valueTooLow); + if (value > 100) return t(translations.valueTooHigh); + return null; + }; + + const seedWeights = (): Record => + Object.fromEntries( + resolvedTabs.map((tb) => [tb.id, tb.gradebookWeight ?? 0]), + ); + const seedModes = (): Record => + Object.fromEntries( + resolvedTabs.map((tb) => [tb.id, tb.weightMode ?? 'equal']), + ); + const seedAssessmentWeights = (): Record => + Object.fromEntries(assessments.map((a) => [a.id, a.gradebookWeight ?? 0])); + const seedExclusions = (): Record => + Object.fromEntries(assessments.map((a) => [a.id, !!a.gradebookExcluded])); + + const [weights, setWeights] = useState>(seedWeights); + const [modes, setModes] = useState>(seedModes); + const [assessmentWeights, setAssessmentWeights] = useState< + Record + >(seedAssessmentWeights); + const [excluded, setExcluded] = + useState>(seedExclusions); + const [expanded, setExpanded] = useState>({}); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) { + setWeights(seedWeights()); + setModes(seedModes()); + setAssessmentWeights(seedAssessmentWeights()); + setExcluded(seedExclusions()); + setExpanded({}); + } + }, [open]); + + const tabAssessmentIds = (tabId: number): number[] => + assessments.filter((a) => a.tabId === tabId).map((a) => a.id); + + const tabIncludedIds = (tabId: number): number[] => + tabAssessmentIds(tabId).filter((id) => !excluded[id]); + + const customSum = (tabId: number): number => + tabIncludedIds(tabId).reduce( + (acc, id) => acc + (assessmentWeights[id] ?? 0), + 0, + ); + + const isUnbalanced = (tabId: number): boolean => + (modes[tabId] ?? 'equal') === 'custom' && + tabIncludedIds(tabId).length > 0 && + cents(customSum(tabId)) !== cents(weights[tabId] ?? 0); + + const isAllExcluded = (tabId: number): boolean => + tabAssessmentIds(tabId).length > 0 && tabIncludedIds(tabId).length === 0; + + // An all-excluded tab contributes nothing, so its stored weight is treated as 0 + // for the Total. The stored value is retained (still saved, restored on re-include). + const effectiveWeight = (tabId: number): number => + isAllExcluded(tabId) ? 0 : weights[tabId] ?? 0; + + const sum = tabs.reduce((acc, tb) => acc + effectiveWeight(tb.id), 0); + const hasInvalid = + Object.values(weights).some((w) => validate(w) !== null) || + Object.values(assessmentWeights).some((w) => validate(w) !== null); + const hasUnbalanced = tabs.some((tb) => isUnbalanced(tb.id)); + + const handleChange = (tabId: number, raw: string): void => { + const parsed = raw === '' ? 0 : Number(raw); + setWeights((prev) => ({ ...prev, [tabId]: parsed })); + }; + + const handleAssessmentChange = (assessmentId: number, raw: string): void => { + const parsed = raw === '' ? 0 : Number(raw); + setAssessmentWeights((prev) => ({ ...prev, [assessmentId]: parsed })); + }; + + const handleToggleExcluded = (assessmentId: number): void => + setExcluded((prev) => ({ ...prev, [assessmentId]: !prev[assessmentId] })); + + const handleModeChange = (tabId: number, next: WeightMode | null): void => { + if (!next) return; // ToggleButtonGroup emits null when clicking the active button + setModes((prev) => ({ ...prev, [tabId]: next })); + if (next === 'custom') { + const includedIds = tabIncludedIds(tabId); + const allZero = includedIds.every( + (id) => (assessmentWeights[id] ?? 0) === 0, + ); + if (allZero) { + const seeded = distributeEqual(weights[tabId] ?? 0, includedIds); + setAssessmentWeights((prev) => ({ ...prev, ...seeded })); + } + setExpanded((prev) => ({ ...prev, [tabId]: true })); + } + }; + + const toggleExpanded = (tabId: number): void => + setExpanded((prev) => ({ ...prev, [tabId]: !prev[tabId] })); + + const handleSave = async (): Promise => { + if (hasInvalid || hasUnbalanced) return; + setSubmitting(true); + try { + await dispatch( + updateGradebookWeights( + tabs.map((tb) => { + const mode = modes[tb.id] ?? 'equal'; + const entry = { + tabId: tb.id, + weight: weights[tb.id] ?? 0, + weightMode: mode, + excludedAssessmentIds: tabAssessmentIds(tb.id).filter( + (id) => excluded[id], + ), + }; + if (mode === 'custom') { + return { + ...entry, + assessmentWeights: tabAssessmentIds(tb.id).map((id) => ({ + assessmentId: id, + weight: assessmentWeights[id] ?? 0, + })), + }; + } + return entry; + }), + ), + ); + onClose(); + } catch { + toast.error(t(translations.saveError)); + } finally { + setSubmitting(false); + } + }; + + return ( + + {showingDefaults && ( + + {t(translations.defaultsHint)} + + )} + + {t(translations.descriptionIntro)} + +
    + {[ + translations.descriptionWeights, + translations.descriptionExclusion, + translations.descriptionModes, + ].map((key) => ( + + {t(key)} + + ))} +
+ + {categories.map((cat) => ( +
+ {cat.title} + + {tabs + .filter((tb) => tb.categoryId === cat.id) + .map((tb) => { + const value = weights[tb.id] ?? 0; + const err = validate(value); + const tabAssessments = assessments.filter( + (a) => a.tabId === tb.id, + ); + const mode = modes[tb.id] ?? 'equal'; + const isExpanded = !!expanded[tb.id]; + const unbalanced = isUnbalanced(tb.id); + const noAssessments = tabAssessments.length === 0; + const includedCount = tabIncludedIds(tb.id).length; + const excludedCount = tabAssessments.length - includedCount; + const pct = includedCount > 0 ? r2(value / includedCount) : 0; + + return ( +
+
+ toggleExpanded(tb.id)} + size="small" + > + {isExpanded ? ( + + ) : ( + + )} + + + {tb.title} + {excludedCount > 0 && ( + <> + {' · '} + + {isAllExcluded(tb.id) + ? t(translations.allExcludedCount, { + n: excludedCount, + }) + : t(translations.excludedCount, { + n: excludedCount, + })} + + + )} + + handleModeChange(tb.id, next)} + options={[ + { + value: 'equal', + label: t(translations.equalMode), + }, + { + value: 'custom', + label: t(translations.customMode), + }, + ]} + value={mode} + /> + + 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={isAllExcluded(tb.id) ? 0 : value} + /> +
+ {err && ( + + {err} + + )} + {unbalanced && ( + + {t(translations.unbalanced, { tab: tb.title })} + + )} + {isAllExcluded(tb.id) && ( + + {t(translations.allExcluded, { tab: tb.title })} + + )} + + + {tabAssessments.map((a) => { + const isExcluded = !!excluded[a.id]; + const checkbox = ( + handleToggleExcluded(a.id)} + size="small" + /> + ); + if (mode === 'custom') { + const awValue = assessmentWeights[a.id] ?? 0; + const awErr = validate(awValue); + return ( +
+
+ {checkbox} + + {a.title} + +
+ {isExcluded ? ( + + {t(translations.excluded)} + + ) : ( + + setAssessmentWeights((prev) => ({ + ...prev, + [a.id]: r2(prev[a.id] ?? 0), + })) + } + onChange={(e) => + handleAssessmentChange( + a.id, + e.target.value, + ) + } + size="small" + sx={{ width: 88 }} + type="number" + value={awValue} + /> + )} +
+ ); + } + return ( +
+
+ {checkbox} + + {a.title} + +
+ + {isExcluded + ? t(translations.excluded) + : t(translations.ofGrade, { + pct: pct.toFixed(2), + })} + +
+ ); + })} + {mode === 'custom' && ( + + {t(translations.customSum, { + sum: r2(customSum(tb.id)).toFixed(2), + total: value.toFixed(2), + })} + + )} +
+
+
+ ); + })} +
+
+ ))} +
+ + {t(translations.total, { sum })} + + {sum !== 100 && ( + + {t(translations.weightsDoNotSum)} + + )} +
+ ); +}; + +export default ConfigureWeightsPrompt; diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx index eac4f665074..3cc71f803b2 100644 --- a/client/app/bundles/course/gradebook/components/GradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -9,6 +9,7 @@ import { import { defineMessages } from 'react-intl'; import { Checkbox, + Chip, Paper, type SxProps, Table, @@ -18,10 +19,13 @@ import { TableHead, TableRow, TableSortLabel, + TextField, type Theme, Tooltip, } from '@mui/material'; +import { lighten } from '@mui/material/styles'; import { flexRender } from '@tanstack/react-table'; +import palette from 'theme/palette'; import Link from 'lib/components/core/Link'; import type { @@ -36,10 +40,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 } from '../operations'; import type { AssessmentData, CategoryData, @@ -65,6 +72,10 @@ const COL_WIDTHS = { const CHECKBOX_WIDTH = 56; +// Faint blue wash that marks external-assessment columns. Kept very light (4% of +// info.main) so the band reads as a subtle tint, not a coloured header. +export const EXTERNAL_ASSESSMENT_BACKGROUND = lighten(palette.info.main, 0.96); + const getColWidth = (id: string): number => COL_WIDTHS[id as keyof typeof COL_WIDTHS] ?? COL_WIDTHS.assessment; @@ -110,6 +121,22 @@ 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.', + }, + gradeSaved: { + id: 'course.gradebook.GradebookTable.gradeSaved', + defaultMessage: 'Grade saved. {title} · {name}: {oldGrade} → {newGrade}', + }, }); const HeaderLabel = forwardRef< @@ -177,6 +204,96 @@ 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)); + // Persistent confirmation: external grades flow to exported finals, and the + // optimistic update has already discarded the old value from the cell — this + // toast is the only in-session record of what changed, so it must echo the + // full mutation (who · which assessment · old → new) to catch a row/column + // misclick and stay until the user dismisses it (no auto-dismiss). + toast.success( + t(translations.gradeSaved, { + title, + name: studentName, + oldGrade: prev ?? '—', + newGrade: next ?? '—', + }), + { autoClose: false }, + ); + } 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} + + ); +}; + interface GradebookRow { studentId: number; name: string; @@ -212,14 +329,14 @@ const GradebookTable = ({ const { t } = useTranslation(); const submissionsByStudent = useMemo(() => { - const map = new Map>(); + const map = new Map(); submissions.forEach((s) => { - let byAssessment = map.get(s.studentId); - if (!byAssessment) { - byAssessment = new Map(); - map.set(s.studentId, byAssessment); + const existing = map.get(s.studentId); + if (existing) { + existing.push(s); + } else { + map.set(s.studentId, [s]); } - byAssessment.set(s.assessmentId, s); }); return map; }, [submissions]); @@ -227,11 +344,11 @@ const GradebookTable = ({ const rows = useMemo( () => students.map((student) => { - const subs = submissionsByStudent.get(student.id); + const subs = submissionsByStudent.get(student.id) ?? []; const grades: Partial> = {}; const submissionIds: Partial> = {}; assessments.forEach((a) => { - const sub = subs?.get(a.id); + const sub = subs.find((s) => s.assessmentId === a.id); if (sub != null) { grades[a.id] = sub.grade; submissionIds[a.id] = sub.submissionId; @@ -329,6 +446,17 @@ const GradebookTable = ({ }, }, cell: (row) => { + if (asn.external) { + return ( + + ); + } const grade = row.grades[asn.id]; if (grade === undefined) return '—'; if (grade === null) return ''; @@ -361,6 +489,16 @@ const GradebookTable = ({ [assessments], ); + const externalAssessmentColumnIds = useMemo( + () => + new Set( + assessments + .filter((assessment) => assessment.external) + .map((assessment) => buildAssessmentColumnId(assessment.id)), + ), + [assessments], + ); + const columnPicker = useMemo( () => ({ render: (context: ColumnPickerRenderContext) => ( @@ -597,6 +735,7 @@ const GradebookTable = ({ const isLeft = isLeftAligned(id); const fits = headerFits[id] ?? false; const sort = sortByColId.get(id); + const isExternalCol = externalAssessmentColumnIds.has(id); const labelNode = ( @@ -607,6 +746,17 @@ const GradebookTable = ({ ); + const sortedLabel = sort ? ( + + {labelNode} + + ) : ( + labelNode + ); return ( - {sort ? ( - - {labelNode} - + {sortedLabel} + + ) : ( - labelNode + sortedLabel )} ); @@ -675,11 +831,11 @@ 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); + let cellNode: React.ReactNode = ''; + if (id === 'name') cellNode = t(translations.maxMarks); else if (asnId !== null) { const maxGrade = assessmentMaxGrades.get(asnId); - cellContent = maxGrade != null ? `/${maxGrade}` : ''; + cellNode = maxGrade != null ? `/${maxGrade}` : ''; } return ( - {cellContent} + {cellNode} ); })} @@ -773,17 +929,22 @@ const GradebookTable = ({ borderRight: `1px solid ${theme.palette.grey[200]}`, }, }); + let cellSx: SxProps | undefined; + if (cell.column.id === 'name') cellSx = nameCellSx; + else if ( + externalAssessmentColumnIds.has(cell.column.id) + ) + // bgcolor is the faint external-column tint. + cellSx = { + bgcolor: EXTERNAL_ASSESSMENT_BACKGROUND, + }; return ( {flexRender( cell.column.columnDef.cell, 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..1ebe6b381f8 --- /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 WeightedGradebookTable) 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/WeightedGradebookTable.tsx b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx new file mode 100644 index 00000000000..c546f765f20 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx @@ -0,0 +1,1066 @@ +import { Fragment, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Download, + InfoOutlined, + KeyboardArrowDown, + KeyboardArrowRight, +} from '@mui/icons-material'; +import { + Alert, + Button, + Checkbox, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; + +import SegmentedSwitch from 'lib/components/core/buttons/SegmentedSwitch'; +import SearchField from 'lib/components/core/fields/SearchField'; +import type { ColumnPickerRenderContext } from 'lib/components/table'; +import type { ColumnTemplate } from 'lib/components/table/builder'; +import MuiColumnPickerPrompt from 'lib/components/table/MuiTableAdapter/MuiColumnPickerPrompt'; +import MuiTablePagination from 'lib/components/table/MuiTableAdapter/MuiTablePagination'; +import useTanStackTableBuilder from 'lib/components/table/TanStackTableBuilder'; +import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; + +import type { AssessmentContribution, WeightedRow } from '../computeWeighted'; +import { + computeStudentBreakdown, + computeWeightedRows, + resolveTabWeights, + usingDefaultWeights, +} from '../computeWeighted'; + +import ConfigureWeightsPrompt from './ConfigureWeightsPrompt'; +import ProjectedTotalHint, { + projectedTotalPolicyTranslations, +} from './ProjectedTotalHint'; +import WeightedGradebookColumnTree from './WeightedGradebookColumnTree'; + +const translations = defineMessages({ + configureWeights: { + id: 'course.gradebook.WeightedGradebookTable.configureWeights', + defaultMessage: 'Configure Weights', + }, + noWeightsConfigured: { + id: 'course.gradebook.WeightedGradebookTable.noWeightsConfigured', + defaultMessage: + 'No weights configured - all tab weights are 0. Click "Configure Weights" to assign weights.', + }, + noWeightsNoAccess: { + id: 'course.gradebook.WeightedGradebookTable.noWeightsNoAccess', + defaultMessage: 'No tab weights have been configured yet.', + }, + defaultWeights: { + id: 'course.gradebook.WeightedGradebookTable.defaultWeights', + defaultMessage: + 'Showing default weights - every tab counts equally. Click "Configure Weights" to set your own.', + }, + defaultWeightsNoAccess: { + id: 'course.gradebook.WeightedGradebookTable.defaultWeightsNoAccess', + defaultMessage: + 'Showing default weights - every tab counts equally until weights are configured.', + }, + percentOfGrade: { + id: 'course.gradebook.WeightedGradebookTable.percentOfGrade', + defaultMessage: '{weight}% of grade', + }, + percentTotalExact: { + id: 'course.gradebook.WeightedGradebookTable.percentTotalExact', + defaultMessage: '100% total', + }, + percentTotalWarning: { + id: 'course.gradebook.WeightedGradebookTable.percentTotalWarning', + defaultMessage: '{weight}% total', + }, + outOfWeight: { + id: 'course.gradebook.WeightedGradebookTable.outOfWeight', + defaultMessage: '/{weight}', + }, + displayMode: { + id: 'course.gradebook.WeightedGradebookTable.displayMode', + defaultMessage: 'Display mode', + }, + displayPoints: { + id: 'course.gradebook.WeightedGradebookTable.displayPoints', + defaultMessage: 'Points', + }, + displayPointsTooltip: { + id: 'course.gradebook.WeightedGradebookTable.displayPointsTooltip', + defaultMessage: + 'How many grade points each tab contributes. Columns add up to the projected total.', + }, + displayPercent: { + id: 'course.gradebook.WeightedGradebookTable.displayPercent', + defaultMessage: 'Percentage', + }, + displayPercentTooltip: { + id: 'course.gradebook.WeightedGradebookTable.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.', + }, + weightsDoNotSum: { + id: 'course.gradebook.WeightedGradebookTable.weightsDoNotSum', + defaultMessage: 'Weights do not sum to 100. Total may be inaccurate.', + }, + searchStudents: { + id: 'course.gradebook.WeightedGradebookTable.searchStudents', + defaultMessage: 'Search students', + }, + downloadCsv: { + id: 'course.gradebook.WeightedGradebookTable.downloadCsv', + defaultMessage: 'Download as CSV', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + exportButton: { + id: 'course.gradebook.GradebookIndex.exportButton', + defaultMessage: 'Export all rows', + }, + exportRows: { + id: 'course.gradebook.GradebookIndex.exportRows', + defaultMessage: 'Export {count, plural, one {# row} other {# rows}}', + }, + exportAllTooltip: { + id: 'course.gradebook.GradebookIndex.exportAllTooltip', + defaultMessage: 'No rows selected - all rows will be exported.', + }, + expandRow: { + id: 'course.gradebook.WeightedGradebookTable.expandRow', + defaultMessage: 'Expand {name}', + }, + collapseRow: { + id: 'course.gradebook.WeightedGradebookTable.collapseRow', + defaultMessage: 'Collapse {name}', + }, + excluded: { + id: 'course.gradebook.WeightedGradebookTable.excluded', + defaultMessage: 'Excluded', + }, + total: { + id: 'course.gradebook.WeightedGradebookTable.total', + defaultMessage: 'Total', + }, +}); + +type DisplayMode = 'points' | 'percent'; + +interface Props { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + canManageWeights: boolean; + courseTitle: string; + courseId: number; +} + +const precisionNeeded = (v: number): 0 | 1 | 2 => { + const at2 = Math.round(v * 100) / 100; + const at1 = Math.round(v * 10) / 10; + const at0 = Math.round(v); + if (Math.abs(at2 - at1) > 1e-9) return 2; + if (Math.abs(at1 - at0) > 1e-9) return 1; + return 0; +}; + +const columnPrecision = (values: (number | null)[]): 0 | 1 | 2 => { + const precs = values + .filter((v): v is number => v !== null) + .map(precisionNeeded); + if (precs.includes(2)) return 2; + if (precs.includes(1)) return 1; + return 0; +}; + +const fmtCsv = (v: number | null): string => { + if (v === null) return ''; + return v.toFixed(2); +}; + +const CHECKBOX_WIDTH = 56; +const NAME_WIDTH_COLLAPSED = 150; +const NAME_WIDTH_EXPANDED = 260; +const EMAIL_WIDTH = 250; +const EXTERNAL_ID_WIDTH = 150; +const TAB_WIDTH = 120; + +const WeightedGradebookTable = ({ + categories, + tabs, + assessments, + students, + submissions, + canManageWeights, + courseTitle, + courseId, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [configureOpen, setConfigureOpen] = useState(false); + const [pickerOpen, setPickerOpen] = useState(false); + const [displayMode, setDisplayMode] = useState('points'); + + const resolvedTabs = useMemo( + () => resolveTabWeights(tabs, assessments), + [tabs, assessments], + ); + const showingDefaults = useMemo( + () => usingDefaultWeights(tabs, assessments), + [tabs, assessments], + ); + + const tabDisplayValue = ( + sub: number | null, + weight: number, + ): number | null => { + if (sub === null) return null; + return displayMode === 'percent' ? sub * 100 : sub * weight; + }; + + const allExcludedTabIds = useMemo(() => { + const byTab = new Map(); + assessments.forEach((a) => { + const allExcludedSoFar = byTab.get(a.tabId); + byTab.set(a.tabId, (allExcludedSoFar ?? true) && !!a.gradebookExcluded); + }); + return new Set( + [...byTab.entries()].filter(([, allExc]) => allExc).map(([id]) => id), + ); + }, [assessments]); + + const totalWeight = resolvedTabs.reduce( + (acc, tab) => + acc + (allExcludedTabIds.has(tab.id) ? 0 : tab.gradebookWeight ?? 0), + 0, + ); + const allWeightsZero = totalWeight === 0; + + const totalDisplayValue = (total: number | null): number | null => { + if (total === null) return null; + if (displayMode === 'percent') { + return totalWeight > 0 ? (total / totalWeight) * 100 : null; + } + return total; + }; + + const fmtDisplay = (v: number | null, prec: 0 | 1 | 2): string => { + if (v === null) return '—'; + const s = v.toFixed(prec); + return displayMode === 'percent' ? `${s}%` : s; + }; + + const breakdownDisplayValue = (a: AssessmentContribution): number | null => { + if (displayMode === 'percent') { + return a.grade === null ? null : (a.grade / a.maxGrade) * 100; + } + return a.points; + }; + + const [expandedIds, setExpandedIds] = useState>(new Set()); + const toggleExpanded = (studentId: number): void => + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(studentId)) next.delete(studentId); + else next.add(studentId); + return next; + }); + + // Widen the sticky Name column while any row's breakdown is open, so long + // assessment titles in the breakdown aren't clipped; shrink back when all + // rows are collapsed. + const nameWidth = + expandedIds.size > 0 ? NAME_WIDTH_EXPANDED : NAME_WIDTH_COLLAPSED; + + const breakdownsByStudent = useMemo( + () => + new Map( + [...expandedIds].map((studentId) => [ + studentId, + computeStudentBreakdown({ + studentId, + tabs: resolvedTabs, + assessments, + submissions, + }), + ]), + ), + [expandedIds, resolvedTabs, assessments, submissions], + ); + + const row1Ref = useRef(null); + const row2Ref = useRef(null); + const [row2Top, setRow2Top] = useState(0); + const [row3Top, setRow3Top] = useState(0); + + const tabSubheaderLabel = (tab: TabData): string => { + if (allExcludedTabIds.has(tab.id)) return t(translations.excluded); + const weight = tab.gradebookWeight ?? 0; + return displayMode === 'percent' + ? t(translations.percentOfGrade, { weight }) + : t(translations.outOfWeight, { weight }); + }; + + const categoryTabCounts = useMemo(() => { + const counts = new Map(); + resolvedTabs.forEach((tab) => { + counts.set(tab.categoryId, (counts.get(tab.categoryId) ?? 0) + 1); + }); + return counts; + }, [resolvedTabs]); + + const visibleCategories = useMemo( + () => categories.filter((cat) => categoryTabCounts.has(cat.id)), + [categories, categoryTabCounts], + ); + + const tabIsCategoryEnd = useMemo( + () => + resolvedTabs.map( + (tab, i) => + i === resolvedTabs.length - 1 || + tab.categoryId !== resolvedTabs[i + 1].categoryId, + ), + [resolvedTabs], + ); + const groupEndIf = (cond: boolean): { 'data-group-end'?: true } => + cond ? { 'data-group-end': true } : {}; + + useLayoutEffect(() => { + const row1 = row1Ref.current; + const row2 = row2Ref.current; + if (!row1 || !row2) return undefined; + + const measure = (): void => { + const h1 = row1.getBoundingClientRect().height; + const h2 = row2.getBoundingClientRect().height; + setRow2Top(h1); + setRow3Top(h1 + h2); + }; + + measure(); + const observer = new ResizeObserver(measure); + observer.observe(row1); + observer.observe(row2); + return () => observer.disconnect(); + }, [visibleCategories, resolvedTabs]); + + const rows = useMemo( + () => + computeWeightedRows({ + students, + tabs: resolvedTabs, + assessments, + submissions, + }), + [students, resolvedTabs, assessments, submissions], + ); + + const columnPrecisions = useMemo(() => { + const tabPrecs = resolvedTabs.map((tab, idx) => + columnPrecision( + rows.map((r) => + tabDisplayValue(r.subtotals[idx], tab.gradebookWeight ?? 0), + ), + ), + ); + return { + tabs: tabPrecs, + total: columnPrecision(rows.map((r) => totalDisplayValue(r.total))), + }; + }, [rows, resolvedTabs, displayMode, totalWeight]); + + 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, + }, + { + id: 'email', + title: t(tableTranslations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + defaultVisible: false, + }, + { + id: 'externalId', + title: t(tableTranslations.externalId), + of: 'externalId', + cell: (row) => row.externalId ?? '', + csvDownloadable: true, + searchable: true, + defaultVisible: hasExternalIds, + }, + ]; + + resolvedTabs.forEach((tab, idx) => { + const weight = tab.gradebookWeight ?? 0; + const prec = columnPrecisions.tabs[idx]; + cols.push({ + id: `tab-${tab.id}`, + title: tab.title, + accessorFn: (row) => + fmtCsv(tabDisplayValue(row.subtotals[idx], weight)), + cell: (row) => + fmtDisplay(tabDisplayValue(row.subtotals[idx], weight), prec), + csvDownloadable: true, + }); + }); + + cols.push({ + id: 'total', + title: t(translations.total), + accessorFn: (row) => fmtCsv(totalDisplayValue(row.total)), + cell: (row) => + fmtDisplay(totalDisplayValue(row.total), columnPrecisions.total), + csvDownloadable: true, + }); + + return cols; + }, [ + resolvedTabs, + t, + hasExternalIds, + columnPrecisions, + displayMode, + totalWeight, + ]); + + const columnPicker = useMemo( + () => ({ + render: (context: ColumnPickerRenderContext) => ( + + ), + locked: ['name'], + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + storageKey: `gradebook_weighted_columns_${courseId}`, + }), + [courseId, t], + ); + + const { + toolbar: toolbarProps, + body, + pagination, + } = useTanStackTableBuilder({ + data: rows, + columns, + getRowId: (row) => row.studentId.toString(), + getRowEqualityData: (row) => row, + indexing: { rowSelectable: true }, + pagination: { + rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }, + search: { searchPlaceholder: t(translations.searchStudents) }, + toolbar: { show: true, keepNative: true }, + csvDownload: { + filename: `${courseTitle}_weighted_gradebook`, + showDownloadButton: false, + }, + columnPicker, + }); + + const toolbar = toolbarProps!; + + const selectedCount = body.selectedCount ?? 0; + const directExportLabel = useMemo((): string => { + const isPartial = selectedCount > 0 && selectedCount < rows.length; + if (isPartial) return t(translations.exportRows, { count: selectedCount }); + return t(translations.exportButton); + }, [selectedCount, rows.length, t]); + + const visibility = toolbar.getColumnVisibility?.() ?? {}; + const showEmail = (visibility.email ?? false) === true; + const showExternalId = (visibility.externalId ?? false) === true; + let lastIdentityField: 'name' | 'email' | 'externalId' = 'name'; + if (showExternalId) lastIdentityField = 'externalId'; + else if (showEmail) lastIdentityField = 'email'; + + const allRowsSelected = body.allFilteredSelected ?? false; + const someRowsSelected = body.someFilteredSelected ?? false; + const toggleAllRows = (): void => body.toggleAllFiltered?.(); + + const totalWeightHeaderLabel = + displayMode === 'percent' + ? t(translations.percentTotalExact) + : t(translations.outOfWeight, { weight: totalWeight }); + + return ( +
+ {!allWeightsZero && !showingDefaults && } +
+ +
+ +
+ + {canManageWeights && ( + + )} + + {toolbar.onDirectExport && ( + + + + + + )} +
+
+ + {showingDefaults && ( + + {canManageWeights + ? t(translations.defaultWeights) + : t(translations.defaultWeightsNoAccess)} + + )} + {allWeightsZero && !showingDefaults && ( + + {canManageWeights + ? t(translations.noWeightsConfigured) + : t(translations.noWeightsNoAccess)} + + )} + ({ + maxHeight: 'calc(100vh - 22rem)', + overflowX: 'auto', + borderTop: `1px solid ${theme.palette.grey[400]}`, + borderLeft: `1px solid ${theme.palette.grey[400]}`, + borderRight: `1px solid ${theme.palette.grey[400]}`, + })} + > + { + const line = theme.palette.grey[400]; + const right = `inset -1px 0 0 ${line}`; + const bottom = `inset 0 -1px 0 ${line}`; + const groupRight = `inset -2px 0 0 ${line}`; + return { + tableLayout: 'fixed', + borderCollapse: 'separate', + borderSpacing: 0, + '& th, & td': { + boxSizing: 'border-box', + border: 0, + boxShadow: `${right}, ${bottom}`, + py: 0.25, + px: 1, + lineHeight: 1.2, + height: 32, + overflow: 'hidden', + textOverflow: 'ellipsis', + }, + '& [data-group-end]': { + boxShadow: `${groupRight}, ${bottom}`, + }, + }; + }} + > + + + + {showEmail && } + {showExternalId && } + {resolvedTabs.map((tab) => ( + + ))} + + + + + + + + + {t(tableTranslations.name)} + + {showEmail && ( + + {t(tableTranslations.email)} + + )} + {showExternalId && ( + + {t(tableTranslations.externalId)} + + )} + {visibleCategories.map((cat) => ( + + {cat.title} + + ))} + + + {t(translations.total)} + + + + + + + + + + + {resolvedTabs.map((tab, i) => ( + + + {tab.title} + + + ))} + + + + {resolvedTabs.map((tab, i) => ( + + {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 ( + + + + + + + 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; + const weightText = t( + translations.percentOfGrade, + { + weight: + Math.round(a.effectiveWeight * 100) / 100, + }, + ); + const gradeText = + a.grade === null + ? `-/${a.maxGrade}` + : `${a.grade}/${a.maxGrade}`; + return ( + + + + + + {a.title} + + + + {`${gradeText} · ${isExcluded ? t(translations.excluded) : weightText}`} + + + {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 WeightedGradebookTable; diff --git a/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx b/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx new file mode 100644 index 00000000000..aaa54991392 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import Link from 'lib/components/core/Link'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const WEIGHTED_VIEW_HINT_KEY = 'gradebook_weighted_view_hint'; + +const translations = defineMessages({ + hint: { + id: 'course.gradebook.WeightedViewHint.hint', + defaultMessage: + 'Want a weighted total grade? You can set how much each tab counts toward each student’s overall grade and view the weighted total here. Turn it on in {link}.', + }, + settingsLink: { + id: 'course.gradebook.WeightedViewHint.settingsLink', + defaultMessage: 'Gradebook settings', + }, +}); + +interface Props { + courseId: number; +} + +/** + * One-time, dismissable nudge shown to managers in the (always-visible) gradebook view + * when the weighted view is turned off. It advertises the capability and links to the + * setting that enables it, since that setting is otherwise buried in course admin. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const WeightedViewHint: FC = ({ courseId }) => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + WEIGHTED_VIEW_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
+ + + {t(translations.hint, { + link: ( + + {t(translations.settingsLink)} + + ), + })} + + +
+ ); +}; + +export default WeightedViewHint; diff --git a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts 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/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts new file mode 100644 index 00000000000..e8f90afb6d1 --- /dev/null +++ b/client/app/bundles/course/gradebook/computeWeighted.ts @@ -0,0 +1,326 @@ +// 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}`; + +// 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(); + 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 ? effectiveGrade(grade, a) / 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 += (effectiveGrade(grade, a) / 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 ? effectiveGrade(grade, a) / 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..d47b3aa43e9 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,38 @@ 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)); + }; + +// 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 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..ccd9a80a3fa 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'; @@ -13,14 +13,18 @@ import useTranslation from 'lib/hooks/useTranslation'; import { useCourseContext } from '../../../container/CourseLoader'; import GradebookTable from '../../components/GradebookTable'; import GradeLinkHint from '../../components/GradeLinkHint'; +import WeightedGradebookTable from '../../components/WeightedGradebookTable'; +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..2776f912113 100644 --- a/client/app/bundles/course/gradebook/selectors.ts +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -22,3 +22,11 @@ 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; +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 00e3291032b..75f9072a516 100644 --- a/client/app/bundles/course/gradebook/store.ts +++ b/client/app/bundles/course/gradebook/store.ts @@ -1,5 +1,9 @@ import { produce } from 'immer'; -import type { GradebookData } from 'types/course/gradebook'; +import type { + ExternalGradePayload, + GradebookData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; import type { AssessmentData, @@ -10,6 +14,8 @@ import type { } from './types'; const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; +const UPDATE_TAB_WEIGHTS = 'course/gradebook/UPDATE_TAB_WEIGHTS'; +const SET_EXTERNAL_GRADE = 'course/gradebook/SET_EXTERNAL_GRADE'; interface GradebookState { categories: CategoryData[]; @@ -18,6 +24,8 @@ interface GradebookState { students: StudentData[]; submissions: SubmissionData[]; gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; } interface SaveGradebookAction { @@ -25,6 +33,16 @@ interface SaveGradebookAction { payload: GradebookData; } +interface UpdateTabWeightsAction { + type: typeof UPDATE_TAB_WEIGHTS; + payload: UpdateWeightsPayload; +} + +interface SetExternalGradeAction { + type: typeof SET_EXTERNAL_GRADE; + payload: ExternalGradePayload; +} + const initialState: GradebookState = { categories: [], tabs: [], @@ -32,10 +50,18 @@ const initialState: GradebookState = { students: [], submissions: [], gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, }; const reducer = produce( - (draft: GradebookState, action: SaveGradebookAction) => { + ( + draft: GradebookState, + action: + | SaveGradebookAction + | UpdateTabWeightsAction + | SetExternalGradeAction, + ) => { switch (action.type) { case SAVE_GRADEBOOK: { draft.categories = action.payload.categories; @@ -44,6 +70,52 @@ 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; + } + 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: @@ -58,6 +130,15 @@ export const actions = { type: SAVE_GRADEBOOK, payload: data, }), + updateTabWeights: ( + payload: UpdateWeightsPayload, + ): UpdateTabWeightsAction => ({ + type: UPDATE_TAB_WEIGHTS, + payload, + }), + setExternalGrade: ( + payload: ExternalGradePayload, + ): SetExternalGradeAction => ({ type: SET_EXTERNAL_GRADE, 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/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/lib/components/core/buttons/SegmentedSwitch.tsx b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx new file mode 100644 index 00000000000..88956fae16b --- /dev/null +++ b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx @@ -0,0 +1,222 @@ +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). + */ +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), + ); + + useLayoutEffect(() => { + const measure = (): void => { + const el = optionRefs.current[activeIndex]; + const container = containerRef.current; + if (!el || !container) return; + 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}`, + opacity: disabled ? 0.5 : 1, + pointerEvents: disabled ? 'none' : 'auto', + })} + > + ({ + 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, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontFamily: 'inherit', + fontSize: theme.typography.pxToRem(FONT_PX), + height: '100%', + fontWeight: selected ? 650 : 550, + letterSpacing: '0.01em', + color: selected + ? theme.palette.text.primary + : theme.palette.text.secondary, + px: PADDING_X, + 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..df09d449854 --- /dev/null +++ b/client/app/lib/components/core/buttons/__test__/SegmentedSwitch.test.tsx @@ -0,0 +1,131 @@ +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('falls back to selecting the first option when value matches none', () => { + setup('points', { value: 'nonexistent' as 'points' | 'percent' }); + expect(screen.getByRole('radio', { name: 'Points' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + expect(screen.getByRole('radio', { name: 'Percentage' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + 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('shows a tooltip on hover for an option that supplies one', async () => { + const user = userEvent.setup(); + setup('points', { + options: [ + { value: 'points', label: 'Points', tooltip: 'Raw points earned' }, + { value: 'percent', label: 'Percentage' }, + ], + }); + await user.hover(screen.getByRole('radio', { name: 'Points' })); + expect(await screen.findByRole('tooltip')).toHaveTextContent( + 'Raw points earned', + ); + }); + + it('uses an explicit option ariaLabel as the radio accessible name', () => { + setup('points', { + options: [ + { value: 'points', label: 'Points', ariaLabel: 'Show as points' }, + { value: 'percent', label: 'Percentage' }, + ], + }); + expect( + screen.getByRole('radio', { name: 'Show as points' }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('radio', { name: 'Points' }), + ).not.toBeInTheDocument(); + }); + + 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/table/TanStackTableBuilder/columnsBuilder.ts b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts index 8ebb3127667..2b1a825b0eb 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts @@ -61,6 +61,9 @@ const buildTanStackColumns = ( column.sortProps!.sort!(rowA.original, rowB.original) : 'alphanumeric', sortUndefined: column.sortProps?.undefinedPriority ?? false, + ...(column.sortProps?.descFirst !== undefined && { + sortDescFirst: column.sortProps.descFirst, + }), filterFn: column.filterProps?.shouldInclude && Object.assign( diff --git a/client/app/lib/components/table/builder/ColumnTemplate.ts b/client/app/lib/components/table/builder/ColumnTemplate.ts index cbfe6132ac7..265e16f7f8c 100644 --- a/client/app/lib/components/table/builder/ColumnTemplate.ts +++ b/client/app/lib/components/table/builder/ColumnTemplate.ts @@ -17,6 +17,7 @@ interface SearchingProps { interface SortingProps { sort?: (datumA: D, datumB: D) => number; undefinedPriority?: false | 'first' | 'last'; + descFirst?: boolean; } interface ColumnTemplate { diff --git a/client/app/lib/components/table/utils.ts b/client/app/lib/components/table/utils.ts index 6b84e7a373c..916dfdba696 100644 --- a/client/app/lib/components/table/utils.ts +++ b/client/app/lib/components/table/utils.ts @@ -2,10 +2,13 @@ import { downloadFile } from 'utilities/downloadFile'; const DEFAULT_CSV_FILENAME = 'data' as const; +// Prepend UTF-8 BOM so Excel on macOS/Windows detects UTF-8 encoding instead +// of falling back to a legacy code page (Mac Roman / Windows-1252), which +// misreads multibyte characters like em dash (U+2014) as mojibake (e.g. "‚Äî"). export const downloadCsv = (csvData: string, filename?: string): void => { downloadFile( 'text/csv;charset=utf-8', - csvData, + `\uFEFF${csvData}`, `${filename ?? DEFAULT_CSV_FILENAME}.csv`, ); }; diff --git a/client/app/lib/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/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..a87156279ee 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,11 @@ export interface AssessmentData { title: string; tabId: number; maxGrade: number; + gradebookWeight?: number | null; + gradebookExcluded?: boolean; + external?: boolean; + floorAtZero?: boolean; + capAtMaximum?: boolean; } export interface StudentData { @@ -28,7 +35,7 @@ export interface StudentData { export interface SubmissionData { studentId: number; assessmentId: number; - submissionId: number; + submissionId?: number; grade: number | null; } @@ -39,4 +46,22 @@ 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 }[]; + }[]; +} + +export interface ExternalGradePayload { + studentId: number; + assessmentId: number; + grade: number | null; } diff --git a/client/locales/en.json b/client/locales/en.json index 5a536c15e2f..1e752da5742 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9268,5 +9268,176 @@ }, "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.WeightedGradebookTable.collapseRow": { + "defaultMessage": "Collapse {name}" + }, + "course.gradebook.WeightedGradebookTable.configureWeights": { + "defaultMessage": "Configure Weights" + }, + "course.gradebook.WeightedGradebookTable.defaultWeights": { + "defaultMessage": "Showing default weights - every tab counts equally. Click \"Configure Weights\" to set your own." + }, + "course.gradebook.WeightedGradebookTable.defaultWeightsNoAccess": { + "defaultMessage": "Showing default weights - every tab counts equally until weights are configured." + }, + "course.gradebook.WeightedGradebookTable.displayPercent": { + "defaultMessage": "Percentage" + }, + "course.gradebook.WeightedGradebookTable.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.WeightedGradebookTable.displayPoints": { + "defaultMessage": "Points" + }, + "course.gradebook.WeightedGradebookTable.displayPointsTooltip": { + "defaultMessage": "How many grade points each tab contributes. Columns add up to the projected total." + }, + "course.gradebook.WeightedGradebookTable.downloadCsv": { + "defaultMessage": "Download as CSV" + }, + "course.gradebook.WeightedGradebookTable.email": { + "defaultMessage": "Email" + }, + "course.gradebook.WeightedGradebookTable.excluded": { + "defaultMessage": "Excluded" + }, + "course.gradebook.WeightedGradebookTable.expandRow": { + "defaultMessage": "Expand {name}" + }, + "course.gradebook.WeightedGradebookTable.name": { + "defaultMessage": "Name" + }, + "course.gradebook.WeightedGradebookTable.noWeightsConfigured": { + "defaultMessage": "No weights configured - all tab weights are 0. Click \"Configure Weights\" to assign weights." + }, + "course.gradebook.WeightedGradebookTable.noWeightsNoAccess": { + "defaultMessage": "No tab weights have been configured yet." + }, + "course.gradebook.WeightedGradebookTable.outOfWeight": { + "defaultMessage": "/{weight}" + }, + "course.gradebook.WeightedGradebookTable.percentOfGrade": { + "defaultMessage": "{weight}% of grade" + }, + "course.gradebook.WeightedGradebookTable.percentTotalExact": { + "defaultMessage": "100% total" + }, + "course.gradebook.WeightedGradebookTable.percentTotalWarning": { + "defaultMessage": "{weight}% total" + }, + "course.gradebook.WeightedGradebookTable.total": { + "defaultMessage": "Total" + }, + "course.gradebook.WeightedGradebookTable.searchStudents": { + "defaultMessage": "Search students" + }, + "course.gradebook.WeightedGradebookTable.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" + }, + "course.gradebook.GradebookTable.externalBadge": { + "defaultMessage": "External" + }, + "course.gradebook.GradebookTable.externalGradeAria": { + "defaultMessage": "{title} grade for {name}" + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "Could not save the grade. Please try again." + }, + "course.gradebook.GradebookTable.gradeSaved": { + "defaultMessage": "Grade saved. {title} · {name}: {oldGrade} → {newGrade}" } } diff --git a/client/locales/ko.json b/client/locales/ko.json index 2855b1a3a4e..8272d0b57d6 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9253,5 +9253,176 @@ }, "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.WeightedGradebookTable.collapseRow": { + "defaultMessage": "{name} 접기" + }, + "course.gradebook.WeightedGradebookTable.configureWeights": { + "defaultMessage": "가중치 설정" + }, + "course.gradebook.WeightedGradebookTable.defaultWeights": { + "defaultMessage": "기본 가중치를 표시하고 있습니다. 모든 탭이 동일하게 반영됩니다. \"가중치 설정\"을 클릭하여 직접 설정하세요." + }, + "course.gradebook.WeightedGradebookTable.defaultWeightsNoAccess": { + "defaultMessage": "기본 가중치를 표시하고 있습니다. 가중치가 설정되기 전까지 모든 탭이 동일하게 반영됩니다." + }, + "course.gradebook.WeightedGradebookTable.displayPercent": { + "defaultMessage": "백분율" + }, + "course.gradebook.WeightedGradebookTable.displayPercentTooltip": { + "defaultMessage": "학생이 각 탭에서 획득한 비율입니다. 비중이 20%인 탭에서 100%는 해당 탭의 20점을 모두 획득했음을 의미합니다." + }, + "course.gradebook.WeightedGradebookTable.displayPoints": { + "defaultMessage": "점수" + }, + "course.gradebook.WeightedGradebookTable.displayPointsTooltip": { + "defaultMessage": "각 탭이 기여하는 성적 점수입니다. 각 열의 합이 예상 총점이 됩니다." + }, + "course.gradebook.WeightedGradebookTable.downloadCsv": { + "defaultMessage": "CSV로 다운로드" + }, + "course.gradebook.WeightedGradebookTable.email": { + "defaultMessage": "이메일" + }, + "course.gradebook.WeightedGradebookTable.excluded": { + "defaultMessage": "제외됨" + }, + "course.gradebook.WeightedGradebookTable.expandRow": { + "defaultMessage": "{name} 펼치기" + }, + "course.gradebook.WeightedGradebookTable.name": { + "defaultMessage": "이름" + }, + "course.gradebook.WeightedGradebookTable.noWeightsConfigured": { + "defaultMessage": "설정된 가중치가 없습니다. 모든 탭의 가중치가 0입니다. \"가중치 설정\"을 클릭하여 가중치를 지정하세요." + }, + "course.gradebook.WeightedGradebookTable.noWeightsNoAccess": { + "defaultMessage": "아직 탭 가중치가 설정되지 않았습니다." + }, + "course.gradebook.WeightedGradebookTable.outOfWeight": { + "defaultMessage": "/{weight}" + }, + "course.gradebook.WeightedGradebookTable.percentOfGrade": { + "defaultMessage": "성적의 {weight}%" + }, + "course.gradebook.WeightedGradebookTable.percentTotalExact": { + "defaultMessage": "합계 100%" + }, + "course.gradebook.WeightedGradebookTable.percentTotalWarning": { + "defaultMessage": "합계 {weight}%" + }, + "course.gradebook.WeightedGradebookTable.total": { + "defaultMessage": "총점" + }, + "course.gradebook.WeightedGradebookTable.searchStudents": { + "defaultMessage": "학생 검색" + }, + "course.gradebook.WeightedGradebookTable.weightsDoNotSum": { + "defaultMessage": "가중치 합계가 100이 아닙니다. 총점이 정확하지 않을 수 있습니다." + }, + "course.gradebook.TotalHint.policy": { + "defaultMessage": "총점은 채점되지 않은 평가를 0점으로 계산합니다." + }, + "course.gradebook.WeightedViewHint.hint": { + "defaultMessage": "가중 총점이 필요하신가요? 각 탭이 학생의 전체 성적에 반영되는 비중을 설정하고 여기에서 가중 총점을 확인할 수 있습니다. {link}에서 활성화하세요." + }, + "course.gradebook.WeightedViewHint.settingsLink": { + "defaultMessage": "성적부 설정" + }, + "course.gradebook.GradebookTable.externalBadge": { + "defaultMessage": "외부" + }, + "course.gradebook.GradebookTable.externalGradeAria": { + "defaultMessage": "{name}의 {title} 성적" + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "성적을 저장할 수 없습니다. 다시 시도해 주세요." + }, + "course.gradebook.GradebookTable.gradeSaved": { + "defaultMessage": "성적이 저장되었습니다. {title} · {name}: {oldGrade} → {newGrade}" } } diff --git a/client/locales/zh.json b/client/locales/zh.json index 41c456b03e7..c0d05778e9c 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9247,5 +9247,176 @@ }, "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.WeightedGradebookTable.collapseRow": { + "defaultMessage": "收起 {name}" + }, + "course.gradebook.WeightedGradebookTable.configureWeights": { + "defaultMessage": "配置权重" + }, + "course.gradebook.WeightedGradebookTable.defaultWeights": { + "defaultMessage": "正在显示默认权重-所有标签页比重相同。点击“配置权重”进行自定义设置。" + }, + "course.gradebook.WeightedGradebookTable.defaultWeightsNoAccess": { + "defaultMessage": "正在显示默认权重-在配置权重之前,所有标签页比重相同。" + }, + "course.gradebook.WeightedGradebookTable.displayPercent": { + "defaultMessage": "百分比" + }, + "course.gradebook.WeightedGradebookTable.displayPercentTooltip": { + "defaultMessage": "学生在各标签页所获得的比例。在比重为 20% 的标签页获得 100%,即表示学生获得了该标签页的全部 20 个成绩分。" + }, + "course.gradebook.WeightedGradebookTable.displayPoints": { + "defaultMessage": "分数" + }, + "course.gradebook.WeightedGradebookTable.displayPointsTooltip": { + "defaultMessage": "各标签页贡献的成绩分数。各列相加即为预计总成绩。" + }, + "course.gradebook.WeightedGradebookTable.downloadCsv": { + "defaultMessage": "下载为 CSV" + }, + "course.gradebook.WeightedGradebookTable.email": { + "defaultMessage": "电子邮件" + }, + "course.gradebook.WeightedGradebookTable.excluded": { + "defaultMessage": "已排除" + }, + "course.gradebook.WeightedGradebookTable.expandRow": { + "defaultMessage": "展开 {name}" + }, + "course.gradebook.WeightedGradebookTable.name": { + "defaultMessage": "姓名" + }, + "course.gradebook.WeightedGradebookTable.noWeightsConfigured": { + "defaultMessage": "尚未配置权重-所有标签页的权重均为 0。点击“配置权重”以分配权重。" + }, + "course.gradebook.WeightedGradebookTable.noWeightsNoAccess": { + "defaultMessage": "尚未配置任何标签页权重。" + }, + "course.gradebook.WeightedGradebookTable.outOfWeight": { + "defaultMessage": "/{weight}" + }, + "course.gradebook.WeightedGradebookTable.percentOfGrade": { + "defaultMessage": "占成绩的 {weight}%" + }, + "course.gradebook.WeightedGradebookTable.percentTotalExact": { + "defaultMessage": "合计 100%" + }, + "course.gradebook.WeightedGradebookTable.percentTotalWarning": { + "defaultMessage": "合计 {weight}%" + }, + "course.gradebook.WeightedGradebookTable.total": { + "defaultMessage": "总成绩" + }, + "course.gradebook.WeightedGradebookTable.searchStudents": { + "defaultMessage": "搜索学生" + }, + "course.gradebook.WeightedGradebookTable.weightsDoNotSum": { + "defaultMessage": "权重合计不为 100。总成绩可能不准确。" + }, + "course.gradebook.TotalHint.policy": { + "defaultMessage": "总成绩将未评分的评估按 0 分计算。" + }, + "course.gradebook.WeightedViewHint.hint": { + "defaultMessage": "需要加权总成绩吗?您可以设置每个标签页在每位学生总成绩中所占的比重,并在此查看加权总成绩。请在{link}中启用。" + }, + "course.gradebook.WeightedViewHint.settingsLink": { + "defaultMessage": "成绩册设置" + }, + "course.gradebook.GradebookTable.externalBadge": { + "defaultMessage": "外部" + }, + "course.gradebook.GradebookTable.externalGradeAria": { + "defaultMessage": "{name} 的 {title} 成绩" + }, + "course.gradebook.GradebookTable.gradeSaveError": { + "defaultMessage": "无法保存成绩。请重试。" + }, + "course.gradebook.GradebookTable.gradeSaved": { + "defaultMessage": "成绩已保存。{title} · {name}:{oldGrade} → {newGrade}" } } diff --git a/config/locales/en/activerecord/errors.yml b/config/locales/en/activerecord/errors.yml index 95f0da1660a..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: @@ -97,6 +101,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..76f97ba3710 100644 --- a/config/locales/ko/activerecord/errors.yml +++ b/config/locales/ko/activerecord/errors.yml @@ -6,6 +6,10 @@ ko: attributes: reference_timelines: must_have_at_most_one_default: '최대 하나의 기본값만 있어야 합니다' + course/gradebook/contribution: + attributes: + base: + exactly_one_contributor: '정확히 하나의 기여 대상(탭 또는 외부 평가)을 참조해야 합니다' course/announcement: attributes: end_at: @@ -95,6 +99,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..c67c1acf637 100644 --- a/config/locales/zh/activerecord/errors.yml +++ b/config/locales/zh/activerecord/errors.yml @@ -6,6 +6,10 @@ zh: attributes: reference_timelines: must_have_at_most_one_default: '至少要包含一个默认值' + course/gradebook/contribution: + attributes: + base: + exactly_one_contributor: '必须且只能引用一个贡献项(一个标签页或一个外部评估)' course/announcement: attributes: end_at: @@ -95,6 +99,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..9e8094d2148 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,12 @@ resource :gradebook, only: [] do get '/' => 'gradebook#index' + patch '/weights' => 'gradebook#update_weights' + resources :external_assessments, only: [] do + member do + put 'grades' => 'external_assessments#grades' + end + end 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..4a0c687c1a0 --- /dev/null +++ b/db/migrate/20260611000000_create_gradebook_contribution_tables.rb @@ -0,0 +1,36 @@ +# 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.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/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 1d320432f9a..3f5f85a0d88 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_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 @@ -856,6 +888,37 @@ 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.bigint "creator_id", null: false + 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| t.bigint "course_id", null: false t.string "name", default: "", null: false @@ -1896,6 +1959,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" @@ -1915,6 +1985,14 @@ 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", "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" 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..f63f5c4dee8 --- /dev/null +++ b/spec/controllers/course/admin/gradebook_settings_controller_spec.rb @@ -0,0 +1,156 @@ +# 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) } + let(:student) { create(:course_student, 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 + + it 'reflects an already-enabled setting' do + ctx = Struct.new(:current_course, :key).new(course, Course::GradebookComponent.key) + Course::Settings::GradebookComponent.new(ctx).weighted_view_enabled = true + course.save! + get :edit, params: { course_id: course.id }, format: :json + expect(JSON.parse(response.body)).to include('weightedViewEnabled' => true) + 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 + + context 'as student' do + before { controller_sign_in(controller, student.user) } + + it 'is denied' do + expect do + get :edit, params: { course_id: course.id }, format: :json + end.to raise_error(CanCan::AccessDenied) + end + end + + context 'when the gradebook component is disabled' do + before do + controller_sign_in(controller, manager.user) + allow(controller).to receive_message_chain('current_component_host.[]').and_return(nil) + end + + it 'raises a component not found error' do + expect do + get :edit, params: { course_id: course.id }, format: :json + end.to raise_error(ComponentNotFoundError) + 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 + + it 'renders errors with 400 when persistence fails' do + allow_any_instance_of(Course).to receive(:save).and_return(false) + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { weighted_view_enabled: true } }, + format: :json + expect(response).to have_http_status(:bad_request) + expect(JSON.parse(response.body)).to have_key('errors') + end + + it 'raises ParameterMissing when settings_gradebook_component is absent' do + expect do + patch :update, params: { course_id: course.id }, format: :json + end.to raise_error(ActionController::ParameterMissing) + end + + it 'ignores attributes outside the permitted set' do + patch :update, + params: { course_id: course.id, + settings_gradebook_component: { + weighted_view_enabled: true, some_forbidden_attr: 'x' + } }, + format: :json + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)).to include('weightedViewEnabled' => true) + 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 + + context 'as student' do + let(:student) { create(:course_student, course: course) } + before { controller_sign_in(controller, student.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/external_assessments_controller_spec.rb b/spec/controllers/course/external_assessments_controller_spec.rb new file mode 100644 index 00000000000..c235b0e628e --- /dev/null +++ b/spec/controllers/course/external_assessments_controller_spec.rb @@ -0,0 +1,92 @@ +# 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 '#grades' do + render_views + let!(:external) { create(:course_external_assessment, course: course) } + let(:gb_student) { create(:course_student, course: course) } + + context 'as a manager' do + before { controller_sign_in(controller, manager.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 teaching assistant' do + before { controller_sign_in(controller, ta.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 + + 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 a213e80f476..74d54d758f7 100644 --- a/spec/controllers/course/gradebook_controller_spec.rb +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -199,6 +199,337 @@ 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 + 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_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_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..94ff1573050 --- /dev/null +++ b/spec/factories/course_gradebook_contributions.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +FactoryBot.define do + 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/models/course/gradebook/assessment_contribution_spec.rb b/spec/models/course/gradebook/assessment_contribution_spec.rb new file mode 100644 index 00000000000..107e55a1e96 --- /dev/null +++ b/spec/models/course/gradebook/assessment_contribution_spec.rb @@ -0,0 +1,50 @@ +# 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 '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 'accepts a non-negative weight' do + contribution = build(:course_gradebook_assessment_contribution, assessment: assessment, weight: 0) + expect(contribution).to be_valid + contribution.weight = 25.5 + expect(contribution).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 'rejects a nil excluded flag' do + contribution = build(:course_gradebook_assessment_contribution, assessment: assessment) + contribution.excluded = nil + expect(contribution).not_to be_valid + 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..189a53c5578 --- /dev/null +++ b/spec/models/course/gradebook/contribution_spec.rb @@ -0,0 +1,239 @@ +# 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 'accepts a weight of exactly 100' do + contribution = build(:course_gradebook_contribution, tab: tab, course: course, weight: 100) + expect(contribution).to be_valid + end + + it 'requires a weight_mode' do + contribution = build(:course_gradebook_contribution, tab: tab, course: course) + contribution.weight_mode = nil + expect(contribution).not_to be_valid + end + + it 'defaults weight 0, mode equal' do + contribution = create(:course_gradebook_contribution, tab: tab, course: course) + expect(contribution.weight).to eq(0) + expect(contribution.weight_mode).to eq('equal') + 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) + expect(described_class.find_by(tab_id: tab1.id).course_id).to eq(course.id) + expect(described_class.where(tab_id: [tab1.id, tab2.id]).count).to eq(2) + end + + it 'is a no-op for an empty updates array' do + expect do + described_class.bulk_update(course: course, updates: []) + end.not_to change(described_class, :count) + 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 'raises RecordNotFound when a custom assessment weight targets an assessment outside the tab' do + foreign = create(:assessment, course: course) + expect do + described_class.bulk_update( + course: course, + updates: [ + tab_id: tab.id, weight: 50.0, weight_mode: 'custom', + assessment_weights: [{ assessment_id: foreign.id, weight: 50.0 }] + ] + ) + end.to raise_error(ActiveRecord::RecordNotFound) + 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..91257859ddb --- /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.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 + + 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..5b4fb09665f --- /dev/null +++ b/spec/models/course/settings/gradebook_component_spec.rb @@ -0,0 +1,75 @@ +# 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 'survives a reload into a fresh component instance' do + settings.weighted_view_enabled = true + course.save! + course.reload + fresh = Course::Settings::GradebookComponent.new( + OpenStruct.new(current_course: course, key: Course::GradebookComponent.key) + ) + expect(fresh.weighted_view_enabled).to eq(true) + 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 "true" as truthy' do + settings.weighted_view_enabled = 'true' + expect(settings.weighted_view_enabled).to eq(true) + end + + it 'handles string "on" as truthy' do + settings.weighted_view_enabled = 'on' + 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 + + it 'handles string "false" as falsy' do + settings.weighted_view_enabled = 'false' + expect(settings.weighted_view_enabled).to eq(false) + end + + it 'handles string "off" as falsy' do + settings.weighted_view_enabled = 'off' + expect(settings.weighted_view_enabled).to eq(false) + end + end + end +end