diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb new file mode 100644 index 00000000000..e9c8a8d9424 --- /dev/null +++ b/app/controllers/components/course/gradebook_component.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true +class Course::GradebookComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Gradebook' + end + + def sidebar_items + main_sidebar_items + settings_sidebar_items + end + + private + + def main_sidebar_items + return [] unless can?(:read_gradebook, current_course) + + [ + { + key: self.class.key, + icon: :gradebook, + title: I18n.t('course.gradebook.component.sidebar_title'), + type: :normal, + weight: 9, + path: course_gradebook_path(current_course) + } + ] + end + + def settings_sidebar_items + return [] unless can?(:manage_gradebook_settings, current_course) + + [ + { + key: self.class.key, + type: :settings, + weight: 14, + path: course_admin_gradebook_path(current_course) + } + ] + end +end diff --git a/app/controllers/course/admin/gradebook_settings_controller.rb b/app/controllers/course/admin/gradebook_settings_controller.rb new file mode 100644 index 00000000000..6e6f3313ed5 --- /dev/null +++ b/app/controllers/course/admin/gradebook_settings_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +class Course::Admin::GradebookSettingsController < Course::Admin::Controller + def edit + respond_to(&:json) + end + + def update + if @settings.update(gradebook_settings_params) && current_course.save + render 'edit' + else + render json: { errors: @settings.errors }, status: :bad_request + end + end + + private + + def gradebook_settings_params + params.require(:settings_gradebook_component).permit(:weighted_view_enabled) + end + + def component + current_component_host[:course_gradebook_component] + end + + def authorize_admin + authorize! :manage_gradebook_settings, current_course + end +end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb new file mode 100644 index 00000000000..65cb7a35dea --- /dev/null +++ b/app/controllers/course/gradebook_controller.rb @@ -0,0 +1,134 @@ +# 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 + @course_max_level = [current_course.levels.count - 1, 0].max + @level_config = current_course.gradebook_level_config if @weighted_view_enabled + 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 + ) + 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) + level_config = persist_level_contribution + response_body = { weights: serialize_weight_updates(updates) } + response_body[:levelContribution] = serialize_level_contribution(level_config) if level_config + render json: response_body + 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 persist_level_contribution + attrs = level_contribution_attrs + return nil if attrs.nil? + + Course::Gradebook::LevelConfig.upsert_for(course: current_course, attrs: attrs) + end + + def level_contribution_attrs + lc = params[:levelContribution] + return nil if lc.blank? + + permitted = lc.permit(:enabled, :formula, :weight, :show) + { + enabled: permitted[:enabled], + formula: permitted[:formula], + weight: permitted[:weight], + show: permitted[:show] + } + end + + def serialize_level_contribution(config) + { + enabled: config.enabled, + formula: config.formula, + weight: config.weight.to_f, + show: config.show + } + 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 fetch_students + current_course.levels.to_a + current_course.course_users.students.without_phantom_users. + calculated(:experience_points).includes(user: :emails).to_a. + sort_by { |cu| cu.user.name } + 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..d54a56cca62 --- /dev/null +++ b/app/models/components/course/gradebook_ability_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true +module Course::GradebookAbilityComponent + include AbilityHost::Component + + def define_permissions + can :read_gradebook, Course, id: course.id if course_user&.staff? + can :manage_gradebook_weights, Course, id: course.id if course_user&.manager_or_owner? + can :manage_gradebook_settings, Course, id: course.id if course_user&.manager_or_owner? + super + end +end diff --git a/app/models/course.rb b/app/models/course.rb index 8a402ce88c8..41eddd7055f 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_one :gradebook_level_config, class_name: 'Course::Gradebook::LevelConfig', + dependent: :destroy, inverse_of: :course 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..f1305a645c1 100644 --- a/app/models/course/assessment/tab.rb +++ b/app/models/course/assessment/tab.rb @@ -8,6 +8,9 @@ class Course::Assessment::Tab < ApplicationRecord belongs_to :category, class_name: 'Course::Assessment::Category', inverse_of: :tabs has_many :assessments, class_name: 'Course::Assessment', dependent: :destroy, inverse_of: :tab + has_one :gradebook_contribution, class_name: 'Course::Gradebook::Contribution', + dependent: :destroy, inverse_of: :tab + has_many :folders, class_name: 'Course::Material::Folder', through: :assessments, inverse_of: nil @@ -33,17 +36,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/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..734e8a6b00f --- /dev/null +++ b/app/models/course/gradebook/contribution.rb @@ -0,0 +1,131 @@ +# 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 + + validates :creator, presence: true + validates :updater, presence: true + validates :weight, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 } + validates :weight_mode, presence: true + validates :keep_highest, numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :tab_id, uniqueness: true + 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:) + course_tab_ids = course.assessment_tabs.pluck(:id).to_set + updates.each { |e| raise ActiveRecord::RecordNotFound unless course_tab_ids.include?(e[:tab_id]) } + + tabs_by_id = Course::Assessment::Tab.where(id: updates.map { |e| e[:tab_id] }). + includes(:assessments).index_by(&:id) + + transaction { updates.each { |entry| apply_entry(course, tabs_by_id, entry) } } + 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.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 + + # Until the external-alignment design adds `external_assessment_id`, the only + # contributor is the tab, so "exactly one" reduces to "tab present". + def exactly_one_contributor + errors.add(:tab, :blank) if tab_id.blank? + end + + def course_matches_contributor + return if tab.nil? || course.nil? + + errors.add(:course, :invalid) if tab.category.course_id != course_id + end +end diff --git a/app/models/course/gradebook/level_config.rb b/app/models/course/gradebook/level_config.rb new file mode 100644 index 00000000000..a05ac4350f2 --- /dev/null +++ b/app/models/course/gradebook/level_config.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +class Course::Gradebook::LevelConfig < ApplicationRecord + include ApplicationUserstampConcern + + belongs_to :course, inverse_of: :gradebook_level_config + + validates :weight, numericality: { greater_than_or_equal_to: 0 } + validates :formula, presence: true, if: :enabled + validates :course_id, uniqueness: true + + # Upserts the singleton Level config for a course from a normalized attrs hash + # (symbol keys, snake_case). Raises ActiveRecord::RecordInvalid on validation failure. + def self.upsert_for(course:, attrs:) + config = find_or_initialize_by(course_id: course.id) + config.assign_attributes( + enabled: ActiveRecord::Type::Boolean.new.cast(attrs[:enabled]), + formula: attrs[:formula].to_s, + weight: attrs[:weight].to_f, + show: ActiveRecord::Type::Boolean.new.cast(attrs[:show]) == true + ) + config.save! + config + 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/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/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder new file mode 100644 index 00000000000..7f19af51239 --- /dev/null +++ b/app/views/course/gradebook/index.json.jbuilder @@ -0,0 +1,65 @@ +# frozen_string_literal: true +json.weightedViewEnabled @weighted_view_enabled +json.canManageWeights can?(:manage_gradebook_weights, current_course) + +json.categories @categories do |cat| + json.id cat.id + json.title cat.title +end + +json.tabs @tabs do |tab| + json.id tab.id + json.title tab.title + json.categoryId tab.category_id + if @weighted_view_enabled + contribution = @tab_contributions[tab.id] + json.gradebookWeight (contribution&.weight || 0).to_f + json.weightMode(contribution&.weight_mode || 'equal') + end +end + +json.assessments @published_assessments do |assessment| + json.id assessment.id + json.title assessment.title + json.tabId assessment.tab_id + json.maxGrade @assessment_max_grades[assessment.id] || 0 + if @weighted_view_enabled + contribution = @assessment_contributions[assessment.id] + json.gradebookWeight contribution&.weight&.to_f + json.gradebookExcluded(contribution&.excluded || false) + 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 @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 + +json.gamificationEnabled current_course.gamified? + +json.courseMaxLevel @course_max_level + +json.levelContribution do + if @weighted_view_enabled && @level_config + json.enabled @level_config.enabled + json.formula @level_config.formula + json.weight @level_config.weight.to_f + json.show @level_config.show + else + json.enabled false + json.formula '' + json.weight 0 + json.show false + end +end 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..7603f1f2a1b --- /dev/null +++ b/client/app/api/course/Gradebook.ts @@ -0,0 +1,21 @@ +import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook'; + +import { APIResponse } from 'api/types'; + +import BaseCourseAPI from './Base'; + +export default class GradebookAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/gradebook`; + } + + index(): APIResponse { + return this.client.get(this.#urlPrefix); + } + + updateWeights( + payload: UpdateWeightsPayload, + ): APIResponse { + return this.client.patch(`${this.#urlPrefix}/weights`, payload); + } +} diff --git a/client/app/api/course/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__/ConfigureWeightsPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx new file mode 100644 index 00000000000..838cefceeb9 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx @@ -0,0 +1,728 @@ +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 LEVEL_FORMULA = 'min(level, 10) * 0.8'; + +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 defaultLevelContribution = { + enabled: false, + formula: '', + weight: 0, + show: false, +}; + +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: [], + }, + ], + expect.objectContaining({ enabled: false }), + ); + }); + }); + + 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(); + }); + }); +}); + +const enabledLevel = (over = {}): Record => ({ + enabled: true, + formula: LEVEL_FORMULA, + weight: 8, + show: true, + ...over, +}); + +const students = [ + { id: 1, name: 'A', email: 'a@x', externalId: null, level: 5, totalXp: 0 }, + { id: 2, name: 'B', email: 'b@x', externalId: null, level: 12, totalXp: 0 }, +]; + +describe('level contribution section', () => { + beforeEach(() => jest.clearAllMocks()); + + it('is not rendered when gamificationEnabled is false', () => { + setup({ gamificationEnabled: false }); + expect(screen.queryByText(/level contribution/i)).not.toBeInTheDocument(); + }); + + it('renders the level section when gamificationEnabled is true', () => { + setup({ gamificationEnabled: true }); + expect(screen.getByText(/level contribution/i)).toBeInTheDocument(); + }); + + it('seeds enabled, formula, weight, and show from the levelContribution prop', () => { + setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); + expect(screen.getByLabelText(/formula/i)).toHaveValue(LEVEL_FORMULA); + expect( + screen.getByRole('checkbox', { name: /level contribution/i }), + ).toBeChecked(); + }); + + it('no longer offers a "custom max level" control', () => { + setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); + expect( + screen.queryByRole('checkbox', { name: /custom max level/i }), + ).not.toBeInTheDocument(); + expect(screen.queryByLabelText(/max level/i)).not.toBeInTheDocument(); + }); + + it('shows the highest student level and the course maximum level', () => { + setup({ + gamificationEnabled: true, + courseMaxLevel: 14, + students, // levels 5 and 12 + levelContribution: enabledLevel(), + }); + expect(screen.getByText(/Highest student level: 12/)).toBeInTheDocument(); + expect(screen.getByText(/Course maximum level: 14/)).toBeInTheDocument(); + }); + + it('renders the level weight as a bare number with no caption or tooltip', () => { + setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); + expect( + screen.getByRole('spinbutton', { name: 'Level contribution' }), + ).toBeInTheDocument(); + expect(screen.queryByText(/Suggested maximum/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/never caps or blocks/i)).not.toBeInTheDocument(); + }); + + it('describes the level term without the word "bonus"', () => { + setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); + expect( + screen.getByText(/Adds grade-points from each student/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/bonus/i)).not.toBeInTheDocument(); + }); + + it('lists level, functions and operators in the helper when the formula is empty', () => { + setup({ + gamificationEnabled: true, + levelContribution: enabledLevel({ formula: '' }), + }); + expect( + screen.getByText(/floor, ceil, round, min, max/i), + ).toBeInTheDocument(); + expect(screen.getByText(/operators/i)).toBeInTheDocument(); + }); + + it('shows no warning when every contribution is in range', () => { + setup({ + gamificationEnabled: true, + students, // levels 5, 12 → min(level,10)*0.8 = 4, 8 — both within 0..8 + levelContribution: enabledLevel(), + }); + expect(screen.queryByText(/below 0/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/above/i)).not.toBeInTheDocument(); + }); + + it('names the offending student(s) only on the lower bound when none exceed the max', () => { + setup({ + gamificationEnabled: true, + students, // levels 5, 12 + levelContribution: enabledLevel({ formula: 'level - 8', weight: 10 }), + // contributions -3 (A) and 4 (B): only A is below 0 + }); + expect(screen.getByText('A (-3.00) is below 0.')).toBeInTheDocument(); + expect( + screen.getByText(/every level contribution is at least 0\./), + ).toBeInTheDocument(); + expect(screen.queryByText(/above/i)).not.toBeInTheDocument(); + }); + + it('names the worst offenders (value, highest first) only on the upper bound', () => { + setup({ + gamificationEnabled: true, + students, // levels 5, 12 + levelContribution: enabledLevel({ formula: 'level * 5', weight: 10 }), + // contributions 25 (A) and 60 (B), both above 10 + }); + expect( + screen.getByText('B (60.00) and A (25.00) are above 10.'), + ).toBeInTheDocument(); + expect( + screen.getByText(/every level contribution is at most 10\./), + ).toBeInTheDocument(); + expect(screen.queryByText(/below 0/i)).not.toBeInTheDocument(); + }); + + it('caps the list at two names and appends "and N more"', () => { + const many = [ + { + id: 1, + name: 'S1', + email: 'a@x', + externalId: null, + level: 3, + totalXp: 0, + }, + { + id: 2, + name: 'S2', + email: 'b@x', + externalId: null, + level: 4, + totalXp: 0, + }, + { + id: 3, + name: 'S3', + email: 'c@x', + externalId: null, + level: 5, + totalXp: 0, + }, + { + id: 4, + name: 'S4', + email: 'd@x', + externalId: null, + level: 6, + totalXp: 0, + }, + ]; + setup({ + gamificationEnabled: true, + students: many, + levelContribution: enabledLevel({ formula: 'level * 5', weight: 10 }), + // contributions 15,20,25,30 — all above 10; top two then "and 2 more" + }); + expect( + screen.getByText('S4 (30.00), S3 (25.00) and 2 more are above 10.'), + ).toBeInTheDocument(); + }); + + it('names offenders on both bounds with a combined fix instruction', () => { + setup({ + gamificationEnabled: true, + students, // levels 5, 12 + levelContribution: enabledLevel({ + formula: 'level * 5 - 30', + weight: 10, + }), + // contributions -5 (A) and 30 (B): one below 0, one above 10 + }); + expect( + screen.getByText('B (30.00) is above 10. A (-5.00) is below 0.'), + ).toBeInTheDocument(); + expect( + screen.getByText(/every level contribution is between 0 and 10\./), + ).toBeInTheDocument(); + }); + + it('seeds the level weight to the course max level when unconfigured (works on first open)', () => { + setup({ + gamificationEnabled: true, + courseMaxLevel: 30, + students, // levels 5, 12 + // fresh course: enabled, but no formula / weight yet + levelContribution: { enabled: true, formula: '', weight: 0, show: false }, + }); + // weight defaults to 30 and the formula seeds to min(level, 30) → in range, + // so there is no out-of-range warning. + expect( + screen.getByRole('spinbutton', { name: 'Level contribution' }), + ).toHaveValue(30); + expect(screen.queryByText(/below 0/i)).not.toBeInTheDocument(); + }); + + it('shows a parse error and disables Save when the formula is invalid', () => { + setup({ + gamificationEnabled: true, + levelContribution: enabledLevel({ formula: 'level +' }), + }); + expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); + }); + + it('includes levelContribution in the save payload without a maxLevel field', async () => { + setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => + expect(operations.updateGradebookWeights).toHaveBeenCalled(), + ); + const [, lvl] = (operations.updateGradebookWeights as jest.Mock).mock + .calls[0]; + expect(lvl).toMatchObject({ + enabled: true, + formula: LEVEL_FORMULA, + weight: 8, + show: true, + }); + expect(lvl).not.toHaveProperty('maxLevel'); + }); + + it('adds the level weight to the Total when enabled', () => { + // tabs sum to 100; level adds 8 → Total should be 108 + setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); + expect(screen.getByText(/Total:\s*108%/)).toBeInTheDocument(); + }); +}); 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..889f73b0a4a --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -0,0 +1,271 @@ +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, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: 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, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: 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, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: 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(); + }); + + 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..210004541e5 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx @@ -0,0 +1,1411 @@ +import userEvent from '@testing-library/user-event'; +import { store as appStore } from 'store'; +import { render, screen, waitFor, within } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +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'; +const LEVEL_CONTRIBUTION = 'Level Contribution'; + +// 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 }); + +const defaultLevelContribution = { + enabled: false, + formula: '', + weight: 0, + show: false, +}; + +interface RenderWeightedOptions { + categories?: CategoryData[]; + tabs?: TabData[]; + assessments?: AssessmentData[]; + students?: StudentData[]; + submissions?: SubmissionData[]; + canManageWeights?: boolean; + courseTitle?: string; + courseId?: number; + gamificationEnabled?: boolean; + courseMaxLevel?: number; + levelContribution?: typeof defaultLevelContribution; +} + +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%'); + }); + }); +}); + +describe('level contribution columns', () => { + beforeEach(() => localStorage.clear()); + + // `enabled` drives the always-on "Level Contribution" column; `show` + // additionally drives the raw "Level" column. Formula uses only the `level` + // grammar variable so these tests assert layout without coupling to the + // contribution-value computation (owned elsewhere). + const levelOn = { + enabled: true, + formula: 'level', + weight: 10, + show: true, + }; + + // The first row carries every column's top header cell left-to-right + // (Name, raw Level, Level Contribution, category groups, Total) — the cleanest + // place to assert presence and ordering. + const headerRow1 = (): HTMLElement => + document.querySelector('thead tr') as HTMLElement; + + it('shows neither level column when gamification is off', () => { + renderWeighted({ gamificationEnabled: false, levelContribution: levelOn }); + expect(screen.queryByText(LEVEL_CONTRIBUTION)).not.toBeInTheDocument(); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + }); + + it('shows neither level column when the contribution is disabled', () => { + renderWeighted({ + gamificationEnabled: true, + levelContribution: { ...levelOn, enabled: false }, + }); + expect(screen.queryByText(LEVEL_CONTRIBUTION)).not.toBeInTheDocument(); + expect(screen.queryByText('Level')).not.toBeInTheDocument(); + }); + + it('shows Level Contribution but hides raw Level when show is off', () => { + renderWeighted({ + gamificationEnabled: true, + levelContribution: { ...levelOn, show: false }, + }); + expect( + within(headerRow1()).getByText(LEVEL_CONTRIBUTION), + ).toBeInTheDocument(); + expect(within(headerRow1()).queryByText('Level')).not.toBeInTheDocument(); + }); + + it('shows both Level Contribution and raw Level when enabled and show are on', () => { + renderWeighted({ gamificationEnabled: true, levelContribution: levelOn }); + expect( + within(headerRow1()).getByText(LEVEL_CONTRIBUTION), + ).toBeInTheDocument(); + expect(within(headerRow1()).getByText('Level')).toBeInTheDocument(); + }); + + it('orders raw Level last among student info and Level Contribution first among contributions', () => { + renderWeighted({ + gamificationEnabled: true, + levelContribution: levelOn, + students: [{ ...makeStudent(1, 'Alice'), externalId: 'EXT-1' }], + }); + const headers = within(headerRow1()) + .getAllByRole('columnheader') + .map((c) => c.textContent); + const ext = headers.indexOf('External ID'); + const raw = headers.indexOf('Level'); + const contrib = headers.indexOf(LEVEL_CONTRIBUTION); + const cat = headers.indexOf('Cat A'); + // External ID (student info) < raw Level (last student info) < Level + // Contribution (first contribution) < Cat A (tab group). + expect(ext).toBeLessThan(raw); + expect(raw).toBeLessThan(contrib); + expect(contrib).toBeLessThan(cat); + }); + + it("renders each student's actual level in the raw Level column", () => { + renderWeighted({ + gamificationEnabled: true, + // level * 2 keeps the contribution value (14) distinct from the raw level + // (7) so the assertion targets the raw Level cell unambiguously. + levelContribution: { ...levelOn, formula: 'level * 2' }, + students: [{ ...makeStudent(1, 'Alice'), level: 7 }], + }); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect(within(aliceRow).getByText('7')).toBeInTheDocument(); + }); + + it('lists the Level row first in the expanded breakdown', async () => { + const user = userEvent.setup(); + renderWeighted({ + gamificationEnabled: true, + levelContribution: { ...levelOn, formula: 'level' }, + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Quiz 1', 10, 10)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 8)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + const bdRows = await screen.findAllByTestId(/^breakdown-row-/); + // Synthetic level tab uses LEVEL_TAB_ID (-1); its row must come first, before + // the real tab (id 10) rows — mirroring the column order. + expect(bdRows[0].getAttribute('data-testid')).toMatch( + /^breakdown-row-1--1-/, + ); + expect( + bdRows.some((r) => r.getAttribute('data-testid')?.includes('-10-')), + ).toBe(true); + }); + + it('shows the raw level (no max-level denominator) in the Level breakdown row', async () => { + const user = userEvent.setup(); + renderWeighted({ + gamificationEnabled: true, + // courseMaxLevel plays no part in the contribution, so showing "14/20" + // would falsely imply a level/maxLevel derivation. Expect a plain "Level 14". + levelContribution: { ...levelOn, formula: 'level', weight: 20 }, + courseMaxLevel: 20, + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Quiz 1', 10, 10)], + students: [{ ...makeStudent(1, 'Alice'), level: 14 }], + submissions: [makeSub(1, 100, 8)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + const levelRow = (await screen.findAllByTestId(/^breakdown-row-1--1-/))[0]; + expect(within(levelRow).getByText(/Level 14/)).toBeInTheDocument(); + // The misleading "14/20" fraction must NOT appear. + expect( + within(levelRow).queryByText(/14\s*\/\s*\d+/), + ).not.toBeInTheDocument(); + }); + + // Over-budget warning: the level weight is a suggested maximum, so a formula can + // push a student's contribution past it or below 0. The Level Contribution subheader + // shows a bound-aware message (above-only, below-only, or both). + it('shows above-only tooltip when a student exceeds the level weight', () => { + renderWeighted({ + gamificationEnabled: true, + // level * 5 → Alice (level 3) contributes 15, over the 10-pt budget. + levelContribution: { ...levelOn, formula: 'level * 5', weight: 10 }, + students: [{ ...makeStudent(1, 'Alice'), level: 3 }], + }); + expect( + screen.getByLabelText(/above the level weight/i), + ).toBeInTheDocument(); + }); + + it('shows below-only tooltip when a student contribution falls below 0', () => { + renderWeighted({ + gamificationEnabled: true, + // level - 10 → Alice (level 1) contributes -9, below 0. + levelContribution: { ...levelOn, formula: 'level - 10', weight: 10 }, + students: [{ ...makeStudent(1, 'Alice'), level: 1 }], + }); + expect( + screen.getByLabelText(/below 0/i), + ).toBeInTheDocument(); + }); + + it('shows both-bounds tooltip when students breach both bounds', () => { + renderWeighted({ + gamificationEnabled: true, + // level - 3 with weight 5: Alice (level 1) → -2 (below 0), Bob (level 10) → 7 (above 5). + levelContribution: { ...levelOn, formula: 'level - 3', weight: 5 }, + students: [ + { ...makeStudent(1, 'Alice'), level: 1 }, + { ...makeStudent(2, 'Bob'), level: 10 }, + ], + }); + expect( + screen.getByLabelText(/outside the valid range/i), + ).toBeInTheDocument(); + }); + + it('does not flag the subheader when every student is within budget', () => { + renderWeighted({ + gamificationEnabled: true, + // level → Alice (level 3) contributes 3, within the 10-pt budget. + levelContribution: { ...levelOn, formula: 'level', weight: 10 }, + students: [{ ...makeStudent(1, 'Alice'), level: 3 }], + }); + expect( + screen.queryByLabelText(/above the level weight|below 0|outside the valid range/i), + ).not.toBeInTheDocument(); + }); + + // Regression: neither level column is user-pickable, so a stale persisted-hidden + // entry from a prior session must NOT keep them hidden (no picker to recover). + it('keeps Level Contribution visible despite a stale persisted-hidden entry', () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ levelContribution: false }), + ); + renderWeighted({ gamificationEnabled: true, levelContribution: levelOn }); + expect( + within(headerRow1()).getByText(LEVEL_CONTRIBUTION), + ).toBeInTheDocument(); + }); + + it('keeps raw Level visible despite a stale persisted-hidden entry', () => { + localStorage.setItem( + WEIGHTED_STORAGE_KEY, + JSON.stringify({ level: false }), + ); + renderWeighted({ gamificationEnabled: true, levelContribution: levelOn }); + expect(within(headerRow1()).getByText('Level')).toBeInTheDocument(); + }); + + // Regression: toggling settings while the table is already mounted (e.g. the + // teacher saves the dialog) must reveal the affected column live. + it('reveals raw Level when show is toggled on after mount', () => { + const props = { + assessments: [makeAssessment(100, 'Quiz 1', 10, 150)], + canManageWeights: true, + categories: [makeCategory(1, 'Cat A')], + courseId: 1, + courseMaxLevel: 10, + courseTitle: 'Test Course', + gamificationEnabled: true, + students: [makeStudent(1, 'Alice')], + submissions: [], + tabs: [makeTab(10, 'Tab 1', 1, 100)], + }; + const { rerender } = render( + , + { state: userState }, + ); + expect(within(headerRow1()).queryByText('Level')).not.toBeInTheDocument(); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + rerender( + + + , + ); + expect(within(headerRow1()).getByText('Level')).toBeInTheDocument(); + }); + + it('reveals Level Contribution when the contribution is enabled after mount', () => { + const props = { + assessments: [makeAssessment(100, 'Quiz 1', 10, 150)], + canManageWeights: true, + categories: [makeCategory(1, 'Cat A')], + courseId: 1, + courseMaxLevel: 10, + courseTitle: 'Test Course', + gamificationEnabled: true, + students: [makeStudent(1, 'Alice')], + submissions: [], + tabs: [makeTab(10, 'Tab 1', 1, 100)], + }; + const { rerender } = render( + , + { state: userState }, + ); + expect( + within(headerRow1()).queryByText(LEVEL_CONTRIBUTION), + ).not.toBeInTheDocument(); + + rerender( + + + , + ); + expect( + within(headerRow1()).getByText(LEVEL_CONTRIBUTION), + ).toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ProjectedTotalHint.test.tsx b/client/app/bundles/course/gradebook/__tests__/ProjectedTotalHint.test.tsx new file mode 100644 index 00000000000..45250fe1ba1 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ProjectedTotalHint.test.tsx @@ -0,0 +1,54 @@ +import { store as appStore } from 'store'; +import { fireEvent, render, screen } from 'test-utils'; + +import ProjectedTotalHint, { + PROJECTED_TOTAL_POLICY_HINT_KEY, +} from '../components/ProjectedTotalHint'; + +// Render synchronously without the real provider's locale-loading spinner +// (uses the manual mock at lib/components/wrappers/__mocks__/I18nProvider). +jest.mock('lib/components/wrappers/I18nProvider'); + +const USER_ID = 42; +const STORAGE_KEY = `${USER_ID}:${PROJECTED_TOTAL_POLICY_HINT_KEY}`; + +const userState = { + global: { + ...appStore.getState().global, + user: { + ...appStore.getState().global.user, + user: { id: USER_ID, name: '', imageUrl: '' }, + }, + }, +}; + +const renderHint = (): void => { + render(, { state: userState }); +}; + +beforeEach(() => localStorage.clear()); + +describe('ProjectedTotalHint', () => { + it('teaches the projected-total policy on first view', () => { + renderHint(); + expect(screen.getByText(/ungraded assessments as 0/i)).toBeInTheDocument(); + }); + + it('hides and persists dismissal when the close button is clicked', () => { + renderHint(); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + + expect( + screen.queryByText(/ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe('true'); + }); + + it('does not render when already dismissed', () => { + localStorage.setItem(STORAGE_KEY, 'true'); + renderHint(); + expect( + screen.queryByText(/ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookColumnTree.test.tsx new file mode 100644 index 00000000000..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__/computeWeighted.test.ts b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts new file mode 100644 index 00000000000..75c5db261a6 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts @@ -0,0 +1,857 @@ +// client/app/bundles/course/gradebook/__tests__/computeWeighted.test.ts +import type { LevelContributionData } from 'types/course/gradebook'; + +import { + computeStudentBreakdown, + computeStudentTotal, + computeTabSubtotal, + computeWeightedRows, + LEVEL_TAB_ID, + levelOffenders, + levelOutOfRange, + resolveTabWeights, + sumWeights, + usingDefaultWeights, +} from '../computeWeighted'; +import { parseFormula } from '../levelFormula'; + +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); + }); +}); + +const lc = ( + over: Partial = {}, +): LevelContributionData => ({ + enabled: true, + formula: 'level / 30 * 8', // cap baked in literally — no maxLevel variable + weight: 8, + show: false, + ...over, +}); + +describe('computeWeightedRows — level contribution', () => { + const students = [ + { id: 1, name: 'A', email: 'a@x', externalId: null, level: 15, totalXp: 0 }, + ]; + + it('adds the level term to the total when enabled', () => { + const rows = computeWeightedRows({ + students, + tabs: [], + assessments: [], + submissions: [], + levelContribution: lc(), + }); + expect(rows[0].levelContribution).toBeCloseTo(4); // 15/30*8 + expect(rows[0].level).toBe(15); + expect(rows[0].total).toBeCloseTo(4); + }); + + it('contributes null and excludes the term when disabled', () => { + const rows = computeWeightedRows({ + students, + tabs: [], + assessments: [], + submissions: [], + levelContribution: lc({ enabled: false }), + }); + expect(rows[0].levelContribution).toBeNull(); + expect(rows[0].total).toBeNull(); + }); + + it('contributes null when the formula is invalid', () => { + const rows = computeWeightedRows({ + students, + tabs: [], + assessments: [], + submissions: [], + levelContribution: lc({ formula: 'level /' }), + }); + expect(rows[0].levelContribution).toBeNull(); + expect(rows[0].total).toBeNull(); + }); + + it('omits the level term entirely when no levelContribution is passed', () => { + const rows = computeWeightedRows({ + students, + tabs: [], + assessments: [], + submissions: [], + }); + expect(rows[0].levelContribution).toBeNull(); + expect(rows[0].level).toBe(15); + }); +}); + +describe('levelOutOfRange', () => { + it('flags a student whose contribution exceeds the weight', () => { + const parsed = parseFormula('level'); // raw level as points + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect( + levelOutOfRange( + [{ level: 50 }], + lc({ formula: 'level', weight: 8 }), + parsed, + ), + ).toBe(true); + expect( + levelOutOfRange( + [{ level: 5 }], + lc({ formula: 'level', weight: 8 }), + parsed, + ), + ).toBe(false); + } + }); +}); + +describe('levelOffenders', () => { + const A = { id: 1, name: 'A', level: 5 }; + const B = { id: 2, name: 'B', level: 12 }; + const C = { id: 3, name: 'C', level: 3 }; + + it('returns no offenders when the formula does not parse', () => { + const parsed = parseFormula('level /'); + expect(levelOffenders([A], parsed, 10)).toEqual({ below: [], above: [] }); + }); + + it('returns no offenders when every contribution is within [0, max]', () => { + const parsed = parseFormula('level * 0.1'); // 0.5, 1.2 — within [0, 10] + if (!parsed.ok) throw new Error('expected ok'); + expect(levelOffenders([A, B], parsed, 10)).toEqual({ + below: [], + above: [], + }); + }); + + it('lists students above the max, most extreme first', () => { + const parsed = parseFormula('level * 5'); // A 25, B 60, C 15 + if (!parsed.ok) throw new Error('expected ok'); + const { above, below } = levelOffenders([A, B, C], parsed, 10); + expect(below).toEqual([]); + expect(above.map((o) => o.name)).toEqual(['B', 'A', 'C']); + expect(above[0]).toMatchObject({ id: 2, name: 'B', value: 60 }); + }); + + it('lists students below 0, most negative first', () => { + const parsed = parseFormula('level - 8'); // A -3, B 4, C -5 + if (!parsed.ok) throw new Error('expected ok'); + const { above, below } = levelOffenders([A, B, C], parsed, 10); + expect(above).toEqual([]); + expect(below.map((o) => o.name)).toEqual(['C', 'A']); + expect(below[0]).toMatchObject({ id: 3, name: 'C', value: -5 }); + }); + + it('splits offenders across both bounds', () => { + const parsed = parseFormula('level * 5 - 30'); // A -5, B 30 + if (!parsed.ok) throw new Error('expected ok'); + const { above, below } = levelOffenders([A, B], parsed, 10); + expect(below.map((o) => o.value)).toEqual([-5]); + expect(above.map((o) => o.value)).toEqual([30]); + }); +}); + +describe('computeStudentBreakdown — level', () => { + it('appends a synthetic Level breakdown entry when enabled', () => { + const breakdown = computeStudentBreakdown({ + studentId: 1, + tabs: [], + assessments: [], + submissions: [], + level: 15, + levelContribution: lc(), + }); + const levelTab = breakdown.find((tb) => tb.tabId === LEVEL_TAB_ID); + expect(levelTab).toBeDefined(); + expect(levelTab!.assessments[0].title).toBe('Level'); + expect(levelTab!.assessments[0].points).toBeCloseTo(4); + expect(levelTab!.assessments[0].effectiveWeight).toBe(8); + }); + + it('omits the Level entry when disabled', () => { + const breakdown = computeStudentBreakdown({ + studentId: 1, + tabs: [], + assessments: [], + submissions: [], + level: 15, + levelContribution: lc({ enabled: false }), + }); + expect(breakdown.find((tb) => tb.tabId === LEVEL_TAB_ID)).toBeUndefined(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts b/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts new file mode 100644 index 00000000000..0de83a92a69 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts @@ -0,0 +1,153 @@ +import { parseFormula, seedLevelFormula } from '../levelFormula'; + +const evalOk = (src: string, level: number): number => { + const parsed = parseFormula(src); + if (!parsed.ok) throw new Error(`expected ok, got: ${parsed.error}`); + return parsed.evaluate(level); +}; + +describe('parseFormula', () => { + it('evaluates arithmetic with correct precedence', () => { + expect(evalOk('1 + 2 * 3', 0)).toBe(7); + expect(evalOk('(1 + 2) * 3', 0)).toBe(9); + expect(evalOk('10 / 2 - 1', 0)).toBe(4); + }); + + it('binds level as a raw variable (no clamp)', () => { + expect(evalOk('level * 0.05', 15)).toBeCloseTo(0.75); + expect(evalOk('level', 40)).toBe(40); + }); + + it('caps with min (the common cap idiom)', () => { + expect(evalOk('min(level, 25) * 0.05', 25)).toBeCloseTo(1.25); + expect(evalOk('min(level, 25) * 0.05', 40)).toBeCloseTo(1.25); // capped + expect(evalOk('min(level, 25) * 0.05', 10)).toBeCloseTo(0.5); + }); + + it('supports min and max (two-arg)', () => { + expect(evalOk('max(0, level - 5) * 0.1', 5)).toBe(0); + expect(evalOk('max(0, level - 5) * 0.1', 30)).toBeCloseTo(2.5); + }); + + it('supports floor, ceil, round (one-arg)', () => { + expect(evalOk('floor(level / 5)', 12)).toBe(2); + expect(evalOk('ceil(level / 10)', 11)).toBe(2); + expect(evalOk('round(level / 3)', 5)).toBe(2); // 1.66 -> 2 + }); + + it('supports unary minus', () => { + expect(evalOk('-level + 10', 3)).toBe(7); + }); + + it('rejects maxLevel — it is no longer a variable', () => { + expect(parseFormula('maxLevel').ok).toBe(false); + expect(parseFormula('level / maxLevel').ok).toBe(false); + }); + + it('rejects unknown identifiers (no eval surface)', () => { + expect(parseFormula('system').ok).toBe(false); + expect(parseFormula('window').ok).toBe(false); + expect(parseFormula('level.constructor').ok).toBe(false); + }); + + it('rejects unknown functions and wrong arity', () => { + expect(parseFormula('sqrt(level)').ok).toBe(false); + expect(parseFormula('eval(level)').ok).toBe(false); + expect(parseFormula('min(level)').ok).toBe(false); // min needs 2 args + expect(parseFormula('floor(level, 2)').ok).toBe(false); // floor needs 1 arg + }); + + it('rejects malformed input with a message', () => { + const r = parseFormula('level / '); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toMatch(/unfinished/i); + }); + + it('treats division by zero as a non-finite guard (returns 0)', () => { + expect(evalOk('level / 0', 5)).toBe(0); + }); +}); + +// Plain-language errors for non-technical staff. The thrown message is shown +// verbatim under the formula field, so it must read like prose, never compiler +// jargon (no "identifier", "token", "rparen"). +const errorOf = (src: string): string => { + const r = parseFormula(src); + if (r.ok) throw new Error(`expected a parse error for "${src}"`); + return r.error; +}; + +describe('parseFormula — plain-language errors', () => { + it('never leaks compiler jargon', () => { + const jargon = /identifier|token|rparen|lparen|trailing input/i; + [ + 'lveel', + 'min(level 20)', + 'level %2', + 'level / ', + 'min(level,)', + 'level 2', + 'flor(level)', + ].forEach((src) => { + expect(errorOf(src)).not.toMatch(jargon); + }); + }); + + it('suggests the nearest variable for a misspelt word', () => { + expect(errorOf('lveel')).toMatch(/“lveel” is not recognised/); + expect(errorOf('lveel')).toMatch(/did you mean “level”/i); + expect(errorOf('levl')).toMatch(/did you mean “level”/i); + }); + + it('suggests the nearest function for a misspelt call', () => { + expect(errorOf('flor(level)')).toMatch(/did you mean “floor”/i); + expect(errorOf('mn(level, 2)')).toMatch(/did you mean “min”/i); + }); + + it('lists the valid words when nothing is close', () => { + const msg = errorOf('xyzzy'); + expect(msg).toMatch(/“xyzzy” is not recognised/); + expect(msg).not.toMatch(/did you mean/i); + expect(msg).toMatch(/level, floor, ceil, round, min, max/); + }); + + it('lists only functions when an unknown call has no close match', () => { + const msg = errorOf('frobnicate(level)'); + expect(msg).not.toMatch(/did you mean/i); + expect(msg).toMatch(/floor, ceil, round, min, max/); + expect(msg).not.toMatch(/level,/); + }); + + it('explains a missing comma with an example', () => { + expect(errorOf('min(level 20)')).toMatch(/comma/i); + expect(errorOf('min(level 20)')).toMatch(/min\(level, 25\)/); + }); + + it('explains a missing closing bracket', () => { + expect(errorOf('min(level, 20')).toMatch(/closing bracket/i); + }); + + it('explains a stray symbol', () => { + expect(errorOf('level % 2')).toMatch(/“%” cannot be used/); + }); + + it('explains an unfinished formula', () => { + expect(errorOf('level *')).toMatch(/unfinished/i); + }); + + it('flags two values with a missing operator', () => { + expect(errorOf('level 2')).toMatch(/operator is missing/i); + expect(errorOf('level 2')).toMatch(/level × 2/); + }); + + it('explains an invalid number', () => { + expect(errorOf('1.2.3')).toMatch(/“1.2.3” is not a valid number/); + }); +}); + +describe('seedLevelFormula', () => { + it('bakes the course max as a cap (the hardcoded-cap idiom)', () => { + expect(seedLevelFormula(14)).toBe('min(level, 14)'); + expect(seedLevelFormula(25)).toBe('min(level, 25)'); + }); +}); 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..8eb293bf20b --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/store.test.ts @@ -0,0 +1,210 @@ +import reducer, { actions } from '../store'; + +const baseState = { + categories: [], + tabs: [ + { id: 1, title: 'T1', categoryId: 1, gradebookWeight: 50 }, + { id: 2, title: 'T2', categoryId: 1, gradebookWeight: 50 }, + ], + assessments: [ + { id: 101, title: 'A1', tabId: 1, maxGrade: 100 }, + { id: 102, title: 'A2', tabId: 1, maxGrade: 100 }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + }, +}; + +describe('UPDATE_TAB_WEIGHTS reducer', () => { + it('updates gradebookWeight and weightMode for the matching tab', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 80, weightMode: 'equal' }], + }), + ); + expect(next.tabs.find((t) => t.id === 1)?.gradebookWeight).toBe(80); + expect(next.tabs.find((t) => t.id === 1)?.weightMode).toBe('equal'); + expect(next.tabs.find((t) => t.id === 2)?.gradebookWeight).toBe(50); + }); + + it('does not set any excluded field', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 0, weightMode: 'equal' }], + }), + ); + const tab = next.tabs.find((t) => t.id === 1)!; + expect(tab).not.toHaveProperty('gradebookExcluded'); + }); + + it('updates multiple tabs in one action', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [ + { tabId: 1, weight: 30, weightMode: 'equal' }, + { tabId: 2, weight: 70, weightMode: 'custom' }, + ], + }), + ); + expect(next.tabs.find((t) => t.id === 1)?.gradebookWeight).toBe(30); + expect(next.tabs.find((t) => t.id === 2)?.gradebookWeight).toBe(70); + expect(next.tabs.find((t) => t.id === 2)?.weightMode).toBe('custom'); + }); + + it('applies per-assessment weights for custom mode', () => { + const next = reducer( + baseState, + actions.updateTabWeights({ + weights: [ + { + tabId: 1, + weight: 100, + weightMode: 'custom', + assessmentWeights: [ + { assessmentId: 101, weight: 30 }, + { assessmentId: 102, weight: 70 }, + ], + }, + ], + }), + ); + expect(next.assessments.find((a) => a.id === 101)?.gradebookWeight).toBe( + 30, + ); + expect(next.assessments.find((a) => a.id === 102)?.gradebookWeight).toBe( + 70, + ); + }); + + it('clears per-assessment weights for equal mode', () => { + const stateWithWeights = { + ...baseState, + assessments: [ + { id: 101, title: 'A1', tabId: 1, maxGrade: 100, gradebookWeight: 30 }, + { id: 102, title: 'A2', tabId: 1, maxGrade: 100, gradebookWeight: 70 }, + ], + }; + const next = reducer( + stateWithWeights, + actions.updateTabWeights({ + weights: [{ tabId: 1, weight: 100, weightMode: 'equal' }], + }), + ); + expect( + next.assessments.find((a) => a.id === 101)?.gradebookWeight, + ).toBeNull(); + expect( + next.assessments.find((a) => a.id === 102)?.gradebookWeight, + ).toBeNull(); + }); + + it('applies per-assessment exclusion flips from the payload', () => { + const start = reducer( + undefined, + actions.saveGradebook({ + categories: [{ id: 1, title: 'C' }], + tabs: [ + { + id: 10, + title: 'T', + categoryId: 1, + gradebookWeight: 50, + weightMode: 'equal', + }, + ], + assessments: [ + { id: 101, title: 'A', tabId: 10, maxGrade: 100 }, + { id: 102, title: 'B', tabId: 10, maxGrade: 100 }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + }, + }), + ); + + const next = reducer( + start, + actions.updateTabWeights({ + weights: [ + { + tabId: 10, + weight: 50, + weightMode: 'equal', + excludedAssessmentIds: [101], + }, + ], + }), + ); + + expect(next.assessments.find((a) => a.id === 101)!.gradebookExcluded).toBe( + true, + ); + expect(next.assessments.find((a) => a.id === 102)!.gradebookExcluded).toBe( + false, + ); + }); +}); + +describe('level contribution', () => { + it('stores levelContribution and courseMaxLevel on SAVE_GRADEBOOK', () => { + const data = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: true, + weightedViewEnabled: true, + canManageWeights: true, + courseMaxLevel: 20, + levelContribution: { + enabled: true, + formula: 'level / 20 * 8', + weight: 8, + show: true, + }, + }; + const next = reducer(undefined, actions.saveGradebook(data)); + expect(next.courseMaxLevel).toBe(20); + expect(next.levelContribution.enabled).toBe(true); + expect(next.levelContribution.weight).toBe(8); + }); + + it('applies an echoed levelContribution on UPDATE_TAB_WEIGHTS', () => { + const next = reducer( + undefined, + actions.updateTabWeights({ + weights: [], + levelContribution: { + enabled: true, + formula: 'level', + weight: 5, + show: false, + }, + }), + ); + expect(next.levelContribution.enabled).toBe(true); + expect(next.levelContribution.weight).toBe(5); + }); +}); 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..727378473de --- /dev/null +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx @@ -0,0 +1,837 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { + Alert, + Checkbox, + Collapse, + FormControlLabel, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; +import type { + AssessmentData, + CategoryData, + LevelContributionData, + StudentData, + 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 type { LevelOffender } from '../computeWeighted'; +import { + levelOffenders, + resolveTabWeights, + usingDefaultWeights, +} from '../computeWeighted'; +import { ParsedFormula, parseFormula, seedLevelFormula } from '../levelFormula'; +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.', + }, + levelTitle: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelTitle', + defaultMessage: 'Level contribution', + }, + levelSubtitle: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelSubtitle', + defaultMessage: + "Adds grade-points from each student's level, on top of tab contributions.", + }, + levelHighestStudent: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelHighestStudent', + defaultMessage: 'Highest student level: {level}', + }, + levelCourseMax: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelCourseMax', + defaultMessage: 'Course maximum level: {courseMaxLevel}', + }, + levelFormulaLabel: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelFormulaLabel', + defaultMessage: 'Formula', + }, + levelFormulaHelper: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelFormulaHelper', + defaultMessage: + 'Variable: level. Functions: floor, ceil, round, min, max. Operators: + − × ÷ and parentheses.', + }, + levelShowLabel: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelShowLabel', + defaultMessage: 'Show level column in table', + }, + levelOffendersAbove: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelOffendersAbove', + defaultMessage: + '{count, plural, =1 {{name1} is above {max}.} =2 {{name1} and {name2} are above {max}.} other {{name1}, {name2} and {extra} more are above {max}.}}', + }, + levelOffendersBelow: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelOffendersBelow', + defaultMessage: + '{count, plural, =1 {{name1} is below 0.} =2 {{name1} and {name2} are below 0.} other {{name1}, {name2} and {extra} more are below 0.}}', + }, + levelFixAtMost: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelFixAtMost', + defaultMessage: + 'Adjust the formula so every level contribution is at most {max}.', + }, + levelFixAtLeast: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelFixAtLeast', + defaultMessage: + 'Adjust the formula so every level contribution is at least 0.', + }, + levelFixBetween: { + id: 'course.gradebook.ConfigureWeightsPrompt.levelFixBetween', + defaultMessage: + 'Adjust the formula so every level contribution is between 0 and {max}.', + }, +}); + +type WeightMode = 'equal' | 'custom'; + +const r2 = (n: number): number => Math.round(n * 100) / 100; +const cents = (n: number): number => Math.round(n * 100); +// "Bob (60.00)" — student name with their contribution at 2dp, for the warning. +const fmtOffender = (o: LevelOffender): string => + `${o.name} (${r2(o.value).toFixed(2)})`; +// 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[]; + gamificationEnabled: boolean; + courseMaxLevel: number; + levelContribution: LevelContributionData; + students: StudentData[]; +} + +const ConfigureWeightsPrompt: FC = ({ + open, + onClose, + categories, + tabs, + assessments, + gamificationEnabled, + courseMaxLevel, + levelContribution, + students, +}) => { + 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); + + const [levelEnabled, setLevelEnabled] = useState(levelContribution.enabled); + const [levelFormula, setLevelFormula] = useState( + levelContribution.formula || seedLevelFormula(courseMaxLevel), + ); + // Seed the suggested maximum to the course's top level when unconfigured (weight 0), + // so the default formula `min(level, courseMaxLevel)` lands every contribution in + // range and the feature works the first time staff enable it — no startup warning. + const [levelWeight, setLevelWeight] = useState( + levelContribution.weight || courseMaxLevel, + ); + const [levelShow, setLevelShow] = useState(levelContribution.show); + + useEffect(() => { + if (open) { + setWeights(seedWeights()); + setModes(seedModes()); + setAssessmentWeights(seedAssessmentWeights()); + setExcluded(seedExclusions()); + setExpanded({}); + setLevelEnabled(levelContribution.enabled); + setLevelFormula( + levelContribution.formula || seedLevelFormula(courseMaxLevel), + ); + setLevelWeight(levelContribution.weight || courseMaxLevel); + setLevelShow(levelContribution.show); + } + }, [open]); + + const tabAssessmentIds = (tabId: number): number[] => + assessments.filter((a) => a.tabId === tabId).map((a) => a.id); + + const tabIncludedIds = (tabId: number): number[] => + tabAssessmentIds(tabId).filter((id) => !excluded[id]); + + const customSum = (tabId: number): number => + tabIncludedIds(tabId).reduce( + (acc, id) => acc + (assessmentWeights[id] ?? 0), + 0, + ); + + const isUnbalanced = (tabId: number): boolean => + (modes[tabId] ?? 'equal') === 'custom' && + tabIncludedIds(tabId).length > 0 && + cents(customSum(tabId)) !== cents(weights[tabId] ?? 0); + + const isAllExcluded = (tabId: number): boolean => + tabAssessmentIds(tabId).length > 0 && tabIncludedIds(tabId).length === 0; + + // An all-excluded tab contributes nothing, so its stored weight is treated as 0 + // for the Total. The stored value is retained (still saved, restored on re-include). + const effectiveWeight = (tabId: number): number => + isAllExcluded(tabId) ? 0 : weights[tabId] ?? 0; + + const parsedLevel: ParsedFormula | null = + levelEnabled && levelFormula ? parseFormula(levelFormula) : null; + const levelParseError = + parsedLevel && !parsedLevel.ok ? parsedLevel.error : null; + // Students whose contribution falls outside [0, levelWeight], split by bound — + // the out-of-range warning names the worst offenders on each side. + const offenders = levelOffenders(students, parsedLevel, levelWeight); + // The cohort's real top level — distinct from courseMaxLevel (the course ceiling). + // Helps staff pick a sensible cap; null when there are no students to read. + const highestStudentLevel = students.length + ? Math.max(...students.map((s) => s.level)) + : null; + // Which bound(s) the cohort breaches — drives a message scoped to the actual + // problem (only-below, only-above, or both). + const hasBelow = offenders.below.length > 0; + const hasAbove = offenders.above.length > 0; + // Fix instruction matching the breached bound(s). + let levelFixMessage = translations.levelFixAtLeast; + if (hasBelow && hasAbove) levelFixMessage = translations.levelFixBetween; + else if (hasAbove) levelFixMessage = translations.levelFixAtMost; + + const sum = + tabs.reduce((acc, tb) => acc + effectiveWeight(tb.id), 0) + + (levelEnabled ? levelWeight : 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 || !!levelParseError) 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; + }), + { + enabled: levelEnabled, + formula: levelFormula, + weight: levelWeight, + show: levelShow, + }, + ), + ); + onClose(); + } catch { + toast.error(t(translations.saveError)); + } finally { + setSubmitting(false); + } + }; + + return ( + + {showingDefaults && ( + + {t(translations.defaultsHint)} + + )} + + {t(translations.descriptionIntro)} + +
    + {[ + translations.descriptionWeights, + translations.descriptionExclusion, + translations.descriptionModes, + ].map((key) => ( + + {t(key)} + + ))} +
+ + {categories.map((cat) => ( +
+ {cat.title} + + {tabs + .filter((tb) => tb.categoryId === cat.id) + .map((tb) => { + const value = weights[tb.id] ?? 0; + const err = validate(value); + const tabAssessments = assessments.filter( + (a) => a.tabId === tb.id, + ); + const mode = modes[tb.id] ?? 'equal'; + const isExpanded = !!expanded[tb.id]; + const unbalanced = isUnbalanced(tb.id); + const noAssessments = tabAssessments.length === 0; + const includedCount = tabIncludedIds(tb.id).length; + const excludedCount = tabAssessments.length - includedCount; + const pct = includedCount > 0 ? r2(value / includedCount) : 0; + + return ( +
+
+ toggleExpanded(tb.id)} + size="small" + > + {isExpanded ? ( + + ) : ( + + )} + + + {tb.title} + {excludedCount > 0 && ( + <> + {' · '} + + {isAllExcluded(tb.id) + ? t(translations.allExcludedCount, { + n: excludedCount, + }) + : t(translations.excludedCount, { + n: excludedCount, + })} + + + )} + + handleModeChange(tb.id, next)} + options={[ + { + value: 'equal', + label: t(translations.equalMode), + }, + { + value: 'custom', + label: t(translations.customMode), + }, + ]} + value={mode} + /> + + setWeights((prev) => ({ + ...prev, + [tb.id]: r2(prev[tb.id] ?? 0), + })) + } + onChange={(e) => handleChange(tb.id, e.target.value)} + size="small" + sx={{ width: 96 }} + type="number" + value={isAllExcluded(tb.id) ? 0 : value} + /> +
+ {err && ( + + {err} + + )} + {unbalanced && ( + + {t(translations.unbalanced, { tab: tb.title })} + + )} + {isAllExcluded(tb.id) && ( + + {t(translations.allExcluded, { tab: tb.title })} + + )} + + + {tabAssessments.map((a) => { + const isExcluded = !!excluded[a.id]; + const checkbox = ( + handleToggleExcluded(a.id)} + size="small" + /> + ); + if (mode === 'custom') { + const awValue = assessmentWeights[a.id] ?? 0; + const awErr = validate(awValue); + return ( +
+
+ {checkbox} + + {a.title} + +
+ {isExcluded ? ( + + {t(translations.excluded)} + + ) : ( + + setAssessmentWeights((prev) => ({ + ...prev, + [a.id]: r2(prev[a.id] ?? 0), + })) + } + onChange={(e) => + handleAssessmentChange( + a.id, + e.target.value, + ) + } + size="small" + sx={{ width: 88 }} + type="number" + value={awValue} + /> + )} +
+ ); + } + return ( +
+
+ {checkbox} + + {a.title} + +
+ + {isExcluded + ? t(translations.excluded) + : t(translations.ofGrade, { + pct: pct.toFixed(2), + })} + +
+ ); + })} + {mode === 'custom' && ( + + {t(translations.customSum, { + sum: r2(customSum(tb.id)).toFixed(2), + total: value.toFixed(2), + })} + + )} +
+
+
+ ); + })} +
+
+ ))} +
+ {gamificationEnabled && ( +
+
+ setLevelEnabled(e.target.checked)} + size="small" + /> + } + label={ + + {t(translations.levelTitle)} + + } + sx={{ flex: 1, mr: 0 }} + /> + setLevelWeight((prev) => r2(prev))} + onChange={(e) => + setLevelWeight( + e.target.value === '' ? 0 : Number(e.target.value), + ) + } + size="small" + sx={{ width: 96 }} + type="number" + value={levelEnabled ? levelWeight : 0} + /> +
+ {levelEnabled && ( + + + {t(translations.levelSubtitle)} + + 0 + ? undefined + : t(translations.levelFormulaHelper)) + } + inputProps={{ 'aria-label': t(translations.levelFormulaLabel) }} + label={t(translations.levelFormulaLabel)} + onChange={(e) => setLevelFormula(e.target.value)} + size="small" + value={levelFormula} + /> + + {t(translations.levelCourseMax, { courseMaxLevel })} + + setLevelShow(e.target.checked)} + size="small" + /> + } + label={ + + {t(translations.levelShowLabel)} + + } + /> + {(hasBelow || hasAbove) && ( + + + {[ + hasAbove && + t(translations.levelOffendersAbove, { + count: offenders.above.length, + name1: fmtOffender(offenders.above[0]), + name2: offenders.above[1] + ? fmtOffender(offenders.above[1]) + : '', + extra: Math.max(0, offenders.above.length - 2), + max: levelWeight, + }), + hasBelow && + t(translations.levelOffendersBelow, { + count: offenders.below.length, + name1: fmtOffender(offenders.below[0]), + name2: offenders.below[1] + ? fmtOffender(offenders.below[1]) + : '', + extra: Math.max(0, offenders.below.length - 2), + }), + ] + .filter(Boolean) + .join(' ')} + + + {t(levelFixMessage, { max: levelWeight })} + + + )} + + )} +
+ )} + + {t(translations.total, { sum })} + + {sum !== 100 && ( + + {t(translations.weightsDoNotSum)} + + )} +
+ ); +}; + +export default ConfigureWeightsPrompt; diff --git a/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx b/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx new file mode 100644 index 00000000000..7b2e8f44a1f --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradeLinkHint.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, Typography } from '@mui/material'; + +import { getUserEntity } from 'bundles/users/selectors'; +import { useAppSelector } from 'lib/hooks/store'; +import useDismissibleOnce from 'lib/hooks/useDismissibleOnce'; +import useTranslation from 'lib/hooks/useTranslation'; + +export const GRADE_LINK_HINT_KEY = 'gradebook_grade_link_hint'; + +const translations = defineMessages({ + hint: { + id: 'course.gradebook.GradeLinkHint.hint', + defaultMessage: + "Each grade is the total of the marks in a student's submission. Click any grade to open that submission and adjust the marks.", + }, +}); + +/** + * One-time, dismissable nudge explaining that each grade in the "All assessments" + * table is a link into the student's submission. Grades have no gradebook-level edit + * (an assessment's total is always the sum of its question marks), so clicking into the + * submission is the only path to changing a grade — an affordance that surprises users + * because a number is not expected to be clickable. + * Dismissal is remembered per user via localStorage (see useDismissibleOnce). + */ +const GradeLinkHint: FC = () => { + const { t } = useTranslation(); + const userId = useAppSelector(getUserEntity).id; + const { dismissed, dismiss } = useDismissibleOnce( + GRADE_LINK_HINT_KEY, + userId, + ); + + if (dismissed) return null; + + return ( +
+ + {t(translations.hint)} + +
+ ); +}; + +export default GradeLinkHint; diff --git a/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx new file mode 100644 index 00000000000..e2b5d526485 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookColumnTree.tsx @@ -0,0 +1,219 @@ +import { useMemo } from 'react'; +import { defineMessages } from 'react-intl'; +import { Chip } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import { + ColumnPickerRenderContext, + ColumnPickerTreeGroup, +} from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; + +import { + GAMIFICATION_COL_IDS, + type GamificationColId, + STUDENT_INFO_COL_IDS, + type StudentInfoColId, +} from '../constants'; +import type { AssessmentData, CategoryData, TabData } from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; + +const translations = defineMessages({ + studentInfo: { + id: 'course.gradebook.GradebookColumnTree.studentInfo', + defaultMessage: 'Student info', + }, + gamification: { + id: 'course.gradebook.GradebookColumnTree.gamification', + defaultMessage: 'Gamification', + }, + grades: { + id: 'course.gradebook.GradebookColumnTree.grades', + defaultMessage: 'Grades', + }, + alwaysIncluded: { + id: 'course.gradebook.GradebookColumnTree.alwaysIncluded', + defaultMessage: 'Always included', + }, +}); + +interface GradebookColumnTreeProps extends ColumnPickerRenderContext { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + gamificationEnabled: boolean; +} + +const GAMIFICATION_ALL_IDS = [...GAMIFICATION_COL_IDS]; + +const GradebookColumnTree = ({ + isVisible, + setVisible, + setManyVisible, + categories, + tabs, + assessments, + gamificationEnabled, +}: GradebookColumnTreeProps): JSX.Element => { + const { t } = useTranslation(); + const context: ColumnPickerRenderContext = { + isVisible, + setVisible, + setManyVisible, + }; + + const asnIds = useMemo( + () => assessments.map((a) => buildAssessmentColumnId(a.id)), + [assessments], + ); + + const tabAsnIds = useMemo(() => { + const map = new Map(); + assessments.forEach((a) => { + const existing = map.get(a.tabId) ?? []; + map.set(a.tabId, [...existing, buildAssessmentColumnId(a.id)]); + }); + return map; + }, [assessments]); + + const catTabs = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, tab]); + }); + return map; + }, [tabs]); + + const asnById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + + const catAsnIds = useMemo(() => { + const map = new Map(); + tabs.forEach((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + const existing = map.get(tab.categoryId) ?? []; + map.set(tab.categoryId, [...existing, ...tabIds]); + }); + return map; + }, [tabs, tabAsnIds]); + + return ( +
+ + {STUDENT_INFO_COL_IDS.map((id: StudentInfoColId) => + id === 'name' ? ( + + {t(tableTranslations[id])} + + + } + /> + ) : ( + setVisible(id, e.target.checked)} + /> + ), + )} + + + {gamificationEnabled && ( + + {GAMIFICATION_COL_IDS.map((id: GamificationColId) => ( + setVisible(id, e.target.checked)} + /> + ))} + + )} + + + {categories.map((cat) => { + const catIds = catAsnIds.get(cat.id) ?? []; + const thisCatTabs = catTabs.get(cat.id) ?? []; + return ( + + {thisCatTabs.map((tab) => { + const tabIds = tabAsnIds.get(tab.id) ?? []; + return ( + + {tabIds.map((id) => { + const asnId = parseAssessmentColumnId(id); + const asn = + asnId !== null ? asnById.get(asnId) : undefined; + if (!asn) return null; + return ( + setVisible(id, e.target.checked)} + /> + ); + })} + + ); + })} + + ); + })} + +
+ ); +}; + +export default GradebookColumnTree; diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx new file mode 100644 index 00000000000..5c6c7abe4d1 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -0,0 +1,809 @@ +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { defineMessages } from 'react-intl'; +import { + Checkbox, + Paper, + type SxProps, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TableSortLabel, + type Theme, + Tooltip, +} from '@mui/material'; +import { flexRender } from '@tanstack/react-table'; + +import Link from 'lib/components/core/Link'; +import type { + ColumnPickerRenderContext, + ColumnTemplate, +} from 'lib/components/table/builder'; +import MuiTablePagination from 'lib/components/table/MuiTableAdapter/MuiTablePagination'; +import MuiTableToolbar from 'lib/components/table/MuiTableAdapter/MuiTableToolbar'; +import useTanStackTableBuilder from 'lib/components/table/TanStackTableBuilder'; +import { + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + DEFAULT_TABLE_ROWS_PER_PAGE, +} from 'lib/constants/sharedConstants'; +import { getEditSubmissionURL } from 'lib/helpers/url-builders'; +import useTranslation from 'lib/hooks/useTranslation'; +import tableTranslations from 'lib/translations/table'; + +import { GAMIFICATION_COL_IDS } from '../constants'; +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from '../types'; + +import { + buildAssessmentColumnId, + parseAssessmentColumnId, +} from './buildAssessmentColumnIds'; +import GradebookColumnTree from './GradebookColumnTree'; + +const COL_WIDTHS = { + name: 160, + email: 250, + externalId: 160, + level: 80, + totalXp: 120, + assessment: 150, +} as const; + +const CHECKBOX_WIDTH = 56; + +const getColWidth = (id: string): number => + COL_WIDTHS[id as keyof typeof COL_WIDTHS] ?? COL_WIDTHS.assessment; + +const isLeftAligned = (id: string): boolean => + id === 'name' || id === 'email' || id === 'externalId'; + +const translations = defineMessages({ + searchStudents: { + id: 'course.gradebook.GradebookIndex.searchStudents', + defaultMessage: 'Search students', + }, + exportButton: { + id: 'course.gradebook.GradebookIndex.exportButton', + defaultMessage: 'Export all rows', + }, + exportRows: { + id: 'course.gradebook.GradebookIndex.exportRows', + defaultMessage: 'Export {count, plural, one {# row} other {# rows}}', + }, + exportAllTooltip: { + id: 'course.gradebook.GradebookIndex.exportAllTooltip', + defaultMessage: 'No rows selected - all rows will be exported.', + }, + selectColumns: { + id: 'course.gradebook.GradebookIndex.selectColumns', + defaultMessage: 'Select Columns', + }, + dialogTitle: { + id: 'course.gradebook.GradebookIndex.dialogTitle', + defaultMessage: 'Select columns', + }, + maxMarks: { + id: 'course.gradebook.GradebookTable.maxMarks', + defaultMessage: 'Max Marks', + }, + noDataColumnsHint: { + id: 'course.gradebook.GradebookTable.noDataColumnsHint', + defaultMessage: + 'No grade columns selected - export will include student info only.', + }, + noDataColumnsHintWithGamification: { + id: 'course.gradebook.GradebookTable.noDataColumnsHintWithGamification', + defaultMessage: + 'No grade or gamification columns selected - export will include student info only.', + }, +}); + +const HeaderLabel = forwardRef< + HTMLSpanElement, + { text: string; onSingleLine: (fits: boolean) => void } +>(({ text, onSingleLine }, forwardedRef): JSX.Element => { + const innerRef = useRef(null); + const [display, setDisplay] = useState(text); + + useLayoutEffect(() => { + const el = innerRef.current; + if (!el) return; + + const lh = parseFloat(getComputedStyle(el).lineHeight) || 20; + const oneLineH = lh + 1; + const twoLineH = lh * 2 + 1; + + el.textContent = text; + + if (el.scrollHeight <= oneLineH) { + onSingleLine(true); + setDisplay(text); + return; + } + + onSingleLine(false); + + if (el.scrollHeight <= twoLineH) { + setDisplay(text); + return; + } + + let lo = 1; + let hi = text.length; + let best = `${text[0]}…`; + while (lo <= hi) { + const mid = Math.floor((lo + hi) / 2); + const candidate = `${text.slice(0, mid)}…`; + el.textContent = candidate; + if (el.scrollHeight <= twoLineH) { + best = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + // Ensure DOM reflects `best` before React reconciles — the loop's last + // el.textContent assignment may be a too-long candidate, not `best`. + el.textContent = best; + setDisplay(best); + }, [text, onSingleLine]); + + return ( + { + innerRef.current = node; + if (typeof forwardedRef === 'function') forwardedRef(node); + else if (forwardedRef) forwardedRef.current = node; + }} + style={{ display: 'block' }} + > + {display} + + ); +}); +HeaderLabel.displayName = 'HeaderLabel'; + +interface GradebookRow { + studentId: number; + name: string; + email: string; + externalId: string | null; + level: number; + totalXp: number; + grades: Partial>; + submissionIds: Partial>; +} + +interface GradebookTableProps { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + courseTitle: string; + courseId: number; + gamificationEnabled: boolean; +} + +const GradebookTable = ({ + categories, + tabs, + assessments, + students, + submissions, + courseTitle, + courseId, + gamificationEnabled, +}: GradebookTableProps): JSX.Element => { + const { t } = useTranslation(); + + const submissionsByStudent = useMemo(() => { + const map = new Map(); + submissions.forEach((s) => { + const existing = map.get(s.studentId); + if (existing) { + existing.push(s); + } else { + map.set(s.studentId, [s]); + } + }); + return map; + }, [submissions]); + + const rows = useMemo( + () => + students.map((student) => { + const subs = submissionsByStudent.get(student.id) ?? []; + const grades: Partial> = {}; + const submissionIds: Partial> = {}; + assessments.forEach((a) => { + const sub = subs.find((s) => s.assessmentId === a.id); + if (sub != null) { + grades[a.id] = sub.grade; + submissionIds[a.id] = sub.submissionId; + } + }); + return { + studentId: student.id, + name: student.name, + email: student.email, + externalId: student.externalId, + level: student.level, + totalXp: student.totalXp, + grades, + submissionIds, + }; + }), + [students, assessments, submissionsByStudent], + ); + + const hasExternalIds = useMemo( + () => students.some((s) => s.externalId != null && s.externalId !== ''), + [students], + ); + + const columns = useMemo[]>(() => { + const cols: ColumnTemplate[] = [ + { + id: 'name', + title: t(tableTranslations.name), + of: 'name', + cell: (row) => row.name, + csvDownloadable: true, + searchable: true, + sortable: true, + searchProps: { getValue: (row) => row.name }, + }, + { + id: 'email', + title: t(tableTranslations.email), + of: 'email', + cell: (row) => row.email, + csvDownloadable: true, + searchable: true, + sortable: true, + }, + ]; + + // The External ID column is always offered in the picker, but only shown by + // default when the course actually uses external IDs (see column picker). + cols.push({ + id: 'externalId', + title: t(tableTranslations.externalId), + of: 'externalId', + cell: (row) => row.externalId ?? '', + csvDownloadable: true, + searchable: true, + sortable: true, + defaultVisible: hasExternalIds, + }); + + if (gamificationEnabled) { + cols.push({ + id: 'level', + title: t(tableTranslations.level), + of: 'level', + cell: (row) => row.level, + csvDownloadable: true, + sortable: true, + }); + cols.push({ + id: 'totalXp', + title: t(tableTranslations.totalXp), + of: 'totalXp', + cell: (row) => row.totalXp, + csvDownloadable: true, + sortable: true, + }); + } + + assessments.forEach((asn) => { + const colId = buildAssessmentColumnId(asn.id); + cols.push({ + id: colId, + title: asn.title, + // null (ungraded) → undefined so sortUndefined: 'last' fires for both missing and ungraded rows + accessorFn: (row) => row.grades[asn.id] ?? undefined, + sortable: true, + sortProps: { + undefinedPriority: 'last', + descFirst: false, + sort: (a, b) => { + const aGrade = a.grades[asn.id]; + const bGrade = b.grades[asn.id]; + if (aGrade == null || bGrade == null) return 0; + return aGrade - bGrade; + }, + }, + cell: (row) => { + const grade = row.grades[asn.id]; + if (grade === undefined) return '—'; + if (grade === null) return ''; + const submissionId = row.submissionIds[asn.id]; + if (submissionId != null) + return ( + + {grade} + + ); + return grade; + }, + csvDownloadable: true, + defaultVisible: false, + }); + }); + return cols; + }, [assessments, gamificationEnabled, hasExternalIds, t]); + + const assessmentMaxGrades = useMemo( + () => new Map(assessments.map((a) => [a.id, a.maxGrade])), + [assessments], + ); + + const dataColumnIds = useMemo( + () => [ + ...assessments.map((a) => buildAssessmentColumnId(a.id)), + ...GAMIFICATION_COL_IDS, + ], + [assessments], + ); + + const columnPicker = useMemo( + () => ({ + render: (context: ColumnPickerRenderContext) => ( + + ), + locked: ['name'], + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + getExtraHeaderRows: (colIds): string[][] => { + const hasAssessments = colIds.some( + (id) => parseAssessmentColumnId(id) !== null, + ); + if (!hasAssessments) return []; + return [ + colIds.map((id) => { + if (id === 'name') return t(translations.maxMarks); + const asnId = parseAssessmentColumnId(id); + if (asnId !== null) + return String(assessmentMaxGrades.get(asnId) ?? ''); + return ''; + }), + ]; + }, + storageKey: `gradebook_columns_${courseId}`, + dataColumnIds, + noDataColumnsHint: gamificationEnabled + ? t(translations.noDataColumnsHintWithGamification) + : t(translations.noDataColumnsHint), + }), + [ + assessments, + categories, + gamificationEnabled, + tabs, + t, + assessmentMaxGrades, + courseId, + dataColumnIds, + ], + ); + + const { toolbar, body, pagination, header } = + useTanStackTableBuilder({ + data: rows, + columns, + getRowId: (row) => row.studentId.toString(), + getRowEqualityData: (row) => row, + indexing: { rowSelectable: true }, + pagination: { + initialPageSize: DEFAULT_TABLE_ROWS_PER_PAGE, + rowsPerPage: [ + DEFAULT_MINI_TABLE_ROWS_PER_PAGE, + 25, + 50, + DEFAULT_TABLE_ROWS_PER_PAGE, + ], + showAllRows: true, + }, + search: { searchPlaceholder: t(translations.searchStudents) }, + sort: { + initially: { by: 'name', order: 'asc' }, + enableRemoval: false, + resetOnHide: true, + }, + toolbar: { show: true, keepNative: true }, + csvDownload: { + filename: `${courseTitle}_gradebook`, + showDownloadButton: false, + }, + columnPicker, + }); + + const visibility = toolbar?.getColumnVisibility?.() ?? {}; + const isColVisible = (id: string): boolean => visibility[id] ?? true; + const visibleCols = columns.filter((c) => + isColVisible(c.id ?? (c.of as string)), + ); + + const sortByColId = new Map( + (header?.headers ?? []).map( + (h, i) => [h.id, header?.forEach(h, i).sorting] as const, + ), + ); + + const selectedCount = body.selectedCount ?? 0; + + const directExportLabel = useMemo((): string => { + const isPartialSelection = selectedCount > 0 && selectedCount < rows.length; + if (isPartialSelection) + return t(translations.exportRows, { count: selectedCount }); + return t(translations.exportButton); + }, [selectedCount, rows.length, t]); + + const toolbarWithLabel = toolbar?.columnPicker + ? { + ...toolbar, + columnPicker: { + ...toolbar.columnPicker, + directExportLabel, + directExportTooltip: + selectedCount === 0 ? t(translations.exportAllTooltip) : undefined, + }, + } + : toolbar; + + const totalWidth = useMemo( + () => + CHECKBOX_WIDTH + + visibleCols.reduce((sum, c) => { + const id = c.id ?? (c.of as string); + return sum + getColWidth(id); + }, 0), + [visibleCols], + ); + + const allRowsSelected = body.allFilteredSelected ?? false; + const someRowsSelected = body.someFilteredSelected ?? false; + const toggleAllRows = (): void => body.toggleAllFiltered?.(); + + const hasVisibleAssessments = useMemo( + () => + visibleCols.some( + (c) => parseAssessmentColumnId(c.id ?? (c.of as string)) !== null, + ), + [visibleCols], + ); + + const row1Ref = useRef(null); + const [row2Top, setRow2Top] = useState(0); + useLayoutEffect(() => { + setRow2Top(row1Ref.current?.offsetHeight ?? 0); + }, [visibleCols]); + + const headerFitsRef = useRef>({}); + const [headerFits, setHeaderFits] = useState>({}); + const onSingleLine = useCallback((id: string, fits: boolean): void => { + if (headerFitsRef.current[id] !== fits) { + headerFitsRef.current[id] = fits; + setHeaderFits((prev) => ({ ...prev, [id]: fits })); + } + }, []); + const singleLineCallbacks = useMemo( + () => + new Map( + visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return [id, (f: boolean): void => onSingleLine(id, f)]; + }), + ), + [visibleCols, onSingleLine], + ); + + return ( +
+ +
+ + {/* A bounded maxHeight is what makes `stickyHeader` actually stick: + `overflowX: 'auto'` already promotes this container to a scroll + container on both axes, so the sticky and the frozen + checkbox/Name columns pin relative to THIS element. Without a height + cap the container grows to fit every row and never scrolls + internally, leaving the header no scroll range. */} + + ({ + tableLayout: 'fixed', + borderCollapse: 'separate', + borderSpacing: 0, + + '& th, & td': { + boxSizing: 'border-box', + border: 0, + + // Draws the cell grid without relying on collapsed borders. + borderBottom: `0.5px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + return ; + })} + + + + ({ + top: 0, + zIndex: 3, + position: 'sticky', + left: 0, + bgcolor: 'background.default', + width: CHECKBOX_WIDTH, + minWidth: CHECKBOX_WIDTH, + maxWidth: CHECKBOX_WIDTH, + px: 0, + textAlign: 'center', + verticalAlign: 'middle', + // Solid 1px bottom seam under the frozen header columns. The + // table's 0.5px grid border (specificity 0,1,1) outranks a + // plain per-cell sx (0,1,0), so double the selector with `&&` + // (0,2,0) to win — and a full 1px survives sticky scroll + // compositing where 0.5px can drop and let the body show + // through the row1/row2 seam. + '&&': { + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + })} + > + + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const label = typeof c.title === 'string' ? c.title : id; + const isLeft = isLeftAligned(id); + const fits = headerFits[id] ?? false; + const sort = sortByColId.get(id); + const labelNode = ( + + + + + + ); + return ( + ({ + verticalAlign: isLeft || fits ? 'middle' : 'bottom', + ...(id === 'name' && { + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 3, + bgcolor: 'background.default', + // Right edge of the frozen region + matching 1px + // bottom seam. `&&` (0,2,0) is needed to beat the + // table's `& th` border rule (0,1,1). + '&&': { + borderRight: `1px solid ${theme.palette.grey[200]}`, + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + }), + })} + > + {sort ? ( + + {labelNode} + + ) : ( + labelNode + )} + + ); + })} + + {hasVisibleAssessments && ( + ({ + '& .MuiTableCell-stickyHeader': { + top: row2Top, + }, + // Solid 1px bottom edge under the whole Max Marks row so the + // frozen columns read as a complete header block and the + // body never shows through on scroll. `& .MuiTableCell-root` + // (0,2,0) outranks the table's `& th` rule (0,1,1). + '& .MuiTableCell-root': { + borderTop: `1px solid ${theme.palette.grey[200]}`, + borderBottom: `1px solid ${theme.palette.grey[200]}`, + }, + })} + > + + {visibleCols.map((c) => { + const id = c.id ?? (c.of as string); + const asnId = parseAssessmentColumnId(id); + let cellContent: string = ''; + if (id === 'name') cellContent = t(translations.maxMarks); + else if (asnId !== null) { + const maxGrade = assessmentMaxGrades.get(asnId); + cellContent = maxGrade != null ? `/${maxGrade}` : ''; + } + return ( + ({ + bgcolor: 'grey.100', + ...(id === 'name' && { + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 3, + // Continue the frozen region's right edge. + '&&': { + borderTop: `1px solid ${theme.palette.grey[200]}`, + borderRight: `1px solid ${theme.palette.grey[200]}`, + }, + }), + })} + > + {cellContent} + + ); + })} + + )} + + + {body.rows.map((row, idx) => { + const rowProps = body.forEachRow(row, idx); + return ( + + ({ + position: 'sticky', + left: 0, + zIndex: 1, + bgcolor: 'background.paper', + width: CHECKBOX_WIDTH, + minWidth: CHECKBOX_WIDTH, + maxWidth: CHECKBOX_WIDTH, + px: 0, + textAlign: 'center', + // Sticky cells composite on their own layer, so this + // cell's `borderBottom` gets covered by the next row's + // opaque sticky background (Blink) — dropping the + // separator. Draw it as the lower row's `borderTop` + // instead; that border is owned by the row's own + // layer and always paints. Row 0's top edge is already + // the header cell's (higher z-index) bottom border. + borderBottom: 'none', + borderTop: + idx === 0 + ? undefined + : `0.5px solid ${theme.palette.grey[200]}`, + })} + > + + + {row + .getVisibleCells() + .filter((cell) => cell.column.id !== 'rowSelector') + .map((cell) => { + // Sticky cover for the frozen `name` column, mirroring + // the checkbox cell above. Declared as a directly-typed + // const so the callback is contextually typed (a ternary + // in the `sx` prop would strip that context). + const nameCellSx: SxProps = (theme) => ({ + position: 'sticky', + left: CHECKBOX_WIDTH, + zIndex: 1, + bgcolor: 'background.paper', + // Same sticky-layer cover as the checkbox column: draw + // the separator as the lower row's `borderTop`, not a + // covered `borderBottom`. + borderBottom: 'none', + borderTop: + idx === 0 + ? undefined + : `0.5px solid ${theme.palette.grey[200]}`, + // Continue the frozen region's right edge down the data + // rows. `&&` (0,2,0) beats the table's `& td` border + // rule (0,1,1). + '&&': { + borderRight: `1px solid ${theme.palette.grey[200]}`, + }, + }); + return ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ); + })} + + ); + })} + +
+
+ {pagination && } +
+
+
+ ); +}; + +export default GradebookTable; diff --git a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx new file mode 100644 index 00000000000..94b901effc9 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx @@ -0,0 +1,1348 @@ +import { Fragment, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Download, + InfoOutlined, + KeyboardArrowDown, + KeyboardArrowRight, + WarningAmber, +} 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, + LevelContributionData, + 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, + levelOffenders, + LEVEL_TAB_ID, + resolveTabWeights, + usingDefaultWeights, +} from '../computeWeighted'; +import { parseFormula } from '../levelFormula'; + +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', + }, + levelHeader: { + id: 'course.gradebook.GradebookWeightedTable.levelHeader', + defaultMessage: 'Level', + }, + levelContributionHeader: { + id: 'course.gradebook.GradebookWeightedTable.levelContributionHeader', + defaultMessage: 'Level Contribution', + }, + levelBreakdownDetail: { + id: 'course.gradebook.GradebookWeightedTable.levelBreakdownDetail', + defaultMessage: 'Level {level}', + }, + levelOverBudgetAboveOnly: { + id: 'course.gradebook.GradebookWeightedTable.levelOverBudgetAboveOnly', + defaultMessage: + "Some students' level contributions are above the level weight. Adjust the formula in Configure Weights to bring them within range.", + }, + levelOverBudgetBelowOnly: { + id: 'course.gradebook.GradebookWeightedTable.levelOverBudgetBelowOnly', + defaultMessage: + "Some students' level contributions are below 0. Adjust the formula in Configure Weights to bring them within range.", + }, + levelOverBudgetBoth: { + id: 'course.gradebook.GradebookWeightedTable.levelOverBudgetBoth', + defaultMessage: + "Some students' level contributions are outside the valid range (below 0 or above the level weight). Adjust the formula in Configure Weights to bring them within range.", + }, +}); + +type DisplayMode = 'points' | 'percent'; + +interface Props { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + canManageWeights: boolean; + courseTitle: string; + courseId: number; + gamificationEnabled: boolean; + courseMaxLevel: number; + levelContribution: LevelContributionData; +} + +// 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, + gamificationEnabled, + courseMaxLevel, + levelContribution, +}: 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]); + + // Level Contribution column: shown whenever the contribution is active (drives + // the total + per-row points too). + const showLevelContribution = + gamificationEnabled && levelContribution.enabled; + // Raw Level column: the actual student level, shown alongside the contribution + // so staff can sanity-check it. Gated additionally on `show`. + // + // Both columns are server-controlled (not in the column picker), so their + // *presence* — not just their default visibility — is gated here, and they are + // locked visible (below). Plumbing these through `defaultVisible` failed: + // defaultVisible is a one-time seed that loses to stale persisted localStorage, + // stranding a column hidden with no picker entry to recover it. + const showRawLevel = showLevelContribution && levelContribution.show; + + // The level weight is a suggested maximum (never caps the total), so a formula + // can push a student's contribution past it. Flag that on the column subheader. + // Reuses the dialog's check so both views warn on identical conditions. + const levelBudgetOffenders = useMemo( + () => + showLevelContribution + ? levelOffenders( + students, + parseFormula(levelContribution.formula), + levelContribution.weight, + ) + : null, + [showLevelContribution, students, levelContribution], + ); + const levelOverBudget = + levelBudgetOffenders !== null && + (levelBudgetOffenders.below.length > 0 || + levelBudgetOffenders.above.length > 0); + const tabTotalWeight = resolvedTabs.reduce( + (acc, tab) => + acc + (allExcludedTabIds.has(tab.id) ? 0 : tab.gradebookWeight ?? 0), + 0, + ); + const totalWeight = + tabTotalWeight + (showLevelContribution ? levelContribution.weight : 0); + const allWeightsZero = tabTotalWeight === 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 studentLevelById = useMemo( + () => new Map(students.map((s) => [s.id, s.level])), + [students], + ); + + const breakdownsByStudent = useMemo( + () => + new Map( + [...expandedIds].map((studentId) => { + const breakdown = computeStudentBreakdown({ + studentId, + tabs: resolvedTabs, + assessments, + submissions, + level: studentLevelById.get(studentId) ?? 0, + levelContribution: showLevelContribution + ? levelContribution + : undefined, + courseMaxLevel, + }); + // Level row first, mirroring the Level Contribution column being the + // first contribution column (left of the tabs). + const ordered = [ + ...breakdown.filter((tb) => tb.tabId === LEVEL_TAB_ID), + ...breakdown.filter((tb) => tb.tabId !== LEVEL_TAB_ID), + ]; + return [studentId, ordered] as [studentId: number, typeof ordered]; + }), + ), + [ + expandedIds, + resolvedTabs, + assessments, + submissions, + studentLevelById, + showLevelContribution, + levelContribution, + courseMaxLevel, + ], + ); + + 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, + levelContribution: showLevelContribution + ? levelContribution + : undefined, + }), + [ + students, + resolvedTabs, + assessments, + submissions, + showLevelContribution, + levelContribution, + ], + ); + + 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))), + level: columnPrecision(rows.map((r) => r.levelContribution ?? null)), + }; + }, [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, + }, + ]; + + // Raw Level — the right-most student-info column, just left of the + // contribution columns. The actual student level, shown for verification. + if (showRawLevel) { + cols.push({ + id: 'level', + title: t(translations.levelHeader), + accessorFn: (row) => `${row.level}`, + cell: (row) => `${row.level}`, + csvDownloadable: true, + }); + } + + // Level Contribution — the left-most contribution column, immediately right + // of the student-info columns and before the per-tab columns. + if (showLevelContribution) { + cols.push({ + id: 'levelContribution', + title: t(translations.levelContributionHeader), + accessorFn: (row) => fmtCsv(row.levelContribution ?? null), + cell: (row) => + fmtDisplay(row.levelContribution ?? null, columnPrecisions.level), + csvDownloadable: true, + }); + } + + 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, + showLevelContribution, + showRawLevel, + levelContribution.weight, + ]); + + // Lock the level columns visible whenever present so a stale persisted-hidden + // entry can't keep them hidden (the picker doesn't expose them to recover). + const lockedColumns = useMemo(() => { + const locked = ['name']; + if (showLevelContribution) locked.push('levelContribution'); + if (showRawLevel) locked.push('level'); + return locked; + }, [showLevelContribution, showRawLevel]); + + const columnPicker = useMemo( + () => ({ + render: (context: ColumnPickerRenderContext) => ( + + ), + locked: lockedColumns, + triggerLabel: t(translations.selectColumns), + dialogTitle: t(translations.dialogTitle), + storageKey: `gradebook_weighted_columns_${courseId}`, + }), + [courseId, t, lockedColumns], + ); + + 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; + // These columns are locked visible when present, so visibility resolves to true; + // gating the manual header on it keeps the header in lockstep with the body + // (no colSpan drift) even on the first frame before the lock effect runs. + const showLevelContributionCol = + showLevelContribution && (visibility.levelContribution ?? true) === true; + const showRawLevelCol = showRawLevel && (visibility.level ?? true) === 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 }); + + const levelBudgetLabel = + displayMode === 'percent' + ? t(translations.percentOfGrade, { weight: levelContribution.weight }) + : t(translations.outOfWeight, { weight: levelContribution.weight }); + + 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)} + + )} + {/* Raw Level — last student-info column (spans all 3 rows, no + weight subheader). */} + {showRawLevelCol && ( + + {t(translations.levelHeader)} + + )} + {/* Level Contribution — first contribution column, before the + category-grouped tabs. */} + {showLevelContributionCol && ( + + {t(translations.levelContributionHeader)} + + )} + {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 */} + + {/* Level Contribution weight subheader — before the tabs, to + match its column position. (Raw Level spans row 3, so no cell.) */} + {showLevelContributionCol && ( + + {levelOverBudget && levelBudgetOffenders ? ( + 0 && + levelBudgetOffenders.above.length > 0 + ? translations.levelOverBudgetBoth + : levelBudgetOffenders.above.length > 0 + ? translations.levelOverBudgetAboveOnly + : translations.levelOverBudgetBelowOnly, + )} + > + + {levelBudgetLabel} + + + + ) : ( + levelBudgetLabel + )} + + )} + {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 ?? ''} + )} + {showRawLevelCol && ( + + {row.original.level} + + )} + {showLevelContributionCol && ( + + {fmtDisplay( + row.original.levelContribution ?? null, + columnPrecisions.level, + )} + + )} + {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) => + 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 assessmentGradeText = + a.grade === null + ? `—/${a.maxGrade}` + : `${a.grade}/${a.maxGrade}`; + // The level row has no max — courseMaxLevel plays + // no part in the contribution, so showing + // "level/courseMaxLevel" would falsely imply a + // proportional derivation. Show the raw level only. + const gradeText = + tb.tabId === LEVEL_TAB_ID + ? t(translations.levelBreakdownDetail, { + level: a.grade ?? 0, + }) + : assessmentGradeText; + return ( + + {/* Empty checkbox cell so the breakdown row + carries the same checkbox | name divider (the + universal cell borderRight) as the rows above. */} + + {/* Title over a muted "raw mark · weightage" + subtitle, stacked and confined to the (sticky) + Name column. The breakdown row freezes the same + checkbox | Name region as the student rows above — + the identity columns get their own empty cells + after this — so the layout reads identically + whether identity columns are shown or not. A left + indent sits the title under the student name (past + the expand chevron), signalling these are that + student's assessments. */} + + {/* nowrap keeps the title on one line: its + max-content width then drives the table's auto + layout, expanding the (frozen) Name column to fit + the longest title. With the metadata line also + nowrap, every breakdown row is exactly 2 lines — + no fixed widths, no JS measurement. */} + + {a.title} + + {/* Muted metadata on its own line below the + title: raw mark · effective weightage, kept on + one line (nowrap). Weightage is always "% of + grade" — never routed through the points/percent + lens. */} + + {`${gradeText} · ${isExcluded ? t(translations.excluded) : weightText}`} + + + {/* One empty cell per visible identity column so + the grid lines stay aligned with the rows above. + These scroll with the table (only checkbox + Name + are frozen), matching the student rows. */} + {showEmail && } + {showExternalId && } + {/* Raw Level has no per-assessment detail. */} + {showRawLevelCol && } + {/* Level Contribution detail — before the tabs, + matching its column position. */} + {showLevelContributionCol && ( + + {tb.tabId === LEVEL_TAB_ID + ? fmtDisplay( + a.points, + columnPrecisions.level, + ) + : ''} + + )} + {resolvedTabs.map((tab, i) => { + const tabCellValue = isExcluded + ? '—' + : fmtDisplay( + breakdownDisplayValue(a), + columnPrecisions.tabs[i], + ); + return ( + + {/* Place the value by tab id, not array + position, so the breakdown row order is + free to differ from the column order. */} + {tab.id === tb.tabId + ? tabCellValue + : ''} + + ); + })} + + + ); + }), + )} + + ); + })} + +
+
+ {pagination && } +
+
+ + {canManageWeights && ( + setConfigureOpen(false)} + open={configureOpen} + students={students} + 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..d12a4bd26a7 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/buildAssessmentColumnIds.ts @@ -0,0 +1,7 @@ +export const buildAssessmentColumnId = (asnId: number): string => + `asn-${asnId}`; + +export const parseAssessmentColumnId = (colId: string): number | null => { + const match = colId.match(/^asn-(\d+)$/); + return match ? Number(match[1]) : null; +}; diff --git a/client/app/bundles/course/gradebook/computeWeighted.ts b/client/app/bundles/course/gradebook/computeWeighted.ts new file mode 100644 index 00000000000..5e87e7e2275 --- /dev/null +++ b/client/app/bundles/course/gradebook/computeWeighted.ts @@ -0,0 +1,435 @@ +// client/app/bundles/course/gradebook/computeWeighted.ts +import { + AssessmentData, + LevelContributionData, + StudentData, + SubmissionData, + TabData, +} from 'types/course/gradebook'; + +import { ParsedFormula, parseFormula } from './levelFormula'; + +type GradeEntry = Pick; + +// Synthetic ids for the Level term — disjoint from real (positive) tab/assessment ids. +export const LEVEL_TAB_ID = -1; +export const LEVEL_ASSESSMENT_ID = -1; + +export interface WeightedRow { + studentId: number; + name: string; + email: string; + externalId: string | null; + subtotals: (number | null)[]; + level: number; + levelContribution: number | null; + total: number | null; +} + +export interface AssessmentContribution { + assessmentId: number; + title: string; + grade: number | null; + maxGrade: number; + points: number; // contribution to this tab's weighted-points cell + // Share of the overall grade this assessment carries, in percentage points. + // Equal mode: the tab's weight split evenly across its assessments. + // Custom mode: the assessment's own configured weight. + effectiveWeight: number; + excluded: boolean; +} + +export interface TabBreakdown { + tabId: number; + assessments: AssessmentContribution[]; +} + +type GradeLookup = Map; + +const gradeKey = (studentId: number, assessmentId: number): string => + `${studentId}:${assessmentId}`; + +// Index submissions by (student, assessment) once: O(submissions). +const buildGradeLookup = (submissions: GradeEntry[]): GradeLookup => { + const lookup: GradeLookup = new Map(); + submissions.forEach((s) => { + if (s.grade != null) + lookup.set(gradeKey(s.studentId, s.assessmentId), s.grade); + }); + return lookup; +}; + +// Group assessments by tab once: O(assessments). +const buildAssessmentsByTab = ( + assessments: AssessmentData[], +): Map => { + const byTab = new Map(); + assessments.forEach((a) => { + const list = byTab.get(a.tabId); + if (list) list.push(a); + else byTab.set(a.tabId, [a]); + }); + return byTab; +}; + +// Equal-weight formula: average of (grade/maxGrade) ratios over INCLUDED assessments. +// Excluded assessments are dropped from both numerator and count; ungraded included +// contribute 0. Returns null when no assessment is included. +const equalSubtotal = ( + studentId: number, + tabAssessments: AssessmentData[], + gradeLookup: GradeLookup, +): number | null => { + const included = tabAssessments.filter((a) => !a.gradebookExcluded); + if (included.length === 0) return null; + const ratios = included.map((a) => { + const grade = gradeLookup.get(gradeKey(studentId, a.id)); + return grade != null ? grade / a.maxGrade : 0; + }); + return ratios.reduce((acc, r) => acc + r, 0) / ratios.length; +}; + +// Custom-weight formula: Σ(grade_i/maxGrade_i × assessmentWeight_i) / tabWeight over +// INCLUDED assessments. Returns null if tabWeight=0 or no assessment is included; +// ungraded included assessments contribute 0. +const customSubtotal = ( + studentId: number, + tab: TabData, + tabAssessments: AssessmentData[], + gradeLookup: GradeLookup, +): number | null => { + const tabWeight = tab.gradebookWeight ?? 0; + if (tabWeight === 0) return null; + let numerator = 0; + let hasContributing = false; + tabAssessments.forEach((a) => { + if (a.gradebookExcluded) return; + const grade = gradeLookup.get(gradeKey(studentId, a.id)); + const assessmentWeight = a.gradebookWeight ?? 0; + if (grade != null) numerator += (grade / a.maxGrade) * assessmentWeight; + hasContributing = true; + }); + return hasContributing ? numerator / tabWeight : null; +}; + +// Single source of truth for the subtotal math, operating on prebuilt indexes. +const subtotalFromLookup = ( + studentId: number, + tab: TabData, + tabAssessments: AssessmentData[] | undefined, + gradeLookup: GradeLookup, +): number | null => { + if (!tabAssessments || tabAssessments.length === 0) return null; + if (tab.weightMode === 'custom') { + return customSubtotal(studentId, tab, tabAssessments, gradeLookup); + } + return equalSubtotal(studentId, tabAssessments, gradeLookup); +}; + +// Weighted, additive total from already-computed subtotals. +const totalFromSubtotals = ( + subtotals: (number | null)[], + tabs: TabData[], +): number | null => { + let contributingCount = 0; + let total = 0; + subtotals.forEach((sub, i) => { + if (sub == null) return; + contributingCount += 1; + total += (tabs[i].gradebookWeight ?? 0) * sub; + }); + return contributingCount > 0 ? total : null; +}; + +// The Level contribution in grade-points, or null when disabled / invalid. +const levelPoints = ( + level: number, + cfg: LevelContributionData | undefined, + parsed: ParsedFormula | null, +): number | null => { + if (!cfg?.enabled || !parsed?.ok) return null; + return parsed.evaluate(level); +}; + +// Parse the formula once per render, only when the contribution is enabled. +const parseLevel = ( + cfg: LevelContributionData | undefined, +): ParsedFormula | null => (cfg?.enabled ? parseFormula(cfg.formula) : null); + +// Combine the tab total with the Level term: null only when neither contributes. +const combineTotal = ( + tabTotal: number | null, + lvl: number | null, +): number | null => + tabTotal == null && lvl == null ? null : (tabTotal ?? 0) + (lvl ?? 0); + +// True when any student's contribution falls outside [0, weight] (drives the dialog warning). +export const levelOutOfRange = ( + students: { level: number }[], + cfg: LevelContributionData, + parsed: ParsedFormula, +): boolean => { + if (!parsed.ok) return false; + return students.some((s) => { + const p = parsed.evaluate(s.level); + return p < 0 || p > cfg.weight; + }); +}; + +export interface LevelOffender { + id: number; + name: string; + value: number; +} + +export interface LevelOffenders { + // Below 0, most negative first; above max, highest first — so the dialog can + // name the worst offenders on each side. + below: LevelOffender[]; + above: LevelOffender[]; +} + +// Students whose Level contribution falls outside [0, max], split by which bound +// they breach. Empty on both sides when the formula is invalid. Feeds the dialog's +// out-of-range warning, which names the most extreme offenders. +export const levelOffenders = ( + students: { id: number; name: string; level: number }[], + parsed: ParsedFormula | null, + max: number, +): LevelOffenders => { + if (!parsed?.ok) return { below: [], above: [] }; + const evaluated = students.map((s) => ({ + id: s.id, + name: s.name, + value: parsed.evaluate(s.level), + })); + return { + below: evaluated + .filter((e) => e.value < 0) + .sort((a, b) => a.value - b.value), + above: evaluated + .filter((e) => e.value > max) + .sort((a, b) => b.value - a.value), + }; +}; + +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[]; + level?: number; + levelContribution?: LevelContributionData; + // The course's current top level, used only as the Level breakdown row's denominator. + courseMaxLevel?: number; +} + +export const computeStudentTotal = ({ + studentId, + tabs, + assessments, + submissions, + level, + levelContribution, +}: TotalArgs): number | null => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + const subtotals = tabs.map((tab) => + subtotalFromLookup( + studentId, + tab, + assessmentsByTab.get(tab.id), + gradeLookup, + ), + ); + const tabTotal = totalFromSubtotals(subtotals, tabs); + const lvl = levelPoints( + level ?? 0, + levelContribution, + parseLevel(levelContribution), + ); + return combineTotal(tabTotal, lvl); +}; + +export const computeStudentBreakdown = ({ + studentId, + tabs, + assessments, + submissions, + level, + levelContribution, + courseMaxLevel, +}: TotalArgs): TabBreakdown[] => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + const result: TabBreakdown[] = tabs.map((tab) => { + const list = assessmentsByTab.get(tab.id) ?? []; + const weight = tab.gradebookWeight ?? 0; + const includedCount = list.filter((a) => !a.gradebookExcluded).length; + + const contributions = list.map((a) => { + const excluded = !!a.gradebookExcluded; + const grade = gradeLookup.get(gradeKey(studentId, a.id)) ?? null; + const ratio = grade != null ? grade / a.maxGrade : 0; + let points: number; + let effectiveWeight: number; + if (excluded) { + points = 0; + effectiveWeight = 0; + } else if (tab.weightMode === 'custom') { + points = ratio * (a.gradebookWeight ?? 0); + effectiveWeight = a.gradebookWeight ?? 0; + } else { + points = includedCount > 0 ? (ratio / includedCount) * weight : 0; + effectiveWeight = includedCount > 0 ? weight / includedCount : 0; + } + return { + assessmentId: a.id, + title: a.title, + grade, + maxGrade: a.maxGrade, + points, + effectiveWeight, + excluded, + }; + }); + return { tabId: tab.id, assessments: contributions }; + }); + + const parsed = parseLevel(levelContribution); + const lvl = levelPoints(level ?? 0, levelContribution, parsed); + if (levelContribution?.enabled && parsed?.ok && lvl != null) { + result.push({ + tabId: LEVEL_TAB_ID, + assessments: [ + { + assessmentId: LEVEL_ASSESSMENT_ID, + title: 'Level', + grade: level ?? 0, + maxGrade: courseMaxLevel ?? 0, + points: lvl, + effectiveWeight: levelContribution.weight, + excluded: false, + }, + ], + }); + } + return result; +}; + +interface WeightedRowsArgs { + students: StudentData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + submissions: GradeEntry[]; + levelContribution?: LevelContributionData; +} + +// 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, + levelContribution, +}: WeightedRowsArgs): WeightedRow[] => { + const gradeLookup = buildGradeLookup(submissions); + const assessmentsByTab = buildAssessmentsByTab(assessments); + const parsed = parseLevel(levelContribution); + return students.map((student) => { + const subtotals = tabs.map((tab) => + subtotalFromLookup( + student.id, + tab, + assessmentsByTab.get(tab.id), + gradeLookup, + ), + ); + const tabTotal = totalFromSubtotals(subtotals, tabs); + const lvl = levelPoints(student.level, levelContribution, parsed); + return { + studentId: student.id, + name: student.name, + email: student.email, + externalId: student.externalId, + subtotals, + level: student.level, + levelContribution: lvl, + total: combineTotal(tabTotal, lvl), + }; + }); +}; + +export const sumWeights = (tabs: TabData[]): number => + tabs.reduce((acc, t) => acc + (t.gradebookWeight ?? 0), 0); + +const r2 = (n: number): number => Math.round(n * 100) / 100; + +// Ids of tabs that have at least one assessment — only these are eligible for a +// default weight (an empty tab carries no grades, so weighting it is wasted). +const nonEmptyTabIds = ( + tabs: TabData[], + assessments: Pick[], +): number[] => { + const populated = new Set(assessments.map((a) => a.tabId)); + return tabs.filter((t) => populated.has(t.id)).map((t) => t.id); +}; + +// True when no tab weight has been configured (every weight is 0/null) yet there +// is at least one tab to weight — i.e. the table is showing the equal-split +// default rather than an instructor's configuration. Drives the "default weights" +// banner and dialog pre-fill so both read from one source of truth. +export const usingDefaultWeights = ( + tabs: TabData[], + assessments: Pick[], +): boolean => + sumWeights(tabs) === 0 && nonEmptyTabIds(tabs, assessments).length > 0; + +// When no weights are configured, distribute 100 equally across non-empty tabs so +// the weighted view is meaningful out of the box; the last such tab absorbs the +// rounding remainder so the result sums to exactly 100. Returns the input array +// unchanged (same reference) once any tab carries a weight, so a real configuration +// is never overwritten. +export const resolveTabWeights = ( + tabs: TabData[], + assessments: Pick[], +): TabData[] => { + if (sumWeights(tabs) !== 0) return tabs; + const ids = nonEmptyTabIds(tabs, assessments); + const n = ids.length; + if (n === 0) return tabs; + const base = r2(100 / n); + const weightById = new Map( + ids.map((id, i) => [id, i === n - 1 ? r2(100 - base * (n - 1)) : base]), + ); + return tabs.map((tab) => + weightById.has(tab.id) + ? { + ...tab, + gradebookWeight: weightById.get(tab.id), + weightMode: tab.weightMode ?? 'equal', + } + : tab, + ); +}; diff --git a/client/app/bundles/course/gradebook/constants.ts b/client/app/bundles/course/gradebook/constants.ts new file mode 100644 index 00000000000..4c61d2723fb --- /dev/null +++ b/client/app/bundles/course/gradebook/constants.ts @@ -0,0 +1,5 @@ +export const STUDENT_INFO_COL_IDS = ['name', 'email', 'externalId'] as const; +export type StudentInfoColId = (typeof STUDENT_INFO_COL_IDS)[number]; + +export const GAMIFICATION_COL_IDS = ['level', 'totalXp'] as const; +export type GamificationColId = (typeof GAMIFICATION_COL_IDS)[number]; diff --git a/client/app/bundles/course/gradebook/handles.ts b/client/app/bundles/course/gradebook/handles.ts new file mode 100644 index 00000000000..0022bfbd02c --- /dev/null +++ b/client/app/bundles/course/gradebook/handles.ts @@ -0,0 +1,21 @@ +import { defineMessages } from 'react-intl'; + +import type { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +const translations = defineMessages({ + header: { + id: 'course.gradebook.GradebookIndex.gradebook', + defaultMessage: 'Gradebook', + }, +}); + +export const gradebookHandle: DataHandle = (match) => { + const courseId = match.params.courseId; + + return { + getData: async (): Promise => ({ + activePath: `/courses/${courseId}/gradebook`, + content: { title: translations.header }, + }), + }; +}; diff --git a/client/app/bundles/course/gradebook/levelFormula.ts b/client/app/bundles/course/gradebook/levelFormula.ts new file mode 100644 index 00000000000..0116f4b8fe9 --- /dev/null +++ b/client/app/bundles/course/gradebook/levelFormula.ts @@ -0,0 +1,331 @@ +// A deliberately tiny arithmetic grammar mapping a student's Level to grade-points. +// PARSE, DON'T EVAL: the source is read into an AST of known node types and evaluated +// with plain arithmetic. No code path runs the string as code, so a hostile string can +// only ever produce a parse error. +// +// Grammar (standard precedence): +// expr := term (('+' | '-') term)* +// term := factor (('*' | '/') factor)* +// factor := '-' factor | primary +// primary := number | var | func '(' args ')' | '(' expr ')' +// var := 'level' +// func1 := 'floor' | 'ceil' | 'round' (1 arg) +// func2 := 'min' | 'max' (2 args) +// +// Examples (staff type these into the formula field): +// min(level, 20) * 0.5 → 0.5 grade-points per level, capped at level 20 (max 10) +// floor(level / 5) → 1 grade-point per 5 levels +// level → 1 grade-point per level, uncapped +// Anything outside this grammar (assignments, unknown identifiers, unknown +// functions, stray characters, …) is rejected at parse time — parseFormula +// returns { ok: false, error } rather than throwing or running the input. + +interface Scope { + level: number; +} +type AstNode = (scope: Scope) => number; + +const FUNCTIONS_1: Record number> = { + floor: Math.floor, + ceil: Math.ceil, + round: Math.round, +}; +const FUNCTIONS_2: Record number> = { + min: Math.min, + max: Math.max, +}; +const VARIABLES = new Set(['level']); + +// The complete, closed vocabulary a formula may use, in the order we list it back +// to staff. Functions first by arity (1-arg then 2-arg) to match how they're taught. +const FUNCTION_NAMES = [ + ...Object.keys(FUNCTIONS_1), + ...Object.keys(FUNCTIONS_2), +]; +const VALID_NAMES = [...VARIABLES, ...FUNCTION_NAMES]; + +// Levenshtein edit distance — the minimum single-character inserts, deletes, or +// substitutions to turn `a` into `b`. Used only to offer a "did you mean…?" hint, +// so the plain O(m·n) table is more than fast enough for words this short. +const editDistance = (a: string, b: string): number => { + const rows = a.length; + const cols = b.length; + const dp: number[][] = Array.from({ length: rows + 1 }, () => + new Array(cols + 1).fill(0), + ); + for (let i = 0; i <= rows; i += 1) dp[i][0] = i; + for (let j = 0; j <= cols; j += 1) dp[0][j] = j; + for (let i = 1; i <= rows; i += 1) { + for (let j = 1; j <= cols; j += 1) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + dp[i][j] = Math.min( + dp[i - 1][j] + 1, // delete + dp[i][j - 1] + 1, // insert + dp[i - 1][j - 1] + cost, // substitute + ); + } + } + return dp[rows][cols]; +}; + +// The closest known word to `name`, or null if nothing is within two edits. Matched +// case-insensitively so `Level` and `LEVEL` still resolve to `level`. Two edits +// catches single typos and transpositions (e.g. "lveel" → "level") without guessing +// wildly for genuinely unknown words. +const nearestName = ( + name: string, + candidates: readonly string[], +): string | null => { + const lower = name.toLowerCase(); + let best: string | null = null; + let bestDistance = Infinity; + candidates.forEach((candidate) => { + const distance = editDistance(lower, candidate); + if (distance < bestDistance) { + bestDistance = distance; + best = candidate; + } + }); + return bestDistance <= 2 ? best : null; +}; + +// Plain-language "unrecognised word" message: lead with the fix if we can guess it, +// otherwise spell out the whole vocabulary so it's never hidden behind the error. +const unrecognisedError = ( + name: string, + candidates: readonly string[], +): string => { + const suggestion = nearestName(name, candidates); + return suggestion + ? `“${name}” is not recognised - did you mean “${suggestion}”?` + : `“${name}” is not recognised. You can use: ${candidates.join(', ')}.`; +}; + +export type ParsedFormula = + | { ok: true; evaluate: (level: number) => number } + | { ok: false; error: string }; + +type Token = + | { type: 'num'; value: number } + | { type: 'ident'; value: string } + | { type: 'op'; value: '+' | '-' | '*' | '/' } + | { type: 'lparen' } + | { type: 'rparen' } + | { type: 'comma' }; + +const isDigit = (c: string): boolean => c >= '0' && c <= '9'; +const isAlpha = (c: string): boolean => + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); + +const tokenize = (src: string): Token[] => { + const tokens: Token[] = []; + let i = 0; + while (i < src.length) { + const c = src[i]; + if (c === ' ' || c === '\t' || c === '\n' || c === '\r') { + i += 1; + } else if (isDigit(c) || c === '.') { + let j = i + 1; + while (j < src.length && (isDigit(src[j]) || src[j] === '.')) j += 1; + const text = src.slice(i, j); + const value = Number(text); + if (!Number.isFinite(value)) + throw new Error(`“${text}” is not a valid number.`); + tokens.push({ type: 'num', value }); + i = j; + } else if (isAlpha(c)) { + let j = i + 1; + while (j < src.length && isAlpha(src[j])) j += 1; + tokens.push({ type: 'ident', value: src.slice(i, j) }); + i = j; + } else if (c === '+' || c === '-' || c === '*' || c === '/') { + tokens.push({ type: 'op', value: c }); + i += 1; + } else if (c === '(') { + tokens.push({ type: 'lparen' }); + i += 1; + } else if (c === ')') { + tokens.push({ type: 'rparen' }); + i += 1; + } else if (c === ',') { + tokens.push({ type: 'comma' }); + i += 1; + } else { + throw new Error(`“${c}” cannot be used in a formula.`); + } + } + return tokens; +}; + +// Recursive-descent parser building an AST of closures. +const buildAst = (tokens: Token[]): AstNode => { + let pos = 0; + // peek: look at the current token WITHOUT advancing (used to decide what to parse next). + const peek = (): Token | undefined => tokens[pos]; + // eat: consume and return the current token, advancing past it. Throws if we've run off + // the end. "Eat" is the standard parser term for "accept this token and move on". + const eat = (): Token => { + const t = tokens[pos]; + if (!t) throw new Error('The formula looks unfinished.'); + pos += 1; + return t; + }; + + // The only two tokens we ever `expect` are a comma (between function arguments) + // and a closing bracket. Each gets its own plain-language hint, and we peek so a + // run-off-the-end (e.g. `min(level, 20`) still names the missing bracket rather + // than falling through to the generic "unfinished" message. + const expect = (type: 'comma' | 'rparen'): void => { + const t = peek(); + if (!t || t.type !== type) { + throw new Error( + type === 'comma' + ? 'This needs a comma - for example min(level, 25).' + : 'This needs a closing bracket “)”.', + ); + } + pos += 1; + }; + + // The four parse* functions form a PRECEDENCE LADDER — one function per tier of + // binding strength, each calling the next-tighter tier. Looser binds last, so it + // sits at the top; tighter binds first, so it sits at the bottom: + // parseExpr + - loosest — splits on additive operators + // parseTerm * / next — splits on multiplicative operators + // parseFactor unary - tighter — a leading minus, e.g. -level + // parsePrimary atoms tightest — a number, `level`, a function call, or (expr) + // This layering is what makes `2 + 3 * 4` evaluate as 2 + (3*4): + is handled at the + // outer tier, * at the inner one. The grammar is a cycle (primary -> expr via + // parentheses/function args), so exactly one parser must be forward-referenced: + // parseExpr is read inside parsePrimary before it is assigned below. The other three + // are defined in dependency order. + let parseExpr: () => AstNode; + + // parsePrimary — the tightest tier: a single indivisible value. One of: a number + // literal, the `level` variable, a function call like min(a, b), or a parenthesised + // sub-expression (which loops back to parseExpr). Anything else is a parse error. + const parsePrimary = (): AstNode => { + const t = eat(); + if (t.type === 'num') { + const { value } = t; + return (): number => value; + } + if (t.type === 'lparen') { + const inner = parseExpr(); + expect('rparen'); + return inner; + } + if (t.type === 'ident') { + const name = t.value; + if (peek()?.type === 'lparen') { + eat(); // ( + const fn1 = FUNCTIONS_1[name]; + const fn2 = FUNCTIONS_2[name]; + if (fn1) { + const arg = parseExpr(); + expect('rparen'); + return (s): number => fn1(arg(s)); + } + if (fn2) { + const a = parseExpr(); + expect('comma'); + const b = parseExpr(); + expect('rparen'); + return (s): number => fn2(a(s), b(s)); + } + throw new Error(unrecognisedError(name, FUNCTION_NAMES)); + } + if (!VARIABLES.has(name)) + throw new Error(unrecognisedError(name, VALID_NAMES)); + return (s): number => s.level; + } + throw new Error('The formula is not complete here.'); + }; + + // parseFactor — handles a leading unary minus (e.g. -level, -(level + 1)) by negating + // whatever follows; otherwise defers to parsePrimary. Recurses so `--level` works. + const parseFactor = (): AstNode => { + const t = peek(); + if (t && t.type === 'op' && t.value === '-') { + eat(); + const operand = parseFactor(); + return (s): number => -operand(s); + } + return parsePrimary(); + }; + + // parseTerm — the multiplicative tier: parses a factor, then folds in any run of + // `* factor` / `/ factor` left-to-right. Each factor binds tighter than the * / it + // sits beside, which is why * and / outrank + and - below. + const parseTerm = (): AstNode => { + let left = parseFactor(); + let t = peek(); + while (t && t.type === 'op' && (t.value === '*' || t.value === '/')) { + eat(); + const right = parseFactor(); + const op = t.value; + const l = left; + left = + op === '*' + ? (s): number => l(s) * right(s) + : (s): number => { + const d = right(s); + return d === 0 ? 0 : l(s) / d; // div-by-zero guard + }; + t = peek(); + } + return left; + }; + + // parseExpr — the loosest tier and the grammar's entry point: parses a term, then + // folds in any run of `+ term` / `- term` left-to-right. Because it sits outermost, + // additive operators are applied last, after the * / inside each term. + parseExpr = (): AstNode => { + let left = parseTerm(); + let t = peek(); + while (t && t.type === 'op' && (t.value === '+' || t.value === '-')) { + eat(); + const right = parseTerm(); + const op = t.value; + const l = left; + left = + op === '+' + ? (s): number => l(s) + right(s) + : (s): number => l(s) - right(s); + t = peek(); + } + return left; + }; + + const ast = parseExpr(); + if (pos !== tokens.length) + throw new Error( + 'Looks like an operator is missing - for example level × 2.', + ); + return ast; +}; + +export const parseFormula = (src: string): ParsedFormula => { + try { + const tokens = tokenize(src); + if (tokens.length === 0) throw new Error('Enter a formula.'); + const ast = buildAst(tokens); + return { + ok: true, + evaluate: (level): number => { + const result = ast({ level }); + return Number.isFinite(result) ? result : 0; + }, + }; + } catch (e) { + return { + ok: false, + error: e instanceof Error ? e.message : 'This formula is not valid.', + }; + } +}; + +// `min(level, )` — the hardcoded-cap idiom seeded with the course's +// current top level. Staff edit the cap and add a per-level rate (e.g. `* 0.05`). +export const seedLevelFormula = (courseMaxLevel: number): string => + `min(level, ${courseMaxLevel})`; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts new file mode 100644 index 00000000000..71b5479926f --- /dev/null +++ b/client/app/bundles/course/gradebook/operations.ts @@ -0,0 +1,28 @@ +import type { Operation } from 'store'; +import type { + LevelContributionData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; + +import CourseAPI from 'api/course'; + +import { actions } from './store'; + +const fetchGradebook = (): Operation => async (dispatch) => { + const response = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(response.data)); +}; + +export const updateGradebookWeights = + ( + weights: UpdateWeightsPayload['weights'], + levelContribution?: LevelContributionData, + ): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.updateWeights( + levelContribution ? { weights, levelContribution } : { weights }, + ); + dispatch(actions.updateTabWeights(response.data)); + }; + +export default fetchGradebook; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx new file mode 100644 index 00000000000..bf3e0ade65f --- /dev/null +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -0,0 +1,179 @@ +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 fetchGradebook from '../../operations'; +import { + getAssessments, + getCanManageWeights, + getCategories, + getCourseMaxLevel, + getGamificationEnabled, + getLevelContribution, + 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); + const courseMaxLevel = useAppSelector(getCourseMaxLevel); + const levelContribution = useAppSelector(getLevelContribution); + + useEffect(() => { + dispatch(fetchGradebook()) + .finally(() => setIsLoading(false)) + .catch(() => toast.error(t(translations.fetchFailure))); + }, [dispatch]); + + let content: JSX.Element; + if (isLoading) { + content = ; + } else if (students.length === 0) { + content = ( +
+ + + {t(translations.noStudents)} + + + {t(translations.noStudentsHint)} + +
+ ); + } else if (weightedViewEnabled && viewMode === 'weighted') { + content = ( + + ); + } else { + content = ( + + ); + } + + return ( + + {!isLoading && canManageWeights && !weightedViewEnabled && ( + + )} + {weightedViewEnabled && !isLoading && students.length > 0 && ( + + startTransition(() => { + setViewMode(v); + setSearchParams(v === 'weighted' ? { view: 'weighted' } : {}); + }) + } + TabIndicatorProps={{ style: { height: 2 } }} + value={viewMode} + > + + + + )} + {!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..6256f7af9f6 --- /dev/null +++ b/client/app/bundles/course/gradebook/selectors.ts @@ -0,0 +1,34 @@ +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 getLevelContribution = ( + state: AppState, +): GradebookState['levelContribution'] => + getLocalState(state).levelContribution; +export const getCourseMaxLevel = (state: AppState): number => + getLocalState(state).courseMaxLevel; diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts new file mode 100644 index 00000000000..ed3a9c84231 --- /dev/null +++ b/client/app/bundles/course/gradebook/store.ts @@ -0,0 +1,138 @@ +import { produce } from 'immer'; +import type { + GradebookData, + LevelContributionData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; + +import type { + AssessmentData, + CategoryData, + StudentData, + SubmissionData, + TabData, +} from './types'; + +const SAVE_GRADEBOOK = 'course/gradebook/SAVE_GRADEBOOK'; +const UPDATE_TAB_WEIGHTS = 'course/gradebook/UPDATE_TAB_WEIGHTS'; + +interface GradebookState { + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + students: StudentData[]; + submissions: SubmissionData[]; + gamificationEnabled: boolean; + weightedViewEnabled: boolean; + canManageWeights: boolean; + levelContribution: LevelContributionData; + courseMaxLevel: number; +} + +interface SaveGradebookAction { + type: typeof SAVE_GRADEBOOK; + payload: GradebookData; +} + +interface UpdateTabWeightsAction { + type: typeof UPDATE_TAB_WEIGHTS; + payload: UpdateWeightsPayload; +} + +const initialState: GradebookState = { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: false, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + }, + courseMaxLevel: 0, +}; + +const reducer = produce( + ( + draft: GradebookState, + action: SaveGradebookAction | UpdateTabWeightsAction, + ) => { + switch (action.type) { + case SAVE_GRADEBOOK: { + draft.categories = action.payload.categories; + draft.tabs = action.payload.tabs; + draft.assessments = action.payload.assessments; + draft.students = action.payload.students; + draft.submissions = action.payload.submissions; + draft.gamificationEnabled = action.payload.gamificationEnabled; + draft.weightedViewEnabled = action.payload.weightedViewEnabled; + draft.canManageWeights = action.payload.canManageWeights; + draft.levelContribution = + action.payload.levelContribution ?? initialState.levelContribution; + draft.courseMaxLevel = action.payload.courseMaxLevel ?? 0; + 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; + }); + } + }, + ); + if (action.payload.levelContribution) { + draft.levelContribution = action.payload.levelContribution; + } + break; + } + default: + break; + } + }, + initialState, +); + +export const actions = { + saveGradebook: (data: GradebookData): SaveGradebookAction => ({ + type: SAVE_GRADEBOOK, + payload: data, + }), + updateTabWeights: ( + payload: UpdateWeightsPayload, + ): UpdateTabWeightsAction => ({ + type: UPDATE_TAB_WEIGHTS, + payload, + }), +}; + +export default reducer; diff --git a/client/app/bundles/course/gradebook/types.ts b/client/app/bundles/course/gradebook/types.ts new file mode 100644 index 00000000000..b91689df872 --- /dev/null +++ b/client/app/bundles/course/gradebook/types.ts @@ -0,0 +1,9 @@ +export type { + AssessmentData, + CategoryData, + GradebookData, + StudentData, + SubmissionData, + TabData, + UpdateWeightsPayload, +} from 'types/course/gradebook'; diff --git a/client/app/bundles/course/statistics/operations.ts b/client/app/bundles/course/statistics/operations.ts index c2060ed41fe..1c071b810ef 100644 --- a/client/app/bundles/course/statistics/operations.ts +++ b/client/app/bundles/course/statistics/operations.ts @@ -1,8 +1,4 @@ -import { AxiosError } from 'axios'; -import { JobCompleted, JobErrored } from 'types/jobs'; - import CourseAPI from 'api/course'; -import pollJob from 'lib/helpers/jobHelpers'; import { AssessmentsStatistics, @@ -14,8 +10,6 @@ import { StudentsStatistics, } from './types'; -const DOWNLOAD_JOB_POLL_INTERVAL_MS = 2000; - export const fetchStatisticsIndex = async (): Promise => { const response = await CourseAPI.statistics.course.fetchStatisticsIndex(); return response.data; @@ -62,21 +56,3 @@ export const fetchCourseGetHelpActivity = async (params?: { await CourseAPI.statistics.course.fetchCourseGetHelpActivity(params); return response.data; }; - -export const downloadScoreSummary = ( - handleSuccess: (successData: JobCompleted) => void, - handleFailure: (error: JobErrored | AxiosError) => void, - assessmentIds: number[], -): void => { - CourseAPI.statistics.course - .downloadScoreSummary(assessmentIds) - .then((response) => { - pollJob( - response.data.jobUrl, - handleSuccess, - handleFailure, - DOWNLOAD_JOB_POLL_INTERVAL_MS, - ); - }) - .catch(handleFailure); -}; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx deleted file mode 100644 index 355bb97ee45..00000000000 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsScoreSummaryDownload.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { Button, Typography } from '@mui/material'; -import { AxiosError } from 'axios'; -import { JobCompleted, JobErrored } from 'types/jobs'; - -import { downloadScoreSummary } from 'course/statistics/operations'; -import { CourseAssessment } from 'course/statistics/types'; -import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; -import loadingToast, { LoadingToast } from 'lib/hooks/toast/loadingToast'; -import useTranslation from 'lib/hooks/useTranslation'; - -interface AssessmentsScoreSummaryDownloadProps { - assessments: CourseAssessment[]; -} - -const translations = defineMessages({ - selectedNUsers: { - id: 'course.statistics.StatisticsIndex.assessments.selectedNUsers', - defaultMessage: - 'Download Score Summary ({n, plural, =1 {# assessment} other {# assessments}})', - }, - download: { - id: 'course.statistics.StatisticsIndex.assessments.downloadCsv', - defaultMessage: 'Download', - }, - downloadCsvDialogTitle: { - id: 'course.statistics.StatisticsIndex.assessments.downloadCsv', - defaultMessage: 'Download Score Summary for the following Assessments?', - }, - downloadScoreSummarySuccess: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummarySuccess', - defaultMessage: 'Successfully downloaded score summary', - }, - downloadScoreSummaryFailure: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummaryFailure', - defaultMessage: 'An error occurred while downloading score summary', - }, - downloadScoreSummaryPending: { - id: 'course.statistics.StatisticsIndex.assessments.downloadScoreSummaryPending', - defaultMessage: - 'Please wait as your request to download is being processed', - }, -}); - -const AssessmentsScoreSummaryDownload = ( - props: AssessmentsScoreSummaryDownloadProps, -): JSX.Element => { - const { assessments } = props; - const { t } = useTranslation(); - - const [openDialog, setOpenDialog] = useState(false); - const [isDownloading, setIsDownloading] = useState(false); - - const handleSuccess = - (loadToast: LoadingToast) => - (successData: JobCompleted): void => { - window.location.href = successData.redirectUrl!; - loadToast.success(t(translations.downloadScoreSummarySuccess)); - setIsDownloading(false); - setOpenDialog(false); - }; - - const handleFailure = - (loadToast: LoadingToast) => - (error: JobErrored | AxiosError): void => { - const message = - error?.message || t(translations.downloadScoreSummaryFailure); - loadToast.error(message); - setIsDownloading(false); - }; - - const handleOnClick = (): void => { - setIsDownloading(true); - const loadToast = loadingToast(t(translations.downloadScoreSummaryPending)); - downloadScoreSummary( - handleSuccess(loadToast), - handleFailure(loadToast), - assessments.map((assessment) => assessment.id), - ); - }; - - return ( - <> - - - setOpenDialog(false)} - open={openDialog} - primaryColor="info" - primaryLabel={t(translations.download)} - title={t(translations.downloadCsvDialogTitle)} - > - - {assessments.map((assessment) => ( -
  • {assessment.title}
  • - ))} -
    -
    - - ); -}; - -export default AssessmentsScoreSummaryDownload; diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx index f8a8a4ed78a..930eef2ed5d 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatistics.tsx @@ -12,6 +12,7 @@ const AssessmentsStatistics: FC = () => { {(data) => ( )} diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx index 0b686edd539..450e1208850 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/AssessmentsStatisticsTable.tsx @@ -1,6 +1,6 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; -import { Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { CourseAssessment } from 'course/statistics/types'; import Link from 'lib/components/core/Link'; @@ -14,13 +14,12 @@ import { getAssessmentStatisticsURL, getAssessmentWithCategoryURL, getAssessmentWithTabURL, + getCourseGradebookURL, } from 'lib/helpers/url-builders'; import { getCourseId } from 'lib/helpers/url-helpers'; import useTranslation from 'lib/hooks/useTranslation'; import { formatMiniDateTime, formatSecondsDuration } from 'lib/moment'; -import AssessmentsScoreSummaryDownload from './AssessmentsScoreSummaryDownload'; - const translations = defineMessages({ title: { id: 'course.statistics.StatisticsIndex.assessments.title', @@ -78,15 +77,26 @@ const translations = defineMessages({ id: 'course.statistics.StatisticsIndex.assessments.searchBar', defaultMessage: 'Search by Assessment Title, Tab, or Category', }, + subtitle: { + id: 'course.statistics.StatisticsIndex.assessments.subtitle', + defaultMessage: + 'To view and export individual student grades, open Gradebook.', + }, + subtitleDisabled: { + id: 'course.statistics.StatisticsIndex.assessments.subtitleDisabled', + defaultMessage: + 'To view and export individual student grades, enable Gradebook.', + }, }); interface Props { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } const AssessmentsStatisticsTable: FC = (props) => { - const { numStudents, assessments } = props; + const { numStudents, assessments, gradebookEnabled } = props; const courseId = getCourseId(); const { t } = useTranslation(); @@ -243,9 +253,26 @@ const AssessmentsStatisticsTable: FC = (props) => { return ( <> - - {t(translations.tableTitle, { numStudents })} - + + + {t(translations.tableTitle, { numStudents })} + + + {gradebookEnabled + ? t(translations.subtitle, { + url: (chunks) => ( + {chunks} + ), + }) + : t(translations.subtitleDisabled, { + url: (chunks) => ( + + {chunks} + + ), + })} + + = (props) => { } getRowEqualityData={(assessment): CourseAssessment => assessment} getRowId={(assessment): string => assessment.id.toString()} - indexing={{ indices: true, rowSelectable: true }} + indexing={{ indices: true }} pagination={{ rowsPerPage: [DEFAULT_TABLE_ROWS_PER_PAGE], showAllRows: true, @@ -285,15 +312,7 @@ const AssessmentsStatisticsTable: FC = (props) => { }, }, }} - toolbar={{ - show: true, - activeToolbar: (selectedAssessments): JSX.Element => ( - - ), - keepNative: true, - }} + toolbar={{ show: true }} /> ); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx new file mode 100644 index 00000000000..5eb96b0bd7b --- /dev/null +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/assessments/__tests__/AssessmentsStatisticsTable.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from 'test-utils'; + +import { CourseAssessment } from 'course/statistics/types'; + +import AssessmentsStatisticsTable from '../AssessmentsStatisticsTable'; + +const assessments: CourseAssessment[] = [ + { + id: 1, + title: 'Quiz 1', + startAt: new Date('2026-01-01T00:00:00Z'), + tab: { id: 1, title: 'Tab 1' }, + category: { id: 1, title: 'Category 1' }, + maximumGrade: 10, + numSubmitted: 2, + numAttempted: 3, + numLate: 1, + }, +]; + +const renderTable = (gradebookEnabled = true): void => { + render( + , + { at: ['/courses/1/statistics/assessments'] }, + ); +}; + +describe('', () => { + // Regression guard: an orphaned rowSelectable (selection with no activeToolbar) + // makes the toolbar render an empty 6.5rem bar on select, shifting every row down. + // Re-enabling row selection here brings that layout bug back. + it('keeps row selection off so selecting cannot shift the table down', async () => { + renderTable(); + await screen.findByText('Quiz 1'); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); + + it('renders the native CSV download button', async () => { + renderTable(); + expect(await screen.findByTestId('DownloadIcon')).toBeInTheDocument(); + }); + + it('points to the Gradebook for individual student grades when enabled', async () => { + renderTable(true); + const link = await screen.findByRole('link', { name: /gradebook/i }); + expect(link).toHaveAttribute('href', '/courses/1/gradebook'); + }); + + it('points to course settings to enable Gradebook when it is disabled', async () => { + renderTable(false); + const link = await screen.findByRole('link', { name: /gradebook/i }); + expect(link).toHaveAttribute('href', '/courses/1/admin/components'); + }); +}); diff --git a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx index d1dd39720a3..c1e230f8964 100644 --- a/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx +++ b/client/app/bundles/course/statistics/pages/StatisticsIndex/index.tsx @@ -115,7 +115,10 @@ const StatisticsIndex: FC = () => { return ( - + { diff --git a/client/app/bundles/course/statistics/types.ts b/client/app/bundles/course/statistics/types.ts index 8dea0dfc4bd..076743f504c 100644 --- a/client/app/bundles/course/statistics/types.ts +++ b/client/app/bundles/course/statistics/types.ts @@ -137,6 +137,7 @@ export interface CourseAssessment { export interface AssessmentsStatistics { numStudents: number; assessments: CourseAssessment[]; + gradebookEnabled: boolean; } export interface CourseGetHelpActivity { diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index b92b165a744..c52ce359071 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -75,6 +75,10 @@ const translations = defineMessages({ id: 'course.componentTitles.course_forums_component', defaultMessage: 'Forums', }, + course_gradebook_component: { + id: 'course.componentTitles.course_gradebook_component', + defaultMessage: 'Gradebook', + }, course_groups_component: { id: 'course.componentTitles.course_groups_component', defaultMessage: 'Groups', diff --git a/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx b/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx index 51915776959..9f517937cf0 100644 --- a/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/InvitationResultExistingTable.tsx @@ -7,7 +7,6 @@ import { ColumnTemplate } from 'lib/components/table'; import Table from 'lib/components/table/Table'; import { DEFAULT_MINI_TABLE_ROWS_PER_PAGE, - DEFAULT_TABLE_ROWS_PER_PAGE, TIMELINE_ALGORITHMS, } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx b/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx index 3da6e4d2da0..5b2ad57a8ae 100644 --- a/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx +++ b/client/app/bundles/course/user-invitations/components/tables/InvitationResultPrimaryTable.tsx @@ -5,7 +5,6 @@ import { ColumnTemplate } from 'lib/components/table'; import Table from 'lib/components/table/Table'; import { DEFAULT_MINI_TABLE_ROWS_PER_PAGE, - DEFAULT_TABLE_ROWS_PER_PAGE, TIMELINE_ALGORITHMS, } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/users/store.ts b/client/app/bundles/users/store.ts index 230d7149d51..44ff5de5596 100644 --- a/client/app/bundles/users/store.ts +++ b/client/app/bundles/users/store.ts @@ -12,6 +12,8 @@ import { SaveCourseListAction, SaveInstanceListAction, SaveUserAction, + SET_CURRENT_USER_ID, + SetCurrentUserIdAction, } from './types'; const initialState: GlobalUserState = { @@ -33,6 +35,13 @@ const reducer = produce((draft: GlobalUserState, action: GlobalActionType) => { draft.user = userEntity; break; } + // Sets only the authenticated user's id, leaving name/imageUrl untouched. + // The course layout fetch knows the current user id but not the full profile; + // this is enough to namespace per-user client state (e.g. table column prefs). + case SET_CURRENT_USER_ID: { + draft.user.id = action.userId; + break; + } case SAVE_COURSE_LIST: { if (action.courses) { const coursesList = action.courses; @@ -67,6 +76,10 @@ export const actions = { return { type: SAVE_USER, user }; }, + setCurrentUserId: (userId: number): SetCurrentUserIdAction => { + return { type: SET_CURRENT_USER_ID, userId }; + }, + saveCourses: ( courses: UserCourseListData[], courseType: 'current' | 'completed', diff --git a/client/app/bundles/users/types.ts b/client/app/bundles/users/types.ts index f4cf9580e59..6a073977c78 100644 --- a/client/app/bundles/users/types.ts +++ b/client/app/bundles/users/types.ts @@ -12,6 +12,7 @@ import { // Action Names export const SAVE_USER = 'system/SAVE_USER'; +export const SET_CURRENT_USER_ID = 'system/SET_CURRENT_USER_ID'; export const SAVE_COURSE_LIST = 'system/SAVE_COURSE_LIST'; export const SAVE_INSTANCE_LIST = 'system/SAVE_INSTANCE_LIST'; @@ -21,6 +22,11 @@ export interface SaveUserAction { user: UserBasicListData; } +export interface SetCurrentUserIdAction { + type: typeof SET_CURRENT_USER_ID; + userId: number; +} + export interface SaveCourseListAction { type: typeof SAVE_COURSE_LIST; courses: UserCourseListData[]; @@ -34,6 +40,7 @@ export interface SaveInstanceListAction { export type GlobalActionType = | SaveUserAction + | SetCurrentUserIdAction | SaveCourseListAction | SaveInstanceListAction; diff --git a/client/app/lib/components/core/buttons/DownloadButton.tsx b/client/app/lib/components/core/buttons/DownloadButton.tsx index f241597144d..a9788884b08 100644 --- a/client/app/lib/components/core/buttons/DownloadButton.tsx +++ b/client/app/lib/components/core/buttons/DownloadButton.tsx @@ -24,7 +24,7 @@ const DownloadButton = (props: DownloadButtonProps): JSX.Element => ( > - {props.children} + {props.children} diff --git a/client/app/lib/components/core/buttons/SegmentedSwitch.tsx b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx new file mode 100644 index 00000000000..c258aaffb2f --- /dev/null +++ b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx @@ -0,0 +1,236 @@ +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), + height: '100%', + 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 ? (