diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..e9c8a8d9424 --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +class Course::GradebookComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Gradebook' + end + + def sidebar_items + main_sidebar_items + settings_sidebar_items + end + + private + + def main_sidebar_items + return [] unless can?(:read_gradebook, current_course) + + [ + { + key: self.class.key, + icon: :gradebook, + title: I18n.t('course.gradebook.component.sidebar_title'), + type: :normal, + weight: 9, + path: course_gradebook_path(current_course) + } + ] + end + + def settings_sidebar_items + return [] unless can?(:manage_gradebook_settings, current_course) + + [ + { + key: self.class.key, + type: :settings, + weight: 14, + path: course_admin_gradebook_path(current_course) + } + ] + end +end diff --git a/app/controllers/course/admin/gradebook_settings_controller.rb b/app/controllers/course/admin/gradebook_settings_controller.rb new file mode 100644 index 00000000000..6e6f3313ed5 --- /dev/null +++ b/app/controllers/course/admin/gradebook_settings_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Course::Admin::GradebookSettingsController < Course::Admin::Controller + def edit + respond_to(&:json) + end + + def update + if @settings.update(gradebook_settings_params) && current_course.save + render 'edit' + else + render json: { errors: @settings.errors }, status: :bad_request + end + end + + private + + def gradebook_settings_params + params.require(:settings_gradebook_component).permit(:weighted_view_enabled) + end + + def component + current_component_host[:course_gradebook_component] + end + + def authorize_admin + authorize! :manage_gradebook_settings, current_course + end +end diff --git a/app/controllers/course/external_assessment_imports_controller.rb b/app/controllers/course/external_assessment_imports_controller.rb new file mode 100644 index 00000000000..a85d636e744 --- /dev/null +++ b/app/controllers/course/external_assessment_imports_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true +class Course::ExternalAssessmentImportsController < Course::ComponentController + Service = Course::Gradebook::ExternalAssessmentImportService + + def preview + authorize! :manage_gradebook_weights, current_course + @result = build_service.preview + render 'preview' + rescue Service::ImportError => e + render json: { errors: e.payload }, status: :unprocessable_entity + end + + def create + authorize! :manage_gradebook_weights, current_course + @summary = build_service.commit(on_conflict: import_params[:onConflict]) + render 'create' + rescue Service::ImportError => e + render json: { errors: e.payload }, status: :unprocessable_entity + end + + private + + def component + current_component_host[:course_gradebook_component] + end + + def build_service + permitted_params = import_params + weighted_view_enabled = gradebook_settings.weighted_view_enabled + Service.new( + course: current_course, + actor: current_user, + components: permitted_params[:components].map do |c| + { name: c[:name], + weightage: weighted_view_enabled ? c[:weightage].to_i : 0, + maximum_grade: c[:maximumGrade].to_f } + end, + identifier_mode: permitted_params[:identifierMode], + csv_data: permitted_params[:csvData] + ) + end + + def gradebook_settings + @gradebook_settings ||= Course::Settings::GradebookComponent.new(component) + end + + def import_params + @import_params ||= params.slice(:identifierMode, :csvData, :onConflict, :components).permit( + :identifierMode, :csvData, :onConflict, + components: [:name, :weightage, :maximumGrade] + ) + end +end diff --git a/app/controllers/course/external_assessments_controller.rb b/app/controllers/course/external_assessments_controller.rb new file mode 100644 index 00000000000..881f8a7dc87 --- /dev/null +++ b/app/controllers/course/external_assessments_controller.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true +class Course::ExternalAssessmentsController < Course::ComponentController + before_action :load_external_assessment, only: [:update, :destroy, :grades] + + def create + authorize! :manage_gradebook_weights, current_course + @weighted_view_enabled = gradebook_settings.weighted_view_enabled + @external_assessment = Course::ExternalAssessment.create_for_course!( + course: current_course, + title: create_params[:title], + maximum_grade: create_params[:maximumGrade], + weight: create_weight, + floor_at_zero: bound_flag(:floorAtZero, default: true), + cap_at_maximum: bound_flag(:capAtMaximum, default: true) + ) + render 'create' + rescue ActiveRecord::RecordInvalid => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + def update + authorize! :manage_gradebook_weights, current_course + @weighted_view_enabled = gradebook_settings.weighted_view_enabled + @external_assessment.update!(update_params_attrs) + update_weight if @weighted_view_enabled && params.key?(:weight) + render 'update' + rescue ActiveRecord::RecordInvalid => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + def destroy + authorize! :manage_gradebook_weights, current_course + @external_assessment.destroy! + head :ok + end + + def reorder + authorize! :manage_gradebook_weights, current_course + Course::ExternalAssessment.reorder!(course: current_course, ordered_ids: reorder_params) + head :ok + rescue ArgumentError + head :unprocessable_entity + end + + def grades + authorize! :grade, @external_assessment + # The gradebook keys students by user_id (see index/update_grade jbuilders), so the + # `studentId` param is a user_id, not a course_user PK. + course_user = current_course.course_users.find_by!(user_id: grade_params[:studentId]) + @grade = @external_assessment.external_assessment_grades. + find_or_initialize_by(course_user: course_user) + @grade.grade = normalized_grade(grade_params[:grade]) + @grade.save! + render 'update_grade' + rescue ActiveRecord::RecordNotUnique + retry + rescue ActiveRecord::RecordNotFound + head :not_found + rescue ActiveRecord::RecordInvalid => e + render json: { errors: { base: e.message } }, status: :unprocessable_entity + end + + private + + def component + current_component_host[:course_gradebook_component] + end + + def gradebook_settings + @gradebook_settings ||= Course::Settings::GradebookComponent.new(component) + end + + def load_external_assessment + @external_assessment = Course::ExternalAssessment.for_course(current_course).find(params[:id]) + rescue ActiveRecord::RecordNotFound + head :not_found + end + + def create_params + params.permit(:title, :maximumGrade, :weight, :floorAtZero, :capAtMaximum) + end + + def create_weight + @weighted_view_enabled ? (create_params[:weight].presence || 0).to_f : 0 + end + + def update_weight + @external_assessment.gradebook_contribution&.update!(weight: (params[:weight].presence || 0).to_f) + end + + def update_params_attrs + attrs = {} + attrs[:title] = params[:title] if params.key?(:title) + attrs[:maximum_grade] = params[:maximumGrade] if params.key?(:maximumGrade) + attrs[:floor_at_zero] = bound_flag(:floorAtZero, default: true) if params.key?(:floorAtZero) + attrs[:cap_at_maximum] = bound_flag(:capAtMaximum, default: true) if params.key?(:capAtMaximum) + attrs + end + + # Coerce a string/bool HTTP param into a Ruby boolean (defaults when absent). + def bound_flag(key, default:) + return default unless params.key?(key) + + ActiveRecord::Type::Boolean.new.cast(params[key]) + end + + def grade_params + params.permit(:studentId, :grade) + end + + # Blank cell clears the grade to null (ungraded), never zero (decision #7). + def normalized_grade(value) + value.blank? ? nil : value + end + + def reorder_params + params.require(:orderedIds).map(&:to_i) + end +end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb new file mode 100644 index 00000000000..2d1b7e761a5 --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true +class Course::GradebookController < Course::ComponentController + before_action :authorize_read_gradebook! + + def index + respond_to do |format| + format.json do + @weighted_view_enabled = @settings.weighted_view_enabled + @published_assessments = fetch_published_assessments + @categories, @tabs = fetch_categories_and_tabs + @students = fetch_students + assessment_ids = @published_assessments.pluck(:id) + 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 + + def fetch_categories_and_tabs + tabs = @published_assessments.map(&:tab).uniq(&:id) + [tabs.map(&:category).uniq(&:id), tabs] + end + + def load_externals + @external_assessments = Course::ExternalAssessment.for_course(current_course).order(:position). + includes(:gradebook_contribution, external_assessment_grades: :course_user).to_a + @external_grades = @external_assessments.flat_map(&:external_assessment_grades) + @external_contributions = @external_assessments. + index_by(&:id). + transform_values(&:gradebook_contribution) + end + + def fetch_students + current_course.levels.to_a + current_course.course_users.students.without_phantom_users. + calculated(:experience_points).includes(user: :emails).to_a + end + + def fetch_published_assessments + current_course.assessments. + published. + includes(tab: :category). + joins(tab: :category). + reorder('course_assessment_categories.weight, course_assessment_tabs.weight, course_assessments.id') + end +end diff --git a/app/controllers/course/statistics/aggregate_controller.rb b/app/controllers/course/statistics/aggregate_controller.rb index 2e44dc06f81..306984f30e6 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -47,13 +47,6 @@ def activity_get_help @course_user_hash = current_course.course_users.index_by(&:user_id) end - def download_score_summary - job = Course::Statistics::AssessmentsScoreSummaryDownloadJob. - perform_later(current_course, params[:assessment_ids]).job - - render partial: 'jobs/submitted', locals: { job: job } - end - private def sanitize_date_range(start_at_param, end_at_param) diff --git a/app/jobs/course/statistics/assessments_score_summary_download_job.rb b/app/jobs/course/statistics/assessments_score_summary_download_job.rb deleted file mode 100644 index 86ce040b994..00000000000 --- a/app/jobs/course/statistics/assessments_score_summary_download_job.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true -class Course::Statistics::AssessmentsScoreSummaryDownloadJob < ApplicationJob - include TrackableJob - queue_as :lowest - retry_on StandardError, attempts: 0 - - protected - - def perform_tracked(course, assessment_ids) - file_name = "#{Pathname.normalize_filename(course.title)}_score_summary_#{Time.now.strftime '%Y%m%d_%H%M'}.csv" - service = Course::Statistics::AssessmentsScoreSummaryDownloadService.new(course, assessment_ids, file_name) - csv_file = service.generate - redirect_to SendFile.send_file(csv_file, file_name) - ensure - service&.cleanup - end -end diff --git a/app/models/components/course/gradebook_ability_component.rb b/app/models/components/course/gradebook_ability_component.rb new file mode 100644 index 00000000000..7c6d0990d69 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + can :read_gradebook, Course, id: course.id if course_user&.staff? + can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner? + can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner? + can :grade, Course::ExternalAssessment if course_user&.teaching_staff? + super + end +end diff --git a/app/models/course.rb b/app/models/course.rb index 8a402ce88c8..f52778fa958 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', @@ -361,11 +365,22 @@ def nearest_forum_discussions(query_embedding, limit: 3) # Set default values def set_defaults + set_default_times + set_default_timeline + build_creator_course_user + end + + def set_default_times self.start_at ||= Time.zone.now.beginning_of_hour - self.end_at ||= self.start_at + 1.month + self.end_at ||= start_at + 1.month + end + + def set_default_timeline self.default_reference_timeline ||= reference_timelines.new(default: true) self.default_timeline_algorithm ||= 0 # 'fixed' algorithm + end + def build_creator_course_user return unless creator && course_users.empty? course_users.build(user: creator, diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 3128ed42528..f13c3da775c 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 @@ -160,6 +163,22 @@ def self.use_relative_model_naming? true end + # Returns a hash of assessment_id => max_grade (sum of question maximum_grades). + def self.max_grades(assessment_ids) + return {} if assessment_ids.empty? + + rows = find_by_sql( + sanitize_sql_array([<<-SQL.squish, assessment_ids]) + SELECT cqa.assessment_id, COALESCE(SUM(caq.maximum_grade), 0) AS max_grade + FROM course_question_assessments cqa + JOIN course_assessment_questions caq ON caq.id = cqa.question_id + WHERE cqa.assessment_id IN (?) + GROUP BY cqa.assessment_id + SQL + ) + rows.to_h { |row| [row.assessment_id, row.max_grade.to_f] } + end + def to_partial_path 'course/assessment/assessments/assessment' end @@ -195,7 +214,7 @@ def update_randomization(params) # - The assessment don't have any submissions. # - Switching from autograded mode to manually graded mode. def allow_mode_switching? - submissions.count == 0 || autograded? + submissions.none? || autograded? end # @override ConditionalInstanceMethods#permitted_for! diff --git a/app/models/course/assessment/submission.rb b/app/models/course/assessment/submission.rb index c4919d6ec14..cb5df4c80f6 100644 --- a/app/models/course/assessment/submission.rb +++ b/app/models/course/assessment/submission.rb @@ -323,6 +323,27 @@ def self.on_dependent_status_change(answer) answer.submission.last_graded_time = Time.now end + # Returns an array of submission rows for the given students and assessments. + # Each row has: student_id (creator_id), assessment_id, grade (float). + # Only graded/published submissions are included. + def self.grade_summary(student_ids:, assessment_ids:) + return [] if student_ids.empty? || assessment_ids.empty? + + find_by_sql( + sanitize_sql_array([<<-SQL.squish, student_ids, assessment_ids]) + SELECT cas.creator_id AS student_id, cas.assessment_id, + cas.id AS submission_id, SUM(caa.grade) AS grade + FROM course_assessment_submissions cas + JOIN course_assessment_answers caa ON caa.submission_id = cas.id + WHERE cas.creator_id IN (?) + AND cas.assessment_id IN (?) + AND cas.workflow_state IN ('graded', 'published') + AND caa.current_answer = TRUE + GROUP BY cas.creator_id, cas.assessment_id, cas.id + SQL + ) + end + private # Queues the submission for auto grading, after the submission has changed to the submitted state. diff --git a/app/models/course/assessment/tab.rb b/app/models/course/assessment/tab.rb index bb88b5287f2..79bbb5452ba 100644 --- a/app/models/course/assessment/tab.rb +++ b/app/models/course/assessment/tab.rb @@ -8,6 +8,8 @@ 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 @@ -33,17 +35,25 @@ def other_tabs_remaining? end def initialize_duplicate(duplicator, other) - self.category = if duplicator.duplicated?(other.category) - duplicator.duplicate(other.category) - else - duplicator.options[:destination_course].assessment_categories.first - end - assessments << - other.assessments.select { |assessment| duplicator.duplicated?(assessment) }.map do |assessment| - duplicator.duplicate(assessment).tap do |duplicate_assessment| - duplicate_assessment.folder.parent = category.folder - end + self.category = duplicated_category_for(duplicator, other) + + assessments << duplicated_assessments_for(duplicator, other) + end + + def duplicated_category_for(duplicator, other) + return duplicator.duplicate(other.category) if duplicator.duplicated?(other.category) + + duplicator.options[:destination_course].assessment_categories.first + end + + def duplicated_assessments_for(duplicator, other) + other.assessments.filter_map do |assessment| + next unless duplicator.duplicated?(assessment) + + duplicator.duplicate(assessment).tap do |duplicate_assessment| + duplicate_assessment.folder.parent = category.folder end + end end private diff --git a/app/models/course/external_assessment.rb b/app/models/course/external_assessment.rb new file mode 100644 index 00000000000..69eec6ebac8 --- /dev/null +++ b/app/models/course/external_assessment.rb @@ -0,0 +1,80 @@ +# 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 + # delete_all (not destroy): grades carry no destroy callbacks, so destroying the + # parent would otherwise fire one SELECT+DELETE per grade (N+1). delete_all removes + # them in a single statement. Rails must issue it — the DB FK has no ON DELETE CASCADE. + has_many :external_assessment_grades, class_name: 'Course::ExternalAssessmentGrade', + inverse_of: :external_assessment, dependent: :delete_all + + scope :for_course, ->(course) { where(course_id: course.id) } + + before_create :assign_default_position + + # Next free position for the course (positions are 0-based, per course, not + # required to be unique). Drives append-at-end for both manual add and import. + def self.next_position(course) + (for_course(course).maximum(:position) || -1) + 1 + end + + # Rewrites positions to match ordered_ids (the canonical gradebook order). The + # id set must match the course's externals exactly, else the order would be + # corrupted by a stale/partial payload. + def self.reorder!(course:, ordered_ids:) + scope = for_course(course) + raise ArgumentError, 'ordered_ids must match the course externals' unless + ordered_ids.map(&:to_i).sort == scope.pluck(:id).sort + + transaction do + ordered_ids.each_with_index do |id, index| + scope.where(id: id).update_all(position: index) + end + end + end + + # The negative serialized id used by the synthetic tab AND the leaf assessment. + def synthetic_tab_id + -id + end + + # Creates an external assessment and its gradebook contribution in one transaction. + # Raises ActiveRecord::RecordInvalid on a duplicate title within the course. + def self.create_for_course!(course:, title:, maximum_grade:, weight: 0, + floor_at_zero: true, cap_at_maximum: true) + transaction do + external = course.external_assessments.create!( + title: title, maximum_grade: maximum_grade, + floor_at_zero: floor_at_zero, cap_at_maximum: cap_at_maximum + ) + Course::Gradebook::Contribution.create!(course: course, external_assessment: external, + weight: weight, weight_mode: 'equal', keep_highest: 0) + external + end + end + + + private + + def assign_default_position + self.position ||= self.class.next_position(course) + end +end diff --git a/app/models/course/external_assessment_grade.rb b/app/models/course/external_assessment_grade.rb new file mode 100644 index 00000000000..70518c79c9a --- /dev/null +++ b/app/models/course/external_assessment_grade.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true +# One external grade for a (external assessment, course_user). The binding key is +# course_user_id (the authoritative link to the person); imported_identifier is a +# non-authoritative snapshot of the email/Student ID used at import (audit + upsert +# mismatch detection), null for grades typed/edited inline. +class Course::ExternalAssessmentGrade < ApplicationRecord + validates :course_user, presence: true + validates :grade, numericality: true, allow_nil: true + validates :course_user_id, uniqueness: { scope: :external_assessment_id } + validates :creator, presence: true + validates :updater, presence: true + + belongs_to :external_assessment, class_name: 'Course::ExternalAssessment', + inverse_of: :external_assessment_grades + belongs_to :course_user, inverse_of: :external_assessment_grades +end diff --git a/app/models/course/gradebook.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..fcad0bb792c --- /dev/null +++ b/app/models/course/gradebook/contribution.rb @@ -0,0 +1,157 @@ +# 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:, keep_highest:, + # excluded_assessment_ids: [Integer], assessment_weights: [{ assessment_id:, weight: }] } + def self.bulk_update(course:, updates:) + external_updates, tab_updates = updates.partition { |e| e[:tab_id].to_i < 0 } + + course_tab_ids = course.assessment_tabs.pluck(:id).to_set + 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, + keep_highest: entry[:keep_highest] || 0) + 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/services/course/gradebook/external_assessment_import_service.rb b/app/services/course/gradebook/external_assessment_import_service.rb new file mode 100644 index 00000000000..95089ee8f12 --- /dev/null +++ b/app/services/course/gradebook/external_assessment_import_service.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true +require 'csv' + +class Course::Gradebook::ExternalAssessmentImportService # rubocop:disable Metrics/ClassLength + class ImportError < StandardError + attr_reader :payload + + def initialize(payload) + @payload = payload + super(payload.is_a?(Hash) ? payload[:message].to_s : payload.to_s) + end + end + + def initialize(course:, actor:, components:, identifier_mode:, csv_data:) + @course = course + @actor = actor + @components = components.map(&:symbolize_keys) + @identifier_mode = identifier_mode.to_s + @csv_data = csv_data + end + + def preview + rows = parsed_rows + resolution = resolve(rows) + { + ok: resolution[:unresolved].empty? && resolution[:malformed].empty?, + unresolved: resolution[:unresolved], + malformed: resolution[:malformed], + sample: sample(resolution[:resolved]), + conflicts: conflicts(resolution[:resolved]) + } + end + + def commit(on_conflict:) + rows = parsed_rows + resolution = resolve(rows) + unless resolution[:unresolved].empty? && resolution[:malformed].empty? + raise ImportError, { message: 'validation_failed', + unresolved: resolution[:unresolved], + malformed: resolution[:malformed] } + end + + summary = { createdComponents: 0, updatedComponents: 0, gradesWritten: 0 } + ActiveRecord::Base.transaction { write_components(summary, resolution[:resolved], on_conflict) } + summary + end + + private + + def write_components(summary, resolved, on_conflict) + @components.each do |component| + external = existing_external(component[:name]) + if external + summary[:updatedComponents] += 1 + summary[:gradesWritten] += upsert_grades(external, component, resolved, + on_conflict: on_conflict) + else + summary[:createdComponents] += 1 + summary[:gradesWritten] += create_component(component, resolved) + end + end + end + + def parsed_rows + guard_no_duplicate_components! + table = CSV.parse(@csv_data.to_s, headers: true) + guard_header!(table.headers) + table + end + + def guard_no_duplicate_components! + names = @components.map { |c| c[:name] } + return if names.uniq.length == names.length + + raise ImportError, { message: 'duplicate_component_name' } + end + + def guard_header!(headers) + expected = [identifier_header] + @components.map { |c| c[:name] } + return if headers == expected + + raise ImportError, { message: 'bad_header', expected: expected, got: headers } + end + + def identifier_header + (@identifier_mode == 'email') ? 'Email' : 'External ID' + end + + def roster_lookup + @roster_lookup ||= + if @identifier_mode == 'email' + @course.course_users.includes(:user).index_by { |cu| cu.user.email.downcase } + else + @course.course_users.where.not(external_id: [nil, '']).index_by(&:external_id) + end + end + + def lookup_key(identifier) + (@identifier_mode == 'email') ? identifier.to_s.downcase : identifier.to_s + end + + def resolve(table) + resolved = [] + unresolved = [] + malformed = [] + table.each_with_index do |row, idx| + identifier = row[identifier_header].to_s.strip + course_user = roster_lookup[lookup_key(identifier)] + if course_user.nil? + unresolved << identifier + next + end + grades, bad = parse_grades(row, idx) + malformed.concat(bad) + resolved << { course_user: course_user, identifier: identifier, grades: grades } + end + { resolved: resolved, unresolved: unresolved.uniq, malformed: malformed } + end + + def parse_grades(row, row_idx) + grades = {} + malformed = [] + @components.each do |component| + raw = row[component[:name]] + if raw.nil? || raw.to_s.strip.empty? + grades[component[:name]] = nil + elsif numeric?(raw) + grades[component[:name]] = Float(raw) + else + malformed << "row #{row_idx + 2}, #{component[:name]}: #{raw}" + end + end + [grades, malformed] + end + + def numeric?(value) + Float(value) + true + rescue ArgumentError, TypeError + false + end + + def sample(resolved) + resolved.first(5).map do |r| + { studentName: r[:course_user].name, grades: r[:grades] } + end + end + + def conflicts(resolved) + result = [] + @components.each do |component| + conflicts_for_component(component, resolved, result) + end + result + end + + def conflicts_for_component(component, resolved, result) + external = existing_external(component[:name]) + return unless external + + grade_by_cu = external.external_assessment_grades.index_by(&:course_user_id) + resolved.each do |row| + conflict = conflict_entry(component[:name], row, grade_by_cu) + result << conflict if conflict + end + end + + def conflict_entry(component_name, row, grade_by_cu) + in_file = row[:grades][component_name] + return nil if in_file.nil? + + existing = grade_by_cu[row[:course_user].id] + return nil if existing.nil? || existing.grade.nil? + + { + component: component_name, + studentName: row[:course_user].name, + existingGrade: existing.grade.to_f, + inFileGrade: in_file, + identifierMismatch: existing.imported_identifier.present? && + existing.imported_identifier != row[:identifier] + } + end + + def existing_external(name) + @existing_externals ||= {} + @existing_externals[name] ||= Course::ExternalAssessment.for_course(@course).find_by(title: name) + end + + def create_component(component, resolved) + external = User.with_stamper(@actor) do + Course::ExternalAssessment.create_for_course!( + course: @course, title: component[:name], + maximum_grade: component[:maximum_grade], weight: component[:weightage] + ) + end + rows = resolved.filter_map do |r| + build_grade(external, r) unless r[:grades][component[:name]].nil? + end + bulk_insert(rows) + rows.size + end + + def upsert_grades(external, component, resolved, on_conflict:) + grade_by_cu = external.external_assessment_grades.index_by(&:course_user_id) + written = 0 + resolved.each do |row| + written += upsert_one(grade_by_cu, component, row, on_conflict) + end + written + end + + def upsert_one(grade_by_cu, component, row, on_conflict) + in_file = row[:grades][component[:name]] + return 0 if in_file.nil? + + existing = grade_by_cu[row[:course_user].id] + if existing.nil? + bulk_insert([build_grade(existing_external(component[:name]), row, value: in_file)]) + 1 + elsif on_conflict.to_s == 'replace' || existing.grade.nil? + User.with_stamper(@actor) do + existing.update!(grade: in_file, imported_identifier: row[:identifier]) + end + 1 + else + 0 + end + end + + def build_grade(external, resolved_row, value: nil) + Course::ExternalAssessmentGrade.new( + external_assessment: external, + course_user: resolved_row[:course_user], + grade: value.nil? ? resolved_row[:grades][external.title] : value, + imported_identifier: resolved_row[:identifier], + creator: @actor, updater: @actor + ) + end + + def bulk_insert(records) + return if records.empty? + + Course::ExternalAssessmentGrade.import(records, validate: true) + end +end diff --git a/app/services/course/statistics/assessments_score_summary_download_service.rb b/app/services/course/statistics/assessments_score_summary_download_service.rb deleted file mode 100644 index 88e98756c6a..00000000000 --- a/app/services/course/statistics/assessments_score_summary_download_service.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true -require 'csv' -class Course::Statistics::AssessmentsScoreSummaryDownloadService - include TmpCleanupHelper - include ApplicationFormattersHelper - - def initialize(course, assessment_ids, file_name) - @course = course - @assessment_ids = assessment_ids - @file_name = file_name - @base_dir = Dir.mktmpdir('assessment-score-summary-') - end - - def generate - ActsAsTenant.without_tenant do - generate_csv_report - end - end - - def generate_csv_report - assessment_score_summary_file_path = File.join(@base_dir, @file_name) - - load_total_grades - CSV.open(assessment_score_summary_file_path, 'w') do |csv| - download_score_summary(csv) - end - - assessment_score_summary_file_path - end - - private - - def cleanup_entries - [@base_dir] - end - - def load_total_grades - @course_assessment_hash = Course::Assessment.where(id: @assessment_ids, course_id: @course.id).to_h do |assessment| - [assessment.id, assessment] - end - - @assessments = assessments - @submissions = Course::Assessment::Submission.where(assessment_id: @assessments.map(&:id)). - calculated(:grade). - preload(creator: :course_users) - - @submission_grade_hash = submission_grade_hash - @all_students = @course.course_users.students.order_alphabetically.preload(user: :emails) - @include_external_id = @course.course_users.students.where.not(external_id: [nil, '']).exists? - end - - def submission_grade_hash - @submissions.to_h do |submission| - course_user = submission.creator.course_users.find { |u| u.course_id == @course.id } - [[course_user.id, submission.assessment_id], submission.grade] - end - end - - def assessments - @assessment_ids.filter { |assessment_id| !@course_assessment_hash[assessment_id.to_i].nil? }.map do |assessment_id| - @course_assessment_hash[assessment_id.to_i] - end - end - - def download_score_summary(csv) - # header - csv << [ - I18n.t('csv.score_summary.headers.name'), - I18n.t('csv.score_summary.headers.email'), - I18n.t('csv.score_summary.headers.type'), - *(@include_external_id ? [I18n.t('csv.score_summary.headers.external_id')] : []), - *@assessments.map(&:title) - ] - - # content - @all_students.each do |student| - csv << [student.name, student.user.email, student.phantom? ? 'phantom' : 'normal', - *(@include_external_id ? [student.external_id.presence || ''] : []), - *@assessments.flat_map { |a| @submission_grade_hash[[student.id, a.id]] || '' }] - end - end -end diff --git a/app/views/course/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/courses/sidebar.json.jbuilder b/app/views/course/courses/sidebar.json.jbuilder index d7de1bebca1..1a31ba3f24f 100644 --- a/app/views/course/courses/sidebar.json.jbuilder +++ b/app/views/course/courses/sidebar.json.jbuilder @@ -4,6 +4,7 @@ json.courseUrl course_path(current_course) json.courseLogoUrl url_to_course_logo(current_course) json.courseUserUrl url_to_user_or_course_user(current_course, current_course_user) json.userName current_user&.name +json.userId current_user&.id if current_course_user.present? && can?(:read, current_course) json.courseUserName current_course_user.name diff --git a/app/views/course/external_assessment_imports/create.json.jbuilder b/app/views/course/external_assessment_imports/create.json.jbuilder new file mode 100644 index 00000000000..b8db877400d --- /dev/null +++ b/app/views/course/external_assessment_imports/create.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true +json.createdComponents @summary[:createdComponents] +json.updatedComponents @summary[:updatedComponents] +json.gradesWritten @summary[:gradesWritten] diff --git a/app/views/course/external_assessment_imports/preview.json.jbuilder b/app/views/course/external_assessment_imports/preview.json.jbuilder new file mode 100644 index 00000000000..ccbe5f3c8f7 --- /dev/null +++ b/app/views/course/external_assessment_imports/preview.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true +json.ok @result[:ok] +json.unresolved @result[:unresolved] +json.malformed @result[:malformed] +json.sample @result[:sample] do |row| + json.studentName row[:studentName] + json.grades row[:grades] +end +json.conflicts @result[:conflicts] do |c| + json.component c[:component] + json.studentName c[:studentName] + json.existingGrade c[:existingGrade] + json.inFileGrade c[:inFileGrade] + json.identifierMismatch c[:identifierMismatch] +end diff --git a/app/views/course/external_assessments/_external_assessment.json.jbuilder b/app/views/course/external_assessments/_external_assessment.json.jbuilder new file mode 100644 index 00000000000..4c062d9b387 --- /dev/null +++ b/app/views/course/external_assessments/_external_assessment.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true +json.id(-external_assessment.id) +json.title external_assessment.title +json.tabId external_assessment.synthetic_tab_id +json.maxGrade external_assessment.maximum_grade.to_f +json.external true +json.floorAtZero external_assessment.floor_at_zero +json.capAtMaximum external_assessment.cap_at_maximum +json.gradebookExcluded false diff --git a/app/views/course/external_assessments/create.json.jbuilder b/app/views/course/external_assessments/create.json.jbuilder new file mode 100644 index 00000000000..dc35181ad4f --- /dev/null +++ b/app/views/course/external_assessments/create.json.jbuilder @@ -0,0 +1,20 @@ +# frozen_string_literal: true +json.assessment do + json.partial! 'external_assessment', external_assessment: @external_assessment + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + end +end +json.tab do + json.id @external_assessment.synthetic_tab_id + json.title @external_assessment.title + json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + json.weightMode 'equal' + end +end +json.category do + json.id Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + json.title Course::ExternalAssessment::SYNTHETIC_CATEGORY_TITLE +end diff --git a/app/views/course/external_assessments/update.json.jbuilder b/app/views/course/external_assessments/update.json.jbuilder new file mode 100644 index 00000000000..4fadac238cb --- /dev/null +++ b/app/views/course/external_assessments/update.json.jbuilder @@ -0,0 +1,16 @@ +# frozen_string_literal: true +json.assessment do + json.partial! 'external_assessment', external_assessment: @external_assessment + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + end +end +json.tab do + json.id @external_assessment.synthetic_tab_id + json.title @external_assessment.title + json.categoryId Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID + if @weighted_view_enabled + json.gradebookWeight(@external_assessment.gradebook_contribution&.weight&.to_f || 0) + json.weightMode 'equal' + end +end diff --git a/app/views/course/external_assessments/update_grade.json.jbuilder b/app/views/course/external_assessments/update_grade.json.jbuilder new file mode 100644 index 00000000000..f997450dfc3 --- /dev/null +++ b/app/views/course/external_assessments/update_grade.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true +json.studentId @grade.course_user.user_id +json.assessmentId(-@grade.external_assessment_id) +json.grade @grade.grade&.to_f diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder new file mode 100644 index 00000000000..8cab4699a09 --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,99 @@ +# frozen_string_literal: true +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 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 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| + json.id course_user.user_id + json.name course_user.name + json.email course_user.user.email + json.externalId course_user.external_id + json.level course_user.level_number + json.totalXp course_user.experience_points +end + +json.submissions 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/app/views/course/statistics/aggregate/all_assessments.json.jbuilder b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder index 458efa1067c..7bc08582f3d 100644 --- a/app/views/course/statistics/aggregate/all_assessments.json.jbuilder +++ b/app/views/course/statistics/aggregate/all_assessments.json.jbuilder @@ -35,3 +35,6 @@ json.assessments @assessments do |assessment| json.numAttempted @num_attempted_students_hash[assessment.id] || 0 json.numLate @num_late_students_hash[assessment.id] || 0 end + +json.gradebookEnabled current_course.component_enabled?(Course::GradebookComponent) && + can?(:read_gradebook, current_course) diff --git a/client/app/__test__/mocks/localeMock.js b/client/app/__test__/mocks/localeMock.js new file mode 100644 index 00000000000..1f87539212a --- /dev/null +++ b/client/app/__test__/mocks/localeMock.js @@ -0,0 +1,2 @@ +// File used for jest moduleNameMapper - empty locale messages for tests +module.exports = {}; diff --git a/client/app/api/course/Admin/Gradebook.ts b/client/app/api/course/Admin/Gradebook.ts new file mode 100644 index 00000000000..287e4b0c79d --- /dev/null +++ b/client/app/api/course/Admin/Gradebook.ts @@ -0,0 +1,23 @@ +import { AxiosResponse } from 'axios'; +import { + GradebookSettingsData, + GradebookSettingsPostData, +} from 'types/course/admin/gradebook'; + +import BaseAdminAPI from './Base'; + +export default class GradebookAdminAPI extends BaseAdminAPI { + override get urlPrefix(): string { + return `${super.urlPrefix}/gradebook`; + } + + index(): Promise> { + return this.client.get(this.urlPrefix); + } + + update( + data: GradebookSettingsPostData, + ): Promise> { + return this.client.patch(this.urlPrefix, data); + } +} diff --git a/client/app/api/course/Admin/index.ts b/client/app/api/course/Admin/index.ts index 966a1d3b05f..fcd4097b26d 100644 --- a/client/app/api/course/Admin/index.ts +++ b/client/app/api/course/Admin/index.ts @@ -6,6 +6,7 @@ import CommentsAdminAPI from './Comments'; import ComponentsAdminAPI from './Components'; import CourseAdminAPI from './Course'; import ForumsAdminAPI from './Forums'; +import GradebookAdminAPI from './Gradebook'; import LeaderboardAdminAPI from './Leaderboard'; import LessonPlanSettingsAPI from './LessonPlan'; import MaterialsAdminAPI from './Materials'; @@ -28,6 +29,7 @@ const AdminAPI = { lessonPlan: new LessonPlanSettingsAPI(), materials: new MaterialsAdminAPI(), forums: new ForumsAdminAPI(), + gradebook: new GradebookAdminAPI(), videos: new VideosAdminAPI(), notifications: new NotificationsSettingsAPI(), codaveri: new CodaveriAdminAPI(), diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts new file mode 100644 index 00000000000..7ee76d814b7 --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,96 @@ +import { + ExternalAssessmentNode, + ExternalAssessmentUpdate, + ExternalGradePayload, + GradebookData, + ImportCommitSummary, + ImportPreviewRequest, + ImportPreviewResult, + UpdateWeightsPayload, +} from 'types/course/gradebook'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from './Base'; + +export default class GradebookAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/gradebook`; + } + + index(): APIResponse { + return this.client.get(this.#urlPrefix); + } + + updateWeights( + payload: UpdateWeightsPayload, + ): APIResponse { + return this.client.patch(`${this.#urlPrefix}/weights`, payload); + } + + createExternal(payload: { + title: string; + maximumGrade: number; + floorAtZero: boolean; + capAtMaximum: boolean; + weight?: number; + }): APIResponse { + return this.client.post(`${this.#urlPrefix}/external_assessments`, payload); + } + + // `id` is the positive external id (negate the negative serialized id before calling). + updateExternal( + id: number, + payload: { + title?: string; + maximumGrade?: number; + floorAtZero?: boolean; + capAtMaximum?: boolean; + weight?: number; + }, + ): APIResponse { + return this.client.patch( + `${this.#urlPrefix}/external_assessments/${id}`, + payload, + ); + } + + deleteExternal(id: number): APIResponse { + return this.client.delete(`${this.#urlPrefix}/external_assessments/${id}`); + } + + reorderExternals(payload: { orderedIds: number[] }): APIResponse { + return this.client.put( + `${this.#urlPrefix}/external_assessments/reorder`, + payload, + ); + } + + setExternalGrade( + id: number, + payload: { studentId: number; grade: number | null }, + ): APIResponse { + return this.client.put( + `${this.#urlPrefix}/external_assessments/${id}/grades`, + payload, + ); + } + + importPreview( + payload: ImportPreviewRequest, + ): APIResponse { + return this.client.post( + `${this.#urlPrefix}/external_assessment_imports/preview`, + payload, + ); + } + + importCommit( + payload: ImportPreviewRequest & { onConflict: 'keep' | 'replace' }, + ): APIResponse { + return this.client.post( + `${this.#urlPrefix}/external_assessment_imports`, + payload, + ); + } +} diff --git a/client/app/api/course/Statistics/CourseStatistics.ts b/client/app/api/course/Statistics/CourseStatistics.ts index 6e2d3e370f4..b478bec85df 100644 --- a/client/app/api/course/Statistics/CourseStatistics.ts +++ b/client/app/api/course/Statistics/CourseStatistics.ts @@ -1,5 +1,3 @@ -import { JobSubmitted } from 'types/jobs'; - import { APIResponse } from 'api/types'; import { AssessmentsStatistics, @@ -53,10 +51,4 @@ export default class CourseStatisticsAPI extends BaseCourseAPI { params, }); } - - downloadScoreSummary(assessmentIds: number[]): APIResponse { - return this.client.get(`${this.#urlPrefix}/assessments/download`, { - params: { assessment_ids: assessmentIds }, - }); - } } diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 8f5df6176fe..355a5878c53 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -12,6 +12,7 @@ import DuplicationAPI from './Duplication'; import EnrolRequestsAPI from './EnrolRequests'; import ExperiencePointsRecordAPI from './ExperiencePointsRecord'; import ForumAPI from './Forum'; +import GradebookAPI from './Gradebook'; import GroupsAPI from './Groups'; import LeaderboardAPI from './Leaderboard'; import LearningMapAPI from './LearningMap'; @@ -48,6 +49,7 @@ const CourseAPI = { experiencePointsRecord: new ExperiencePointsRecordAPI(), folders: new FoldersAPI(), forum: ForumAPI, + gradebook: new GradebookAPI(), groups: new GroupsAPI(), leaderboard: new LeaderboardAPI(), learningMap: new LearningMapAPI(), diff --git a/client/app/bundles/course/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..d25c9439f6f --- /dev/null +++ b/client/app/bundles/course/admin/pages/GradebookSettings/__tests__/GradebookSettings.test.tsx @@ -0,0 +1,73 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import GradebookSettings from '../index'; + +const mock = createMockAdapter(CourseAPI.admin.gradebook.client); + +describe('', () => { + it('renders the toggle unchecked when weightedViewEnabled is false', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + expect(checkbox).not.toBeChecked(); + }); + + it('PATCHes when toggle is checked and form submitted', async () => { + mock + .onGet(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: false }); + mock + .onPatch(`/courses/${global.courseId}/admin/gradebook`) + .reply(200, { weightedViewEnabled: true }); + + const spy = jest.spyOn(CourseAPI.admin.gradebook, 'update'); + + render(); + + const checkbox = await screen.findByRole('checkbox', { + name: /enable weighted grade view/i, + }); + fireEvent.click(checkbox); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(spy).toHaveBeenCalledWith({ + settings_gradebook_component: { weighted_view_enabled: true }, + }); + }); + }); + + 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 }, + }); + }); + }); +}); 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/container/CourseLoader.ts b/client/app/bundles/course/container/CourseLoader.ts index b55a5866b8f..8abe2df1ec1 100644 --- a/client/app/bundles/course/container/CourseLoader.ts +++ b/client/app/bundles/course/container/CourseLoader.ts @@ -3,9 +3,11 @@ import { useLoaderData, useOutletContext, } from 'react-router-dom'; +import { dispatch as imperativeDispatch } from 'store'; import { CourseLayoutData, SidebarItemData } from 'types/course/courses'; import CourseAPI from 'api/course'; +import { actions as userActions } from 'bundles/users/store'; import { syncSignals } from 'lib/hooks/unread'; const extractUnreadCountsInto = ( @@ -34,6 +36,12 @@ export const loader: LoaderFunction = async ({ params }) => { const response = await CourseAPI.courses.fetchLayout(id); + // Hydrate the authenticated user's id into the global store. It is otherwise + // only populated on the user profile page, so any course page relying on it + // (e.g. per-user localStorage namespacing for table column prefs) would read 0. + const { userId } = response.data; + if (userId != null) imperativeDispatch(userActions.setCurrentUserId(userId)); + syncSignals(extractUnreadCountsFromLayoutData(response.data)); return response.data; diff --git a/client/app/bundles/course/container/__tests__/CourseLoader.test.ts b/client/app/bundles/course/container/__tests__/CourseLoader.test.ts new file mode 100644 index 00000000000..3db67b15dce --- /dev/null +++ b/client/app/bundles/course/container/__tests__/CourseLoader.test.ts @@ -0,0 +1,46 @@ +import { store } from 'store'; + +import CourseAPI from 'api/course'; + +import { loader } from '../CourseLoader'; + +jest.mock('api/course', () => ({ + __esModule: true, + default: { courses: { fetchLayout: jest.fn() } }, +})); + +const mockFetchLayout = CourseAPI.courses.fetchLayout as jest.Mock; + +const runLoader = (courseId: string): Promise => + // The router passes { request, params }; only params.courseId is read here, so + // we route through `unknown` to call it with just the params it reads. + ( + loader as unknown as (args: { + params: { courseId: string }; + }) => Promise + )({ + params: { courseId }, + }); + +describe('CourseLoader loader', () => { + it('hydrates the authenticated user id into the global store', async () => { + mockFetchLayout.mockResolvedValueOnce({ + data: { courseTitle: 'C', userName: 'Alice', userId: 77 }, + }); + + await runLoader('1'); + + expect(store.getState().global.user.user.id).toBe(77); + }); + + it('leaves the global user id untouched when the payload has no user id', async () => { + store.dispatch({ type: 'system/SET_CURRENT_USER_ID', userId: 5 }); + mockFetchLayout.mockResolvedValueOnce({ + data: { courseTitle: 'C', userName: null, userId: null }, + }); + + await runLoader('1'); + + expect(store.getState().global.user.user.id).toBe(5); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx new file mode 100644 index 00000000000..cd8fd577fed --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx @@ -0,0 +1,195 @@ +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import AddExternalColumnPrompt from '../components/AddExternalColumnPrompt'; +import { createExternalAssessment } from '../operations'; + +jest.mock('../operations', () => ({ + createExternalAssessment: jest.fn( + () => (): Promise => Promise.resolve(), + ), +})); + +afterEach(() => { + jest.clearAllMocks(); +}); + +it('submits with both bound flags on by default', async () => { + render(); + // Wait for the Dialog to render (i18n loading) + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + true, + true, + undefined, + ), + ); +}); + +it('submits floorAtZero false when the floor toggle is switched off', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Floor grades at 0' })); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + false, + true, + undefined, + ), + ); +}); + +it('submits capAtMaximum false when the cap toggle is switched off', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + true, + false, + undefined, + ), + ); +}); + +it('disables Create when the name is blank', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); +}); + +it('disables Create when max marks is blank', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); +}); + +it('disables Create when max marks is negative', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '-5' }, + }); + expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled(); +}); + +it('closes the dialog after a successful create', async () => { + const onClose = jest.fn(); + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); +}); + +it('keeps the dialog open when create fails', async () => { + (createExternalAssessment as jest.Mock).mockReturnValueOnce(() => + Promise.reject(new Error('boom')), + ); + const onClose = jest.fn(); + render(); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => expect(createExternalAssessment).toHaveBeenCalled()); + expect(onClose).not.toHaveBeenCalled(); +}); + +it('explains the floor and cap toggles, stressing the grade is unchanged', async () => { + render(); + await waitFor(() => screen.getByLabelText('Name')); + expect( + screen.getByLabelText( + /Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + /Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); +}); + +it('passes the typed weightage when weighted view is on', async () => { + render( + , + ); + await waitFor(() => screen.getByLabelText('Name')); + fireEvent.change(screen.getByLabelText('Name'), { + target: { value: 'Midterm' }, + }); + fireEvent.change(screen.getByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.change(screen.getByLabelText('Weightage'), { + target: { value: '20' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Create' })); + await waitFor(() => + expect(createExternalAssessment).toHaveBeenCalledWith( + 'Midterm', + 50, + true, + true, + 20, + ), + ); +}); + +it('hides the weightage field when weighted view is off', async () => { + render( + , + ); + await waitFor(() => screen.getByLabelText('Name')); + expect(screen.queryByLabelText('Weightage')).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx new file mode 100644 index 00000000000..cce652258c0 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx @@ -0,0 +1,480 @@ +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; + +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 + .spyOn(operations, 'updateGradebookWeights') + .mockReturnValue(async () => {}); + +const A1 = 'Assignment 1'; +const A2 = 'Assignment 2'; +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('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('Assignments: Assignment 1')).toHaveValue(25); + expect(screen.getByLabelText('Assignments: Assignment 2')).toHaveValue(25); + }); + + 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('Assignments: Assignment 1'), { + 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('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('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(); + }); + + it('does not render an Exclude checkbox', () => { + setup(); + expect( + screen.queryByRole('checkbox', { name: /exclude/i }), + ).not.toBeInTheDocument(); + }); +}); + +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('Assignments: Assignment 1')).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__/DeleteExternalColumnPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/DeleteExternalColumnPrompt.test.tsx new file mode 100644 index 00000000000..68cd9858899 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/DeleteExternalColumnPrompt.test.tsx @@ -0,0 +1,101 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import DeleteExternalColumnPrompt from '../components/DeleteExternalColumnPrompt'; +import { deleteExternalAssessment } from '../operations'; + +jest.mock('../operations', () => ({ + __esModule: true, + ...jest.requireActual('../operations'), + deleteExternalAssessment: jest.fn(), +})); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn() }, +})); + +const mockDelete = deleteExternalAssessment as jest.Mock; +const mockToast = toast as jest.Mocked; + +const baseProps = { + open: true, + assessmentId: -1, + title: 'Quiz 2', + onClose: jest.fn(), +}; + +afterEach(() => jest.clearAllMocks()); + +it('renders nothing when closed', () => { + render(); + + expect( + screen.queryByRole('button', { name: 'Delete' }), + ).not.toBeInTheDocument(); +}); + +it('shows a loading spinner on the delete button while the deletion is in flight', async () => { + let resolveDelete: () => void = () => {}; + mockDelete.mockReturnValue( + () => + new Promise((resolve) => { + resolveDelete = resolve; + }), + ); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); + + // While the request is pending, the delete button shows its loading state. + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + + resolveDelete(); + await waitFor(() => expect(baseProps.onClose).toHaveBeenCalled()); +}); + +it('dispatches the delete operation with the assessment id on confirm', async () => { + mockDelete.mockReturnValue(() => Promise.resolve()); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); + + await waitFor(() => + expect(mockDelete).toHaveBeenCalledWith(baseProps.assessmentId), + ); + await waitFor(() => expect(baseProps.onClose).toHaveBeenCalled()); +}); + +it('toasts an error and keeps the dialog open when the delete fails', async () => { + mockDelete.mockReturnValue(() => Promise.reject(new Error('boom'))); + + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mockToast.error).toHaveBeenCalled()); + expect(baseProps.onClose).not.toHaveBeenCalled(); + // finally{} resets saving → confirm button leaves its loading state. + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), + ); +}); + +it('closes without deleting when Cancel is clicked', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: 'Cancel' })); + + expect(baseProps.onClose).toHaveBeenCalled(); + expect(mockDelete).not.toHaveBeenCalled(); +}); + +it('names the assessment being deleted in the confirmation body', async () => { + render(); + + expect(await screen.findByText(/Delete "Quiz 2"\?/)).toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx new file mode 100644 index 00000000000..1b76a774c0e --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx @@ -0,0 +1,307 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import EditExternalAssessmentPrompt from '../components/manage/EditExternalAssessmentPrompt'; +import { editExternalAssessment } from '../operations'; + +jest.mock('../operations', () => ({ + editExternalAssessment: jest.fn(() => (): Promise => Promise.resolve()), +})); +jest.mock('lib/hooks/toast', () => ({ error: jest.fn() })); + +const assessment = { + id: -3, + title: 'Quiz', + tabId: -3, + maxGrade: 20, + external: true, + floorAtZero: true, + capAtMaximum: true, +}; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('saves the edited name and a toggled cap flag', async () => { + render( + , + ); + const name = await screen.findByLabelText('Name'); + await userEvent.clear(name); + await userEvent.type(name, 'Quiz 1'); + fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz 1', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: false, + }), + ); +}, 10000); + +it('trims surrounding whitespace from the saved name', async () => { + render( + , + ); + const name = await screen.findByLabelText('Name'); + await userEvent.clear(name); + await userEvent.type(name, ' Quiz 2 '); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz 2', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: true, + }), + ); +}); + +it('saves a toggled floor flag', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('checkbox', { name: 'Floor grades at 0' })); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 20, + floorAtZero: false, + capAtMaximum: true, + }), + ); +}); + +it('saves an edited max marks value', async () => { + render( + , + ); + fireEvent.change(await screen.findByLabelText('Max marks'), { + target: { value: '50' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 50, + floorAtZero: true, + capAtMaximum: true, + }), + ); +}); + +it('defaults floor and cap switches to checked when the assessment omits them', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + expect( + screen.getByRole('checkbox', { name: 'Floor grades at 0' }), + ).toBeChecked(); + expect( + screen.getByRole('checkbox', { name: 'Cap grades at max' }), + ).toBeChecked(); +}); + +it('explains the floor and cap toggles, explaining the grade is unchanged', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + expect( + screen.getByLabelText( + /Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); + expect( + screen.getByLabelText( + /Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged./i, + ), + ).toBeInTheDocument(); +}); + +it('shows a weightage field seeded from the current weight and includes it when saving (weighted view on)', async () => { + render( + , + ); + const weight = await screen.findByLabelText('Weightage'); + expect(weight).toHaveValue(30); + fireEvent.change(weight, { target: { value: '45' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: true, + weight: 45, + }), + ); +}); + +it('defaults weightage to 0 when no currentWeight is provided (weighted view on)', async () => { + render( + , + ); + const weight = await screen.findByLabelText('Weightage'); + expect(weight).toHaveValue(0); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(editExternalAssessment).toHaveBeenCalledWith(-3, { + title: 'Quiz', + maximumGrade: 20, + floorAtZero: true, + capAtMaximum: true, + weight: 0, + }), + ); +}); + +it('omits the weightage field when weighted view is off', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + expect(screen.queryByLabelText('Weightage')).not.toBeInTheDocument(); +}); + +it('disables Save when the name is blank', async () => { + render( + , + ); + const name = await screen.findByLabelText('Name'); + await userEvent.clear(name); + await userEvent.type(name, ' '); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +it('disables Save when max marks is blank', async () => { + render( + , + ); + fireEvent.change(await screen.findByLabelText('Max marks'), { + target: { value: '' }, + }); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +it('disables Save when max marks is negative', async () => { + render( + , + ); + fireEvent.change(await screen.findByLabelText('Max marks'), { + target: { value: '-5' }, + }); + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); +}); + +it('calls onClose when Cancel is clicked', async () => { + const onClose = jest.fn(); + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onClose).toHaveBeenCalled(); +}); + +it('closes the dialog after a successful save', async () => { + const onClose = jest.fn(); + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); +}); + +it('shows an error toast and keeps the dialog open when saving fails', async () => { + (editExternalAssessment as jest.Mock).mockImplementationOnce( + () => (): Promise => Promise.reject(new Error('boom')), + ); + const onClose = jest.fn(); + render( + , + ); + await screen.findByLabelText('Name'); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + expect(onClose).not.toHaveBeenCalled(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradeLinkHint.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradeLinkHint.test.tsx new file mode 100644 index 00000000000..2ad267268aa --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradeLinkHint.test.tsx @@ -0,0 +1,53 @@ +import { store as appStore } from 'store'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import GradeLinkHint, { + GRADE_LINK_HINT_KEY, +} from '../components/GradeLinkHint'; + +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:${GRADE_LINK_HINT_KEY}`; + +// The dismissal flag is namespaced by the authenticated user id from the global +// user store, which the course layout loader hydrates on every course page. +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +const renderHint = (): void => { + render(, { state: userState }); +}; + +beforeEach(() => localStorage.clear()); + +describe('GradeLinkHint', () => { + it('explains that grades are clickable and lead to the submission', async () => { + renderHint(); + expect(await screen.findByText(/click any grade/i)).toBeInTheDocument(); + }); + + it('hides and persists dismissal when the close button is clicked', async () => { + renderHint(); + fireEvent.click(await screen.findByRole('button', { name: /close/i })); + + expect(screen.queryByText(/click any grade/i)).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); + + it('does not render when already dismissed', async () => { + localStorage.setItem(STORAGE_KEY, 'true'); + renderHint(); + // Wait for the async locale load to settle (spinner gone) before asserting + // absence, so we are not just observing the pre-load loading state. + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), + ); + expect(screen.queryByText(/click any grade/i)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx new file mode 100644 index 00000000000..280f3c71b7d --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookColumnTree.test.tsx @@ -0,0 +1,305 @@ +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, screen } from '@testing-library/react'; + +import { buildAssessmentColumnId } from '../components/buildAssessmentColumnIds'; +import GradebookColumnTree from '../components/GradebookColumnTree'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { id: 101, title: 'Quiz 2', tabId: 10, maxGrade: 10 }, +]; + +const asnId100 = buildAssessmentColumnId(100); +const asnId101 = buildAssessmentColumnId(101); +const allIds = ['name', 'email', 'externalId', 'level', asnId100, asnId101]; + +const wrap = (node: JSX.Element): JSX.Element => ( + + {node} + +); + +describe('GradebookColumnTree', () => { + it('renders Student info and Grades branch labels', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Student info')).toBeInTheDocument(); + expect(screen.getByText('Grades')).toBeInTheDocument(); + }); + + it('renders Gamification branch when gamificationEnabled', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Gamification')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^level$/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /^total xp$/i }), + ).toBeInTheDocument(); + }); + + it('hides Gamification branch when gamificationEnabled is false', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.queryByText('Gamification')).not.toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /^level$/i }), + ).not.toBeInTheDocument(); + }); + + it('renders an External ID checkbox in the Student info group', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect( + screen.getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + + it('clicking the External ID checkbox calls setVisible with its column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /external id/i })); + expect(setVisible).toHaveBeenCalledWith('externalId', expect.any(Boolean)); + }); + + it('name checkbox is disabled and always checked', () => { + const visibility: Record = { + name: false, + email: true, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const nameCheckbox = screen.getByRole('checkbox', { name: /^name/i }); + expect(nameCheckbox).toBeDisabled(); + expect(nameCheckbox).toBeChecked(); + }); + + it('non-name student info checkboxes are enabled and reflect visibility state', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + const emailCheckbox = screen.getByRole('checkbox', { name: /^email$/i }); + expect(emailCheckbox).not.toBeDisabled(); + expect(emailCheckbox).not.toBeChecked(); + }); + + it('clicking a student info checkbox calls setVisible with its column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /^email$/i })); + expect(setVisible).toHaveBeenCalledWith('email', expect.any(Boolean)); + }); + + it('renders Category, Tab, and assessment checkboxes', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Cat A')).toBeInTheDocument(); + expect(screen.getByText('Tab 1')).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 1/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('checkbox', { name: /quiz 2/i }), + ).toBeInTheDocument(); + }); + + it('clicking an assessment checkbox calls setVisible with the single column id', () => { + const setVisible = jest.fn(); + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={setVisible} + tabs={tabs} + />, + ), + ); + fireEvent.click(screen.getByRole('checkbox', { name: /quiz 1/i })); + expect(setVisible).toHaveBeenCalledWith(asnId100, expect.any(Boolean)); + }); + + it('renders "Always included" chip next to the Name row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getByText('Always included')).toBeInTheDocument(); + }); + + it('does not render "Always included" chip next to email row', () => { + const visibility = Object.fromEntries(allIds.map((id) => [id, true])); + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect(screen.getAllByText('Always included')).toHaveLength(1); + }); + + it('Student info branch is indeterminate when some but not all student cols are visible', () => { + const visibility: Record = { + name: true, + email: false, + [asnId100]: true, + [asnId101]: true, + }; + render( + wrap( + visibility[id] ?? true} + setManyVisible={jest.fn()} + setVisible={jest.fn()} + tabs={tabs} + />, + ), + ); + expect( + screen.getByRole('checkbox', { name: /student info/i }), + ).toHaveAttribute('data-indeterminate', 'true'); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx new file mode 100644 index 00000000000..3baf0b609fd --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,263 @@ +import { fireEvent, render, screen, waitFor, within } from 'test-utils'; + +import toast from 'lib/hooks/toast'; + +import fetchGradebook from '../operations'; +import GradebookIndex from '../pages/GradebookIndex'; + +jest.mock('../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; id: number } => ({ + courseTitle: 'Test Course', + id: 1, + }), +})); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn(), success: jest.fn() }, +})); + +jest.mock('../operations', () => ({ + __esModule: true, + default: jest.fn(() => (): Promise => Promise.resolve()), +})); + +const mockFetchGradebook = fetchGradebook as jest.Mock; + +const emptyState = { + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const noStudentsState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const populatedState = { + gradebook: { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: null, + level: 3, + totalXp: 150, + }, + ], + submissions: [ + { studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 }, + ], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }, +}; + +const populatedStateWithGamification = { + gradebook: { + ...populatedState.gradebook, + gamificationEnabled: true, + }, +}; + +const populatedStateWithWeightedView = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: false, + }, +}; + +const populatedStateWithWeightedViewAndGamification = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + gamificationEnabled: true, + canManageWeights: false, + }, +}; + +const populatedStateManagerWeightedOff = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: false, + canManageWeights: true, + }, +}; + +const populatedStateManagerWeightedOn = { + gradebook: { + ...populatedState.gradebook, + weightedViewEnabled: true, + canManageWeights: true, + }, +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); +}); + +describe('GradebookIndex', () => { + it('shows loading indicator initially', () => { + render(, { state: emptyState }); + expect(screen.getByRole('progressbar')).toBeInTheDocument(); + }); + + it('shows the gradebook table after data loads', async () => { + render(, { state: populatedState }); + expect( + await screen.findByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + + it('shows the page title', async () => { + render(, { state: populatedState }); + expect(await screen.findByText('Gradebook')).toBeInTheDocument(); + }); + + it('shows empty students message when there are no students', async () => { + render(, { state: noStudentsState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows empty students message when both assessments and students are absent', async () => { + render(, { state: emptyState }); + expect( + await screen.findByText('No students enrolled yet'), + ).toBeInTheDocument(); + }); + + it('shows error toast when fetch fails', async () => { + mockFetchGradebook.mockReturnValueOnce( + (): Promise => Promise.reject(new Error('Network error')), + ); + render(, { state: emptyState }); + await waitFor(() => expect(toast.error).toHaveBeenCalled()); + }); + + it('shows grade-only hint in column picker when gamification is disabled and no data cols selected', async () => { + render(, { state: populatedState }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + expect( + await screen.findByText( + 'No grade columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); + + it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => { + render(, { state: populatedStateWithGamification }); + fireEvent.click( + await screen.findByRole('button', { name: /select columns/i }), + ); + fireEvent.click( + await screen.findByRole('checkbox', { name: /gamification/i }), + ); + expect( + await screen.findByText( + 'No grade or gamification columns selected - export will include student info only.', + ), + ).toBeInTheDocument(); + }); + + it('does not render view toggle when weightedViewEnabled is false', async () => { + render(, { state: populatedState }); + // Wait for loading to finish + await screen.findByRole('button', { name: /export/i }); + expect(screen.queryByText(/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(); + }); + + it('shows the manage button and not the old import/add buttons', async () => { + render(, { state: populatedStateManagerWeightedOff }); + expect( + await screen.findByRole('button', { name: 'Manage external assessments' }), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Import external assessments' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add external assessment' }), + ).not.toBeInTheDocument(); + }); + + describe('weighted-view discoverability hint', () => { + 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__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx new file mode 100644 index 00000000000..904d4513cb6 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -0,0 +1,789 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; + +import GradebookTable from '../components/GradebookTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; +const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; +const assessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, +]; +const students: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: null, + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + externalId: null, + level: 5, + totalXp: 300, + }, +]; +const submissions: SubmissionData[] = [ + { submissionId: 0, studentId: 1, assessmentId: 100, grade: 8 }, +]; + +const makeStudents = (n: number): StudentData[] => + Array.from({ length: n }, (_, i) => ({ + id: i + 1, + name: `Student ${i + 1}`, + email: `student${i + 1}@example.com`, + externalId: null, + level: 1, + totalXp: 0, + })); + +// Asserts the given texts appear in this top-to-bottom DOM order. +const expectInOrder = (names: string[]): void => { + for (let i = 0; i < names.length - 1; i += 1) { + const earlier = screen.getByText(names[i]); + const later = screen.getByText(names[i + 1]); + expect( + // eslint-disable-next-line no-bitwise + earlier.compareDocumentPosition(later) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + } +}; + +// User id used in all renders so localStorage is keyed as `${USER_ID}:gradebook_columns_1`. +// It is seeded into the global user store, which the course layout loader hydrates +// on every course page; the builder namespaces column persistence by that id. +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:gradebook_columns_1`; +const SORT_STORAGE_KEY = `${STORAGE_KEY}_sort`; + +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +interface RenderOptions { + gamificationEnabled?: boolean; +} + +const renderTable = ({ + gamificationEnabled = true, +}: RenderOptions = {}): void => { + render( + , + { state: userState }, + ); +}; + +const renderTableWithAssessmentVisible = ( + options: RenderOptions = {}, +): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + 'asn-100': true, + }), + ); + renderTable(options); +}; + +describe('GradebookTable', () => { + beforeEach(() => localStorage.clear()); + + it('renders both student names', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(await screen.findByText('Bob')).toBeInTheDocument(); + }); + + it('renders two header rows (column titles and max marks)', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + const { container } = render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + expect(container.querySelectorAll('thead tr')).toHaveLength(2); + }); + + it('shows Select Columns button and Export button', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /select columns/i }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); + }); + + describe('export button label reflects selection', () => { + it('shows "Export all rows" when no rows are selected', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(); + }); + + it('shows tooltip "all rows will be exported" when no rows are selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const exportBtn = await screen.findByRole('button', { + name: /export all rows/i, + }); + await user.hover(exportBtn); + expect( + await screen.findByText(/all rows will be exported/i), + ).toBeInTheDocument(); + }); + + it('hides the tooltip when a row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + const exportBtn = await screen.findByRole('button', { + name: /export 1 row/i, + }); + await user.hover(exportBtn); + expect( + screen.queryByText(/all rows will be exported/i), + ).not.toBeInTheDocument(); + }); + + it('shows "Export 1 row" when one row is selected', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + }); + + it('shows "Export all rows" when all rows are selected via the corner checkbox', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[0]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export all rows/i }), + ).toBeInTheDocument(), + ); + expect( + screen.queryByRole('button', { name: /export \d+ row/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('shows the Max Marks header row', async () => { + renderTableWithAssessmentVisible(); + expect(await screen.findByText('Max Marks')).toBeInTheDocument(); + }); + + it('renders row selection checkboxes', async () => { + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(2); + }); + + describe('row selection', () => { + it('keeps search input visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('keeps Export button visible after selecting a row', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + expect( + screen.getByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + }); + }); + + it('does not show assessment columns in the table by default', async () => { + renderTable(); + await screen.findByText('Alice'); + expect(screen.queryByText('Quiz 1')).not.toBeInTheDocument(); + }); + + it('shows gamification columns by default when gamification is enabled', async () => { + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Level')).toBeInTheDocument(); + expect(screen.getByText('Total XP')).toBeInTheDocument(); + }); + + describe('gamification columns', () => { + it('shows level and totalXp in the column picker when gamification is enabled', async () => { + const user = userEvent.setup(); + renderTable({ gamificationEnabled: true }); + const selectColumnsBtn = await screen.findByRole('button', { + name: /select columns/i, + }); + await user.click(selectColumnsBtn); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Level')).toBeInTheDocument(); + expect(within(dialog).getByText('Total XP')).toBeInTheDocument(); + }); + }); + + describe('locked name column', () => { + it('name is always visible even when localStorage sets it to false', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: false, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + await waitFor(() => + expect(screen.getByText('Alice')).toBeInTheDocument(), + ); + }); + }); + + describe('gamification disabled', () => { + it('level and totalXp absent from table headers when gamification is disabled', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + level: true, + totalXp: true, + 'asn-100': true, + }), + ); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + expect(screen.queryByText('Total XP')).not.toBeInTheDocument(); + }); + }); + + it('shows the table when gamification columns are visible and assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('export button is always enabled regardless of which columns are selected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByRole('button', { name: /export/i })).not.toBeDisabled(); + }); + + it('shows the table (not an empty state) when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows the table when all optional columns are deselected with gamification', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ 'asn-100': false, level: false, totalXp: false }), + ); + renderTable({ gamificationEnabled: true }); + expect(await screen.findByRole('table')).toBeInTheDocument(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + }); + + it('shows pagination when all assessments are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + expect(screen.getByText(/rows per page/i)).toBeInTheDocument(); + }); + + it('shows the table with assessment columns when restored from localStorage', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + renderTable(); + expect(await screen.findByText('Quiz 1')).toBeInTheDocument(); + }); + + // An assessment selected (and persisted) earlier may be deleted before the next + // visit, leaving a stale column id in localStorage. The table must ignore it + // rather than crash, and still render the surviving assessment columns. + it('does not crash when localStorage references a deleted assessment', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + 'asn-100': true, + 'asn-999': true, + }), + ); + renderTable(); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + // The surviving assessment still renders; the deleted one is silently dropped. + expect(screen.getByText('Quiz 1')).toBeInTheDocument(); + }); + + describe('search', () => { + it('filters by name', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'Alice'); + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('filters by email', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + const input = await screen.findByRole('textbox'); + await user.type(input, 'bob@example.com'); + await waitFor(() => + expect(screen.queryByText('Alice')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); + }); + + describe('external ID column', () => { + const studentsWithExtId: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: 'EXT-001', + level: 3, + totalXp: 150, + }, + { + id: 2, + name: 'Bob', + email: 'bob@example.com', + externalId: null, + level: 5, + totalXp: 300, + }, + ]; + + const renderWith = (studs: StudentData[]): void => { + render( + , + { state: userState }, + ); + }; + + it('shows the External ID column by default when a student has an external ID', async () => { + renderWith(studentsWithExtId); + expect(await screen.findByText('External ID')).toBeInTheDocument(); + expect(screen.getByText('EXT-001')).toBeInTheDocument(); + }); + + it('hides the External ID column by default when no student has an external ID', async () => { + renderWith(students); + await screen.findByText('Alice'); + expect(screen.queryByText('External ID')).not.toBeInTheDocument(); + }); + + it('treats a blank external ID as none and hides the column by default', async () => { + const studentsWithBlankExtId: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: '', + level: 3, + totalXp: 150, + }, + ]; + renderWith(studentsWithBlankExtId); + await screen.findByText('Alice'); + expect(screen.queryByText('External ID')).not.toBeInTheDocument(); + }); + + it('offers the External ID checkbox in the picker even when no student has one', async () => { + const user = userEvent.setup(); + renderWith(students); + const btn = await screen.findByRole('button', { + name: /select columns/i, + }); + await user.click(btn); + const dialog = await screen.findByRole('dialog'); + expect( + within(dialog).getByRole('checkbox', { name: /external id/i }), + ).toBeInTheDocument(); + }); + + it('filters by external ID', async () => { + const user = userEvent.setup(); + renderWith(studentsWithExtId); + const input = await screen.findByRole('textbox'); + await user.type(input, 'EXT-001'); + await waitFor(() => + expect(screen.queryByText('Bob')).not.toBeInTheDocument(), + ); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + }); + + describe('sorting', () => { + it('defaults to sorting by name ascending on first load', async () => { + // Raw prop order is Bob, then Alice. A working default name-asc sort must + // reorder the rendered rows to Alice, then Bob. + render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + expectInOrder(['Alice', 'Bob']); + }); + + it('cycles back to ascending on a third click (sort is never cleared)', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /name/i })); // → desc + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + await user.click(screen.getByRole('button', { name: /name/i })); // → asc again + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + }); + + it('toggles to descending when the name header is clicked', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expectInOrder(['Alice', 'Bob']); // default ascending + await user.click(screen.getByRole('button', { name: /name/i })); + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + }); + + it('preserves the active sort after searching', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + // Switch to descending so the order is distinct from both raw and default. + await user.click(screen.getByRole('button', { name: /name/i })); + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + // A search matching both students must not reset the descending sort. + await user.type(screen.getByRole('textbox'), 'example.com'); + await waitFor(() => + expect(screen.getByText('Alice')).toBeInTheDocument(), + ); + expectInOrder(['Bob', 'Alice']); + }); + + it('resets to name ascending when the sorted column is hidden', async () => { + const user = userEvent.setup(); + // Start with Quiz 1 visible and sort by it descending. + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // sort by grade asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // → desc + await waitFor(() => expectInOrder(['Alice', 'Bob'])); // Alice has grade 8, Bob has none + + // Hide Quiz 1 via the column picker. + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + await user.click( + within(dialog).getByRole('checkbox', { name: /quiz 1/i }), + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // Sort should reset to name ascending: Alice before Bob. + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + }); + + it('saves the sort to localStorage when the user clicks a column header', async () => { + const user = userEvent.setup(); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /name/i })); // → desc + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + expect( + JSON.parse(localStorage.getItem(SORT_STORAGE_KEY) ?? 'null'), + ).toEqual([{ id: 'name', desc: true }]); + }); + + it('restores sort from localStorage on re-mount', async () => { + // Pre-seed descending name sort so the table should render Bob before Alice. + localStorage.setItem( + SORT_STORAGE_KEY, + JSON.stringify([{ id: 'name', desc: true }]), + ); + renderTableWithAssessmentVisible(); + await screen.findByText('Alice'); + expectInOrder(['Bob', 'Alice']); + }); + + it('persists the name-ascending reset to localStorage when a sorted column is hidden', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc + + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + await user.click( + within(dialog).getByRole('checkbox', { name: /quiz 1/i }), + ); + await user.click(screen.getByRole('button', { name: /apply/i })); + + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + expect( + JSON.parse(localStorage.getItem(SORT_STORAGE_KEY) ?? 'null'), + ).toEqual([{ id: 'name', desc: false }]); + }); + + describe('assessment grade sorting', () => { + const studentFor = (id: number, name: string): StudentData => ({ + id, + name, + email: `${name.toLowerCase()}@example.com`, + externalId: null, + level: 1, + totalXp: 0, + }); + + const renderGrades = ( + studs: StudentData[], + subs: SubmissionData[], + ): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + }; + + // Alice graded 8, Bob graded 3, Carol no submission (undefined), + // Dave submitted but ungraded (null). + const mixedStudents: StudentData[] = [ + studentFor(1, 'Alice'), + studentFor(2, 'Bob'), + studentFor(3, 'Carol'), + studentFor(4, 'Dave'), + ]; + const mixedSubmissions: SubmissionData[] = [ + { submissionId: 1, studentId: 1, assessmentId: 100, grade: 8 }, + { submissionId: 2, studentId: 2, assessmentId: 100, grade: 3 }, + { submissionId: 4, studentId: 4, assessmentId: 100, grade: null }, + ]; + + it('sorts missing grades to the bottom in ascending order', async () => { + const user = userEvent.setup(); + renderGrades(mixedStudents, mixedSubmissions); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); + // Ascending: Bob(3), Alice(8), then the two missing rows last. + await waitFor(() => expectInOrder(['Bob', 'Alice'])); + expectInOrder(['Alice', 'Carol']); + expectInOrder(['Alice', 'Dave']); + }); + + it('keeps missing grades at the bottom in descending order', async () => { + const user = userEvent.setup(); + renderGrades(mixedStudents, mixedSubmissions); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc + // Descending: Alice(8), Bob(3), then the two missing rows still last. + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + expectInOrder(['Bob', 'Carol']); + expectInOrder(['Bob', 'Dave']); + }); + + it('sorts grades numerically (9 before 10), not lexically', async () => { + const user = userEvent.setup(); + renderGrades( + [studentFor(1, 'Alice'), studentFor(2, 'Bob')], + [ + { submissionId: 1, studentId: 1, assessmentId: 100, grade: 9 }, + { submissionId: 2, studentId: 2, assessmentId: 100, grade: 10 }, + ], + ); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /quiz 1/i })); + // Numeric ascending: 9 (Alice) before 10 (Bob). Lexical would reverse this. + await waitFor(() => expectInOrder(['Alice', 'Bob'])); + }); + }); + }); + + describe('cross-page selection', () => { + it('export label reflects selection count across pages', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ + name: true, + email: true, + + 'asn-100': true, + }), + ); + render( + , + { state: userState }, + ); + + const checkboxes = await screen.findAllByRole('checkbox'); + await user.click(checkboxes[1]); + await waitFor(() => + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(), + ); + + // Default page size is DEFAULT_TABLE_ROWS_PER_PAGE (100), so 101 students + // span two pages: Student 1 is on page 1, Student 101 alone on page 2. + await user.click( + screen.getByRole('button', { name: /go to next page/i }), + ); + await waitFor(() => + expect(screen.getByText('Student 101')).toBeInTheDocument(), + ); + expect(screen.queryByText('Student 1')).not.toBeInTheDocument(); + + expect( + screen.getByRole('button', { name: /export 1 row/i }), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx new file mode 100644 index 00000000000..9bc42a310ed --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx @@ -0,0 +1,1119 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; + +import GradebookWeightedTable from '../components/GradebookWeightedTable'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +// 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('GradebookWeightedTable', () => { + beforeEach(() => localStorage.clear()); + + // 1. Row 1: category cells with colSpan + it('renders category cells in row 1 with colSpan equal to number of tabs in that category', () => { + const cats = [makeCategory(1, 'Cat A'), makeCategory(2, 'Cat B')]; + const tabs = [ + makeTab(10, 'Tab 1', 1, 50), + makeTab(11, 'Tab 2', 1, 50), + makeTab(20, 'Tab 3', 2, 0), + ]; + const assessments = [ + makeAssessment(100, 'Q1', 10, 10), + makeAssessment(101, 'Q2', 11, 10), + makeAssessment(102, 'Q3', 20, 10), + ]; + renderWeighted({ categories: cats, tabs, assessments }); + const thead = document.querySelector('thead')!; + const rows = thead.querySelectorAll('tr'); + const row1Cells = rows[0].querySelectorAll('th'); + const catACell = Array.from(row1Cells).find( + (c) => c.textContent === 'Cat A', + ); + const catBCell = Array.from(row1Cells).find( + (c) => c.textContent === 'Cat B', + ); + expect(catACell).toBeTruthy(); + expect(catBCell).toBeTruthy(); + expect( + catACell!.getAttribute('colspan') ?? catACell!.colSpan.toString(), + ).toBe('2'); + expect( + catBCell!.getAttribute('colspan') ?? catBCell!.colSpan.toString(), + ).toBe('1'); + }); + + // 1b. Category not in tabs → header absent + it('does not render a category header for categories with no tabs', () => { + renderWeighted({ + categories: [makeCategory(1, 'Active'), makeCategory(2, 'Ghost')], + tabs: [makeTab(10, 'Tab 1', 1, 100)], // only category 1 has a tab + }); + expect(screen.getByText('Active')).toBeInTheDocument(); + expect(screen.queryByText('Ghost')).not.toBeInTheDocument(); + }); + + // 2. Row 2: tab title cells + it('renders tab title cells in row 2', () => { + renderWeighted({ + tabs: [makeTab(10, 'Homework', 1, 60), makeTab(11, 'Exams', 1, 40)], + }); + const thead = document.querySelector('thead')!; + const row2 = thead.querySelectorAll('tr')[1]; + expect( + within(row2 as HTMLElement).getByText('Homework'), + ).toBeInTheDocument(); + expect(within(row2 as HTMLElement).getByText('Exams')).toBeInTheDocument(); + }); + + // 3. Weight subheader shows "/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 — the explanatory + // sentence is no longer an inline second line (it lives in the tooltip instead). + it('shows "/N" with no inline warning line when weight sum ≠ 100 in total header', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], + }); + expect(screen.getByText('/60')).toBeInTheDocument(); + expect(screen.queryByText(/does not sum to 100/i)).not.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(), + ); + }); + + // 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); + }); + + // 6. Tab with no assessments → cell shows "—" + it('shows "—" for a tab with no assessments', () => { + renderWeighted({ + tabs: [makeTab(10, 'Empty Tab', 1, 100)], + assessments: [], + students: [makeStudent(1, 'Alice')], + submissions: [], + }); + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); + }); + + // 7. Student with no graded submissions → cell shows 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(); + }); + + // 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(); + }); + }); + + 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(); + }); + }); + + 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), + ); + }); + }); + + 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(); + }); + + 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('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%'); + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx new file mode 100644 index 00000000000..dd96bf41a91 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx @@ -0,0 +1,420 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import ImportExternalAssessmentsWizard from '../components/import/ImportExternalAssessmentsWizard'; + +jest.mock('api/course'); +jest.mock('lib/components/wrappers/I18nProvider'); + +const defaultProps = { + existingAssessments: [], + onClose: jest.fn(), + weightedViewEnabled: true, +}; + +const renderWizard = (): void => { + render( + , + ); +}; + +const studentsState = (students: object[]): object => ({ + gradebook: { + categories: [], tabs: [], submissions: [], assessments: [], + gamificationEnabled: false, weightedViewEnabled: false, canManageWeights: true, + students, + }, +}); + +const fillOneComponent = async (): Promise => { + await userEvent.type(screen.getByLabelText('Component name'), 'Midterm'); +}; + +const file = (text: string): File => + new File([text], 'marks.csv', { type: 'text/csv' }); + +describe('ImportExternalAssessmentsWizard', () => { + beforeEach(() => jest.clearAllMocks()); + + it('walks define → upload → verify → commit with no conflicts', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + sample: [{ studentName: 'Alice', grades: { Midterm: 41 } }], + conflicts: [], + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 1 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + + renderWizard(); + // Step 1: type a component + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + + // Step 2: upload + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nA001,41\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + // Step 3: preview shows the sample + expect(await screen.findByText('Alice')).toBeVisible(); + await userEvent.click( + screen.getByRole('button', { name: /continue|confirm/i }), + ); + + await waitFor(() => + expect(CourseAPI.gradebook.importCommit).toHaveBeenCalled(), + ); + const payload = (CourseAPI.gradebook.importCommit as jest.Mock).mock + .calls[0][0]; + expect(payload.onConflict).toBe('replace'); + expect(payload.identifierMode).toBe('student_id'); + expect(payload.components[0]).toMatchObject({ + name: 'Midterm', + weightage: 30, + maximumGrade: 50, + }); + }); + + it('shows unresolved identifiers and stays on the upload step', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ'], + malformed: [], + sample: [], + conflicts: [], + }, + }); + renderWizard(); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nZZZ,1\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + expect(await screen.findByText(/ZZZ/)).toBeVisible(); + expect(CourseAPI.gradebook.importCommit).not.toHaveBeenCalled(); + }); + + it('hides weightage field when weightedViewEnabled is false', async () => { + render( + , + ); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + expect(screen.queryByLabelText(/weightage/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/max marks/i)).toBeInTheDocument(); + }); + + it('disables Next when component name is empty', () => { + renderWizard(); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + it('labels the identifier toggle "External ID"', () => { + render( + , + ); + expect(screen.getByRole('radio', { name: 'External ID' })).toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: 'Student ID' })).not.toBeInTheDocument(); + }); + + it('commits with keep when Keep Existing is clicked on the conflict prompt', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + sample: [{ studentName: 'Alice', grades: { Midterm: 20 } }], + conflicts: [ + { + component: 'Midterm', + studentName: 'Alice', + existingGrade: 10, + inFileGrade: 20, + identifierMismatch: false, + }, + ], + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 0 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + renderWizard(); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nA001,20\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /confirm import/i }), + ); + await userEvent.click( + await screen.findByRole('button', { name: /keep existing/i }), + ); + await waitFor(() => + expect( + (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] + .onConflict, + ).toBe('keep'), + ); + }); + + it('blocks Next in External ID mode while a student has no External ID', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice Lim', email: 'a@x.com', externalId: null, level: 0, totalXp: 0 }, + { id: 2, name: 'Bob Tan', email: 'b@x.com', externalId: 'E2', level: 0, totalXp: 0 }, + ]), + }); + await fillOneComponent(); + expect(screen.getByText(/Alice Lim has no External ID/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + }); + + it('enables Next once matching by Email instead', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice Lim', email: 'a@x.com', externalId: null, level: 0, totalXp: 0 }, + ]), + }); + await fillOneComponent(); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); + }); + + it('lists the exact required headers on the upload step', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice', email: 'a@x.com', externalId: 'E1', level: 0, totalXp: 0 }, + ]), + }); + await userEvent.type(screen.getByLabelText('Component name'), 'Midterm'); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + expect( + screen.getByText(/Your CSV needs these column headers: External ID, Midterm/), + ).toBeInTheDocument(); + }); + + it('summarises the count when several students lack an External ID', async () => { + render(, { + state: studentsState([ + { id: 1, name: 'Alice Lim', email: 'a@x.com', externalId: null, level: 0, totalXp: 0 }, + { id: 2, name: 'Bob Tan', email: 'b@x.com', externalId: '', level: 0, totalXp: 0 }, + ]), + }); + await fillOneComponent(); + expect(screen.getByText(/2 students have no External ID, including Alice Lim/)).toBeInTheDocument(); + }); + + it('does not show Confirm import button when preview has errors', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ'], + malformed: [], + sample: [], + conflicts: [], + }, + }); + renderWizard(); + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nZZZ,1\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await screen.findByText(/ZZZ/); + expect( + screen.queryByRole('button', { name: /confirm import/i }), + ).not.toBeInTheDocument(); + }); + + it('opens the conflict prompt and commits with keep/replace', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + sample: [{ studentName: 'Alice', grades: { Midterm: 20 } }], + conflicts: [ + { + component: 'Midterm', + studentName: 'Alice', + existingGrade: 10, + inFileGrade: 20, + identifierMismatch: false, + }, + ], + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 1 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + render( + , + ); + // Step 1: 'Midterm' matches existing → max/weightage locked; just Next. + await userEvent.type(screen.getByLabelText(/component name/i), 'Midterm'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Identifier,Midterm\nA001,20\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /continue|confirm/i }), + ); + // conflict prompt + await userEvent.click( + await screen.findByRole('button', { name: /replace/i }), + ); + await waitFor(() => + expect( + (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] + .onConflict, + ).toBe('replace'), + ); + }); + + it('renders existing external chips in the define step', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Midterm' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Finals' })).toBeInTheDocument(); + }); + + it('clicking an existing chip inserts a locked row pre-filled with correct max and weight', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Midterm' })); + + // The chip-inserted row's name field is read-only (disabled input) + const nameInput = screen.getByDisplayValue('Midterm'); + expect(nameInput).toBeDisabled(); + + // Max and weight are pre-filled with the existing values + expect(screen.getByDisplayValue('50')).toBeDisabled(); + expect(screen.getByDisplayValue('30')).toBeDisabled(); + + // "Updates existing" label is shown + expect(screen.getByText(/updates existing/i)).toBeInTheDocument(); + }); + + it('hides a chip once the corresponding external has been added to the component list', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: 'Midterm' })); + // After clicking, chip disappears (already in the list) + expect(screen.queryByRole('button', { name: 'Midterm' })).not.toBeInTheDocument(); + }); + + it('does not render the From existing section when there are no existing externals', () => { + render( + , + ); + expect(screen.queryByText(/from existing/i)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx new file mode 100644 index 00000000000..fc1b01e7752 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx @@ -0,0 +1,20 @@ +import { fireEvent, render, screen } from 'test-utils'; +import ManageExternalAssessmentsButton from '../components/manage/ManageExternalAssessmentsButton'; + +const state = { + gradebook: { + categories: [], tabs: [], students: [], submissions: [], assessments: [], + gamificationEnabled: false, weightedViewEnabled: false, canManageWeights: true, + }, +}; + +it('opens the panel on click', async () => { + render(, { state }); + fireEvent.click(await screen.findByRole('button', { name: 'Manage external assessments' })); + expect(await screen.findByText('External assessments')).toBeVisible(); +}); + +it('does not open the external assessments panel by default', () => { + render(, { state }); + expect(screen.queryByText('External assessments')).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx new file mode 100644 index 00000000000..73639d95921 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx @@ -0,0 +1,452 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from 'test-utils'; + +import ManageExternalAssessmentsPanel from '../components/manage/ManageExternalAssessmentsPanel'; +import type { DropResult } from '@hello-pangea/dnd'; +import type { AppDispatch } from 'store'; +import { + moveItem, + handleDragEnd, +} from '../components/manage/ManageExternalAssessmentsPanel'; +import { reorderExternalAssessments } from '../operations'; + +const dropResult = (from: number, to: number | null): DropResult => + ({ + draggableId: 'x', + type: 'DEFAULT', + mode: 'FLUID', + reason: 'DROP', + combine: null, + source: { index: from, droppableId: 'external-assessments' }, + destination: + to === null ? null : { index: to, droppableId: 'external-assessments' }, + }) as DropResult; + +jest.mock('../components/import/ImportExternalAssessmentsWizard', () => ({ + __esModule: true, + default: ({ + existingAssessments, + onClose, + open, + }: { + existingAssessments: { name: string }[]; + onClose: () => void; + open: boolean; + }): JSX.Element | null => + open ? ( +
+

Import external assessments

+ {existingAssessments.map((assessment) => ( + + ))} + + +
+ ) : null, +})); + +jest.mock('../operations', () => ({ + __esModule: true, + ...jest.requireActual('../operations'), + reorderExternalAssessments: jest.fn(() => () => Promise.resolve()), +})); + +const preloadedState = { + gradebook: { + categories: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + tabs: [ + { id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }, + { id: -2, title: 'Final', categoryId: -1, gradebookWeight: 50 }, + ], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + { + id: -2, + title: 'Final', + tabId: -2, + maxGrade: 100, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + { id: 7, title: 'Native quiz', tabId: 3, maxGrade: 10 }, + ], + }, +}; + +const externalWith = (overrides: { + floorAtZero: boolean; + capAtMaximum: boolean; +}): typeof preloadedState => ({ + gradebook: { + ...preloadedState.gradebook, + tabs: [{ id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + ...overrides, + }, + ], + }, +}); + +describe('handleDragEnd', () => { + beforeEach(() => { + (reorderExternalAssessments as jest.Mock).mockClear(); + }); + + it('does nothing when the row is dropped outside the list', () => { + const dispatch = jest.fn() as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(0, null), dispatch, jest.fn()); + expect(reorderExternalAssessments).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('does nothing when the row is dropped back in its original position', () => { + const dispatch = jest.fn() as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(1, 1), dispatch, jest.fn()); + expect(reorderExternalAssessments).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches the reordered ids on a valid move', () => { + const dispatch = jest.fn(() => Promise.resolve()) as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(2, 0), dispatch, jest.fn()); + expect(reorderExternalAssessments).toHaveBeenCalledWith([-3, -1, -2]); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('invokes the error callback when the reorder dispatch rejects', async () => { + const onError = jest.fn(); + const dispatch = jest.fn(() => + Promise.reject(new Error('nope')), + ) as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(0, 2), dispatch, onError); + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + }); +}); + +it('shows an empty state when there are no externals', async () => { + render(, { + state: { gradebook: { ...preloadedState.gradebook, assessments: [] } }, + }); + expect(await screen.findByText('No external assessments yet')).toBeVisible(); + expect( + screen.getByText( + 'Add one manually, or import a CSV of grades earned outside Coursemology.', + ), + ).toBeVisible(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); +}); + +it('lists only external assessments', async () => { + render(, { + state: preloadedState, + }); + expect(await screen.findByText('Name')).toBeVisible(); + expect(await screen.findByText('Midterm')).toBeVisible(); + expect(screen.queryByText('Native quiz')).not.toBeInTheDocument(); +}); + +it('opens the add dialog', async () => { + render(, { + state: preloadedState, + }); + await userEvent.click(await screen.findByRole('button', { name: 'Add' })); + await waitFor(() => + expect(screen.getByText('Add external assessment')).toBeVisible(), + ); +}); + +it('opens the edit prompt for a row', async () => { + render(, { + state: preloadedState, + }); + await userEvent.click( + await screen.findByRole('button', { name: 'edit Midterm' }), + ); + await waitFor(() => + expect(screen.getByText('Edit external assessment')).toBeVisible(), + ); +}); + +it('opens the delete prompt for a row', async () => { + render(, { + state: preloadedState, + }); + await userEvent.click( + await screen.findByRole('button', { name: 'delete Midterm' }), + ); + await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); +}); + +it('invokes onClose when the close button is clicked', async () => { + const onClose = jest.fn(); + render(, { + state: preloadedState, + }); + await userEvent.click(await screen.findByRole('button', { name: 'Close' })); + expect(onClose).toHaveBeenCalledTimes(1); +}); + +it('opens the edit dialog for a row', async () => { + render(, { + state: preloadedState, + }); + + await userEvent.click(await screen.findByLabelText('edit Midterm')); + expect(await screen.findByText('Edit external assessment')).toBeVisible(); +}); + +it('opens the delete confirmation for a row', async () => { + render(, { + state: preloadedState, + }); + + await userEvent.click(await screen.findByLabelText('delete Midterm')); + expect(await screen.findByText('Delete external assessment')).toBeVisible(); + // the confirmation body names the assessment being deleted + expect( + screen.getByText( + /Delete "Midterm"\? This permanently removes the column and every student grade in it\. This cannot be undone\./, + ), + ).toBeVisible(); +}); + +it('hides weights when weighted view is disabled', async () => { + render(, { + state: preloadedState, + }); + + await screen.findByText('Midterm'); + expect(screen.queryByText('Weight')).not.toBeInTheDocument(); + expect(screen.queryByText('25')).not.toBeInTheDocument(); +}); + +it('shows external assessment weights when weighted view is enabled', async () => { + render(, { + state: { + gradebook: { + ...preloadedState.gradebook, + weightedViewEnabled: true, + }, + }, + }); + + expect(await screen.findByText('Weight')).toBeVisible(); + expect(screen.getByText('25')).toBeVisible(); +}); + +it('renders bounds chips per the floor/cap flags', async () => { + const stateWithMixedBounds = { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: false, + }, + ], + }, + }; + render(, { + state: stateWithMixedBounds, + }); + + expect(await screen.findByText('≥ 0')).toBeVisible(); + expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); +}); + +it('shows both bounds chips by default when flags are absent', async () => { + const stateWithDefaultBounds = { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { id: -1, title: 'Midterm', tabId: -1, maxGrade: 50, external: true }, + ], + }, + }; + render(, { + state: stateWithDefaultBounds, + }); + + expect(await screen.findByText('≥ 0')).toBeVisible(); + expect(screen.getByText('≤ max')).toBeVisible(); +}); + +it('shows the ≥ 0 and ≤ max chips for a floored and capped external', async () => { + render(, { + state: externalWith({ floorAtZero: true, capAtMaximum: true }), + }); + + expect(await screen.findByText('≥ 0')).toBeVisible(); + expect(screen.getByText('≤ max')).toBeVisible(); + expect(screen.getByText('50')).toBeVisible(); +}); + +it('hides both bound chips when an external is neither floored nor capped', async () => { + render(, { + state: externalWith({ floorAtZero: false, capAtMaximum: false }), + }); + + await screen.findByText('Midterm'); + expect(screen.queryByText('≥ 0')).not.toBeInTheDocument(); + expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); +}); + +it('passes existing external names as chips to the import wizard', async () => { + const stateWithExternal = { + gradebook: { + categories: [], + tabs: [{ id: 1, title: 'External', categoryId: 0, gradebookWeight: 30 }], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: 1, + maxGrade: 50, + gradebookWeight: 30, + external: true, + }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }; + render(, { + state: stateWithExternal, + }); + + // Open the import wizard + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + + // The "Midterm" chip must appear in the define step + expect( + within(await screen.findByTestId('import-wizard')).getByText('Midterm'), + ).toBeInTheDocument(); +}); + +it('hides the external assessments panel while importing and reopens it on cancel', async () => { + render(, { + state: preloadedState, + }); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); + + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + + expect(await screen.findByText('Import external assessments')).toBeVisible(); + await waitFor(() => + expect(screen.queryByText('External assessments')).not.toBeInTheDocument(), + ); + + await userEvent.click( + within(screen.getByTestId('import-wizard')).getByText('Cancel'), + ); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); +}); + +it('reopens the external assessments panel after a successful import', async () => { + render(, { + state: preloadedState, + }); + + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + expect(await screen.findByText('Import external assessments')).toBeVisible(); + await userEvent.click( + within(screen.getByTestId('import-wizard')).getByText('Confirm import'), + ); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); +}); + +it('renders a drag handle per external assessment', async () => { + const page = render( + , + { + state: preloadedState, + }, + ); + expect(await page.findByLabelText('reorder Midterm')).toBeVisible(); + expect(await page.findByLabelText('reorder Final')).toBeVisible(); +}); + +it('hides the drag handle when there is only one external assessment', async () => { + const page = render( + , + { + state: { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + ], + }, + }, + }, + ); + // The row still renders, with its grade in place... + expect(await page.findByText('Midterm')).toBeVisible(); + expect(page.getByText('50')).toBeVisible(); + // ...but the lone row has no reorder handle, so it cannot be dragged. + expect(page.queryByLabelText('reorder Midterm')).not.toBeInTheDocument(); +}); + +describe('moveItem', () => { + it('moves an item from one index to another, preserving the rest', () => { + expect(moveItem([-1, -2, -3], 2, 0)).toEqual([-3, -1, -2]); + expect(moveItem([-1, -2, -3], 0, 2)).toEqual([-2, -3, -1]); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/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..f05dd4e7abe --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx @@ -0,0 +1,56 @@ +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 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('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); + }); +}); 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__/buildTemplate.test.ts b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts new file mode 100644 index 00000000000..c05c7d0796f --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts @@ -0,0 +1,70 @@ +import { buildTemplateCsv, identifierHeader } from '../components/import/buildTemplate'; + +describe('identifierHeader', () => { + it('maps mode to the concrete header', () => { + expect(identifierHeader('student_id')).toBe('External ID'); + expect(identifierHeader('email')).toBe('Email'); + }); +}); + +describe('buildTemplateCsv', () => { + const components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }]; + + it('uses the External ID header in student_id mode', () => { + expect(buildTemplateCsv(components, 'student_id')).toBe('External ID,Midterm\n'); + }); + + it('uses the Email header in email mode', () => { + expect(buildTemplateCsv(components, 'email')).toBe('Email,Midterm\n'); + }); + + it('quotes a component name containing a comma', () => { + const csv = buildTemplateCsv([ + { name: 'Lab, week 1', weightage: 10, maximumGrade: 20 }, + ], 'student_id'); + expect(csv.split('\n')[0]).toBe('External ID,"Lab, week 1"'); + }); + + it('returns "External ID\\n" for empty components array in student_id mode', () => { + expect(buildTemplateCsv([], 'student_id')).toBe('External ID\n'); + }); + + it('quotes a component name containing a double-quote', () => { + const csv = buildTemplateCsv([ + { name: 'My "Best" Quiz', weightage: 10, maximumGrade: 20 }, + ], 'student_id'); + expect(csv.split('\n')[0]).toBe('External ID,"My ""Best"" Quiz"'); + }); + + it('quotes a component name containing a newline', () => { + const csv = buildTemplateCsv([ + { name: 'Lab\nWeek1', weightage: 10, maximumGrade: 20 }, + ], 'student_id'); + // The quoted cell spans two lines; verify the full header row content. + expect(csv.startsWith('External ID,"Lab\nWeek1"')).toBe(true); + }); + + it('always ends with exactly one newline', () => { + const csv = buildTemplateCsv([ + { name: 'A', weightage: 0, maximumGrade: 100 }, + ], 'student_id'); + expect(csv.endsWith('\n')).toBe(true); + expect(csv.split('\n')).toHaveLength(2); // header line + empty string after trailing \n + }); + + it('emits one column per component, in input order', () => { + const csv = buildTemplateCsv( + [ + { name: 'Midterm', weightage: 30, maximumGrade: 50 }, + { name: 'Final', weightage: 50, maximumGrade: 100 }, + { name: 'Lab', weightage: 20, maximumGrade: 20 }, + ], + 'external_id', + ); + expect(csv).toBe('External ID,Midterm,Final,Lab\n'); + }); + + it('returns "Email\\n" for empty components array in email mode', () => { + expect(buildTemplateCsv([], 'email')).toBe('Email\n'); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts new file mode 100644 index 00000000000..0cfe294c02a --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts @@ -0,0 +1,730 @@ +// client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts +import { + computeStudentBreakdown, + computeStudentTotal, + computeTabSubtotal, + computeWeightedRows, + resolveTabWeights, + sumWeights, + usingDefaultWeights, + materializedDefaultWeights, +} 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(); + }); +}); + +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); + }); +}); + +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); + }); +}); + +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); + }); +}); + +describe('materializedDefaultWeights', () => { + it('freezes the equal split across populated tabs as an equal-mode payload', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: 2, title: 'T2', categoryId: 1 }, + ]; + const assessments = [{ tabId: 1 }, { tabId: 2 }]; + + expect(materializedDefaultWeights(tabs, assessments)).toEqual([ + { tabId: 1, weight: 50, weightMode: 'equal' }, + { tabId: 2, weight: 50, weightMode: 'equal' }, + ]); + }); + + it('omits tabs with no assessments', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: 2, title: 'T2', categoryId: 1 }, + { id: 3, title: 'Empty', categoryId: 1 }, + ]; + const assessments = [{ tabId: 1 }, { tabId: 2 }]; + + expect(materializedDefaultWeights(tabs, assessments).map((w) => w.tabId)).toEqual([ + 1, 2, + ]); + }); + + it('routes external tabs by their negative store id (matches bulk_update)', () => { + const tabs = [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: -5, title: 'Ext', categoryId: -1 }, + ]; + const assessments = [{ tabId: 1 }, { tabId: -5 }]; + + expect(materializedDefaultWeights(tabs, assessments)).toEqual([ + { tabId: 1, weight: 50, weightMode: 'equal' }, + { tabId: -5, weight: 50, weightMode: 'equal' }, + ]); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/operations.test.ts b/client/app/bundles/course/gradebook/__tests__/operations.test.ts new file mode 100644 index 00000000000..4c8cb32bbb0 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/operations.test.ts @@ -0,0 +1,614 @@ +import type { AppDispatch } from 'store'; + +import CourseAPI from 'api/course'; + +import fetchGradebook, { + commitImport, + createExternalAssessment, + deleteExternalAssessment, + editExternalAssessment, + previewImport, + renameExternalAssessment, + reorderExternalAssessments, + setExternalGrade, + updateGradebookWeights, +} from '../operations'; + +const REORDER = 'course/gradebook/REORDER_EXTERNAL_ASSESSMENTS'; + +const externals = [ + { id: -1, title: 'A', tabId: -1, maxGrade: 10, external: true }, + { id: -2, title: 'B', tabId: -2, maxGrade: 10, external: true }, +]; + +// Minimal thunk harness: record plain actions, stub getState. +const harness = (): { + dispatched: { type: string; payload: unknown }[]; + dispatch: AppDispatch; + getState: () => never; +} => { + const dispatched: { type: string; payload: unknown }[] = []; + return { + dispatched, + // The operation only dispatches plain { type, payload } actions, which we + // record; cast to AppDispatch so it satisfies the thunk's dispatch param. + dispatch: ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch, + getState: () => ({ gradebook: { assessments: externals } }) as never, + }; +}; + +describe('reorderExternalAssessments', () => { + afterEach(() => jest.restoreAllMocks()); + + it('applies optimistically, then reverts and rethrows when the PUT fails', async () => { + const { dispatched, dispatch, getState } = harness(); + jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockRejectedValue(new Error('network')); + + await expect( + reorderExternalAssessments([-2, -1])(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const reorders = dispatched.filter((a) => a.type === REORDER); + expect(reorders[0].payload).toEqual([-2, -1]); // optimistic apply + expect(reorders[reorders.length - 1].payload).toEqual([-1, -2]); // rolled back to original + }); + + it('does not roll back on success', async () => { + const { dispatched, dispatch, getState } = harness(); + jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockResolvedValue({ data: undefined } as never); + + await reorderExternalAssessments([-2, -1])(dispatch, getState, {}); + + const reorders = dispatched.filter((a) => a.type === REORDER); + expect(reorders).toHaveLength(1); + expect(reorders[0].payload).toEqual([-2, -1]); + }); + + it('calls the API with positive external ids (negated store ids)', async () => { + const { dispatch, getState } = harness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockResolvedValue({ data: undefined } as never); + + await reorderExternalAssessments([-2, -1])(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ orderedIds: [2, 1] }); + }); + + it('rolls back only external assessments in their stored order', async () => { + const dispatched: { type: string; payload: unknown }[] = []; + const dispatch = ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch; + const getState = (() => ({ + gradebook: { + assessments: [ + { id: 5, external: false }, + ...externals, // ids -1, -2 + ], + }, + })) as unknown as () => never; + jest + .spyOn(CourseAPI.gradebook, 'reorderExternals') + .mockRejectedValue(new Error('network')); + + await expect( + reorderExternalAssessments([-2, -1])(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const reorders = dispatched.filter((a) => a.type === REORDER); + expect(reorders[reorders.length - 1].payload).toEqual([-1, -2]); + }); +}); + +describe('setExternalGrade', () => { + afterEach(() => jest.restoreAllMocks()); + + const gradeHarness = ( + submissions: { + studentId: number; + assessmentId: number; + grade: number | null; + }[], + ): { + dispatched: { type: string; payload: unknown }[]; + dispatch: AppDispatch; + getState: () => never; + } => { + const dispatched: { type: string; payload: unknown }[] = []; + return { + dispatched, + dispatch: ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch, + getState: () => ({ gradebook: { submissions } }) as never, + }; + }; + + const SET = 'course/gradebook/SET_EXTERNAL_GRADE'; + + it('applies optimistically, calls the API with the negated id, then reconciles', async () => { + const { dispatched, dispatch, getState } = gradeHarness([ + { studentId: 7, assessmentId: -1, grade: 3 }, + ]); + const spy = jest + .spyOn(CourseAPI.gradebook, 'setExternalGrade') + .mockResolvedValue({ + data: { studentId: 7, assessmentId: -1, grade: 9 }, + } as never); + + await setExternalGrade(-1, 7, 9)(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith(1, { studentId: 7, grade: 9 }); + const sets = dispatched.filter((a) => a.type === SET); + expect(sets[0].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 9, + }); // optimistic + expect(sets[sets.length - 1].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 9, + }); // reconciled from response.data + }); + + it('restores the prior grade and rethrows when the PUT fails', async () => { + const { dispatched, dispatch, getState } = gradeHarness([ + { studentId: 7, assessmentId: -1, grade: 3 }, + ]); + jest + .spyOn(CourseAPI.gradebook, 'setExternalGrade') + .mockRejectedValue(new Error('network')); + + await expect( + setExternalGrade(-1, 7, 9)(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const sets = dispatched.filter((a) => a.type === SET); + expect(sets[0].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 9, + }); // optimistic + expect(sets[sets.length - 1].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: 3, + }); // rolled back to prev + }); + + it('rolls back to null when no prior submission exists', async () => { + const { dispatched, dispatch, getState } = gradeHarness([]); + jest + .spyOn(CourseAPI.gradebook, 'setExternalGrade') + .mockRejectedValue(new Error('network')); + + await expect( + setExternalGrade(-1, 7, 9)(dispatch, getState, {}), + ).rejects.toThrow('network'); + + const sets = dispatched.filter((a) => a.type === SET); + expect(sets[sets.length - 1].payload).toEqual({ + studentId: 7, + assessmentId: -1, + grade: null, + }); + }); +}); + +describe('createExternalAssessment', () => { + afterEach(() => jest.restoreAllMocks()); + + const createHarness = (): { + dispatch: AppDispatch; + getState: () => never; + } => ({ + dispatch: ((a: unknown) => a) as unknown as AppDispatch, + getState: (() => ({})) as unknown as () => never, + }); + + it('omits weight from the payload when undefined', async () => { + const { dispatch, getState } = createHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue({ data: {} } as never); + + await createExternalAssessment( + 'A', + 10, + true, + false, + )(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ + title: 'A', + maximumGrade: 10, + floorAtZero: true, + capAtMaximum: false, + }); + }); + + it('includes weight in the payload when provided', async () => { + const { dispatch, getState } = createHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue({ data: {} } as never); + + await createExternalAssessment( + 'A', + 10, + true, + false, + 2, + )(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ + title: 'A', + maximumGrade: 10, + floorAtZero: true, + capAtMaximum: false, + weight: 2, + }); + }); +}); + +describe('id-negating thunks', () => { + afterEach(() => jest.restoreAllMocks()); + + const noopHarness = (): { dispatch: AppDispatch; getState: () => never } => ({ + dispatch: ((a: unknown) => a) as unknown as AppDispatch, + getState: (() => ({})) as unknown as () => never, + }); + + it('renameExternalAssessment negates the id for the API call', async () => { + const { dispatch, getState } = noopHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateExternal') + .mockResolvedValue({ data: {} } as never); + + await renameExternalAssessment(-3, 'New')(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith(3, { title: 'New' }); + }); + + it('editExternalAssessment negates the id and forwards the patch', async () => { + const { dispatch, getState } = noopHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateExternal') + .mockResolvedValue({ data: {} } as never); + + await editExternalAssessment(-3, { maximumGrade: 20 })( + dispatch, + getState, + {}, + ); + + expect(spy).toHaveBeenCalledWith(3, { maximumGrade: 20 }); + }); + + it('deleteExternalAssessment negates the id for the API but dispatches the original id', async () => { + const dispatched: { type: string; payload: unknown }[] = []; + const dispatch = ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch; + const spy = jest + .spyOn(CourseAPI.gradebook, 'deleteExternal') + .mockResolvedValue({ data: undefined } as never); + + await deleteExternalAssessment(-3)(dispatch, (() => ({})) as never, {}); + + expect(spy).toHaveBeenCalledWith(3); + expect(dispatched).toContainEqual({ + type: 'course/gradebook/DELETE_EXTERNAL_ASSESSMENT', + payload: -3, + }); + }); +}); + +describe('commitImport', () => { + afterEach(() => jest.restoreAllMocks()); + + it('commits, refreshes the gradebook, and returns the commit summary', async () => { + const dispatched: { type: string; payload: unknown }[] = []; + const dispatch = ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch; + jest + .spyOn(CourseAPI.gradebook, 'importCommit') + .mockResolvedValue({ data: { inserted: 2 } } as never); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: { refreshed: true } } as never); + + const summary = await commitImport({ + components: [], + onConflict: 'replace', + } as never)( + dispatch, + (() => ({})) as never, + {}, + ); + + expect(summary).toEqual({ inserted: 2 }); + expect(dispatched).toContainEqual({ + type: 'course/gradebook/SAVE_GRADEBOOK', + payload: { refreshed: true }, + }); + }); +}); + +describe('simple pass-through thunks', () => { + afterEach(() => jest.restoreAllMocks()); + + const passHarness = (): { + dispatched: { type: string; payload: unknown }[]; + dispatch: AppDispatch; + getState: () => never; + } => { + const dispatched: { type: string; payload: unknown }[] = []; + return { + dispatched, + dispatch: ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch, + getState: (() => ({})) as unknown as () => never, + }; + }; + + it('fetchGradebook dispatches saveGradebook with the index response', async () => { + const { dispatched, dispatch, getState } = passHarness(); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: { loaded: true } } as never); + + await fetchGradebook()(dispatch, getState, {}); + + expect(dispatched).toContainEqual({ + type: 'course/gradebook/SAVE_GRADEBOOK', + payload: { loaded: true }, + }); + }); + + it('updateGradebookWeights wraps weights and dispatches updateTabWeights', async () => { + const { dispatched, dispatch, getState } = passHarness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + + await updateGradebookWeights([])(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith({ weights: [] }); + expect(dispatched).toContainEqual({ + type: 'course/gradebook/UPDATE_TAB_WEIGHTS', + payload: { weights: [] }, + }); + }); + + it('previewImport returns the API data without dispatching', async () => { + const { dispatched, dispatch, getState } = passHarness(); + jest + .spyOn(CourseAPI.gradebook, 'importPreview') + .mockResolvedValue({ data: { conflicts: [] } } as never); + + const result = await previewImport({} as never)(dispatch, getState, {}); + + expect(result).toEqual({ conflicts: [] }); + expect(dispatched).toHaveLength(0); + }); +}); + +// Thunk-aware harness: executes nested thunks (createExternalAssessment dispatches +// the materialize thunk, which dispatches updateGradebookWeights) and records plain +// actions. getState returns the supplied gradebook slice. +const thunkHarness = ( + gradebook: unknown, +): { dispatch: AppDispatch; getState: () => never } => { + const getState = (() => ({ gradebook })) as unknown as () => never; + const dispatch = ((a: unknown) => + typeof a === 'function' + ? (a as (d: unknown, g: unknown, e: unknown) => unknown)( + dispatch, + getState, + {}, + ) + : a) as unknown as AppDispatch; + return { dispatch, getState }; +}; + +const defaultGradebook = { + tabs: [ + { id: 1, title: 'T1', categoryId: 1 }, + { id: 2, title: 'T2', categoryId: 1 }, + ], + assessments: [ + { id: 10, title: 'a', tabId: 1, maxGrade: 10 }, + { id: 11, title: 'b', tabId: 2, maxGrade: 10 }, + ], +}; + +const createdNode = { + data: { + assessment: { id: -1, title: 'X', tabId: -1, maxGrade: 10, external: true }, + tab: { id: -1, title: 'X', categoryId: -1, gradebookWeight: 20 }, + category: { id: -1, title: 'External Assessments' }, + }, +}; + +describe('createExternalAssessment — default-weight materialization', () => { + afterEach(() => jest.restoreAllMocks()); + + it('persists the equal split before creating when a non-zero weight is set', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const createExternal = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue(createdNode as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await createExternalAssessment('X', 10, true, true, 20)(dispatch, getState, {}); + + expect(updateWeights).toHaveBeenCalledWith({ + weights: [ + { tabId: 1, weight: 50, weightMode: 'equal' }, + { tabId: 2, weight: 50, weightMode: 'equal' }, + ], + }); + expect(updateWeights.mock.invocationCallOrder[0]).toBeLessThan( + createExternal.mock.invocationCallOrder[0], + ); + }); + + it('does not materialize when the new weight is zero', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue(createdNode as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await createExternalAssessment('X', 10, true, true, 0)(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + }); + + it('does not materialize when weights are already configured', async () => { + const configured = { + tabs: [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 70 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 30 }, + ], + assessments: defaultGradebook.assessments, + }; + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const createExternal = jest + .spyOn(CourseAPI.gradebook, 'createExternal') + .mockResolvedValue(createdNode as never); + const { dispatch, getState } = thunkHarness(configured); + + await createExternalAssessment('X', 10, true, true, 20)(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + expect(createExternal).toHaveBeenCalled(); + }); +}); + +describe('editExternalAssessment — default-weight materialization', () => { + afterEach(() => jest.restoreAllMocks()); + + it('persists the equal split before updating when a non-zero weight is set', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const updateExternal = jest + .spyOn(CourseAPI.gradebook, 'updateExternal') + .mockResolvedValue({ + data: { + assessment: { id: -1, title: 'X', tabId: -1, maxGrade: 10, external: true }, + tab: { id: -1, title: 'X', categoryId: -1, gradebookWeight: 40 }, + }, + } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await editExternalAssessment(-1, { weight: 40 })(dispatch, getState, {}); + + expect(updateWeights).toHaveBeenCalled(); + expect(updateWeights.mock.invocationCallOrder[0]).toBeLessThan( + updateExternal.mock.invocationCallOrder[0], + ); + }); + + it('does not materialize when the edit carries no weight', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + jest.spyOn(CourseAPI.gradebook, 'updateExternal').mockResolvedValue({ + data: { + assessment: { id: -1, title: 'X', tabId: -1, maxGrade: 10, external: true }, + tab: { id: -1, title: 'X', categoryId: -1 }, + }, + } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await editExternalAssessment(-1, { title: 'X' })(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + }); +}); + +describe('commitImport — default-weight materialization', () => { + afterEach(() => jest.restoreAllMocks()); + + const fullGradebook = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }; + + const importPayload = (weightage: number) => ({ + components: [{ name: 'Q', weightage, maximumGrade: 10 }], + identifierMode: 'email' as const, + csvData: 'x', + onConflict: 'keep' as const, + }); + + it('persists the equal split before committing when any component is weighted', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + const importCommit = jest + .spyOn(CourseAPI.gradebook, 'importCommit') + .mockResolvedValue({ + data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 0 }, + } as never); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: fullGradebook } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await commitImport(importPayload(20))(dispatch, getState, {}); + + expect(updateWeights).toHaveBeenCalled(); + expect(updateWeights.mock.invocationCallOrder[0]).toBeLessThan( + importCommit.mock.invocationCallOrder[0], + ); + }); + + it('does not materialize when no imported component is weighted', async () => { + const updateWeights = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + jest.spyOn(CourseAPI.gradebook, 'importCommit').mockResolvedValue({ + data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 0 }, + } as never); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: fullGradebook } as never); + const { dispatch, getState } = thunkHarness(defaultGradebook); + + await commitImport(importPayload(0))(dispatch, getState, {}); + + expect(updateWeights).not.toHaveBeenCalled(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/store.test.ts b/client/app/bundles/course/gradebook/__tests__/store.test.ts new file mode 100644 index 00000000000..ad8b591e424 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/store.test.ts @@ -0,0 +1,536 @@ +import reducer, { actions } from '../store'; + +const EXTERNAL_ASSESSMENTS = 'External Assessments'; + +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('SAVE_GRADEBOOK reducer', () => { + it('returns the initial state for an unknown action', () => { + const next = reducer(undefined, { type: 'unknown' } as never); + expect(next).toEqual({ + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + }); + }); + + it('hydrates every field from the payload', () => { + const next = reducer( + undefined, + actions.saveGradebook({ + categories: [{ id: 1, title: 'C' }], + tabs: [{ id: 10, title: 'T', categoryId: 1, gradebookWeight: 50 }], + assessments: [{ id: 100, title: 'A', tabId: 10, maxGrade: 100 }], + students: [], + submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: true, + weightedViewEnabled: true, + canManageWeights: true, + }), + ); + expect(next.categories).toHaveLength(1); + expect(next.tabs[0].gradebookWeight).toBe(50); + expect(next.assessments[0].id).toBe(100); + expect(next.submissions[0].grade).toBe(8); + expect(next.gamificationEnabled).toBe(true); + expect(next.weightedViewEnabled).toBe(true); + expect(next.canManageWeights).toBe(true); + }); +}); + +describe('UPDATE_TAB_WEIGHTS reducer', () => { + it('updates gradebookWeight and weightMode for the matching tab', () => { + const next = reducer( + 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 in tabs', () => { + 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, + ); + }); + + it('clears assessment exclusion when excludedAssessmentIds is omitted', () => { + const seeded = { + ...baseState, + assessments: [ + { + id: 101, + title: 'A1', + tabId: 1, + maxGrade: 100, + gradebookExcluded: true, + }, + { + id: 102, + title: 'A2', + tabId: 1, + maxGrade: 100, + gradebookExcluded: true, + }, + ], + }; + const next = reducer( + seeded, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 50, weightMode: 'equal' }], + }), + ); + expect(next.assessments.find((a) => a.id === 101)?.gradebookExcluded).toBe( + false, + ); + expect(next.assessments.find((a) => a.id === 102)?.gradebookExcluded).toBe( + false, + ); + }); +}); + +describe('external assessment reducers', () => { + const state = { + categories: [{ id: 1, title: 'Cat A' }], + tabs: [{ id: 10, title: 'Tab 1', categoryId: 1 }], + assessments: [{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }], + students: [], + submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }], + gamificationEnabled: false, + userId: 0, + weightedViewEnabled: false, + canManageWeights: true, + }; + + it('applyCreatedExternal adds category, tab and assessment', () => { + const next = reducer( + state, + actions.applyCreatedExternal({ + assessment: { + id: -5, + title: 'Midterm', + tabId: 200, + maxGrade: 50, + external: true, + }, + tab: { id: 200, title: 'Midterm', categoryId: 2 }, + category: { id: 2, title: EXTERNAL_ASSESSMENTS }, + }), + ); + expect(next.categories.find((c) => c.id === 2)?.title).toBe( + EXTERNAL_ASSESSMENTS, + ); + expect(next.tabs.find((t) => t.id === 200)?.title).toBe('Midterm'); + expect(next.assessments.find((a) => a.id === -5)?.external).toBe(true); + }); + + it('applyCreatedExternal does not duplicate existing category, tab, or assessment', () => { + const seeded = { + ...state, + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer( + seeded, + actions.applyCreatedExternal({ + assessment: { + id: -6, + title: 'Final', + tabId: 200, + maxGrade: 100, + external: true, + }, + tab: { id: 200, title: 'Midterm', categoryId: 2 }, + category: { id: 2, title: EXTERNAL_ASSESSMENTS }, + }), + ); + expect(next.categories.filter((c) => c.id === 2)).toHaveLength(1); + expect(next.tabs.filter((t) => t.id === 200)).toHaveLength(1); + expect(next.assessments.filter((a) => a.id === -6)).toHaveLength(1); + }); + + it('updateExternalAssessment changes title and maxGrade and syncs tab title', () => { + const seeded = { + ...state, + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer( + seeded, + actions.updateExternalAssessment({ + assessment: { + id: -5, + title: 'Midterm Exam', + tabId: 200, + maxGrade: 60, + external: true, + }, + tab: { id: 200, title: 'Midterm Exam', categoryId: 2 }, + }), + ); + expect(next.assessments.find((a) => a.id === -5)?.title).toBe( + 'Midterm Exam', + ); + expect(next.assessments.find((a) => a.id === -5)?.maxGrade).toBe(60); + expect(next.tabs.find((t) => t.id === 200)?.title).toBe('Midterm Exam'); + }); + + it('updateExternalAssessment writes tab.gradebookWeight when provided and preserves it when omitted', () => { + const seeded = { + ...state, + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [ + ...state.tabs, + { id: 200, title: 'Midterm', categoryId: 2, gradebookWeight: 40 }, + ], + }; + const withWeight = reducer( + seeded, + actions.updateExternalAssessment({ + assessment: { + id: -5, + title: 'M', + tabId: 200, + maxGrade: 50, + external: true, + }, + tab: { id: 200, title: 'M', categoryId: 2, gradebookWeight: 75 }, + }), + ); + expect(withWeight.tabs.find((t) => t.id === 200)?.gradebookWeight).toBe(75); + + const noWeight = reducer( + seeded, + actions.updateExternalAssessment({ + assessment: { + id: -5, + title: 'M', + tabId: 200, + maxGrade: 50, + external: true, + }, + tab: { id: 200, title: 'M', categoryId: 2 }, + }), + ); + expect(noWeight.tabs.find((t) => t.id === 200)?.gradebookWeight).toBe(40); + }); + + it('deleteExternalAssessment removes the assessment and its now-empty tab', () => { + const seeded = { + ...state, + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + submissions: [ + ...state.submissions, + { studentId: 1, assessmentId: -5, grade: 30 }, + ], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-5)); + expect(next.assessments.find((a) => a.id === -5)).toBeUndefined(); + expect(next.tabs.find((t) => t.id === 200)).toBeUndefined(); + expect(next.submissions.find((s) => s.assessmentId === -5)).toBeUndefined(); + }); + + it('deleteExternalAssessment drops the synthetic category once its last external is gone', () => { + const seeded = { + ...state, + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-5)); + expect(next.categories.find((c) => c.id === 2)).toBeUndefined(); + expect(next.categories.find((c) => c.id === 1)).toBeDefined(); + }); + + it('deleteExternalAssessment keeps the category while other externals remain', () => { + const seeded = { + ...state, + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + { id: -6, title: 'Final', tabId: 201, maxGrade: 100, external: true }, + ], + tabs: [ + ...state.tabs, + { id: 200, title: 'Midterm', categoryId: 2 }, + { id: 201, title: 'Final', categoryId: 2 }, + ], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-5)); + expect(next.categories.find((c) => c.id === 2)).toBeDefined(); + expect(next.tabs.find((t) => t.id === 201)).toBeDefined(); + }); + + it('setExternalGrade upserts a submission row by (studentId, assessmentId)', () => { + const inserted = reducer( + state, + actions.setExternalGrade({ studentId: 1, assessmentId: -5, grade: 42 }), + ); + expect( + inserted.submissions.find( + (s) => s.studentId === 1 && s.assessmentId === -5, + )?.grade, + ).toBe(42); + + const updated = reducer( + inserted, + actions.setExternalGrade({ studentId: 1, assessmentId: -5, grade: 17 }), + ); + expect( + updated.submissions.filter( + (s) => s.studentId === 1 && s.assessmentId === -5, + ), + ).toHaveLength(1); + expect( + updated.submissions.find( + (s) => s.studentId === 1 && s.assessmentId === -5, + )?.grade, + ).toBe(17); + }); + + it('setExternalGrade can clear a grade to null', () => { + const next = reducer( + state, + actions.setExternalGrade({ studentId: 1, assessmentId: -5, grade: null }), + ); + expect( + next.submissions.find((s) => s.studentId === 1 && s.assessmentId === -5) + ?.grade, + ).toBeNull(); + }); + + it('UPDATE_EXTERNAL_ASSESSMENT copies bound flags', () => { + const seed = (overrides = {}): ReturnType => + reducer( + undefined, + actions.saveGradebook({ + categories: [], + tabs: [{ id: -1, title: 'Ext', categoryId: -1 }], + assessments: [ + { + id: -1, + title: 'Ext', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + ...overrides, + }), + ); + + const next = reducer( + seed(), + actions.updateExternalAssessment({ + assessment: { + id: -1, + title: 'Ext', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: false, + capAtMaximum: false, + }, + tab: { id: -1, title: 'Ext', categoryId: -1 }, + }), + ); + const a = next.assessments.find((x) => x.id === -1)!; + expect(a.floorAtZero).toBe(false); + expect(a.capAtMaximum).toBe(false); + }); + + it('REORDER_EXTERNAL_ASSESSMENTS permutes external assessments and their synthetic tabs', () => { + const externalState = { + ...baseState, + tabs: [ + { id: -1, title: 'Quiz', categoryId: -1 }, + { id: -2, title: 'Exam', categoryId: -1 }, + ], + assessments: [ + { id: -1, title: 'Quiz', tabId: -1, maxGrade: 10, external: true }, + { id: -2, title: 'Exam', tabId: -2, maxGrade: 20, external: true }, + ], + }; + const next = reducer( + externalState, + actions.reorderExternalAssessments([-2, -1]), + ); + expect(next.assessments.map((a) => a.id)).toEqual([-2, -1]); + expect(next.tabs.map((t) => t.id)).toEqual([-2, -1]); + }); + + it('deleteExternalAssessment is a no-op for an unknown id', () => { + const seeded = { + ...state, + categories: [...state.categories, { id: 2, title: EXTERNAL_ASSESSMENTS }], + assessments: [ + ...state.assessments, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ], + tabs: [...state.tabs, { id: 200, title: 'Midterm', categoryId: 2 }], + }; + const next = reducer(seeded, actions.deleteExternalAssessment(-999)); + expect(next.assessments.find((a) => a.id === -5)).toBeDefined(); + expect(next.tabs.find((t) => t.id === 200)).toBeDefined(); + expect(next.categories.find((c) => c.id === 2)).toBeDefined(); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx new file mode 100644 index 00000000000..f872da82f88 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx @@ -0,0 +1,207 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import InfoOutlined from '@mui/icons-material/InfoOutlined'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Switch, + TextField, + Tooltip, +} from '@mui/material'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { createExternalAssessment } from '../operations'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.AddExternalColumnPrompt.title', + defaultMessage: 'Add external assessment', + }, + nameLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.nameLabel', + defaultMessage: 'Name', + }, + maxLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.maxLabel', + defaultMessage: 'Max marks', + }, + weightLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.weightLabel', + defaultMessage: 'Weightage', + }, + floorLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.floorLabel', + defaultMessage: 'Floor grades at 0', + }, + capLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.capLabel', + defaultMessage: 'Cap grades at max', + }, + floorHint: { + id: 'course.gradebook.AddExternalColumnPrompt.floorHint', + defaultMessage: + 'Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged.', + }, + capHint: { + id: 'course.gradebook.AddExternalColumnPrompt.capHint', + defaultMessage: + 'Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged.', + }, + cancel: { + id: 'course.gradebook.AddExternalColumnPrompt.cancel', + defaultMessage: 'Cancel', + }, + create: { + id: 'course.gradebook.AddExternalColumnPrompt.create', + defaultMessage: 'Create', + }, + error: { + id: 'course.gradebook.AddExternalColumnPrompt.error', + defaultMessage: 'Could not create the external assessment.', + }, + success: { + id: 'course.gradebook.AddExternalColumnPrompt.success', + defaultMessage: 'External assessment created.', + }, +}); + +interface Props { + open: boolean; + weightedViewEnabled?: boolean; + onClose: () => void; +} + +const AddExternalColumnPrompt: FC = ({ + open, + weightedViewEnabled = false, + onClose, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [name, setName] = useState(''); + const [max, setMax] = useState(''); + const [floorAtZero, setFloorAtZero] = useState(true); + const [capAtMaximum, setCapAtMaximum] = useState(true); + const [saving, setSaving] = useState(false); + const [weight, setWeight] = useState('0'); + + const reset = (): void => { + setName(''); + setMax(''); + setFloorAtZero(true); + setCapAtMaximum(true); + setWeight('0'); + }; + + const canSave = + name.trim() !== '' && max.trim() !== '' && Number(max) >= 0 && !saving; + + const submit = async (): Promise => { + setSaving(true); + try { + await dispatch( + createExternalAssessment( + name.trim(), + Number(max), + floorAtZero, + capAtMaximum, + weightedViewEnabled ? Number(weight) : undefined, + ), + ); + toast.success(t(translations.success)); + reset(); + onClose(); + } catch { + toast.error(t(translations.error)); + } finally { + setSaving(false); + } + }; + + return ( + + {t(translations.title)} + + setName(e.target.value)} + value={name} + /> + setMax(e.target.value)} + type="number" + value={max} + /> + {weightedViewEnabled && ( + setWeight(e.target.value)} + type="number" + value={weight} + /> + )} +
+ setFloorAtZero(e.target.checked)} + /> + } + label={t(translations.floorLabel)} + /> + + + +
+
+ setCapAtMaximum(e.target.checked)} + /> + } + label={t(translations.capLabel)} + /> + + + +
+
+ + + + +
+ ); +}; + +export default AddExternalColumnPrompt; 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..1c4ebd19a6a --- /dev/null +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx @@ -0,0 +1,655 @@ +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; + + // Skip a category with no tabs — e.g. the synthetic "External Assessments" + // group once its last external is deleted — so no bare header lingers. + const visibleCategories = categories.filter((cat) => + tabs.some((tb) => tb.categoryId === cat.id), + ); + + const sum = tabs.reduce((acc, tb) => acc + effectiveWeight(tb.id), 0); + const hasInvalid = + Object.values(weights).some((w) => validate(w) !== null) || + 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)} + + ))} +
+ + {visibleCategories.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, + ); + // An external assessment is always one-per-tab; its tab has + // no internal structure, so render just name + weight (no + // mode toggle, no expand, no per-assessment exclusion). + const isExternal = + tabAssessments.length > 0 && + tabAssessments.every((a) => a.external); + if (isExternal) { + return ( +
+
+
+ + {tb.title} + + + setWeights((prev) => ({ + ...prev, + [tb.id]: r2(prev[tb.id] ?? 0), + })) + } + onChange={(e) => + handleChange(tb.id, e.target.value) + } + size="small" + sx={{ width: 96 }} + type="number" + value={value} + /> +
+ {err && ( + + {err} + + )} +
+ ); + } + const mode = modes[tb.id] ?? 'equal'; + const isExpanded = !!expanded[tb.id]; + const unbalanced = isUnbalanced(tb.id); + 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/DeleteExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/DeleteExternalColumnPrompt.tsx new file mode 100644 index 00000000000..3d8ab0b44b2 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/DeleteExternalColumnPrompt.tsx @@ -0,0 +1,95 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { LoadingButton } from '@mui/lab'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { deleteExternalAssessment } from '../operations'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.DeleteExternalColumnPrompt.title', + defaultMessage: 'Delete external assessment', + }, + body: { + id: 'course.gradebook.DeleteExternalColumnPrompt.body', + defaultMessage: + 'Delete "{title}"? This permanently removes the column and every student grade in it. This cannot be undone.', + }, + cancel: { + id: 'course.gradebook.DeleteExternalColumnPrompt.cancel', + defaultMessage: 'Cancel', + }, + confirm: { + id: 'course.gradebook.DeleteExternalColumnPrompt.confirm', + defaultMessage: 'Delete', + }, + error: { + id: 'course.gradebook.DeleteExternalColumnPrompt.error', + defaultMessage: 'Could not delete the external assessment.', + }, +}); + +interface Props { + open: boolean; + assessmentId: number; + title: string; + onClose: () => void; +} + +const DeleteExternalColumnPrompt: FC = ({ + open, + assessmentId, + title, + onClose, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [saving, setSaving] = useState(false); + + const submit = async (): Promise => { + setSaving(true); + try { + await dispatch(deleteExternalAssessment(assessmentId)); + onClose(); + } catch { + toast.error(t(translations.error)); + } finally { + setSaving(false); + } + }; + + return ( + + {t(translations.title)} + + {t(translations.body, { title })} + + + + + {t(translations.confirm)} + + + + ); +}; + +export default DeleteExternalColumnPrompt; diff --git a/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx b/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx new file mode 100644 index 00000000000..7b2e8f44a1f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const GRADE_LINK_HINT_KEY = 'gradebook_grade_link_hint'; + +const translations = defineMessages({ + hint: { + id: 'course.gradebook.GradeLinkHint.hint', + defaultMessage: + "Each grade is the total of the marks in a student's submission. Click any grade to open that submission and adjust the marks.", + }, +}); + +/** + * One-time, dismissable nudge explaining that each grade in the "All assessments" + * table is a link into the student's submission. Grades have no gradebook-level edit + * (an assessment's total is always the sum of its question marks), so clicking into the + * submission is the only path to changing a grade — an affordance that surprises users + * because a number is not expected to be clickable. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const GradeLinkHint: FC = () => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + GRADE_LINK_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
+ + {t(translations.hint)} + +
+ ); +}; + +export default GradeLinkHint; diff --git a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx new file mode 100644 index 00000000000..e42d629dfc5 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -0,0 +1,223 @@ +import { useMemo } from 'react'; +import { defineMessages } from 'react-intl'; +import { Chip } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import { + ColumnPickerRenderContext, + ColumnPickerTreeGroup, +} from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; + +import { + EXTERNAL_CATEGORY_ID, + GAMIFICATION_COL_IDS, + type GamificationColId, + STUDENT_INFO_COL_IDS, + type StudentInfoColId, +} from '../constants'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; + +const translations = defineMessages({ + studentInfo: { + id: 'course.gradebook.GradebookColumnTree.studentInfo', + defaultMessage: 'Student info', + }, + gamification: { + id: 'course.gradebook.GradebookColumnTree.gamification', + defaultMessage: 'Gamification', + }, + grades: { + id: 'course.gradebook.GradebookColumnTree.grades', + defaultMessage: 'Grades', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +interface GradebookColumnTreeProps extends ColumnPickerRenderContext { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + gamificationEnabled: boolean; +} + +const GAMIFICATION_ALL_IDS = [...GAMIFICATION_COL_IDS]; + +const GradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, + categories, + tabs, + assessments, + gamificationEnabled, +}: GradebookColumnTreeProps): JSX.Element => { + const { t } = useTranslation(); + const context: ColumnPickerRenderContext = { + isVisible, + setVisible, + setManyVisible, + }; + + const asnIds = useMemo( + () => assessments.map((a) => buildAssessmentColumnId(a.id)), + [assessments], + ); + + const tabAsnIds = useMemo(() => { + const map = new Map(); + assessments.forEach((a) => { + const existing = map.get(a.tabId) ?? []; + map.set(a.tabId, [...existing, buildAssessmentColumnId(a.id)]); + }); + return map; + }, [assessments]); + + const catTabs = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, tab]); + }); + return map; + }, [tabs]); + + const asnById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + + const catAsnIds = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, ...tabIds]); + }); + return map; + }, [tabs, tabAsnIds]); + + const renderLeaf = (id: string, indentLevel: number): JSX.Element | null => { + const asnId = parseAssessmentColumnId(id); + const asn = asnId !== null ? asnById.get(asnId) : undefined; + if (!asn) return null; + return ( + setVisible(id, e.target.checked)} + /> + ); + }; + + return ( +
+ + {STUDENT_INFO_COL_IDS.map((id: StudentInfoColId) => + id === 'name' ? ( + + {t(tableTranslations[id])} + + + } + /> + ) : ( + setVisible(id, e.target.checked)} + /> + ), + )} + + + {gamificationEnabled && ( + + {GAMIFICATION_COL_IDS.map((id: GamificationColId) => ( + setVisible(id, e.target.checked)} + /> + ))} + + )} + + + {categories.map((cat) => { + const catIds = catAsnIds.get(cat.id) ?? []; + const thisCatTabs = catTabs.get(cat.id) ?? []; + return ( + + {cat.id === EXTERNAL_CATEGORY_ID + ? catIds.map((id) => renderLeaf(id, 2)) + : thisCatTabs.map((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + return ( + + {tabIds.map((id) => renderLeaf(id, 3))} + + ); + })} + + ); + })} + +
+ ); +}; + +export default GradebookColumnTree; diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx new file mode 100644 index 00000000000..c30b362ea2e --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -0,0 +1,1044 @@ +import { + cloneElement, + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { defineMessages } from 'react-intl'; +import { + Checkbox, + Chip, + CircularProgress, + Paper, + type SxProps, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + TextField, + type Theme, + Tooltip, +} from '@mui/material'; +import { flexRender } from '@tanstack/react-table'; + +import Link from 'lib/components/core/Link'; +import type { + ColumnPickerRenderContext, + ColumnTemplate, +} from 'lib/components/table/builder'; +import MuiTablePagination from 'lib/components/table/MuiTableAdapter/MuiTablePagination'; +import MuiTableToolbar from 'lib/components/table/MuiTableAdapter/MuiTableToolbar'; +import useTanStackTableBuilder from 'lib/components/table/TanStackTableBuilder'; +import { + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + DEFAULT_TABLE_ROWS_PER_PAGE, +} from 'lib/constants/sharedConstants'; +import { getEditSubmissionURL } from 'lib/helpers/url-builders'; +import { 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, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; +import GradebookColumnTree from './GradebookColumnTree'; + +const COL_WIDTHS = { + name: 160, + email: 250, + externalId: 160, + level: 80, + totalXp: 120, + assessment: 150, +} as const; + +const CHECKBOX_WIDTH = 56; + +const getColWidth = (id: string): number => + COL_WIDTHS[id as keyof typeof COL_WIDTHS] ?? COL_WIDTHS.assessment; + +const isLeftAligned = (id: string): boolean => + id === 'name' || id === 'email' || id === 'externalId'; + +const translations = defineMessages({ + searchStudents: { + id: 'course.gradebook.GradebookIndex.searchStudents', + defaultMessage: 'Search students', + }, + exportButton: { + id: 'course.gradebook.GradebookIndex.exportButton', + defaultMessage: 'Export all rows', + }, + exportRows: { + id: 'course.gradebook.GradebookIndex.exportRows', + defaultMessage: 'Export {count, plural, one {# row} other {# rows}}', + }, + exportAllTooltip: { + id: 'course.gradebook.GradebookIndex.exportAllTooltip', + defaultMessage: 'No rows selected - all rows will be exported.', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + maxMarks: { + id: 'course.gradebook.GradebookTable.maxMarks', + defaultMessage: 'Max Marks', + }, + noDataColumnsHint: { + id: 'course.gradebook.GradebookTable.noDataColumnsHint', + defaultMessage: + 'No grade columns selected - export will include student info only.', + }, + noDataColumnsHintWithGamification: { + id: 'course.gradebook.GradebookTable.noDataColumnsHintWithGamification', + defaultMessage: + 'No grade or gamification columns selected - export will include student info only.', + }, + 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 {title} grade for {name}. Please try again.', + }, + externalMaxAria: { + id: 'course.gradebook.GradebookTable.externalMaxAria', + defaultMessage: '{title} max marks', + }, + maxSaveError: { + id: 'course.gradebook.GradebookTable.maxSaveError', + defaultMessage: 'Could not save the max marks. Please try again.', + }, +}); + +const HeaderLabel = forwardRef< + HTMLSpanElement, + { text: string; onSingleLine: (fits: boolean) => void } +>(({ text, onSingleLine }, forwardedRef): JSX.Element => { + const innerRef = useRef(null); + const [display, setDisplay] = useState(text); + + useLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + + const lh = parseFloat(getComputedStyle(el).lineHeight) || 20; + const oneLineH = lh + 1; + const twoLineH = lh * 2 + 1; + + el.textContent = text; + + if (el.scrollHeight <= oneLineH) { + onSingleLine(true); + setDisplay(text); + return; + } + + onSingleLine(false); + + if (el.scrollHeight <= twoLineH) { + setDisplay(text); + return; + } + + let lo = 1; + let hi = text.length; + let best = `${text[0]}…`; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const candidate = `${text.slice(0, mid)}…`; + el.textContent = candidate; + if (el.scrollHeight <= twoLineH) { + best = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // Ensure DOM reflects `best` before React reconciles — the loop's last + // el.textContent assignment may be a too-long candidate, not `best`. + el.textContent = best; + setDisplay(best); + }, [text, onSingleLine]); + + return ( + { + innerRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }} + style={{ display: 'block' }} + > + {display} + + ); +}); +HeaderLabel.displayName = 'HeaderLabel'; + +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 [saving, setSaving] = useState(false); + + 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); + setSaving(true); + try { + await dispatch(setExternalGrade(assessmentId, studentId, next)); + } catch { + setLocalValue(prev); + toast.error(t(translations.gradeSaveError, { name: studentName, title })); + } finally { + setSaving(false); + } + }; + + 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" + // Fill the cell so the whole editable column band is the click target, + // not just the digits at the right edge. + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + width: '100%', + }} + tabIndex={0} + > + {saving && } + {localValue == null ? '—' : localValue} + + ); +}; + +const ExternalMaxCell = ({ + assessmentId, + title, + value, +}: { + assessmentId: number; + title: string; + value: number; +}): JSX.Element => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [editing, setEditing] = useState(false); + const [text, setText] = useState(''); + + const commit = async (): Promise => { + setEditing(false); + const next = Number(text.trim()); + if (text.trim() === '' || Number.isNaN(next) || next < 0) return; + if (next === value) return; + try { + await dispatch(updateExternalMaxGrade(assessmentId, next)); + } catch { + toast.error(t(translations.maxSaveError)); + } + }; + + if (editing) { + return ( + setText(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') setEditing(false); + }} + size="small" + value={text} + variant="standard" + /> + ); + } + + return ( + { + setText(String(value)); + setEditing(true); + }} + role="button" + style={{ cursor: 'pointer' }} + tabIndex={0} + > + {`/${value}`} + + ); +}; + +interface GradebookRow { + studentId: number; + name: string; + email: string; + externalId: string | null; + level: number; + totalXp: number; + grades: Partial>; + submissionIds: Partial>; +} + +interface GradebookTableProps { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + courseTitle: string; + courseId: number; + gamificationEnabled: boolean; + /** Optional action rendered in the toolbar, left of the column picker. */ + toolbarAction?: JSX.Element; +} + +const GradebookTable = ({ + categories, + tabs, + assessments, + students, + submissions, + courseTitle, + courseId, + gamificationEnabled, + toolbarAction, +}: GradebookTableProps): JSX.Element => { + const { t } = useTranslation(); + + const submissionsByStudent = useMemo(() => { + const map = new Map(); + submissions.forEach((s) => { + const existing = map.get(s.studentId); + if (existing) { + existing.push(s); + } else { + map.set(s.studentId, [s]); + } + }); + return map; + }, [submissions]); + + const rows = useMemo( + () => + students.map((student) => { + const subs = submissionsByStudent.get(student.id) ?? []; + const grades: Partial> = {}; + const submissionIds: Partial> = {}; + assessments.forEach((a) => { + const sub = subs.find((s) => s.assessmentId === a.id); + if (sub != null) { + grades[a.id] = sub.grade; + submissionIds[a.id] = sub.submissionId; + } + }); + return { + studentId: student.id, + name: student.name, + email: student.email, + externalId: student.externalId, + level: student.level, + totalXp: student.totalXp, + grades, + submissionIds, + }; + }), + [students, assessments, submissionsByStudent], + ); + + const hasExternalIds = useMemo( + () => students.some((s) => s.externalId != null && s.externalId !== ''), + [students], + ); + + const columns = useMemo[]>(() => { + const cols: ColumnTemplate[] = [ + { + id: 'name', + title: t(tableTranslations.name), + of: 'name', + cell: (row) => row.name, + csvDownloadable: true, + searchable: true, + sortable: true, + searchProps: { getValue: (row) => row.name }, + }, + { + id: 'email', + title: t(tableTranslations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + sortable: true, + }, + ]; + + // The External ID column is always offered in the picker, but only shown by + // default when the course actually uses external IDs (see column picker). + cols.push({ + id: 'externalId', + title: t(tableTranslations.externalId), + of: 'externalId', + cell: (row) => row.externalId ?? '', + csvDownloadable: true, + searchable: true, + sortable: true, + defaultVisible: hasExternalIds, + }); + + if (gamificationEnabled) { + cols.push({ + id: 'level', + title: t(tableTranslations.level), + of: 'level', + cell: (row) => row.level, + csvDownloadable: true, + sortable: true, + }); + cols.push({ + id: 'totalXp', + title: t(tableTranslations.totalXp), + of: 'totalXp', + cell: (row) => row.totalXp, + csvDownloadable: true, + sortable: true, + }); + } + + assessments.forEach((asn) => { + const colId = buildAssessmentColumnId(asn.id); + cols.push({ + id: colId, + title: asn.title, + // null (ungraded) → undefined so sortUndefined: 'last' fires for both missing and ungraded rows + accessorFn: (row) => row.grades[asn.id] ?? undefined, + sortable: true, + sortProps: { + undefinedPriority: 'last', + descFirst: false, + sort: (a, b) => { + const aGrade = a.grades[asn.id]; + const bGrade = b.grades[asn.id]; + if (aGrade == null || bGrade == null) return 0; + return aGrade - bGrade; + }, + }, + cell: (row) => { + if (asn.external) { + return ( + + ); + } + const grade = row.grades[asn.id]; + if (grade === undefined) return '—'; + if (grade === null) return ''; + const submissionId = row.submissionIds[asn.id]; + if (submissionId != null) + return ( + + {grade} + + ); + return grade; + }, + csvDownloadable: true, + defaultVisible: asn.external ?? false, + }); + }); + return cols; + }, [assessments, gamificationEnabled, hasExternalIds, t]); + + const assessmentMaxGrades = useMemo( + () => new Map(assessments.map((a) => [a.id, a.maxGrade])), + [assessments], + ); + + const dataColumnIds = useMemo( + () => [ + ...assessments.map((a) => buildAssessmentColumnId(a.id)), + ...GAMIFICATION_COL_IDS, + ], + [assessments], + ); + + const columnPicker = useMemo( + () => ({ + render: (context: ColumnPickerRenderContext) => ( + + ), + locked: ['name'], + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + getExtraHeaderRows: (colIds): string[][] => { + const hasAssessments = colIds.some( + (id) => parseAssessmentColumnId(id) !== null, + ); + if (!hasAssessments) return []; + return [ + colIds.map((id) => { + if (id === 'name') return t(translations.maxMarks); + const asnId = parseAssessmentColumnId(id); + if (asnId !== null) + return String(assessmentMaxGrades.get(asnId) ?? ''); + return ''; + }), + ]; + }, + storageKey: `gradebook_columns_${courseId}`, + dataColumnIds, + noDataColumnsHint: gamificationEnabled + ? t(translations.noDataColumnsHintWithGamification) + : t(translations.noDataColumnsHint), + }), + [ + assessments, + categories, + gamificationEnabled, + tabs, + t, + assessmentMaxGrades, + courseId, + dataColumnIds, + ], + ); + + const { toolbar, body, pagination, header } = + useTanStackTableBuilder({ + data: rows, + columns, + getRowId: (row) => row.studentId.toString(), + getRowEqualityData: (row) => row, + indexing: { rowSelectable: true }, + pagination: { + initialPageSize: DEFAULT_TABLE_ROWS_PER_PAGE, + rowsPerPage: [ + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + 25, + 50, + DEFAULT_TABLE_ROWS_PER_PAGE, + ], + showAllRows: true, + }, + search: { searchPlaceholder: t(translations.searchStudents) }, + sort: { + initially: { by: 'name', order: 'asc' }, + enableRemoval: false, + resetOnHide: true, + }, + toolbar: { show: true, keepNative: true }, + csvDownload: { + filename: `${courseTitle}_gradebook`, + showDownloadButton: false, + }, + columnPicker, + }); + + const visibility = toolbar?.getColumnVisibility?.() ?? {}; + const isColVisible = (id: string): boolean => visibility[id] ?? true; + const visibleCols = columns.filter((c) => + isColVisible(c.id ?? (c.of as string)), + ); + + const sortByColId = new Map( + (header?.headers ?? []).map( + (h, i) => [h.id, header?.forEach(h, i).sorting] as const, + ), + ); + + const selectedCount = body.selectedCount ?? 0; + + const directExportLabel = useMemo((): string => { + const isPartialSelection = selectedCount > 0 && selectedCount < rows.length; + if (isPartialSelection) + return t(translations.exportRows, { count: selectedCount }); + return t(translations.exportButton); + }, [selectedCount, rows.length, t]); + + const toolbarWithLabel = toolbar + ? { + ...toolbar, + buttons: toolbarAction + ? [ + ...(toolbar.buttons ?? []), + cloneElement(toolbarAction, { key: 'toolbar-action' }), + ] + : toolbar.buttons, + ...(toolbar.columnPicker && { + columnPicker: { + ...toolbar.columnPicker, + directExportLabel, + directExportTooltip: + selectedCount === 0 ? t(translations.exportAllTooltip) : undefined, + }, + }), + } + : toolbar; + + const totalWidth = useMemo( + () => + CHECKBOX_WIDTH + + visibleCols.reduce((sum, c) => { + const id = c.id ?? (c.of as string); + return sum + getColWidth(id); + }, 0), + [visibleCols], + ); + + const allRowsSelected = body.allFilteredSelected ?? false; + const someRowsSelected = body.someFilteredSelected ?? false; + const toggleAllRows = (): void => body.toggleAllFiltered?.(); + + const hasVisibleAssessments = useMemo( + () => + visibleCols.some( + (c) => parseAssessmentColumnId(c.id ?? (c.of as string)) !== null, + ), + [visibleCols], + ); + + const row1Ref = useRef(null); + const [row2Top, setRow2Top] = useState(0); + useLayoutEffect(() => { + setRow2Top(row1Ref.current?.offsetHeight ?? 0); + }, [visibleCols]); + + const headerFitsRef = useRef>({}); + const [headerFits, setHeaderFits] = useState>({}); + const onSingleLine = useCallback((id: string, fits: boolean): void => { + if (headerFitsRef.current[id] !== fits) { + headerFitsRef.current[id] = fits; + setHeaderFits((prev) => ({ ...prev, [id]: fits })); + } + }, []); + const singleLineCallbacks = useMemo( + () => + new Map( + visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return [id, (f: boolean): void => onSingleLine(id, f)]; + }), + ), + [visibleCols, onSingleLine], + ); + + return ( +
+ +
+ + {/* A bounded maxHeight is what makes `stickyHeader` actually stick: + `overflowX: 'auto'` already promotes this container to a scroll + container on both axes, so the sticky and the frozen + checkbox/Name columns pin relative to THIS element. Without a height + cap the container grows to fit every row and never scrolls + internally, leaving the header no scroll range. */} + + ({ + tableLayout: 'fixed', + borderCollapse: 'separate', + borderSpacing: 0, + + '& th, & td': { + boxSizing: 'border-box', + border: 0, + + // Draws the cell grid without relying on collapsed borders. + borderBottom: `0.5px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return ; + })} + + + + ({ + top: 0, + zIndex: 3, + position: 'sticky', + left: 0, + bgcolor: 'background.default', + width: CHECKBOX_WIDTH, + minWidth: CHECKBOX_WIDTH, + maxWidth: CHECKBOX_WIDTH, + px: 0, + textAlign: 'center', + verticalAlign: 'middle', + // Solid 1px bottom seam under the frozen header columns. The + // table's 0.5px grid border (specificity 0,1,1) outranks a + // plain per-cell sx (0,1,0), so double the selector with `&&` + // (0,2,0) to win — and a full 1px survives sticky scroll + // compositing where 0.5px can drop and let the body show + // through the row1/row2 seam. + '&&': { + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const label = typeof c.title === 'string' ? c.title : id; + const isLeft = isLeftAligned(id); + const fits = headerFits[id] ?? false; + const sort = sortByColId.get(id); + const asnId = parseAssessmentColumnId(id); + const isExternalCol = + asnId !== null && + assessments.find((a) => a.id === asnId)?.external === + true; + const labelNode = ( + + + + + + ); + const sortedLabel = sort ? ( + + {labelNode} + + ) : ( + labelNode + ); + return ( + ({ + verticalAlign: isLeft || fits ? 'middle' : 'bottom', + ...(id === 'name' && { + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 3, + bgcolor: 'background.default', + // Right edge of the frozen region + matching 1px + // bottom seam. `&&` (0,2,0) is needed to beat the + // table's `& th` border rule (0,1,1). + '&&': { + borderRight: `1px solid ${theme.palette.grey[200]}`, + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + }), + })} + > + {isExternalCol ? ( + + {sortedLabel} + + + ) : ( + sortedLabel + )} + + ); + })} + + {hasVisibleAssessments && ( + ({ + '& .MuiTableCell-stickyHeader': { + top: row2Top, + }, + // Solid 1px bottom edge under the whole Max Marks row so the + // frozen columns read as a complete header block and the + // body never shows through on scroll. `& .MuiTableCell-root` + // (0,2,0) outranks the table's `& th` rule (0,1,1). + '& .MuiTableCell-root': { + borderTop: `1px solid ${theme.palette.grey[200]}`, + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + })} + > + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const asnId = parseAssessmentColumnId(id); + const asn = + asnId !== null + ? assessments.find((a) => a.id === asnId) + : undefined; + let cellNode: React.ReactNode = ''; + if (id === 'name') cellNode = t(translations.maxMarks); + else if (asn?.external) { + cellNode = ( + + ); + } else if (asnId !== null) { + const maxGrade = assessmentMaxGrades.get(asnId); + cellNode = maxGrade != null ? `/${maxGrade}` : ''; + } + return ( + ({ + bgcolor: 'grey.100', + ...(id === 'name' && { + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 3, + // Continue the frozen region's right edge. + '&&': { + borderTop: `1px solid ${theme.palette.grey[200]}`, + borderRight: `1px solid ${theme.palette.grey[200]}`, + }, + }), + })} + > + {cellNode} + + ); + })} + + )} + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + return ( + + ({ + position: 'sticky', + left: 0, + zIndex: 1, + bgcolor: 'background.paper', + width: CHECKBOX_WIDTH, + minWidth: CHECKBOX_WIDTH, + maxWidth: CHECKBOX_WIDTH, + px: 0, + textAlign: 'center', + // Sticky cells composite on their own layer, so this + // cell's `borderBottom` gets covered by the next row's + // opaque sticky background (Blink) — dropping the + // separator. Draw it as the lower row's `borderTop` + // instead; that border is owned by the row's own + // layer and always paints. Row 0's top edge is already + // the header cell's (higher z-index) bottom border. + borderBottom: 'none', + borderTop: + idx === 0 + ? undefined + : `0.5px solid ${theme.palette.grey[200]}`, + })} + > + + + {row + .getVisibleCells() + .filter((cell) => cell.column.id !== 'rowSelector') + .map((cell) => { + // Sticky cover for the frozen `name` column, mirroring + // the checkbox cell above. Declared as a directly-typed + // const so the callback is contextually typed (a ternary + // in the `sx` prop would strip that context). + const nameCellSx: SxProps = (theme) => ({ + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 1, + bgcolor: 'background.paper', + // Same sticky-layer cover as the checkbox column: draw + // the separator as the lower row's `borderTop`, not a + // covered `borderBottom`. + borderBottom: 'none', + borderTop: + idx === 0 + ? undefined + : `0.5px solid ${theme.palette.grey[200]}`, + // Continue the frozen region's right edge down the data + // rows. `&&` (0,2,0) beats the table's `& td` border + // rule (0,1,1). + '&&': { + borderRight: `1px solid ${theme.palette.grey[200]}`, + }, + }); + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
+
+ {pagination && } +
+
+
+ ); +}; + +export default GradebookTable; diff --git a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx new file mode 100644 index 00000000000..73074702475 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx @@ -0,0 +1,1143 @@ +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, + effectiveGrade, + resolveTabWeights, + usingDefaultWeights, +} from '../computeWeighted'; + +import ConfigureWeightsPrompt from './ConfigureWeightsPrompt'; +import ProjectedTotalHint, { + projectedTotalPolicyTranslations, +} from './ProjectedTotalHint'; +import WeightedGradebookColumnTree from './WeightedGradebookColumnTree'; + +const translations = defineMessages({ + configureWeights: { + id: 'course.gradebook.GradebookWeightedTable.configureWeights', + defaultMessage: 'Configure Weights', + }, + noWeightsConfigured: { + id: 'course.gradebook.GradebookWeightedTable.noWeightsConfigured', + defaultMessage: + 'No weights configured — all tab weights are 0. Click "Configure Weights" to assign weights.', + }, + noWeightsNoAccess: { + id: 'course.gradebook.GradebookWeightedTable.noWeightsNoAccess', + defaultMessage: 'No tab weights have been configured yet.', + }, + defaultWeights: { + id: 'course.gradebook.GradebookWeightedTable.defaultWeights', + defaultMessage: + 'Showing default weights — every tab counts equally. Click "Configure Weights" to set your own.', + }, + defaultWeightsNoAccess: { + id: 'course.gradebook.GradebookWeightedTable.defaultWeightsNoAccess', + defaultMessage: + 'Showing default weights — every tab counts equally until weights are configured.', + }, + percentOfGrade: { + id: 'course.gradebook.GradebookWeightedTable.percentOfGrade', + defaultMessage: '{weight}% of grade', + }, + percentTotalExact: { + id: 'course.gradebook.GradebookWeightedTable.percentTotalExact', + defaultMessage: '100% total', + }, + percentTotalWarning: { + id: 'course.gradebook.GradebookWeightedTable.percentTotalWarning', + defaultMessage: '{weight}% total', + }, + outOfWeight: { + id: 'course.gradebook.GradebookWeightedTable.outOfWeight', + defaultMessage: '/{weight}', + }, + displayMode: { + id: 'course.gradebook.GradebookWeightedTable.displayMode', + defaultMessage: 'Display mode', + }, + displayPoints: { + id: 'course.gradebook.GradebookWeightedTable.displayPoints', + defaultMessage: 'Points', + }, + displayPointsTooltip: { + id: 'course.gradebook.GradebookWeightedTable.displayPointsTooltip', + defaultMessage: + 'How many grade points each tab contributes. Columns add up to the projected total.', + }, + displayPercent: { + id: 'course.gradebook.GradebookWeightedTable.displayPercent', + defaultMessage: 'Percentage', + }, + displayPercentTooltip: { + id: 'course.gradebook.GradebookWeightedTable.displayPercentTooltip', + defaultMessage: + 'What fraction of each tab the student earned. 100% on a tab worth 20% = the student earned all 20 grade points from that tab.', + }, + weightsDoNotSum: { + id: 'course.gradebook.GradebookWeightedTable.weightsDoNotSum', + defaultMessage: 'Weights do not sum to 100. Total may be inaccurate.', + }, + searchStudents: { + id: 'course.gradebook.GradebookWeightedTable.searchStudents', + defaultMessage: 'Search students', + }, + downloadCsv: { + id: 'course.gradebook.GradebookWeightedTable.downloadCsv', + defaultMessage: 'Download as CSV', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + exportButton: { + id: 'course.gradebook.GradebookIndex.exportButton', + defaultMessage: 'Export all rows', + }, + exportRows: { + id: 'course.gradebook.GradebookIndex.exportRows', + defaultMessage: 'Export {count, plural, one {# row} other {# rows}}', + }, + exportAllTooltip: { + id: 'course.gradebook.GradebookIndex.exportAllTooltip', + defaultMessage: 'No rows selected - all rows will be exported.', + }, + expandRow: { + id: 'course.gradebook.GradebookWeightedTable.expandRow', + defaultMessage: 'Expand {name}', + }, + collapseRow: { + id: 'course.gradebook.GradebookWeightedTable.collapseRow', + defaultMessage: 'Collapse {name}', + }, + excluded: { + id: 'course.gradebook.GradebookWeightedTable.excluded', + defaultMessage: 'Excluded', + }, + total: { + id: 'course.gradebook.GradebookWeightedTable.total', + defaultMessage: 'Total', + }, + gradeCapped: { + id: 'course.gradebook.GradebookWeightedTable.gradeCapped', + defaultMessage: 'Capped at {value}', + }, + gradeFloored: { + id: 'course.gradebook.GradebookWeightedTable.gradeFloored', + defaultMessage: 'Floored to {value}', + }, + gradeCountsAs: { + id: 'course.gradebook.GradebookWeightedTable.gradeCountsAs', + defaultMessage: 'Counts as {value}', + }, +}); + +type DisplayMode = 'points' | 'percent'; + +interface Props { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + canManageWeights: boolean; + courseTitle: string; + courseId: number; +} + +// How many decimal places a single value needs (0, 1, or 2). +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; +}; + +// Maximum precision needed across a column's values. +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 GradebookWeightedTable = ({ + 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'); + + // When no weights are configured, fall back to an equal split across non-empty + // tabs so the weighted view is meaningful out of the box. Every weight-dependent + // calculation and header below reads `resolvedTabs`; the prop `tabs` is passed + // verbatim to the configure dialog, which derives the same default itself. + const resolvedTabs = useMemo( + () => resolveTabWeights(tabs, assessments), + [tabs, assessments], + ); + const showingDefaults = useMemo( + () => usingDefaultWeights(tabs, assessments), + [tabs, assessments], + ); + + // Mode-aware display value for a tab cell. + const tabDisplayValue = ( + sub: number | null, + weight: number, + ): number | null => { + if (sub === null) return null; + return displayMode === 'percent' ? sub * 100 : sub * weight; + }; + + // A tab whose every assessment is excluded contributes nothing: its subtotal is + // null and computeWeighted already drops it from the row total. Its stored weight + // is therefore treated as 0 here too — for the row-3 subheader and the projected + // total's weight (so percent-mode normalization divides by live weight only). + 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; + + // Mode-aware display value for the total cell. + 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; + }; + + // Mode-aware display value for a single assessment in the expanded breakdown: + // its points contribution in points mode, its own grade percentage in percent + // mode (null grade → null so fmtDisplay renders "—"). + 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; + }); + + const assessmentsById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + + 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); + + // Row-3 subheader for a tab: "Excluded" when the tab contributes nothing, + // else the weight in the active lens ("/{w}" points / "{w}% of grade"). + 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], + ); + + useLayoutEffect(() => { + const row1 = row1Ref.current; + const row2 = row2Ref.current; + if (!row1 || !row2) return undefined; + + // Re-measure on every header-row resize, not just on mount. Expanding or + // collapsing a row, switching display mode and showing/hiding columns all + // reflow the header after mount; with a one-shot measurement rows 2–3 keep + // a stale `top` and stay permanently dislodged from the rows above them. + const measure = (): void => { + const h1 = row1.offsetHeight; + setRow2Top(h1); + setRow3Top(h1 + row2.offsetHeight); + }; + + 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; + + 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 ( +
+ {/* One-time policy banner — only meaningful once real projected totals + are on screen from a deliberate configuration. Suppressed while the + equal-split default is in effect (the default-weights banner speaks + instead) and in the degenerate all-zero/empty case. */} + {!allWeightsZero && !showingDefaults && } + {/* Table + toolbar share a fit-content container so toolbar never outruns the table */} +
+ + {/* Single-row toolbar */} +
+ +
+ + {canManageWeights && ( + + )} + + {toolbar.onDirectExport && ( + + + + + + )} +
+
+ + {/* Default-weights banner: weights are unconfigured, so the table is + showing the equal-split fallback. Keeps the Configure CTA for managers. */} + {showingDefaults && ( + + {canManageWeights + ? t(translations.defaultWeights) + : t(translations.defaultWeightsNoAccess)} + + )} + {/* Degenerate case: weights are 0 and there is nothing to default + (no tab has any assessment). */} + {allWeightsZero && !showingDefaults && ( + + {canManageWeights + ? t(translations.noWeightsConfigured) + : t(translations.noWeightsNoAccess)} + + )} + {/* A bounded maxHeight is what makes `stickyHeader` actually stick: + `overflowX: 'auto'` already promotes this container to a scroll + container on BOTH axes (CSS computes overflow-y to `auto` when + overflow-x isn't `visible`), so the sticky sticks relative + to THIS element, not the page. Without a height cap the container + grows to fit all rows and never scrolls internally, leaving the + header no scroll range. Capping the height makes the body scroll + within the container while the header (and the frozen Name/checkbox + columns, same scroll context) stay pinned. The cap subtracts the + chrome above the body — breadcrumb, page header, view tabs, + toolbar — plus the pagination below, so the table fills the + remaining viewport; shorter classes shrink to fit (no whitespace). */} + + { + // One definition for every grid line so horizontal and vertical + // separators share the same width and colour. + const gridLine = `1px solid ${theme.palette.divider}`; + return { + tableLayout: 'auto', + borderCollapse: 'separate', + borderSpacing: 0, + // Outer top + left edges. Interior lines come from each cell's + // own bottom + right, so every separator stays a single 1px + // (no doubling) and the whole table reads as one uniform grid. + borderTop: gridLine, + borderLeft: gridLine, + '& th, & td': { + boxSizing: 'border-box', + border: 0, + borderBottom: gridLine, + borderRight: gridLine, + py: 0.25, + px: 1, + lineHeight: 1.2, + height: 32, + }, + // MUI's default `.MuiTableRow-root:last-child th { border: 0 }` + // (specificity 0,2,1, the `:last-child` pseudo-class) outranks + // the `& th` rule above (0,1,1) and silently zeroes ALL borders + // on the weight row — it is the last in . Re-assert + // the grid lines with a higher-specificity selector so they + // survive through the "% of grade" row. + '& thead tr:last-of-type th': { + borderTop: gridLine, + borderBottom: gridLine, + borderRight: gridLine, + }, + }; + }} + > + + {/* Row 1: Checkbox + Student + Categories + Total */} + + + + + + {t(tableTranslations.name)} + + {showEmail && ( + + {t(tableTranslations.email)} + + )} + {showExternalId && ( + + {t(tableTranslations.externalId)} + + )} + {visibleCategories.map((cat) => ( + + {cat.title} + + ))} + + + {t(translations.total)} + {/* The policy moved out of the header label into this ⓘ — + the descriptive sentence stays available on demand after + the one-time banner is dismissed. */} + + + + + + + + + + {/* Row 2: Tab titles */} + + {resolvedTabs.map((tab) => ( + + {tab.title} + + ))} + + + {/* Row 3: Weight subheaders */} + + {resolvedTabs.map((tab) => ( + + {tabSubheaderLabel(tab)} + + ))} + + {totalWeight === 100 ? ( + totalWeightHeaderLabel + ) : ( + + + {displayMode === 'percent' + ? t(translations.percentTotalWarning, { + weight: totalWeight, + }) + : t(translations.outOfWeight, { + weight: totalWeight, + })} + + + )} + + + + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + const studentId = row.original.studentId; + const isExpanded = expandedIds.has(studentId); + return ( + + + {/* Body sticky-left cells sit at zIndex 1 — strictly + below the header's sticky cells (MUI gives every + stickyHeader cell zIndex 2). On a z-index tie the cell + later in the DOM (the body) wins, so matching z2 here + lets scrolled rows bleed up over the header in any + column the frozen Name cell (z4) doesn't cover — i.e. + the identity columns once they're toggled on. */} + + + + + toggleExpanded(studentId)} + size="small" + sx={{ + mr: 0.5, + p: 0.25, + }} + > + {isExpanded ? ( + + ) : ( + + )} + + {row.original.name} + + {showEmail && ( + {row.original.email} + )} + {showExternalId && ( + {row.original.externalId ?? ''} + )} + {row.original.subtotals.map((subtotal, i) => { + const weight = resolvedTabs[i].gradebookWeight ?? 0; + return ( + + {fmtDisplay( + tabDisplayValue(subtotal, weight), + columnPrecisions.tabs[i], + )} + + ); + })} + + {fmtDisplay( + totalDisplayValue(row.original.total), + columnPrecisions.total, + )} + + + {isExpanded && + (breakdownsByStudent.get(studentId) ?? []).flatMap( + (tb, tabIdx) => + tb.assessments.map((a) => { + const isExcluded = a.excluded; + // Weightage is always "% of grade" — it never + // follows the points/percent lens. + const weightText = t( + translations.percentOfGrade, + { + weight: + Math.round(a.effectiveWeight * 100) / 100, + }, + ); + const gradeText = + a.grade === null + ? `—/${a.maxGrade}` + : `${a.grade}/${a.maxGrade}`; + const assessmentData = assessmentsById.get( + a.assessmentId, + ); + const eff = + a.grade != null && assessmentData != null + ? effectiveGrade(a.grade, assessmentData) + : null; + const wasCapped = + eff != null && + a.grade != null && + a.grade > a.maxGrade && + eff !== a.grade; + const wasFloored = + eff != null && + a.grade != null && + a.grade < 0 && + eff !== a.grade; + const clamped = wasCapped || wasFloored; + return ( + + {/* Empty checkbox cell so the breakdown row + carries the same checkbox | name divider (the + universal cell borderRight) as the rows above. */} + + {/* Title over a muted "raw mark · weightage" + subtitle, stacked and confined to the (sticky) + Name column. The breakdown row freezes the same + checkbox | Name region as the student rows above — + the identity columns get their own empty cells + after this — so the layout reads identically + whether identity columns are shown or not. A left + indent sits the title under the student name (past + the expand chevron), signalling these are that + student's assessments. */} + + {/* nowrap keeps the title on one line: its + max-content width then drives the table's auto + layout, expanding the (frozen) Name column to fit + the longest title. With the metadata line also + nowrap, every breakdown row is exactly 2 lines — + no fixed widths, no JS measurement. */} + + {a.title} + + {/* Muted metadata on its own line below the + title: raw mark · effective weightage, kept on + one line (nowrap). Weightage is always "% of + grade" — never routed through the points/percent + lens. */} + + {`${gradeText} · ${isExcluded ? t(translations.excluded) : weightText}`} + {clamped && ( + + + + )} + + + {/* One empty cell per visible identity column so + the grid lines stay aligned with the rows above. + These scroll with the table (only checkbox + Name + are frozen), matching the student rows. */} + {showEmail && } + {showExternalId && } + {resolvedTabs.map((tab, i) => { + const tabCellValue = isExcluded + ? '—' + : fmtDisplay( + breakdownDisplayValue(a), + columnPrecisions.tabs[i], + ); + return ( + + {i === tabIdx ? tabCellValue : ''} + + ); + })} + + + ); + }), + )} + + ); + })} + +
+
+ {pagination && } +
+
+ + {canManageWeights && ( + setConfigureOpen(false)} + open={configureOpen} + tabs={tabs} + /> + )} + + {toolbar.columnPicker && toolbar.commitColumnVisibility && ( + setPickerOpen(false)} + open={pickerOpen} + /> + )} +
+ ); +}; + +export default GradebookWeightedTable; diff --git a/client/app/bundles/course/gradebook/components/ProjectedTotalHint.tsx b/client/app/bundles/course/gradebook/components/ProjectedTotalHint.tsx new file mode 100644 index 00000000000..3ff0f0a4d2f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/ProjectedTotalHint.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const PROJECTED_TOTAL_POLICY_HINT_KEY = + 'gradebook_projected_total_policy_hint'; + +// Single source for the projected-total policy sentence: the one-time banner +// below and the ⓘ tooltip on the Total header (in GradebookWeightedTable) both +// render this exact message, so the explanation never drifts between the two. +export const projectedTotalPolicyTranslations = defineMessages({ + policy: { + id: 'course.gradebook.ProjectedTotalHint.policy', + defaultMessage: 'Totals count ungraded assessments as 0.', + }, +}); + +/** + * One-time, dismissable banner shown the first time a manager opens the weighted + * (Weighted total) gradebook view. It teaches the projected-total policy — ungraded + * assessments are counted as 0 — so a low total isn't mistaken for a bug. After + * dismissal the policy stays available via the ⓘ tooltip on the Total header. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const ProjectedTotalHint: FC = () => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + PROJECTED_TOTAL_POLICY_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
+ + + {t(projectedTotalPolicyTranslations.policy)} + + +
+ ); +}; + +export default ProjectedTotalHint; diff --git a/client/app/bundles/course/gradebook/components/WeightedGradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/WeightedGradebookColumnTree.tsx new file mode 100644 index 00000000000..fb09b295390 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/WeightedGradebookColumnTree.tsx @@ -0,0 +1,79 @@ +import { defineMessages } from 'react-intl'; +import { Chip } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import { + ColumnPickerRenderContext, + ColumnPickerTreeGroup, +} from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; + +import { STUDENT_INFO_COL_IDS, type StudentInfoColId } from '../constants'; + +const translations = defineMessages({ + studentInfo: { + id: 'course.gradebook.GradebookColumnTree.studentInfo', + defaultMessage: 'Student info', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +const WeightedGradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, +}: ColumnPickerRenderContext): JSX.Element => { + const { t } = useTranslation(); + const context: ColumnPickerRenderContext = { + isVisible, + setVisible, + setManyVisible, + }; + + return ( +
+ + {STUDENT_INFO_COL_IDS.map((id: StudentInfoColId) => + id === 'name' ? ( + + {t(tableTranslations[id])} + + + } + /> + ) : ( + setVisible(id, e.target.checked)} + /> + ), + )} + +
+ ); +}; + +export default WeightedGradebookColumnTree; diff --git a/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx b/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx new file mode 100644 index 00000000000..aaa54991392 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/WeightedViewHint.tsx @@ -0,0 +1,65 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import Link from 'lib/components/core/Link'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const WEIGHTED_VIEW_HINT_KEY = 'gradebook_weighted_view_hint'; + +const translations = defineMessages({ + hint: { + id: 'course.gradebook.WeightedViewHint.hint', + defaultMessage: + 'Want a weighted total grade? You can set how much each tab counts toward each student’s overall grade and view the weighted total here. Turn it on in {link}.', + }, + settingsLink: { + id: 'course.gradebook.WeightedViewHint.settingsLink', + defaultMessage: 'Gradebook settings', + }, +}); + +interface Props { + courseId: number; +} + +/** + * One-time, dismissable nudge shown to managers in the (always-visible) gradebook view + * when the weighted view is turned off. It advertises the capability and links to the + * setting that enables it, since that setting is otherwise buried in course admin. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const WeightedViewHint: FC = ({ courseId }) => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + WEIGHTED_VIEW_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
+ + + {t(translations.hint, { + link: ( + + {t(translations.settingsLink)} + + ), + })} + + +
+ ); +}; + +export default WeightedViewHint; diff --git a/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts new file mode 100644 index 00000000000..13f84cf48a9 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts @@ -0,0 +1,7 @@ +export const buildAssessmentColumnId = (asnId: number): string => + `asn-${asnId}`; + +export const parseAssessmentColumnId = (colId: string): number | null => { + const match = colId.match(/^asn-(-?\d+)$/); + return match ? Number(match[1]) : null; +}; diff --git a/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx new file mode 100644 index 00000000000..209f8be72b6 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx @@ -0,0 +1,88 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, +} from '@mui/material'; +import type { ImportConflict } from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import ExternalGradeConflictTable from './ExternalGradeConflictTable'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ExternalGradeConflictPrompt.title', + defaultMessage: 'Resolve grade conflicts', + }, + body: { + id: 'course.gradebook.ExternalGradeConflictPrompt.body', + defaultMessage: + 'These students already have a grade for these components. Keep their existing grades, or replace them with the values from your file? New students and blank cells are unaffected.', + }, + goBack: { + id: 'course.gradebook.ExternalGradeConflictPrompt.goBack', + defaultMessage: 'Go Back', + }, + keepExisting: { + id: 'course.gradebook.ExternalGradeConflictPrompt.keepExisting', + defaultMessage: 'Keep Existing', + }, + replace: { + id: 'course.gradebook.ExternalGradeConflictPrompt.replace', + defaultMessage: 'Replace', + }, +}); + +interface Props { + open: boolean; + conflicts: ImportConflict[]; + disabled?: boolean; + onKeepExisting: () => void; + onReplaceAll: () => void; + onCancel: () => void; +} + +const ExternalGradeConflictPrompt: FC = ({ + open, + conflicts, + disabled = false, + onKeepExisting, + onReplaceAll, + onCancel, +}) => { + const { t } = useTranslation(); + return ( + + {t(translations.title)} + + {t(translations.body)} + + + + + + + + + + + ); +}; + +export default ExternalGradeConflictPrompt; diff --git a/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx new file mode 100644 index 00000000000..19972bef837 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx @@ -0,0 +1,86 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { WarningAmber } from '@mui/icons-material'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import type { ImportConflict } from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + component: { + id: 'course.gradebook.ExternalGradeConflictTable.component', + defaultMessage: 'Component', + }, + student: { + id: 'course.gradebook.ExternalGradeConflictTable.student', + defaultMessage: 'Student', + }, + existing: { + id: 'course.gradebook.ExternalGradeConflictTable.existing', + defaultMessage: 'Existing grade', + }, + inFile: { + id: 'course.gradebook.ExternalGradeConflictTable.inFile', + defaultMessage: 'In-file grade', + }, + mismatch: { + id: 'course.gradebook.ExternalGradeConflictTable.mismatch', + defaultMessage: + 'This identifier now resolves to a different student than the existing grade was imported under.', + }, +}); + +interface Props { + rows: ImportConflict[]; +} + +const ExternalGradeConflictTable: FC = ({ rows }) => { + const { t } = useTranslation(); + return ( + + + + {t(translations.component)} + {t(translations.student)} + {t(translations.existing)} + {t(translations.inFile)} + + + + {rows.map((row) => ( + + {row.component} + + {row.studentName} + {row.identifierMismatch && ( + + + + )} + + {row.existingGrade} + + + {row.inFileGrade} + + + + ))} + +
+ ); +}; + +export default ExternalGradeConflictTable; diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx new file mode 100644 index 00000000000..cd5fd46f885 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx @@ -0,0 +1,575 @@ +import { FC, useEffect, useMemo, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import Dropzone from 'react-dropzone'; +import { Add, Delete } from '@mui/icons-material'; +import { + Alert, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Link as MuiLink, + Step, + StepLabel, + Stepper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import { FilePreview } from 'lib/components/form/fields/SingleFileInput'; +import SegmentedSwitch from 'lib/components/core/buttons/SegmentedSwitch'; +import type { + ExistingExternalAssessment, + IdentifierMode, + ImportComponent, + ImportPreviewResult, +} from 'types/course/gradebook'; + +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { getStudents } from '../../selectors'; + +import { commitImport, previewImport } from '../../operations'; + +import { downloadTemplate, identifierHeader, readFileText } from './buildTemplate'; +import ExternalGradeConflictPrompt from './ExternalGradeConflictPrompt'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ImportWizard.title', + defaultMessage: 'Import external assessments', + }, + stepDefine: { + id: 'course.gradebook.ImportWizard.stepDefine', + defaultMessage: 'Define components', + }, + stepUpload: { + id: 'course.gradebook.ImportWizard.stepUpload', + defaultMessage: 'Template & upload', + }, + stepVerify: { + id: 'course.gradebook.ImportWizard.stepVerify', + defaultMessage: 'Verify', + }, + componentName: { + id: 'course.gradebook.ImportWizard.componentName', + defaultMessage: 'Component name', + }, + weightage: { + id: 'course.gradebook.ImportWizard.weightage', + defaultMessage: 'Weightage', + }, + maxMarks: { + id: 'course.gradebook.ImportWizard.maxMarks', + defaultMessage: 'Max marks', + }, + addComponent: { + id: 'course.gradebook.ImportWizard.addComponent', + defaultMessage: 'Add component', + }, + updatesExisting: { + id: 'course.gradebook.ImportWizard.updatesExisting', + defaultMessage: 'Updates existing — managed in the gradebook', + }, + fromExisting: { + id: 'course.gradebook.ImportWizard.fromExisting', + defaultMessage: 'From existing', + }, + identifierMode: { + id: 'course.gradebook.ImportWizard.identifierMode', + defaultMessage: 'Match students by', + }, + externalId: { + id: 'course.gradebook.ImportWizard.externalId', + defaultMessage: 'External ID', + }, + email: { id: 'course.gradebook.ImportWizard.email', defaultMessage: 'Email' }, + requiredHeaders: { + id: 'course.gradebook.ImportWizard.requiredHeaders', + defaultMessage: 'Your CSV needs these column headers: {headers}', + }, + dropzone: { + id: 'course.gradebook.ImportWizard.dropzone', + defaultMessage: 'Drag a CSV here, or click to choose a file', + }, + downloadTemplate: { + id: 'course.gradebook.ImportWizard.downloadTemplate', + defaultMessage: 'Download template', + }, + upload: { + id: 'course.gradebook.ImportWizard.upload', + defaultMessage: 'Upload filled CSV', + }, + back: { id: 'course.gradebook.ImportWizard.back', defaultMessage: 'Back' }, + next: { id: 'course.gradebook.ImportWizard.next', defaultMessage: 'Next' }, + verify: { + id: 'course.gradebook.ImportWizard.verify', + defaultMessage: 'Verify', + }, + cancel: { + id: 'course.gradebook.ImportWizard.cancel', + defaultMessage: 'Cancel', + }, + continue: { + id: 'course.gradebook.ImportWizard.continue', + defaultMessage: 'Confirm import', + }, + unresolved: { + id: 'course.gradebook.ImportWizard.unresolved', + defaultMessage: 'These identifiers were not found in the course: {ids}', + }, + malformed: { + id: 'course.gradebook.ImportWizard.malformed', + defaultMessage: 'These cells are not valid numbers: {cells}', + }, + committed: { + id: 'course.gradebook.ImportWizard.committed', + defaultMessage: 'Import complete.', + }, + commitError: { + id: 'course.gradebook.ImportWizard.commitError', + defaultMessage: 'Import failed. Nothing was saved.', + }, + previewError: { + id: 'course.gradebook.ImportWizard.previewError', + defaultMessage: 'Could not verify the file. Please try again.', + }, + externalIdHint: { + id: 'course.gradebook.ImportWizard.externalIdHint', + defaultMessage: + "Matching uses each student's External ID. Keep External IDs up to date in Manage Users.", + }, + externalIdBlocked: { + id: 'course.gradebook.ImportWizard.externalIdBlocked', + defaultMessage: + '{count, plural, one {{name} has no External ID.} other {# students have no External ID, including {name}.}} Importing by External ID needs every student to have one, so this import is blocked until they are all filled in. In Manage Users, sort by the External ID column to group the blank ones together and fill them in, then come back here. Prefer to match by Email instead? Switch the toggle above.', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; + weightedViewEnabled: boolean; + existingAssessments: ExistingExternalAssessment[]; +} + +let rowId = 0; +const blankComponent = (): ImportComponent & { id: number } => { + rowId += 1; + return { id: rowId, name: '', weightage: 0, maximumGrade: 0 }; +}; + +const ImportExternalAssessmentsWizard: FC = ({ + open, + onClose, + weightedViewEnabled, + existingAssessments, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseId: courseIdParam } = useParams(); + const courseId = courseIdParam ?? ''; + const [step, setStep] = useState(0); + const [components, setComponents] = useState< + (ImportComponent & { id: number })[] + >([blankComponent()]); + const [mode, setMode] = useState('student_id'); + const [file, setFile] = useState(null); + const [csvData, setCsvData] = useState(''); + const [preview, setPreview] = useState(null); + const [conflictOpen, setConflictOpen] = useState(false); + const [busy, setBusy] = useState(false); + + const students = useAppSelector(getStudents); + const missingStudents = useMemo( + () => students.filter((s) => s.externalId == null || s.externalId === ''), + [students], + ); + const identifierReady = mode === 'email' || missingStudents.length === 0; + + useEffect(() => { + if (!open) { + setStep(0); + setComponents([blankComponent()]); + setFile(null); + setCsvData(''); + setPreview(null); + setConflictOpen(false); + setBusy(false); + } + }, [open]); + + const existingMap = useMemo( + () => new Map(existingAssessments.map((a) => [a.name, a])), + [existingAssessments], + ); + const isExisting = (name: string): boolean => existingMap.has(name.trim()); + + const addedNames = useMemo( + () => new Set(components.map((c) => c.name.trim())), + [components], + ); + + const availableChips = useMemo( + () => existingAssessments.filter((a) => !addedNames.has(a.name)), + [existingAssessments, addedNames], + ); + + const updateComponent = ( + i: number, + patch: Partial, + ): void => + setComponents((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c))); + + const insertFromExisting = (a: ExistingExternalAssessment): void => { + rowId += 1; + setComponents((cs) => [ + ...cs, + { id: rowId, name: a.name, weightage: a.weightage, maximumGrade: a.maximumGrade }, + ]); + }; + + const defineValid = + components.length > 0 && + components.every((c) => c.name.trim() !== '') && + new Set(components.map((c) => c.name.trim())).size === components.length; + + const runPreview = async (): Promise => { + setBusy(true); + try { + const result = await dispatch( + previewImport({ + components: components.map(({ id: _, ...rest }) => rest), + identifierMode: mode, + csvData, + }), + ); + setPreview(result); + setStep(2); + } catch { + toast.error(t(translations.previewError)); + } finally { + setBusy(false); + } + }; + + const doCommit = async (onConflict: 'keep' | 'replace'): Promise => { + setBusy(true); + try { + await dispatch( + commitImport({ + components: components.map(({ id: _, ...rest }) => rest), + identifierMode: mode, + csvData, + onConflict, + }), + ); + toast.success(t(translations.committed)); + setConflictOpen(false); + onClose(); + } catch { + toast.error(t(translations.commitError)); + } finally { + setBusy(false); + } + }; + + const onConfirm = (): void => { + if (preview && preview.conflicts.length > 0) setConflictOpen(true); + else doCommit('replace'); + }; + + return ( + + {t(translations.title)} + + + + {t(translations.stepDefine)} + + + {t(translations.stepUpload)} + + + {t(translations.stepVerify)} + + + + {step === 0 && ( + <> + {availableChips.length > 0 && ( +
+ + {t(translations.fromExisting)} + +
+ {availableChips.map((a) => ( + insertFromExisting(a)} + variant="outlined" + /> + ))} +
+
+ )} + + {components.map((c, i) => { + const locked = isExisting(c.name); + const existing = locked ? existingMap.get(c.name.trim()) : undefined; + return ( +
+ + updateComponent(i, { name: e.target.value }) + } + size="small" + value={c.name} + /> + {weightedViewEnabled && ( + + updateComponent(i, { + weightage: Number(e.target.value), + }) + } + size="small" + type="number" + value={locked && existing ? existing.weightage : c.weightage} + /> + )} + + updateComponent(i, { + maximumGrade: Number(e.target.value), + }) + } + size="small" + type="number" + value={locked && existing ? existing.maximumGrade : c.maximumGrade} + /> + {locked && ( + + {t(translations.updatesExisting)} + + )} + + setComponents((cs) => cs.filter((_, j) => j !== i)) + } + size="small" + > + + +
+ ); + })} + + +
+ + {t(translations.identifierMode)} + + +
+ + {mode === 'student_id' && ( + + {identifierReady + ? t(translations.externalIdHint, { + link: (chunks) => ( + + {chunks} + + ), + }) + : t(translations.externalIdBlocked, { + name: missingStudents[0]?.name ?? '', + count: missingStudents.length, + link: (chunks) => ( + + {chunks} + + ), + })} + + )} + + )} + + {step === 1 && ( +
+ + {t(translations.requiredHeaders, { + headers: [ + identifierHeader(mode), + ...components.map((c) => c.name), + ].join(', '), + })} + + + { + const f = files[0]; + if (f) { + setFile(f); + setCsvData(await readFileText(f)); + } + }} + > + {({ getRootProps, getInputProps }) => ( +
+ + {file ? ( + + ) : ( +
{t(translations.dropzone)}
+ )} +
+ )} +
+
+ )} + + {step === 2 && preview && ( + <> + {!preview.ok && preview.unresolved.length > 0 && ( + + {t(translations.unresolved, { + ids: preview.unresolved.join(', '), + })} + + )} + {!preview.ok && preview.malformed.length > 0 && ( + + {t(translations.malformed, { + cells: preview.malformed.join('; '), + })} + + )} + {preview.ok && ( + + + + {t(translations.componentName)} + {components.map((c) => ( + {c.name} + ))} + + + + {preview.sample.map((row) => ( + + {row.studentName} + {components.map((c) => ( + + {row.grades[c.name] ?? '—'} + + ))} + + ))} + +
+ )} + + )} +
+ + + {step > 0 && ( + + )} + {step === 0 && ( + + )} + {step === 1 && ( + + )} + {step === 2 && preview?.ok && ( + + )} + + + setConflictOpen(false)} + onKeepExisting={() => doCommit('keep')} + onReplaceAll={() => doCommit('replace')} + open={conflictOpen} + /> +
+ ); +}; + +export default ImportExternalAssessmentsWizard; diff --git a/client/app/bundles/course/gradebook/components/import/buildTemplate.ts b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts new file mode 100644 index 00000000000..4754820aa2f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts @@ -0,0 +1,43 @@ +import type { IdentifierMode, ImportComponent } from 'types/course/gradebook'; + +const csvCell = (value: string): string => + /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; + +export const identifierHeader = (mode: IdentifierMode): string => + mode === 'email' ? 'Email' : 'External ID'; + +// Header-only template: per-mode identifier header + one column per component. +export const buildTemplateCsv = ( + components: ImportComponent[], + mode: IdentifierMode, +): string => { + const header = [identifierHeader(mode), ...components.map((c) => c.name)] + .map(csvCell) + .join(','); + return `${header}\n`; +}; + +// Triggers a client-side download of the template. +export const downloadTemplate = ( + components: ImportComponent[], + mode: IdentifierMode, +): void => { + const blob = new Blob([buildTemplateCsv(components, mode)], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'external_assessments_template.csv'; + link.click(); + URL.revokeObjectURL(url); +}; + +// Reads an uploaded File to text (raw CSV; the server parses authoritatively). +export const readFileText = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (): void => resolve(String(reader.result)); + reader.onerror = (): void => reject(reader.error); + reader.readAsText(file); + }); diff --git a/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx b/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx new file mode 100644 index 00000000000..d727f8b7c84 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx @@ -0,0 +1,212 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import InfoOutlined from '@mui/icons-material/InfoOutlined'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + Switch, + TextField, + Tooltip, +} from '@mui/material'; +import type { AssessmentData } from 'types/course/gradebook'; + +import { useAppDispatch } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { editExternalAssessment } from '../../operations'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.EditExternalAssessmentPrompt.title', + defaultMessage: 'Edit external assessment', + }, + nameLabel: { + id: 'course.gradebook.EditExternalAssessmentPrompt.nameLabel', + defaultMessage: 'Name', + }, + maxLabel: { + id: 'course.gradebook.EditExternalAssessmentPrompt.maxLabel', + defaultMessage: 'Max marks', + }, + weightLabel: { + id: 'course.gradebook.EditExternalAssessmentPrompt.weightLabel', + defaultMessage: 'Weightage', + }, + floorLabel: { + id: 'course.gradebook.EditExternalAssessmentPrompt.floorLabel', + defaultMessage: 'Floor grades at 0', + }, + capLabel: { + id: 'course.gradebook.EditExternalAssessmentPrompt.capLabel', + defaultMessage: 'Cap grades at max', + }, + floorHint: { + id: 'course.gradebook.EditExternalAssessmentPrompt.floorHint', + defaultMessage: + 'Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged.', + }, + capHint: { + id: 'course.gradebook.EditExternalAssessmentPrompt.capHint', + defaultMessage: + 'Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged.', + }, + cancel: { + id: 'course.gradebook.EditExternalAssessmentPrompt.cancel', + defaultMessage: 'Cancel', + }, + save: { + id: 'course.gradebook.EditExternalAssessmentPrompt.save', + defaultMessage: 'Save', + }, + error: { + id: 'course.gradebook.EditExternalAssessmentPrompt.error', + defaultMessage: 'Could not save the external assessment.', + }, +}); + +interface Props { + open: boolean; + assessment: AssessmentData; + weightedViewEnabled?: boolean; + currentWeight?: number; + onClose: () => void; +} + +const EditExternalAssessmentPrompt: FC = ({ + open, + assessment, + weightedViewEnabled = false, + currentWeight = 0, + onClose, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const [name, setName] = useState(assessment.title); + const [max, setMax] = useState(String(assessment.maxGrade)); + const [weight, setWeight] = useState(String(currentWeight)); + const [floorAtZero, setFloorAtZero] = useState( + assessment.floorAtZero ?? true, + ); + const [capAtMaximum, setCapAtMaximum] = useState( + assessment.capAtMaximum ?? true, + ); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (open) { + setName(assessment.title); + setMax(String(assessment.maxGrade)); + setFloorAtZero(assessment.floorAtZero ?? true); + setCapAtMaximum(assessment.capAtMaximum ?? true); + setWeight(String(currentWeight)); + } + }, [open, assessment]); + + const canSave = + name.trim() !== '' && max.trim() !== '' && Number(max) >= 0 && !saving; + + const submit = async (): Promise => { + setSaving(true); + try { + await dispatch( + editExternalAssessment(assessment.id, { + title: name.trim(), + maximumGrade: Number(max), + floorAtZero, + capAtMaximum, + ...(weightedViewEnabled ? { weight: Number(weight) } : {}), + }), + ); + onClose(); + } catch { + toast.error(t(translations.error)); + } finally { + setSaving(false); + } + }; + + return ( + + {t(translations.title)} + + setName(e.target.value)} + value={name} + /> + setMax(e.target.value)} + type="number" + value={max} + /> + {weightedViewEnabled && ( + setWeight(e.target.value)} + type="number" + value={weight} + /> + )} +
+ setFloorAtZero(e.target.checked)} + /> + } + label={t(translations.floorLabel)} + /> + + + +
+
+ setCapAtMaximum(e.target.checked)} + /> + } + label={t(translations.capLabel)} + /> + + + +
+
+ + + + +
+ ); +}; + +export default EditExternalAssessmentPrompt; diff --git a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx new file mode 100644 index 00000000000..7391c3802e8 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx @@ -0,0 +1,30 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import ManageExternalAssessmentsPanel from './ManageExternalAssessmentsPanel'; + +const translations = defineMessages({ + manage: { + id: 'course.gradebook.ManageExternalAssessmentsButton.label', + defaultMessage: 'Manage external assessments', + }, +}); + +const ManageExternalAssessmentsButton: FC = () => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + <> + + setOpen(false)} open={open} /> + + ); +}; + +export default ManageExternalAssessmentsButton; diff --git a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx new file mode 100644 index 00000000000..2d043c6223b --- /dev/null +++ b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx @@ -0,0 +1,358 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + DragDropContext, + Draggable, + Droppable, + DropResult, +} from '@hello-pangea/dnd'; +import { Add, Delete, DragIndicator, Edit, Upload } from '@mui/icons-material'; +import { + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + Typography, +} from '@mui/material'; +import type { + AssessmentData, + ExistingExternalAssessment, +} from 'types/course/gradebook'; +import type { AppDispatch } from 'store'; + +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { reorderExternalAssessments } from '../../operations'; +import { + getExternalAssessments, + getTabs, + getWeightedViewEnabled, +} from '../../selectors'; +import AddExternalColumnPrompt from '../AddExternalColumnPrompt'; +import DeleteExternalColumnPrompt from '../DeleteExternalColumnPrompt'; +import ImportExternalAssessmentsWizard from '../import/ImportExternalAssessmentsWizard'; + +import EditExternalAssessmentPrompt from './EditExternalAssessmentPrompt'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ManageExternalPanel.title', + defaultMessage: 'External assessments', + }, + add: { + id: 'course.gradebook.ManageExternalPanel.add', + defaultMessage: 'Add', + }, + import: { + id: 'course.gradebook.ManageExternalPanel.import', + defaultMessage: 'Import CSV', + }, + name: { + id: 'course.gradebook.ManageExternalPanel.name', + defaultMessage: 'Name', + }, + max: { + id: 'course.gradebook.ManageExternalPanel.max', + defaultMessage: 'Max', + }, + weight: { + id: 'course.gradebook.ManageExternalPanel.weight', + defaultMessage: 'Weight', + }, + bounds: { + id: 'course.gradebook.ManageExternalPanel.bounds', + defaultMessage: 'Bounds', + }, + floored: { + id: 'course.gradebook.ManageExternalPanel.floored', + defaultMessage: '≥ 0', + }, + capped: { + id: 'course.gradebook.ManageExternalPanel.capped', + defaultMessage: '≤ max', + }, + actions: { + id: 'course.gradebook.ManageExternalPanel.actions', + defaultMessage: 'Actions', + }, + empty: { + id: 'course.gradebook.ManageExternalPanel.empty', + defaultMessage: 'No external assessments yet', + }, + emptyHint: { + id: 'course.gradebook.ManageExternalPanel.emptyHint', + defaultMessage: + 'Add one manually, or import a CSV of grades earned outside Coursemology.', + }, + close: { + id: 'course.gradebook.ManageExternalPanel.close', + defaultMessage: 'Close', + }, + reorderError: { + id: 'course.gradebook.ManageExternalPanel.reorderError', + defaultMessage: 'Could not save the new order. Please try again.', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; +} + +// Returns a new array with the item at `from` moved to `to`. +export const moveItem = (ids: number[], from: number, to: number): number[] => { + const next = [...ids]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +}; + +// Builds the new external order from a drag result and persists it. +// Exported so the no-op / dispatch / failure branches stay unit-testable +// without driving the drag-and-drop library in jsdom. +export const handleDragEnd = ( + externalIds: number[], + result: DropResult, + dispatch: AppDispatch, + onError: () => void, +): void => { + if (!result.destination || result.destination.index === result.source.index) { + return; + } + const order = moveItem( + externalIds, + result.source.index, + result.destination.index, + ); + dispatch(reorderExternalAssessments(order)).catch(onError); +}; + +const ManageExternalAssessmentsPanel: FC = ({ open, onClose }) => { + const { t } = useTranslation(); + const externals = useAppSelector(getExternalAssessments); + const tabs = useAppSelector(getTabs); + const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); + const dispatch = useAppDispatch(); + const [addOpen, setAddOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const tabWeights = Object.fromEntries( + tabs.map((tab) => [tab.id, tab.gradebookWeight ?? 0]), + ); + const existingAssessments: ExistingExternalAssessment[] = externals.map( + (a) => ({ + name: a.title, + maximumGrade: a.maxGrade, + weightage: tabWeights[a.tabId] ?? 0, + }), + ); + + const onDragEnd = (result: DropResult): void => + handleDragEnd( + externals.map((a) => a.id), + result, + dispatch, + () => toast.error(t(translations.reorderError)), + ); + + const gridCols = weightedViewEnabled + ? '2.5rem minmax(10rem,1fr) 5rem 5rem 9.5rem 6rem' + : '2.5rem minmax(10rem,1fr) 5rem 9.5rem 6rem'; + + // A lone assessment has nothing to reorder against, so dragging is disabled + // and the handle is hidden. Its grid column is kept (empty) so the rest of + // the row stays in identical placement. + const reorderable = externals.length > 1; + + return ( + <> + + {t(translations.title)} + + + + + + + {externals.length === 0 ? ( +
+ + {t(translations.empty)} + + + {t(translations.emptyHint)} + +
+ ) : ( + <> +
+ + {t(translations.name)} + {t(translations.max)} + {weightedViewEnabled && {t(translations.weight)}} + {t(translations.bounds)} + {t(translations.actions)} +
+ + + + {(dropProvided) => ( +
+ {externals.map((a, index) => ( + + {(dragProvided, { isDragging }) => ( +
+ {reorderable ? ( + + + + ) : ( + + )} + + {a.title} + + {a.maxGrade} + {weightedViewEnabled && ( + {tabWeights[a.tabId] ?? 0} + )} + + + {(a.floorAtZero ?? true) && ( + + )} + {(a.capAtMaximum ?? true) && ( + + )} + + + + setEditing(a)} + size="small" + > + + + setDeleting(a)} + size="small" + > + + + +
+ )} +
+ ))} + {dropProvided.placeholder} +
+ )} +
+
+ + )} +
+ + + + + setAddOpen(false)} + open={addOpen} + weightedViewEnabled={weightedViewEnabled} + /> + {editing && ( + setEditing(null)} + open={Boolean(editing)} + weightedViewEnabled={weightedViewEnabled} + /> + )} + {deleting && ( + setDeleting(null)} + open={Boolean(deleting)} + title={deleting.title} + /> + )} +
+ setImportOpen(false)} + open={open && importOpen} + weightedViewEnabled={weightedViewEnabled} + /> + + ); +}; + +export default ManageExternalAssessmentsPanel; diff --git a/client/app/bundles/course/gradebook/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts new file mode 100644 index 00000000000..4826683c575 --- /dev/null +++ b/client/app/bundles/course/gradebook/computeWeighted.ts @@ -0,0 +1,346 @@ +// client/app/bundles/course/gradebook/computeWeighted.ts +import { + AssessmentData, + StudentData, + SubmissionData, + TabData, + UpdateWeightsPayload, +} 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, + ); +}; + +// When the table is showing the equal-split default (usingDefaultWeights), setting +// a real weight on one tab would disengage the fallback and collapse every other +// tab to its stored 0. To prevent that, freeze the currently-displayed equal split +// into a real weights-update payload for the populated tabs, so a caller can persist +// it BEFORE applying the new weight. Mode is always 'equal' (the split is an equal +// default); empty tabs carry no weight and are omitted. External tabs keep their +// negative store id, which `Contribution.bulk_update` routes by sign. +export const materializedDefaultWeights = ( + tabs: TabData[], + assessments: Pick[], +): UpdateWeightsPayload['weights'] => + resolveTabWeights(tabs, assessments) + .filter((tab) => (tab.gradebookWeight ?? 0) > 0) + .map((tab) => ({ + tabId: tab.id, + weight: tab.gradebookWeight as number, + weightMode: tab.weightMode ?? 'equal', + })); diff --git a/client/app/bundles/course/gradebook/constants.ts b/client/app/bundles/course/gradebook/constants.ts new file mode 100644 index 00000000000..c5e07ff06f1 --- /dev/null +++ b/client/app/bundles/course/gradebook/constants.ts @@ -0,0 +1,9 @@ +export const STUDENT_INFO_COL_IDS = ['name', 'email', 'externalId'] as const; +export type StudentInfoColId = (typeof STUDENT_INFO_COL_IDS)[number]; + +export const GAMIFICATION_COL_IDS = ['level', 'totalXp'] as const; +export type GamificationColId = (typeof GAMIFICATION_COL_IDS)[number]; + +/** Synthetic category id for external assessments (mirrors backend + * Course::ExternalAssessment::SYNTHETIC_CATEGORY_ID). */ +export const EXTERNAL_CATEGORY_ID = -1; diff --git a/client/app/bundles/course/gradebook/handles.ts b/client/app/bundles/course/gradebook/handles.ts new file mode 100644 index 00000000000..0022bfbd02c --- /dev/null +++ b/client/app/bundles/course/gradebook/handles.ts @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl'; + +import type { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +const translations = defineMessages({ + header: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, +}); + +export const gradebookHandle: DataHandle = (match) => { + const courseId = match.params.courseId; + + return { + getData: async (): Promise => ({ + activePath: `/courses/${courseId}/gradebook`, + content: { title: translations.header }, + }), + }; +}; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts new file mode 100644 index 00000000000..a0c7b6b24c0 --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,163 @@ +import type { Operation } from 'store'; +import type { + ImportCommitSummary, + ImportPreviewRequest, + ImportPreviewResult, + UpdateWeightsPayload, +} from 'types/course/gradebook'; + +import CourseAPI from 'api/course'; + +import { actions } from './store'; +import { materializedDefaultWeights, usingDefaultWeights } from './computeWeighted'; + +const fetchGradebook = (): Operation => async (dispatch) => { + const response = await CourseAPI.gradebook.index(); + 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)); + }; + +// When the weighted view is showing the equal-split default (no weights stored), +// persist that visible split for the existing tabs. Call this BEFORE a mutation that +// will set a real weight, so the other tabs keep their on-screen weights instead of +// snapping to their stored 0 once the fallback disengages. No-op when weights are +// already configured. Idempotent / best-effort: it only makes the visible state +// durable, so it needs no rollback if the following mutation fails. +const materializeDefaultWeights = (): Operation => async (dispatch, getState) => { + const { tabs, assessments } = getState().gradebook; + if (!usingDefaultWeights(tabs, assessments)) return; + await dispatch(updateGradebookWeights(materializedDefaultWeights(tabs, assessments))); +}; + +export const createExternalAssessment = + ( + title: string, + maximumGrade: number, + floorAtZero: boolean, + capAtMaximum: boolean, + weight?: number, + ): Operation => + async (dispatch) => { + if (weight) await dispatch(materializeDefaultWeights()); + const response = await CourseAPI.gradebook.createExternal({ + title, + maximumGrade, + floorAtZero, + capAtMaximum, + ...(weight === undefined ? {} : { weight }), + }); + dispatch(actions.applyCreatedExternal(response.data)); + }; + +export const renameExternalAssessment = + (assessmentId: number, title: string): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.updateExternal(-assessmentId, { + title, + }); + dispatch(actions.updateExternalAssessment(response.data)); + }; + +export const editExternalAssessment = + ( + assessmentId: number, + patch: { + title?: string; + maximumGrade?: number; + floorAtZero?: boolean; + capAtMaximum?: boolean; + weight?: number; + }, + ): Operation => + async (dispatch) => { + if (patch.weight) await dispatch(materializeDefaultWeights()); + const response = await CourseAPI.gradebook.updateExternal( + -assessmentId, + patch, + ); + dispatch(actions.updateExternalAssessment(response.data)); + }; + +export const deleteExternalAssessment = + (assessmentId: number): Operation => + async (dispatch) => { + await CourseAPI.gradebook.deleteExternal(-assessmentId); + dispatch(actions.deleteExternalAssessment(assessmentId)); + }; + +// Optimistic: apply the new external order immediately, persist via one PUT. +// On failure, restore the previous order and rethrow so the caller can toast. +// `orderedAssessmentIds` are the negative serialized ids (store ids); the API +// wants the positive external ids, so we negate them. +export const reorderExternalAssessments = + (orderedAssessmentIds: number[]): Operation => + async (dispatch, getState) => { + const prevOrder = getState() + .gradebook.assessments.filter((a) => a.external) + .map((a) => a.id); + dispatch(actions.reorderExternalAssessments(orderedAssessmentIds)); + try { + await CourseAPI.gradebook.reorderExternals({ + orderedIds: orderedAssessmentIds.map((id) => -id), + }); + } catch (error) { + dispatch(actions.reorderExternalAssessments(prevOrder)); + throw error; + } + }; + +// Optimistic: apply the new grade immediately, then reconcile with the server. +// On failure, restore the previous value and rethrow so the caller can toast. +export const setExternalGrade = + (assessmentId: number, studentId: number, grade: number | null): Operation => + async (dispatch, getState) => { + const prev = + getState().gradebook.submissions.find( + (s) => s.studentId === studentId && s.assessmentId === assessmentId, + )?.grade ?? null; + dispatch(actions.setExternalGrade({ studentId, assessmentId, grade })); + try { + const response = await CourseAPI.gradebook.setExternalGrade( + -assessmentId, + { + studentId, + grade, + }, + ); + dispatch(actions.setExternalGrade(response.data)); + } catch (error) { + dispatch( + actions.setExternalGrade({ studentId, assessmentId, grade: prev }), + ); + throw error; + } + }; + +export const previewImport = + (payload: ImportPreviewRequest): Operation => + async () => { + const response = await CourseAPI.gradebook.importPreview(payload); + return response.data; + }; + +export const commitImport = + ( + payload: ImportPreviewRequest & { onConflict: 'keep' | 'replace' }, + ): Operation => + async (dispatch) => { + if (payload.components.some((c) => c.weightage > 0)) { + await dispatch(materializeDefaultWeights()); + } + const response = await CourseAPI.gradebook.importCommit(payload); + const refreshed = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(refreshed.data)); + return response.data; + }; + +export default fetchGradebook; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx new file mode 100644 index 00000000000..403f0c7a58a --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,178 @@ +import { FC, useEffect, useState, useTransition } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { PeopleAlt } from '@mui/icons-material'; +import { Tab, Tabs, Typography } from '@mui/material'; + +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { useCourseContext } from '../../../container/CourseLoader'; +import GradebookTable from '../../components/GradebookTable'; +import GradebookWeightedTable from '../../components/GradebookWeightedTable'; +import GradeLinkHint from '../../components/GradeLinkHint'; +import WeightedViewHint from '../../components/WeightedViewHint'; +import ManageExternalAssessmentsButton from '../../components/manage/ManageExternalAssessmentsButton'; +import fetchGradebook from '../../operations'; +import { + getAssessments, + getCanManageWeights, + getCategories, + getGamificationEnabled, + getStudents, + getSubmissions, + getTabs, + getWeightedViewEnabled, +} from '../../selectors'; + +const translations = defineMessages({ + gradebook: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, + fetchFailure: { + id: 'course.gradebook.GradebookIndex.fetchFailure', + defaultMessage: 'Failed to retrieve Gradebook.', + }, + noStudents: { + id: 'course.gradebook.GradebookIndex.noStudents', + defaultMessage: 'No students enrolled yet', + }, + noStudentsHint: { + id: 'course.gradebook.GradebookIndex.noStudentsHint', + defaultMessage: 'Grades will appear here once students join the course.', + }, + allAssessments: { + id: 'course.gradebook.GradebookIndex.allAssessments', + defaultMessage: 'All assessments', + }, + byWeight: { + id: 'course.gradebook.GradebookIndex.byWeight', + defaultMessage: 'Weighted total', + }, +}); + +const GradebookIndex: FC = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseTitle } = useCourseContext(); + const { courseId: courseIdParam } = useParams(); + const courseId = parseInt(courseIdParam!, 10); + const [searchParams, setSearchParams] = useSearchParams(); + const [isLoading, setIsLoading] = useState(true); + const [viewMode, setViewMode] = useState<'all' | 'weighted'>( + searchParams.get('view') === 'weighted' ? 'weighted' : 'all', + ); + const [isPending, startTransition] = useTransition(); + + const assessments = useAppSelector(getAssessments); + const categories = useAppSelector(getCategories); + const tabs = useAppSelector(getTabs); + const students = useAppSelector(getStudents); + const submissions = useAppSelector(getSubmissions); + const gamificationEnabled = useAppSelector(getGamificationEnabled); + const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); + const canManageWeights = useAppSelector(getCanManageWeights); + + useEffect(() => { + dispatch(fetchGradebook()) + .finally(() => setIsLoading(false)) + .catch(() => toast.error(t(translations.fetchFailure))); + }, [dispatch]); + + let content: JSX.Element; + if (isLoading) { + content = ; + } else if (students.length === 0) { + content = ( +
+ + + {t(translations.noStudents)} + + + {t(translations.noStudentsHint)} + +
+ ); + } else if (weightedViewEnabled && viewMode === 'weighted') { + content = ( + + ); + } else { + content = ( + + ); + } + + return ( + + {!isLoading && canManageWeights && !weightedViewEnabled && ( + + )} + {!isLoading && canManageWeights && students.length > 0 && ( +
+ +
+ )} + {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} +
+
+
+ ); +}; + +export default GradebookIndex; diff --git a/client/app/bundles/course/gradebook/selectors.ts b/client/app/bundles/course/gradebook/selectors.ts new file mode 100644 index 00000000000..2776f912113 --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,32 @@ +import type { AppState } from 'store'; + +type GradebookState = AppState['gradebook']; + +function getLocalState(state: AppState): GradebookState { + return state.gradebook; +} + +export const getCategories = (state: AppState): GradebookState['categories'] => + getLocalState(state).categories; +export const getTabs = (state: AppState): GradebookState['tabs'] => + getLocalState(state).tabs; +export const getAssessments = ( + state: AppState, +): GradebookState['assessments'] => getLocalState(state).assessments; +export const getStudents = (state: AppState): GradebookState['students'] => + getLocalState(state).students; +export const getSubmissions = ( + state: AppState, +): GradebookState['submissions'] => getLocalState(state).submissions; +export const getGamificationEnabled = ( + state: AppState, +): GradebookState['gamificationEnabled'] => + getLocalState(state).gamificationEnabled; +export const getWeightedViewEnabled = (state: AppState): boolean => + getLocalState(state).weightedViewEnabled; +export const getCanManageWeights = (state: AppState): boolean => + getLocalState(state).canManageWeights; +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 new file mode 100644 index 00000000000..644519a9c1e --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,269 @@ +import { produce } from 'immer'; +import type { + ExternalAssessmentNode, + ExternalAssessmentUpdate, + ExternalGradePayload, + GradebookData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from './types'; + +const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; +const UPDATE_TAB_WEIGHTS = 'course/gradebook/UPDATE_TAB_WEIGHTS'; +const APPLY_CREATED_EXTERNAL = 'course/gradebook/APPLY_CREATED_EXTERNAL'; +const UPDATE_EXTERNAL_ASSESSMENT = + 'course/gradebook/UPDATE_EXTERNAL_ASSESSMENT'; +const DELETE_EXTERNAL_ASSESSMENT = + 'course/gradebook/DELETE_EXTERNAL_ASSESSMENT'; +const REORDER_EXTERNAL_ASSESSMENTS = + 'course/gradebook/REORDER_EXTERNAL_ASSESSMENTS'; +const SET_EXTERNAL_GRADE = 'course/gradebook/SET_EXTERNAL_GRADE'; + +interface GradebookState { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +interface UpdateTabWeightsAction { + type: typeof UPDATE_TAB_WEIGHTS; + payload: UpdateWeightsPayload; +} + +interface ApplyCreatedExternalAction { + type: typeof APPLY_CREATED_EXTERNAL; + payload: ExternalAssessmentNode; +} +interface UpdateExternalAssessmentAction { + type: typeof UPDATE_EXTERNAL_ASSESSMENT; + payload: ExternalAssessmentUpdate; +} +interface DeleteExternalAssessmentAction { + type: typeof DELETE_EXTERNAL_ASSESSMENT; + payload: number; // negative serialized assessment id +} +interface ReorderExternalAssessmentsAction { + type: typeof REORDER_EXTERNAL_ASSESSMENTS; + payload: number[]; // negative serialized assessment ids, new order +} +interface SetExternalGradeAction { + type: typeof SET_EXTERNAL_GRADE; + payload: ExternalGradePayload; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, +}; + +const reducer = produce( + ( + draft: GradebookState, + action: + | SaveGradebookAction + | UpdateTabWeightsAction + | ApplyCreatedExternalAction + | UpdateExternalAssessmentAction + | DeleteExternalAssessmentAction + | ReorderExternalAssessmentsAction + | SetExternalGradeAction, + ) => { + switch (action.type) { + case SAVE_GRADEBOOK: { + draft.categories = action.payload.categories; + draft.tabs = action.payload.tabs; + draft.assessments = action.payload.assessments; + draft.students = action.payload.students; + draft.submissions = action.payload.submissions; + draft.gamificationEnabled = action.payload.gamificationEnabled; + draft.weightedViewEnabled = action.payload.weightedViewEnabled; + draft.canManageWeights = action.payload.canManageWeights; + break; + } + case UPDATE_TAB_WEIGHTS: { + action.payload.weights.forEach( + ({ + tabId, + weight, + 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 APPLY_CREATED_EXTERNAL: { + const { assessment, tab, category } = action.payload; + if (!draft.categories.some((c) => c.id === category.id)) { + draft.categories.push(category); + } + if (!draft.tabs.some((t) => t.id === tab.id)) { + draft.tabs.push(tab); + } + if (!draft.assessments.some((a) => a.id === assessment.id)) { + draft.assessments.push(assessment); + } + break; + } + case UPDATE_EXTERNAL_ASSESSMENT: { + const { assessment, tab } = action.payload; + const a = draft.assessments.find((x) => x.id === assessment.id); + if (a) { + a.title = assessment.title; + a.maxGrade = assessment.maxGrade; + a.floorAtZero = assessment.floorAtZero; + a.capAtMaximum = assessment.capAtMaximum; + } + const t = draft.tabs.find((x) => x.id === tab.id); + if (t) { + t.title = tab.title; + if (tab.gradebookWeight !== undefined) { + t.gradebookWeight = tab.gradebookWeight; + } + } + break; + } + case DELETE_EXTERNAL_ASSESSMENT: { + const id = action.payload; + const removed = draft.assessments.find((a) => a.id === id); + draft.assessments = draft.assessments.filter((a) => a.id !== id); + draft.submissions = draft.submissions.filter( + (s) => s.assessmentId !== id, + ); + if ( + removed && + !draft.assessments.some((a) => a.tabId === removed.tabId) + ) { + const removedTab = draft.tabs.find((t) => t.id === removed.tabId); + draft.tabs = draft.tabs.filter((t) => t.id !== removed.tabId); + // Drop the now-empty synthetic "External Assessments" category so its + // header doesn't linger after the last external is deleted (the + // backend omits it entirely once no externals remain). + if ( + removedTab && + !draft.tabs.some((t) => t.categoryId === removedTab.categoryId) + ) { + draft.categories = draft.categories.filter( + (c) => c.id !== removedTab.categoryId, + ); + } + } + break; + } + case REORDER_EXTERNAL_ASSESSMENTS: { + const rank = new Map(action.payload.map((id, i) => [id, i])); + const externalsSorted = draft.assessments + .filter((a) => a.external) + .sort((x, y) => (rank.get(x.id) ?? 0) - (rank.get(y.id) ?? 0)); + let ai = 0; + draft.assessments = draft.assessments.map((a) => + a.external ? externalsSorted[ai++] : a, + ); + const tabRank = new Map(externalsSorted.map((a, i) => [a.tabId, i])); + const tabsSorted = draft.tabs + .filter((t) => tabRank.has(t.id)) + .sort((x, y) => (tabRank.get(x.id) ?? 0) - (tabRank.get(y.id) ?? 0)); + let ti = 0; + draft.tabs = draft.tabs.map((t) => + tabRank.has(t.id) ? tabsSorted[ti++] : t, + ); + break; + } + case SET_EXTERNAL_GRADE: { + const { studentId, assessmentId, grade } = action.payload; + const existing = draft.submissions.find( + (s) => s.studentId === studentId && s.assessmentId === assessmentId, + ); + if (existing) existing.grade = grade; + else draft.submissions.push({ studentId, assessmentId, grade }); + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: (data: GradebookData): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + payload: data, + }), + updateTabWeights: ( + payload: UpdateWeightsPayload, + ): UpdateTabWeightsAction => ({ + type: UPDATE_TAB_WEIGHTS, + payload, + }), + applyCreatedExternal: ( + payload: ExternalAssessmentNode, + ): ApplyCreatedExternalAction => ({ type: APPLY_CREATED_EXTERNAL, payload }), + updateExternalAssessment: ( + payload: ExternalAssessmentUpdate, + ): UpdateExternalAssessmentAction => ({ + type: UPDATE_EXTERNAL_ASSESSMENT, + payload, + }), + deleteExternalAssessment: (id: number): DeleteExternalAssessmentAction => ({ + type: DELETE_EXTERNAL_ASSESSMENT, + payload: id, + }), + reorderExternalAssessments: ( + payload: number[], + ): ReorderExternalAssessmentsAction => ({ + type: REORDER_EXTERNAL_ASSESSMENTS, + 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 new file mode 100644 index 00000000000..b91689df872 --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,9 @@ +export type { + AssessmentData, + CategoryData, + GradebookData, + StudentData, + SubmissionData, + TabData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; diff --git a/client/app/bundles/course/statistics/operations.ts b/client/app/bundles/course/statistics/operations.ts index c2060ed41fe..1c071b810ef 100644 --- a/client/app/bundles/course/statistics/operations.ts +++ b/client/app/bundles/course/statistics/operations.ts @@ -1,8 +1,4 @@ -import { AxiosError } from 'axios'; -import { JobCompleted, JobErrored } from 'types/jobs'; - import CourseAPI from 'api/course'; -import pollJob from 'lib/helpers/jobHelpers'; import { AssessmentsStatistics, @@ -14,8 +10,6 @@ import { StudentsStatistics, } from './types'; -const DOWNLOAD_JOB_POLL_INTERVAL_MS = 2000; - export const fetchStatisticsIndex = async (): Promise => { const response = await CourseAPI.statistics.course.fetchStatisticsIndex(); return response.data; @@ -62,21 +56,3 @@ export const fetchCourseGetHelpActivity = async (params?: { await CourseAPI.statistics.course.fetchCourseGetHelpActivity(params); return response.data; }; - -export const downloadScoreSummary = ( - handleSuccess: (successData: JobCompleted) => void, - handleFailure: (error: JobErrored | AxiosError) => void, - assessmentIds: number[], -): void => { - CourseAPI.statistics.course - .downloadScoreSummary(assessmentIds) - .then((response) => { - pollJob( - response.data.jobUrl, - handleSuccess, - handleFailure, - DOWNLOAD_JOB_POLL_INTERVAL_MS, - ); - }) - .catch(handleFailure); -}; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx deleted file mode 100644 index 355bb97ee45..00000000000 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { Button, Typography } from '@mui/material'; -import { AxiosError } from 'axios'; -import { JobCompleted, JobErrored } from 'types/jobs'; - -import { downloadScoreSummary } from 'course/statistics/operations'; -import { CourseAssessment } from 'course/statistics/types'; -import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; -import loadingToast, { LoadingToast } from 'lib/hooks/toast/loadingToast'; -import useTranslation from 'lib/hooks/useTranslation'; - -interface AssessmentsScoreSummaryDownloadProps { - assessments: CourseAssessment[]; -} - -const translations = defineMessages({ - selectedNUsers: { - id: 'course.statistics.StatisticsIndex.assessments.selectedNUsers', - defaultMessage: - 'Download Score Summary ({n, plural, =1 {# assessment} other {# assessments}})', - }, - download: { - id: 'course.statistics.StatisticsIndex.assessments.downloadCsv', - defaultMessage: 'Download', - }, - downloadCsvDialogTitle: { - id: 'course.statistics.StatisticsIndex.assessments.downloadCsv', - defaultMessage: 'Download Score Summary for the following Assessments?', - }, - downloadScoreSummarySuccess: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummarySuccess', - defaultMessage: 'Successfully downloaded score summary', - }, - downloadScoreSummaryFailure: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummaryFailure', - defaultMessage: 'An error occurred while downloading score summary', - }, - downloadScoreSummaryPending: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummaryPending', - defaultMessage: - 'Please wait as your request to download is being processed', - }, -}); - -const AssessmentsScoreSummaryDownload = ( - props: AssessmentsScoreSummaryDownloadProps, -): JSX.Element => { - const { assessments } = props; - const { t } = useTranslation(); - - const [openDialog, setOpenDialog] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); - - const handleSuccess = - (loadToast: LoadingToast) => - (successData: JobCompleted): void => { - window.location.href = successData.redirectUrl!; - loadToast.success(t(translations.downloadScoreSummarySuccess)); - setIsDownloading(false); - setOpenDialog(false); - }; - - const handleFailure = - (loadToast: LoadingToast) => - (error: JobErrored | AxiosError): void => { - const message = - error?.message || t(translations.downloadScoreSummaryFailure); - loadToast.error(message); - setIsDownloading(false); - }; - - const handleOnClick = (): void => { - setIsDownloading(true); - const loadToast = loadingToast(t(translations.downloadScoreSummaryPending)); - downloadScoreSummary( - handleSuccess(loadToast), - handleFailure(loadToast), - assessments.map((assessment) => assessment.id), - ); - }; - - return ( - <> - - - setOpenDialog(false)} - open={openDialog} - primaryColor="info" - primaryLabel={t(translations.download)} - title={t(translations.downloadCsvDialogTitle)} - > - - {assessments.map((assessment) => ( -
  • {assessment.title}
  • - ))} -
    -
    - - ); -}; - -export default AssessmentsScoreSummaryDownload; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx index f8a8a4ed78a..930eef2ed5d 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx @@ -12,6 +12,7 @@ const AssessmentsStatistics: FC = () => { {(data) => ( )} diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx index 0b686edd539..450e1208850 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; -import { Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { CourseAssessment } from 'course/statistics/types'; import Link from 'lib/components/core/Link'; @@ -14,13 +14,12 @@ import { getAssessmentStatisticsURL, getAssessmentWithCategoryURL, getAssessmentWithTabURL, + getCourseGradebookURL, } from 'lib/helpers/url-builders'; import { getCourseId } from 'lib/helpers/url-helpers'; import useTranslation from 'lib/hooks/useTranslation'; import { formatMiniDateTime, formatSecondsDuration } from 'lib/moment'; -import AssessmentsScoreSummaryDownload from './AssessmentsScoreSummaryDownload'; - const translations = defineMessages({ title: { id: 'course.statistics.StatisticsIndex.assessments.title', @@ -78,15 +77,26 @@ const translations = defineMessages({ id: 'course.statistics.StatisticsIndex.assessments.searchBar', defaultMessage: 'Search by Assessment Title, Tab, or Category', }, + subtitle: { + id: 'course.statistics.StatisticsIndex.assessments.subtitle', + defaultMessage: + 'To view and export individual student grades, open Gradebook.', + }, + subtitleDisabled: { + id: 'course.statistics.StatisticsIndex.assessments.subtitleDisabled', + defaultMessage: + 'To view and export individual student grades, enable Gradebook.', + }, }); interface Props { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } const AssessmentsStatisticsTable: FC = (props) => { - const { numStudents, assessments } = props; + const { numStudents, assessments, gradebookEnabled } = props; const courseId = getCourseId(); const { t } = useTranslation(); @@ -243,9 +253,26 @@ const AssessmentsStatisticsTable: FC = (props) => { return ( <> - - {t(translations.tableTitle, { numStudents })} - + + + {t(translations.tableTitle, { numStudents })} + + + {gradebookEnabled + ? t(translations.subtitle, { + url: (chunks) => ( + {chunks} + ), + }) + : t(translations.subtitleDisabled, { + url: (chunks) => ( + + {chunks} + + ), + })} + + = (props) => { } getRowEqualityData={(assessment): CourseAssessment => assessment} getRowId={(assessment): string => assessment.id.toString()} - indexing={{ indices: true, rowSelectable: true }} + indexing={{ indices: true }} pagination={{ rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], showAllRows: true, @@ -285,15 +312,7 @@ const AssessmentsStatisticsTable: FC = (props) => { }, }, }} - toolbar={{ - show: true, - activeToolbar: (selectedAssessments): JSX.Element => ( - - ), - keepNative: true, - }} + toolbar={{ show: true }} /> ); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx new file mode 100644 index 00000000000..5eb96b0bd7b --- /dev/null +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from 'test-utils'; + +import { CourseAssessment } from 'course/statistics/types'; + +import AssessmentsStatisticsTable from '../AssessmentsStatisticsTable'; + +const assessments: CourseAssessment[] = [ + { + id: 1, + title: 'Quiz 1', + startAt: new Date('2026-01-01T00:00:00Z'), + tab: { id: 1, title: 'Tab 1' }, + category: { id: 1, title: 'Category 1' }, + maximumGrade: 10, + numSubmitted: 2, + numAttempted: 3, + numLate: 1, + }, +]; + +const renderTable = (gradebookEnabled = true): void => { + render( + , + { at: ['/courses/1/statistics/assessments'] }, + ); +}; + +describe('', () => { + // Regression guard: an orphaned rowSelectable (selection with no activeToolbar) + // makes the toolbar render an empty 6.5rem bar on select, shifting every row down. + // Re-enabling row selection here brings that layout bug back. + it('keeps row selection off so selecting cannot shift the table down', async () => { + renderTable(); + await screen.findByText('Quiz 1'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('renders the native CSV download button', async () => { + renderTable(); + expect(await screen.findByTestId('DownloadIcon')).toBeInTheDocument(); + }); + + it('points to the Gradebook for individual student grades when enabled', async () => { + renderTable(true); + const link = await screen.findByRole('link', { name: /gradebook/i }); + expect(link).toHaveAttribute('href', '/courses/1/gradebook'); + }); + + it('points to course settings to enable Gradebook when it is disabled', async () => { + renderTable(false); + const link = await screen.findByRole('link', { name: /gradebook/i }); + expect(link).toHaveAttribute('href', '/courses/1/admin/components'); + }); +}); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx index d1dd39720a3..c1e230f8964 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx @@ -115,7 +115,10 @@ const StatisticsIndex: FC = () => { return ( - + { diff --git a/client/app/bundles/course/statistics/types.ts b/client/app/bundles/course/statistics/types.ts index 8dea0dfc4bd..076743f504c 100644 --- a/client/app/bundles/course/statistics/types.ts +++ b/client/app/bundles/course/statistics/types.ts @@ -137,6 +137,7 @@ export interface CourseAssessment { export interface AssessmentsStatistics { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } export interface CourseGetHelpActivity { diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index b92b165a744..c52ce359071 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -75,6 +75,10 @@ const translations = defineMessages({ id: 'course.componentTitles.course_forums_component', defaultMessage: 'Forums', }, + course_gradebook_component: { + id: 'course.componentTitles.course_gradebook_component', + defaultMessage: 'Gradebook', + }, course_groups_component: { id: 'course.componentTitles.course_groups_component', defaultMessage: 'Groups', diff --git a/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx b/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx index 51915776959..9f517937cf0 100644 --- a/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx @@ -7,7 +7,6 @@ import { ColumnTemplate } from 'lib/components/table'; import Table from 'lib/components/table/Table'; import { DEFAULT_MINI_TABLE_ROWS_PER_PAGE, - DEFAULT_TABLE_ROWS_PER_PAGE, TIMELINE_ALGORITHMS, } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx b/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx index 3da6e4d2da0..5b2ad57a8ae 100644 --- a/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx @@ -5,7 +5,6 @@ import { ColumnTemplate } from 'lib/components/table'; import Table from 'lib/components/table/Table'; import { DEFAULT_MINI_TABLE_ROWS_PER_PAGE, - DEFAULT_TABLE_ROWS_PER_PAGE, TIMELINE_ALGORITHMS, } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx index fdbaedfc26c..0984ac6c527 100644 --- a/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/__test__/index.test.tsx @@ -1,5 +1,5 @@ import userEvent from '@testing-library/user-event'; -import { render, screen, waitForElementToBeRemoved } from 'test-utils'; +import { render, screen, waitFor, waitForElementToBeRemoved, within } from 'test-utils'; import { CourseUserMiniEntity } from 'types/course/courseUsers'; import ManageUsersTable from '../index'; @@ -96,4 +96,30 @@ describe('', () => { expect(screen.getByText('Bob Tan')).toBeInTheDocument(); }); }); + + describe('sort by External ID', () => { + it( + 'clusters blank External IDs together when the column is sorted', + async () => { + const user = userEvent.setup(); + const users: CourseUserMiniEntity[] = [ + { ...baseUser, id: 1, name: 'Alice Lim', externalId: 'B-1' }, + { ...baseUser, id: 2, name: 'Bob Tan', externalId: null }, + { ...baseUser, id: 3, name: 'Cara Ng', externalId: 'A-1' }, + ]; + render(); + await waitForElementToBeRemoved(() => screen.queryByRole('progressbar')); + + const extIdHeader = screen.getByRole('columnheader', { name: /external id/i }); + await user.click(within(extIdHeader).getByRole('button')); + + await waitFor(() => { + const rows = screen.getAllByRole('row').slice(1); // drop the header row + const order = rows.map((r) => within(r).getByText(/Lim|Tan|Ng/).textContent); + expect(order[0]).toContain('Bob Tan'); // blank External ID sorts first + }); + }, + 10000, + ); + }); }); diff --git a/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx b/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx index 76f30227a4f..ec1559934e0 100644 --- a/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx +++ b/client/app/bundles/course/users/components/tables/ManageUsersTable/index.tsx @@ -70,10 +70,23 @@ const ManageUsersTable = (props: ManageUsersTableProps): JSX.Element => { { of: 'externalId', title: t(tableTranslations.externalId), - sortable: false, + sortable: true, searchable: true, cell: (user) => , csvDownloadable: true, + sortProps: { + // Empties first so all blank External IDs cluster onto page 1 for a + // single fix pass; non-empty values sort lexicographically after. + // descFirst: false ensures the first click sorts ascending (empties first). + descFirst: false, + sort: (a, b) => { + const av = a.externalId ?? ''; + const bv = b.externalId ?? ''; + if (!av && bv) return -1; + if (av && !bv) return 1; + return av.localeCompare(bv); + }, + }, }, { of: 'phantom', diff --git a/client/app/bundles/users/store.ts b/client/app/bundles/users/store.ts index 230d7149d51..44ff5de5596 100644 --- a/client/app/bundles/users/store.ts +++ b/client/app/bundles/users/store.ts @@ -12,6 +12,8 @@ import { SaveCourseListAction, SaveInstanceListAction, SaveUserAction, + SET_CURRENT_USER_ID, + SetCurrentUserIdAction, } from './types'; const initialState: GlobalUserState = { @@ -33,6 +35,13 @@ const reducer = produce((draft: GlobalUserState, action: GlobalActionType) => { draft.user = userEntity; break; } + // Sets only the authenticated user's id, leaving name/imageUrl untouched. + // The course layout fetch knows the current user id but not the full profile; + // this is enough to namespace per-user client state (e.g. table column prefs). + case SET_CURRENT_USER_ID: { + draft.user.id = action.userId; + break; + } case SAVE_COURSE_LIST: { if (action.courses) { const coursesList = action.courses; @@ -67,6 +76,10 @@ export const actions = { return { type: SAVE_USER, user }; }, + setCurrentUserId: (userId: number): SetCurrentUserIdAction => { + return { type: SET_CURRENT_USER_ID, userId }; + }, + saveCourses: ( courses: UserCourseListData[], courseType: 'current' | 'completed', diff --git a/client/app/bundles/users/types.ts b/client/app/bundles/users/types.ts index f4cf9580e59..6a073977c78 100644 --- a/client/app/bundles/users/types.ts +++ b/client/app/bundles/users/types.ts @@ -12,6 +12,7 @@ import { // Action Names export const SAVE_USER = 'system/SAVE_USER'; +export const SET_CURRENT_USER_ID = 'system/SET_CURRENT_USER_ID'; export const SAVE_COURSE_LIST = 'system/SAVE_COURSE_LIST'; export const SAVE_INSTANCE_LIST = 'system/SAVE_INSTANCE_LIST'; @@ -21,6 +22,11 @@ export interface SaveUserAction { user: UserBasicListData; } +export interface SetCurrentUserIdAction { + type: typeof SET_CURRENT_USER_ID; + userId: number; +} + export interface SaveCourseListAction { type: typeof SAVE_COURSE_LIST; courses: UserCourseListData[]; @@ -34,6 +40,7 @@ export interface SaveInstanceListAction { export type GlobalActionType = | SaveUserAction + | SetCurrentUserIdAction | SaveCourseListAction | SaveInstanceListAction; diff --git a/client/app/lib/components/core/buttons/DownloadButton.tsx b/client/app/lib/components/core/buttons/DownloadButton.tsx index f241597144d..a9788884b08 100644 --- a/client/app/lib/components/core/buttons/DownloadButton.tsx +++ b/client/app/lib/components/core/buttons/DownloadButton.tsx @@ -24,7 +24,7 @@ const DownloadButton = (props: DownloadButtonProps): JSX.Element => ( > - {props.children} + {props.children} diff --git a/client/app/lib/components/core/buttons/SegmentedSwitch.tsx b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx new file mode 100644 index 00000000000..ad82714b64d --- /dev/null +++ b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx @@ -0,0 +1,235 @@ +import { + KeyboardEvent, + ReactNode, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import { Box, ButtonBase, Tooltip } from '@mui/material'; + +export interface SegmentedSwitchOption { + value: T; + /** Visible content. Keep it short — one word reads best in this control. */ + label: ReactNode; + /** Optional hint shown on hover/focus of the segment. */ + tooltip?: ReactNode; + /** + * Accessible name for the segment. Falls back to `label` when that is a + * plain string; supply this when `label` is an icon or other non-text node. + */ + ariaLabel?: string; +} + +interface SegmentedSwitchProps { + /** The currently selected option's value. */ + value: T; + /** The options, left to right. Designed for 2 but renders any count. */ + options: SegmentedSwitchOption[]; + /** Fired with the next value when a different segment is chosen. */ + onChange: (value: T) => void; + /** Accessible name for the whole control (the radiogroup). */ + ariaLabel: string; + disabled?: boolean; + /** + * Pass `self-stretch` to grow the switch to a taller row neighbour (e.g. a + * small `TextField`), so the two align without hardcoding a height. + */ + className?: string; +} + +// Sized to MUI `size="small"` controls: 13px text, ~30.75px tall. Pixels are +// resolved through the theme's `pxToRem` so the switch matches its siblings +// regardless of the app's `htmlFontSize` (Coursemology uses 10, so a hardcoded +// rem would render ~38% too small). `MIN_HEIGHT` is a floor — `self-stretch` +// lets the switch grow to match a taller neighbour in the same flex row. +const FONT_PX = 13; +const PADDING_X = 1.5; +const MIN_HEIGHT = 30.75; + +/** + * A compact, peer-state mode switcher: a pill track with the options side by + * side and a single elevated thumb that slides to the active one. + * + * Unlike a `Switch`, neither option reads as "off" — both are equally valid — + * and unlike `ToggleButtonGroup` it stays content-width, so it fits a packed + * toolbar or a dense prompt row. Use it when a binary choice has no default + * "on" state (e.g. Points vs. Percentage, Equal vs. Custom). + * + * Keyboard: arrows move and select; roving tabindex keeps it a single tab stop. + */ +const SegmentedSwitch = ( + props: SegmentedSwitchProps, +): JSX.Element => { + const { value, options, onChange, ariaLabel, disabled, className } = props; + + const containerRef = useRef(null); + const optionRefs = useRef<(HTMLButtonElement | null)[]>([]); + const [thumb, setThumb] = useState<{ left: number; width: number }>({ + left: 0, + width: 0, + }); + + const activeIndex = Math.max( + 0, + options.findIndex((o) => o.value === value), + ); + + // Slide the thumb under the active segment. Segments are content-width, so + // both offset and width are measured. Re-measure on selection change and on + // any resize (font load, container reflow) — a one-shot measure would leave + // the thumb stranded after either. + useLayoutEffect(() => { + const measure = (): void => { + const el = optionRefs.current[activeIndex]; + const container = containerRef.current; + if (!el || !container) return; + // `offsetLeft` is measured from the container's border box, but the thumb + // (`left: 0`) is anchored to its padding box — just inside the border. + // Subtract the border width (`clientLeft`) so the two share a coordinate + // system; without it the active-left thumb loses its inset and sits flush + // against the border. + setThumb({ + left: el.offsetLeft - container.clientLeft, + width: el.offsetWidth, + }); + }; + measure(); + const observer = new ResizeObserver(measure); + if (containerRef.current) observer.observe(containerRef.current); + return () => observer.disconnect(); + }, [activeIndex, options.length]); + + const select = (index: number): void => { + const next = options[index]; + if (next && next.value !== value) onChange(next.value); + }; + + const handleKeyDown = (event: KeyboardEvent): void => { + if (disabled) return; + const last = options.length - 1; + let next = activeIndex; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') { + next = activeIndex === last ? 0 : activeIndex + 1; + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') { + next = activeIndex === 0 ? last : activeIndex - 1; + } else { + return; + } + event.preventDefault(); + select(next); + optionRefs.current[next]?.focus(); + }; + + return ( + ({ + position: 'relative', + display: 'inline-flex', + alignItems: 'stretch', + minHeight: MIN_HEIGHT, + boxSizing: 'border-box', + p: '3px', + borderRadius: 999, + bgcolor: theme.palette.action.hover, + border: `1px solid ${theme.palette.divider}`, + lineHeight: 1, + opacity: disabled ? 0.5 : 1, + pointerEvents: disabled ? 'none' : 'auto', + })} + > + {/* The sliding thumb. Hidden until measured (width 0) to avoid a flash + from the top-left corner on first paint. */} + ({ + position: 'absolute', + top: '3px', + bottom: '3px', + left: 0, + width: thumb.width, + borderRadius: 999, + bgcolor: theme.palette.background.paper, + boxShadow: theme.shadows[1], + opacity: thumb.width === 0 ? 0 : 1, + transform: `translateX(${thumb.left}px)`, + transition: theme.transitions.create(['transform', 'width'], { + duration: 260, + easing: 'cubic-bezier(0.34, 1.36, 0.64, 1)', + }), + zIndex: 0, + })} + /> + {options.map((option, index) => { + const selected = index === activeIndex; + const label = + option.ariaLabel ?? + (typeof option.label === 'string' ? option.label : undefined); + const segment = ( + { + optionRefs.current[index] = el; + }} + aria-checked={selected} + aria-label={label} + disabled={disabled} + disableRipple + onClick={() => select(index)} + role="radio" + sx={(theme) => ({ + position: 'relative', + zIndex: 1, + fontFamily: 'inherit', + fontSize: theme.typography.pxToRem(FONT_PX), + fontWeight: selected ? 650 : 550, + letterSpacing: '0.01em', + color: selected + ? theme.palette.text.primary + : theme.palette.text.secondary, + px: PADDING_X, + // Height comes from the track's `minHeight` + `align-items: + // stretch`; the segment fills it and centers its label, so no + // vertical padding of its own. + py: 0, + borderRadius: 999, + whiteSpace: 'nowrap', + transition: theme.transitions.create('color', { duration: 180 }), + '&:hover': { color: theme.palette.text.primary }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: 2, + }, + })} + tabIndex={selected ? 0 : -1} + > + {option.label} + + ); + + return option.tooltip ? ( + + {segment} + + ) : ( + + {segment} + + ); + })} + + ); +}; + +export default SegmentedSwitch; diff --git a/client/app/lib/components/core/buttons/__test__/SegmentedSwitch.test.tsx b/client/app/lib/components/core/buttons/__test__/SegmentedSwitch.test.tsx new file mode 100644 index 00000000000..ee3eb271832 --- /dev/null +++ b/client/app/lib/components/core/buttons/__test__/SegmentedSwitch.test.tsx @@ -0,0 +1,106 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, screen, within } from 'test-utils'; + +import SegmentedSwitch from '../SegmentedSwitch'; + +// Render synchronously without the real provider's locale-loading spinner +// (uses the manual mock at lib/components/wrappers/__mocks__/I18nProvider). +jest.mock('lib/components/wrappers/I18nProvider'); + +const OPTIONS = [ + { value: 'points', label: 'Points' }, + { value: 'percent', label: 'Percentage' }, +] as const; + +const setup = ( + value: 'points' | 'percent', + overrides: Partial< + Parameters>[0] + > = {}, +): { onChange: jest.Mock } => { + const onChange = jest.fn(); + render( + , + ); + return { onChange }; +}; + +describe('', () => { + it('renders a radiogroup with one radio per option, named by ariaLabel', () => { + setup('points'); + const group = screen.getByRole('radiogroup', { name: 'Display mode' }); + expect(within(group).getAllByRole('radio')).toHaveLength(2); + expect(screen.getByRole('radio', { name: 'Points' })).toBeInTheDocument(); + expect( + screen.getByRole('radio', { name: 'Percentage' }), + ).toBeInTheDocument(); + }); + + it('marks only the selected option aria-checked', () => { + setup('percent'); + expect(screen.getByRole('radio', { name: 'Points' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + expect(screen.getByRole('radio', { name: 'Percentage' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + }); + + it('keeps a single tab stop via roving tabindex', () => { + setup('points'); + expect(screen.getByRole('radio', { name: 'Points' })).toHaveAttribute( + 'tabindex', + '0', + ); + expect(screen.getByRole('radio', { name: 'Percentage' })).toHaveAttribute( + 'tabindex', + '-1', + ); + }); + + it('fires onChange with the chosen value when an inactive option is clicked', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + await user.click(screen.getByRole('radio', { name: 'Percentage' })); + expect(onChange).toHaveBeenCalledWith('percent'); + }); + + it('does not fire onChange when the already-active option is clicked', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + await user.click(screen.getByRole('radio', { name: 'Points' })); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('moves selection with arrow keys', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + screen.getByRole('radio', { name: 'Points' }).focus(); + await user.keyboard('{ArrowRight}'); + expect(onChange).toHaveBeenCalledWith('percent'); + }); + + it('wraps arrow navigation at the ends', async () => { + const user = userEvent.setup(); + const { onChange } = setup('points'); + screen.getByRole('radio', { name: 'Points' }).focus(); + await user.keyboard('{ArrowLeft}'); + expect(onChange).toHaveBeenCalledWith('percent'); + }); + + it('disables every option and suppresses onChange when disabled', () => { + const { onChange } = setup('points', { disabled: true }); + const percent = screen.getByRole('radio', { name: 'Percentage' }); + expect(percent).toBeDisabled(); + fireEvent.click(percent); + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/client/app/lib/components/core/dialogs/Prompt.tsx b/client/app/lib/components/core/dialogs/Prompt.tsx index 5330d10c7c4..32f4c7f251a 100644 --- a/client/app/lib/components/core/dialogs/Prompt.tsx +++ b/client/app/lib/components/core/dialogs/Prompt.tsx @@ -15,6 +15,7 @@ interface BasePromptProps { open?: boolean; title?: string | ReactNode; children?: string | ReactNode; + footer?: ReactNode; onClose?: () => void; onClosed?: () => void; disabled?: boolean; @@ -84,6 +85,8 @@ const Prompt = (props: PromptProps): JSX.Element => { )} + {props.footer} + {!props.cancel ? (