From 066457d50644ccb245e9ede9bf533a650f9285c3 Mon Sep 17 00:00:00 2001 From: lws49 Date: Sun, 28 Jun 2026 12:40:14 +0800 Subject: [PATCH 1/4] feat(gradebook): import external assessment grades from CSV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the full external-assessment CSV import feature as a single slice on top of grade validation (pr3). Backend: - Course::Gradebook::ExternalAssessmentImportService — header-order tolerance, duplicate-header/identifier guards, reassigned-identifier detection, out-of-range flagging, and batched (bulk insert + upsert) grade writes. - external_assessment_imports controller (preview + create/commit), create/preview jbuilders, and import routes. Frontend: - ImportExternalAssessmentsWizard (upload → define → verify), with the ExternalGradeConflict prompt/table change-matrix and buildTemplate. - previewImport/commitImport operations + import API endpoints + types. - ManageExternalAssessmentsPanel gains the Import CSV entry point. --- .../external_assessment_imports_controller.rb | 53 + .../external_assessment_import_service.rb | 504 +++++ .../create.json.jbuilder | 4 + .../preview.json.jbuilder | 27 + client/app/api/course/Gradebook.ts | 21 + .../ImportExternalAssessmentsWizard.test.tsx | 1680 +++++++++++++++++ .../ManageExternalAssessmentsPanel.test.tsx | 121 +- .../gradebook/__tests__/buildTemplate.test.ts | 79 + .../gradebook/__tests__/operations.test.ts | 44 + .../import/ExternalGradeConflictPrompt.tsx | 128 ++ .../import/ExternalGradeConflictTable.tsx | 103 + .../ImportExternalAssessmentsWizard.tsx | 951 ++++++++++ .../ExternalGradeConflictTable.test.tsx | 155 ++ .../components/import/buildTemplate.ts | 43 + .../manage/ManageExternalAssessmentsPanel.tsx | 344 ++-- .../bundles/course/gradebook/operations.ts | 21 + client/app/types/course/gradebook.ts | 65 + client/locales/en.json | 170 +- client/locales/ko.json | 170 +- client/locales/zh.json | 170 +- config/routes.rb | 5 + ...rnal_assessment_imports_controller_spec.rb | 195 ++ ...external_assessment_import_service_spec.rb | 777 ++++++++ 23 files changed, 5674 insertions(+), 156 deletions(-) create mode 100644 app/controllers/course/external_assessment_imports_controller.rb create mode 100644 app/services/course/gradebook/external_assessment_import_service.rb create mode 100644 app/views/course/external_assessment_imports/create.json.jbuilder create mode 100644 app/views/course/external_assessment_imports/preview.json.jbuilder create mode 100644 client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts create mode 100644 client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/__tests__/ExternalGradeConflictTable.test.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/buildTemplate.ts create mode 100644 spec/controllers/course/external_assessment_imports_controller_spec.rb create mode 100644 spec/services/course/gradebook/external_assessment_import_service_spec.rb diff --git a/app/controllers/course/external_assessment_imports_controller.rb b/app/controllers/course/external_assessment_imports_controller.rb new file mode 100644 index 0000000000..a85d636e74 --- /dev/null +++ b/app/controllers/course/external_assessment_imports_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true +class Course::ExternalAssessmentImportsController < Course::ComponentController + Service = Course::Gradebook::ExternalAssessmentImportService + + def preview + authorize! :manage_gradebook_weights, current_course + @result = build_service.preview + render 'preview' + rescue Service::ImportError => e + render json: { errors: e.payload }, status: :unprocessable_entity + end + + def create + authorize! :manage_gradebook_weights, current_course + @summary = build_service.commit(on_conflict: import_params[:onConflict]) + render 'create' + rescue Service::ImportError => e + render json: { errors: e.payload }, status: :unprocessable_entity + end + + private + + def component + current_component_host[:course_gradebook_component] + end + + def build_service + permitted_params = import_params + weighted_view_enabled = gradebook_settings.weighted_view_enabled + Service.new( + course: current_course, + actor: current_user, + components: permitted_params[:components].map do |c| + { name: c[:name], + weightage: weighted_view_enabled ? c[:weightage].to_i : 0, + maximum_grade: c[:maximumGrade].to_f } + end, + identifier_mode: permitted_params[:identifierMode], + csv_data: permitted_params[:csvData] + ) + end + + def gradebook_settings + @gradebook_settings ||= Course::Settings::GradebookComponent.new(component) + end + + def import_params + @import_params ||= params.slice(:identifierMode, :csvData, :onConflict, :components).permit( + :identifierMode, :csvData, :onConflict, + components: [:name, :weightage, :maximumGrade] + ) + end +end diff --git a/app/services/course/gradebook/external_assessment_import_service.rb b/app/services/course/gradebook/external_assessment_import_service.rb new file mode 100644 index 0000000000..934bd94a1c --- /dev/null +++ b/app/services/course/gradebook/external_assessment_import_service.rb @@ -0,0 +1,504 @@ +# frozen_string_literal: true +require 'csv' + +class Course::Gradebook::ExternalAssessmentImportService # rubocop:disable Metrics/ClassLength + class ImportError < StandardError + attr_reader :payload + + def initialize(payload) + @payload = payload + super(payload.is_a?(Hash) ? payload[:message].to_s : payload.to_s) + end + end + + HEADER_SUGGESTION_MAX_DISTANCE = 2 + + def initialize(course:, actor:, components:, identifier_mode:, csv_data:) + @course = course + @actor = actor + @components = components.map(&:symbolize_keys) + @identifier_mode = identifier_mode.to_s + @csv_data = csv_data + end + + def preview + rows = parsed_rows + resolution = resolve(rows) + { + ok: resolution[:unresolved].empty? && resolution[:malformed].empty?, + unresolved: resolution[:unresolved], + malformed: resolution[:malformed], + sample: sample(resolution[:resolved]), + conflict_rows: conflict_rows(resolution[:resolved]), + out_of_range: out_of_range(resolution[:resolved]), + reassignments: reassignments(resolution[:resolved]), + total_rows: resolution[:resolved].size, + column_order: column_order(rows) + } + end + + def commit(on_conflict:) + rows = parsed_rows + resolution = resolve(rows) + unless resolution[:unresolved].empty? && resolution[:malformed].empty? + raise ImportError, { message: 'validation_failed', + unresolved: resolution[:unresolved], + malformed: resolution[:malformed] } + end + + summary = { createdComponents: 0, updatedComponents: 0, gradesWritten: 0 } + ActiveRecord::Base.transaction { write_components(summary, resolution[:resolved], on_conflict) } + summary + end + + private + + def write_components(summary, resolved, on_conflict) + @components.each do |component| + external = existing_external(component[:name]) + if external + summary[:updatedComponents] += 1 + summary[:gradesWritten] += upsert_grades(external, component, resolved, + on_conflict: on_conflict) + else + summary[:createdComponents] += 1 + summary[:gradesWritten] += create_component(component, resolved) + end + end + end + + def parsed_rows + guard_no_duplicate_components! + table = CSV.parse(@csv_data.to_s, headers: true) + guard_header!(table.headers) + guard_not_empty!(table) + guard_unique_identifiers!(table) + table + end + + def guard_no_duplicate_components! + names = @components.map { |c| c[:name] } + return if names.uniq.length == names.length + + raise ImportError, { message: 'duplicate_component_name' } + end + + def guard_header!(headers) + expected = [identifier_header] + @components.map { |c| c[:name] } + identifier_not_first = headers.include?(identifier_header) && + headers.compact.first != identifier_header + return if headers.tally == expected.tally && !identifier_not_first + + suggestions = header_suggestions(expected, headers) + raise ImportError, { message: 'bad_header', + missing: missing_headers(expected, headers, suggestions), + unrecognized: unrecognized_headers(expected, headers, suggestions), + suggestions: suggestions, + duplicates: duplicate_headers(headers), + identifierNotFirst: identifier_not_first } + end + + def guard_not_empty!(table) + return unless table.empty? + + raise ImportError, { message: 'empty_csv' } + end + + def guard_unique_identifiers!(table) + keys = table.filter_map do |row| + key = lookup_key(row[identifier_header].to_s.strip) + key unless key.empty? + end + duplicates = keys.tally.select { |_key, count| count > 1 }.keys + return if duplicates.empty? + + raise ImportError, { message: 'duplicate_identifier', identifiers: duplicates } + end + + # Headers that appear more than once in the uploaded file, with their counts. + def duplicate_headers(headers) + headers.compact.tally.select { |_name, count| count > 1 }. + map { |name, count| { name: name, count: count } } + end + + # Expected columns absent from the upload. A column we can pair to a likely + # typo'd upload is reported via suggestions ("did you mean"), not here. + def missing_headers(expected, headers, suggestions) + (expected - headers) - suggestions.map { |s| s[:expected] } + end + + # Uploaded columns that are not expected. The upload side of a typo pair is + # reported via suggestions, so it is excluded here. + def unrecognized_headers(expected, headers, suggestions) + (headers.compact.uniq - expected) - suggestions.map { |s| s[:didYouMean] } + end + + # For each expected header absent from the uploaded file, suggest the closest + # *unused* uploaded header within HEADER_SUGGESTION_MAX_DISTANCE edits (catches + # typos and singular/plural, e.g. required "Midterms" vs uploaded "Midterm"). + def header_suggestions(expected, got) + available = (got - expected).compact + (expected - got).filter_map do |want| + near = available.min_by { |have| levenshtein(want, have) } + next if near.nil? || levenshtein(want, near) > HEADER_SUGGESTION_MAX_DISTANCE + + available.delete(near) # never suggest the same uploaded column twice + { expected: want, didYouMean: near } + end + end + + def levenshtein(source, target) + return target.length if source.empty? + return source.length if target.empty? + + distances = (0..target.length).to_a + + source.each_char.with_index(1) do |source_char, source_index| + distances = next_levenshtein_row( + distances, + target, + source_char, + source_index + ) + end + + distances[target.length] + end + + def next_levenshtein_row(previous, target, source_char, source_index) + current = [source_index] + + target.each_char.with_index(1) do |target_char, target_index| + current << levenshtein_cell( + previous, + current, + source_char, + target_char, + target_index + ) + end + + current + end + + def levenshtein_cell(previous, current, source_char, target_char, target_index) + substitution_cost = (source_char == target_char) ? 0 : 1 + + [ + previous[target_index] + 1, + current[target_index - 1] + 1, + previous[target_index - 1] + substitution_cost + ].min + end + + def identifier_header + (@identifier_mode == 'email') ? 'Email' : 'External ID' + end + + def roster_lookup + @roster_lookup ||= + if @identifier_mode == 'email' + @course.course_users.includes(user: :emails).index_by { |cu| cu.user.email.downcase } + else + @course.course_users.where.not(external_id: [nil, '']).index_by(&:external_id) + end + end + + def lookup_key(identifier) + (@identifier_mode == 'email') ? identifier.to_s.downcase : identifier.to_s + end + + def resolve(table) + resolved = [] + unresolved = [] + malformed = [] + table.each_with_index do |row, idx| + identifier = row[identifier_header].to_s.strip + course_user = roster_lookup[lookup_key(identifier)] + if course_user.nil? + unresolved << identifier + next + end + grades, bad = parse_grades(row, idx) + malformed.concat(bad) + resolved << { course_user: course_user, identifier: normalized_email(identifier, course_user), grades: grades } + end + { resolved: resolved, unresolved: unresolved.uniq, malformed: malformed } + end + + def normalized_email(identifier, course_user) + return course_user.user.email if @identifier_mode == 'email' && course_user&.user&.email.present? + + identifier + end + + def parse_grades(row, row_idx) + grades = {} + malformed = [] + @components.each do |component| + raw = row[component[:name]] + if raw.nil? || raw.to_s.strip.empty? + grades[component[:name]] = nil + elsif numeric?(raw) + grades[component[:name]] = Float(raw) + else + malformed << "row #{row_idx + 2}, #{component[:name]}: #{raw}" + end + end + [grades, malformed] + end + + def numeric?(value) + Float(value) + true + rescue ArgumentError, TypeError + false + end + + # Advisory: grades outside [0, max]. Reported but never blocks the import. + def out_of_range(resolved) + cells = [] + resolved.each do |row| + @components.each do |component| + grade = row[:grades][component[:name]] + next if grade.nil? + + max = component[:maximum_grade] + next unless grade < 0 || grade > max + + cells << { + identifier: row[:identifier], + component: component[:name], + grade: grade, + max: max, + kind: (grade < 0) ? 'below' : 'above' + } + end + end + cells + end + + # A row is "reassigned" when its CSV identifier was previously imported as the + # binding key for a grade now owned by a DIFFERENT course_user — i.e. the + # identifier has changed hands (e.g. an External ID recycled to a new student). + # Resolution still binds by course_user_id, so the grade is placed on whoever + # currently owns the identifier; this advisory asks the user to confirm that is + # the intended person. Unlike a conflict row it can fire on a brand-new insert, + # so it is surfaced standalone, not via the conflict table. One entry per + # identifier. Naturally silent on benign same-student drift and on mode switches + # (an email never equals a stored External ID). + def reassignments(resolved) + owners = snapshot_owners + return [] if owners.empty? + + raw = collect_reassignments(resolved, owners) + names = course_user_names(raw.flat_map { |entry| entry[:previous_ids] }) + raw.map do |entry| + { identifier: entry[:identifier], currentStudent: entry[:current_student], + previousStudents: entry[:previous_ids].filter_map { |id| names[id] } } + end + end + + # One reassignment entry per identifier whose grade is now owned by a different + # course_user than the resolved row. Dedups by identifier, marking it seen only + # once a genuine reassignment is found so the first such row wins. + def collect_reassignments(resolved, owners) + seen = Set.new + resolved.filter_map do |row| + incoming = row[:identifier] + others = owners.fetch(incoming, []).reject { |cu_id| cu_id == row[:course_user].id } + next if others.empty? || !seen.add?(incoming) + + { identifier: incoming, current_student: row[:course_user].name, previous_ids: others } + end + end + + # { imported_identifier => Set[course_user_id] } across all of the course's + # external grades. Two different course_users can share one snapshot value + # (a stale grade plus a fresh one) — that overlap is exactly the reassignment. + def snapshot_owners + @snapshot_owners ||= + Course::ExternalAssessmentGrade. + joins(:external_assessment). + where(course_external_assessments: { course_id: @course.id }). + where.not(imported_identifier: [nil, '']). + pluck(:imported_identifier, :course_user_id). + each_with_object({}) { |(ident, cu_id), acc| (acc[ident] ||= Set.new) << cu_id } + end + + def course_user_names(ids) + return {} if ids.empty? + + CourseUser.where(id: ids.uniq).pluck(:id, :name).to_h + end + + def sample(resolved) + resolved.first(5).map do |r| + { identifier: r[:identifier], grades: r[:grades] } + end + end + + # Component column headers in the order they appear in the uploaded CSV, + # with the identifier header removed. guard_header! has already ensured the + # header set matches @components, so the remainder are exactly the component + # names in CSV order. + def column_order(table) + table.headers.compact - [identifier_header] + end + + def conflict_rows(resolved) + grades_by_component = @components.to_h do |component| + external = existing_external(component[:name]) + grades = external ? external.external_assessment_grades.index_by(&:course_user_id) : {} + [component[:name], grades] + end + + resolved.filter_map { |row| build_conflict_row(row, grades_by_component) } + end + + def build_conflict_row(row, grades_by_component) + cells = {} + changed_any = false + + @components.each do |component| + cell, changed = conflict_cell(component, row, grades_by_component) + changed_any ||= changed + cells[component[:name]] = cell + end + + return nil unless changed_any + + { identifier: row[:identifier], studentName: row[:course_user].name, cells: cells } + end + + def conflict_cell(component, row, grades_by_component) + name = component[:name] + in_file = row[:grades][name] + existing_record = grades_by_component[name][row[:course_user].id] + existing = existing_record&.grade + changed = grade_changed?(existing, in_file) + [{ existing: existing&.to_f, inFile: in_file, changed: changed }, changed] + end + + def grades_comparable?(existing, in_file) + !existing.nil? && !in_file.nil? + end + + def grade_changed?(existing, in_file) + grades_comparable?(existing, in_file) && existing.to_f.round(2) != in_file.to_f.round(2) + end + + def existing_external(name) + @existing_externals ||= {} + @existing_externals[name] ||= Course::ExternalAssessment.for_course(@course).find_by(title: name) + end + + def create_component(component, resolved) + external = User.with_stamper(@actor) do + Course::ExternalAssessment.create_for_course!( + course: @course, title: component[:name], + maximum_grade: component[:maximum_grade], weight: component[:weightage] + ) + end + rows = resolved.filter_map do |r| + build_grade(external, r) unless r[:grades][component[:name]].nil? + end + bulk_insert(rows) + rows.size + end + + def upsert_grades(external, component, resolved, on_conflict:) + inserts, upserts = partition_grade_writes( + external, component[:name], resolved, on_conflict + ) + + bulk_insert(inserts) + bulk_upsert(upserts) + + inserts.size + upserts.size + end + + def partition_grade_writes(external, component_name, resolved, on_conflict) + context = { + external: external, + component_name: component_name, + existing_by_cu: external.external_assessment_grades.index_by(&:course_user_id) + } + + if on_conflict.to_s == 'replace' + collect_replace_grade_writes(resolved, context) + else + collect_keep_grade_writes(resolved, context) + end + end + + def collect_replace_grade_writes(resolved, context) + resolved.each_with_object({ inserts: [], upserts: [] }) do |row, buckets| + existing, record = grade_write_for(row, context) + next if record.nil? + + if existing.nil? + buckets[:inserts] << record + else + buckets[:upserts] << record + end + end.values_at(:inserts, :upserts) + end + + def collect_keep_grade_writes(resolved, context) + resolved.each_with_object({ inserts: [], upserts: [] }) do |row, buckets| + existing, record = grade_write_for(row, context) + next if record.nil? + + if existing.nil? + buckets[:inserts] << record + elsif existing.grade.nil? + buckets[:upserts] << record + end + end.values_at(:inserts, :upserts) + end + + def grade_write_for(row, context) + in_file = row[:grades][context[:component_name]] + return if in_file.nil? + + existing = context[:existing_by_cu][row[:course_user].id] + record = build_grade(context[:external], row, value: in_file) + + [existing, record] + end + + # Bulk update via Postgres ON CONFLICT. Only the listed columns are written on + # conflict, so creator_id/created_at on the existing row are preserved; grade, + # imported_identifier, updater_id and updated_at are refreshed. validate:false + # because the rows already exist (the uniqueness validation would reject them) + # and grades were already coerced to Float during parsing. + def bulk_upsert(records) + return if records.empty? + + Course::ExternalAssessmentGrade.import( + records, + validate: false, + on_duplicate_key_update: { + conflict_target: [:external_assessment_id, :course_user_id], + columns: [:grade, :imported_identifier, :updater_id, :updated_at] + } + ) + end + + def build_grade(external, resolved_row, value: nil) + Course::ExternalAssessmentGrade.new( + external_assessment: external, + course_user: resolved_row[:course_user], + grade: value.nil? ? resolved_row[:grades][external.title] : value, + imported_identifier: resolved_row[:identifier], + creator: @actor, updater: @actor + ) + end + + def bulk_insert(records) + return if records.empty? + + Course::ExternalAssessmentGrade.import(records, validate: true) + end +end diff --git a/app/views/course/external_assessment_imports/create.json.jbuilder b/app/views/course/external_assessment_imports/create.json.jbuilder new file mode 100644 index 0000000000..b8db877400 --- /dev/null +++ b/app/views/course/external_assessment_imports/create.json.jbuilder @@ -0,0 +1,4 @@ +# frozen_string_literal: true +json.createdComponents @summary[:createdComponents] +json.updatedComponents @summary[:updatedComponents] +json.gradesWritten @summary[:gradesWritten] diff --git a/app/views/course/external_assessment_imports/preview.json.jbuilder b/app/views/course/external_assessment_imports/preview.json.jbuilder new file mode 100644 index 0000000000..0ac5f0234c --- /dev/null +++ b/app/views/course/external_assessment_imports/preview.json.jbuilder @@ -0,0 +1,27 @@ +# frozen_string_literal: true +json.ok @result[:ok] +json.unresolved @result[:unresolved] +json.malformed @result[:malformed] +json.sample @result[:sample] do |row| + json.identifier row[:identifier] + json.grades row[:grades] +end +json.conflictRows @result[:conflict_rows] do |row| + json.identifier row[:identifier] + json.studentName row[:studentName] + json.cells row[:cells] +end +json.outOfRange @result[:out_of_range] do |cell| + json.identifier cell[:identifier] + json.component cell[:component] + json.grade cell[:grade] + json.max cell[:max] + json.kind cell[:kind] +end +json.reassignments @result[:reassignments] do |entry| + json.identifier entry[:identifier] + json.currentStudent entry[:currentStudent] + json.previousStudents entry[:previousStudents] +end +json.totalRows @result[:total_rows] +json.columnOrder @result[:column_order] diff --git a/client/app/api/course/Gradebook.ts b/client/app/api/course/Gradebook.ts index 161b1afc4e..7ee76d814b 100644 --- a/client/app/api/course/Gradebook.ts +++ b/client/app/api/course/Gradebook.ts @@ -3,6 +3,9 @@ import { ExternalAssessmentUpdate, ExternalGradePayload, GradebookData, + ImportCommitSummary, + ImportPreviewRequest, + ImportPreviewResult, UpdateWeightsPayload, } from 'types/course/gradebook'; @@ -72,4 +75,22 @@ export default class GradebookAPI extends BaseCourseAPI { payload, ); } + + importPreview( + payload: ImportPreviewRequest, + ): APIResponse { + return this.client.post( + `${this.#urlPrefix}/external_assessment_imports/preview`, + payload, + ); + } + + importCommit( + payload: ImportPreviewRequest & { onConflict: 'keep' | 'replace' }, + ): APIResponse { + return this.client.post( + `${this.#urlPrefix}/external_assessment_imports`, + payload, + ); + } } diff --git a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx new file mode 100644 index 0000000000..d193ba0628 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx @@ -0,0 +1,1680 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from 'test-utils'; +import type { StudentData } from 'types/course/gradebook'; +import TestApp from 'utilities/TestApp'; + +import CourseAPI from 'api/course'; +import toast from 'lib/hooks/toast'; + +import ExternalGradeConflictPrompt from '../components/import/ExternalGradeConflictPrompt'; +import ImportExternalAssessmentsWizard from '../components/import/ImportExternalAssessmentsWizard'; + +jest.mock('api/course'); +jest.mock('lib/components/wrappers/I18nProvider'); +jest.mock('lib/hooks/toast'); + +const EXTERNAL_ID = 'External ID'; +const MIDTERM = 'Midterm'; +const MIDTERMS = 'Midterms'; +const A001 = 'A001'; + +const defaultProps = { + existingAssessments: [], + onClose: jest.fn(), + weightedViewEnabled: true, +}; + +const componentNameInput = (): HTMLElement => + screen.getByRole('textbox', { name: 'Component name' }); + +const file = (text: string): File => + new File([text], 'marks.csv', { type: 'text/csv' }); + +const student = (partial: Partial): StudentData => ({ + id: 1, + name: 'Student', + email: 's@x.com', + externalId: 'E1', + level: 0, + totalXp: 0, + levelContribution: null, + ...partial, +}); + +const studentsState = (students: StudentData[]): object => ({ + gradebook: { + categories: [], + tabs: [], + submissions: [], + assessments: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + students, + }, +}); + +// Render against an isolated store (not the shared singleton, which a commit +// test can leave without a students slice and crash getStudents()). +const renderWizard = ( + props: Partial<{ weightedViewEnabled: boolean }> = {}, +): void => { + render( + , + { state: studentsState([]) }, + ); +}; + +const advanceToVerifyStep = async (): Promise => { + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await screen.findByRole('button', { name: /confirm import/i }); +}; + +const advanceToVerifyFailure = async (csv: string): Promise => { + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload(screen.getByLabelText(/upload/i), file(csv)); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); +}; + +const fillOneComponent = async (): Promise => { + await userEvent.type(componentNameInput(), MIDTERM); +}; + +describe('dialog dismissal guards', () => { + it('does not close the wizard on backdrop click or escape', async () => { + const onClose = jest.fn(); + render( + , + ); + + const backdrop = document.querySelector('.MuiBackdrop-root'); + expect(backdrop).not.toBeNull(); + await userEvent.click(backdrop as Element); + expect(onClose).not.toHaveBeenCalled(); + + await userEvent.keyboard('{Escape}'); + expect(onClose).not.toHaveBeenCalled(); + + // The explicit Cancel button still closes it + await userEvent.click(screen.getByRole('button', { name: /cancel/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('does not close the conflict prompt on backdrop click', async () => { + const onCancel = jest.fn(); + render( + , + ); + + const backdrop = document.querySelector('.MuiBackdrop-root'); + expect(backdrop).not.toBeNull(); + await userEvent.click(backdrop as Element); + expect(onCancel).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByRole('button', { name: /go back/i })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); + +describe('ImportExternalAssessmentsWizard', () => { + beforeEach(() => jest.clearAllMocks()); + + it('walks define → upload → verify → commit with no conflicts', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: A001, grades: { Midterm: 41 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 1 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + + renderWizard(); + // Step 1: type a component + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + + // Step 2: upload + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + // Step 3: preview shows the sample + expect(await screen.findByText(A001)).toBeVisible(); + expect( + screen.getByRole('columnheader', { name: EXTERNAL_ID }), + ).toBeInTheDocument(); + await userEvent.click( + screen.getByRole('button', { name: /continue|confirm/i }), + ); + + await waitFor(() => + expect(CourseAPI.gradebook.importCommit).toHaveBeenCalled(), + ); + const payload = (CourseAPI.gradebook.importCommit as jest.Mock).mock + .calls[0][0]; + expect(payload.onConflict).toBe('replace'); + expect(payload.identifierMode).toBe('external_id'); + expect(payload.components[0]).toMatchObject({ + name: MIDTERM, + weightage: 30, + maximumGrade: 50, + }); + + // success side-effects + await waitFor(() => + expect(toast.success).toHaveBeenCalledWith('Import complete.'), + ); + }); + + it('uses singular copy for a single unresolved external ID', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ'], + malformed: [], + outOfRange: [], + sample: [], + conflictRows: [], + reassignments: [], + }, + }); + renderWizard(); + await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\n`); + expect( + await screen.findByText( + /This external ID was not found in the course: ZZZ/, + ), + ).toBeInTheDocument(); + }); + + it('uses plural copy for multiple unresolved external IDs', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ', 'YYY'], + malformed: [], + outOfRange: [], + sample: [], + conflictRows: [], + reassignments: [], + }, + }); + renderWizard(); + await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\nYYY,2\n`); + expect( + await screen.findByText( + /These external IDs were not found in the course: ZZZ, YYY/, + ), + ).toBeInTheDocument(); + }); + + it('lists up to five malformed cells then summarises the rest', async () => { + const malformed = [ + 'row 2, Midterm: a', + 'row 3, Midterm: b', + 'row 4, Midterm: c', + 'row 5, Midterm: d', + 'row 6, Midterm: e', + 'row 7, Midterm: f', + 'row 8, Midterm: g', + ]; + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: [], + malformed, + outOfRange: [], + sample: [], + conflictRows: [], + reassignments: [], + }, + }); + renderWizard(); + await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\n${A001},a\n`); + expect(await screen.findByText('row 2, Midterm: a')).toBeInTheDocument(); + expect(screen.getByText('row 6, Midterm: e')).toBeInTheDocument(); + expect(screen.queryByText('row 7, Midterm: f')).not.toBeInTheDocument(); + expect(screen.getByText('and 2 more')).toBeInTheDocument(); + }); + + it('shows unresolved identifiers and stays on the upload step', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ'], + malformed: [], + outOfRange: [], + sample: [], + conflictRows: [], + reassignments: [], + }, + }); + renderWizard(); + + await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\n`); + expect(await screen.findByText(/ZZZ/)).toBeVisible(); + expect(CourseAPI.gradebook.importCommit).not.toHaveBeenCalled(); + }); + + it('hides weightage field when weightedViewEnabled is false', async () => { + render( + , + ); + await userEvent.type(componentNameInput(), MIDTERM); + expect(screen.queryByLabelText(/weightage/i)).not.toBeInTheDocument(); + expect(screen.getByLabelText(/max marks/i)).toBeInTheDocument(); + }); + + it('disables Next when component name is empty', () => { + renderWizard(); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + it('labels the identifier toggle "External ID"', () => { + render( + , + ); + expect( + screen.getByRole('radio', { name: EXTERNAL_ID }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('radio', { name: 'Student ID' }), + ).not.toBeInTheDocument(); + }); + + it('keeps the component input label independent of the selected identifier mode', async () => { + renderWizard(); + expect(componentNameInput()).toBeInTheDocument(); + expect( + screen.queryByRole('textbox', { name: EXTERNAL_ID }), + ).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + + expect(componentNameInput()).toBeInTheDocument(); + expect( + screen.queryByRole('textbox', { name: 'Email' }), + ).not.toBeInTheDocument(); + }); + + it('commits with keep when Keep Existing is clicked on the conflict prompt', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: A001, grades: { Midterm: 20 } }], + conflictRows: [ + { + identifier: A001, + studentName: 'Alice', + cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, + }, + ], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 0 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /confirm import/i }), + ); + expect( + await screen.findByText(/1 of 1 rows have changes/i), + ).toBeInTheDocument(); + // The struck old value (10) is unique to the matrix; the new value (20) + // also appears in the Verify sample table behind the dialog, so assert + // only the header + old value to avoid a multiple-match error. + expect(screen.getByText('10')).toBeInTheDocument(); // struck old value + await userEvent.click( + await screen.findByRole('button', { name: /keep existing/i }), + ); + await waitFor(() => + expect( + (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] + .onConflict, + ).toBe('keep'), + ); + }); + + it('blocks Next in External ID mode while a student has no External ID', async () => { + render(, { + state: studentsState([ + student({ id: 1, name: 'Alice Lim', externalId: null }), + student({ id: 2, name: 'Bob Tan', externalId: 'E2' }), + ]), + }); + await fillOneComponent(); + expect( + screen.getByText(/Alice Lim has no External ID/), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + }); + + it('enables Next once matching by Email instead', async () => { + render(, { + state: studentsState([ + student({ id: 1, name: 'Alice Lim', externalId: null }), + ]), + }); + await fillOneComponent(); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); + }); + + it('lists the exact required headers on the upload step', async () => { + render(, { + state: studentsState([ + student({ id: 1, name: 'Alice', externalId: 'E1' }), + ]), + }); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + expect( + screen.getByText( + /Your CSV needs these column headers: External ID, Midterm/, + ), + ).toBeInTheDocument(); + }); + + it('summarises the count when several students lack an External ID', async () => { + render(, { + state: studentsState([ + student({ id: 1, name: 'Alice Lim', externalId: null }), + student({ id: 2, name: 'Bob Tan', externalId: '' }), + student({ id: 3, name: 'Carol Low', externalId: '' }), + ]), + }); + await fillOneComponent(); + expect( + screen.getByText(/Alice Lim and 2 other students have no External ID/), + ).toBeInTheDocument(); + }); + + it('does not show Confirm import button when preview has errors', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['ZZZ'], + malformed: [], + outOfRange: [], + sample: [], + conflictRows: [], + reassignments: [], + }, + }); + renderWizard(); + await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\n`); + await screen.findByText(/ZZZ/); + expect( + screen.queryByRole('button', { name: /confirm import/i }), + ).not.toBeInTheDocument(); + }); + + it('opens the conflict prompt and commits with keep/replace', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: A001, grades: { Midterm: 20 } }], + conflictRows: [ + { + identifier: A001, + studentName: 'Alice', + cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, + }, + ], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 1 }, + }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, + }); + render( + , + ); + // Step 1: MIDTERM matches existing → max/weightage locked; just Next. + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /continue|confirm/i }), + ); + // conflict prompt + await userEvent.click( + await screen.findByRole('button', { name: /replace/i }), + ); + await waitFor(() => + expect( + (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] + .onConflict, + ).toBe('replace'), + ); + }); + + it('renders existing external chips in the define step', () => { + render( + , + ); + expect(screen.getByRole('button', { name: MIDTERM })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Finals' })).toBeInTheDocument(); + }); + + it('clicking an existing chip inserts a locked row pre-filled with correct max and weight', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: MIDTERM })); + + // The chip-inserted row's name field is read-only (disabled input) + const nameInput = screen.getByDisplayValue(MIDTERM); + expect(nameInput).toBeDisabled(); + + // Max and weight are pre-filled with the existing values + expect(screen.getByDisplayValue('50')).toBeDisabled(); + expect(screen.getByDisplayValue('30')).toBeDisabled(); + + // "Updates existing" label is shown + expect(screen.getByText(/updates existing/i)).toBeInTheDocument(); + }); + + it('hides a chip once the corresponding external has been added to the component list', async () => { + render( + , + ); + await userEvent.click(screen.getByRole('button', { name: MIDTERM })); + // After clicking, chip disappears (already in the list) + expect( + screen.queryByRole('button', { name: MIDTERM }), + ).not.toBeInTheDocument(); + }); + + it('does not render the From existing section when there are no existing externals', () => { + render( + , + ); + expect(screen.queryByText(/from existing/i)).not.toBeInTheDocument(); + }); + + it('warns about out-of-range grades at Verify without blocking import', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [ + { + identifier: 'S1', + component: MIDTERM, + grade: 105, + max: 100, + kind: 'above', + }, + ], + sample: [{ identifier: 'S1', grades: { Midterm: 105 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + renderWizard({ weightedViewEnabled: true }); + await advanceToVerifyStep(); + expect( + screen.getByText(/S1 - Midterm: 105 \(max 100\)/), + ).toBeInTheDocument(); + expect( + screen.getByText(/floored or capped in the weighted total/i), + ).toBeInTheDocument(); + // non-blocking: Confirm import still enabled + expect( + screen.getByRole('button', { name: /Confirm import/i }), + ).toBeEnabled(); + }); + + it('omits the weighted-total wording when weighted view is off', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [ + { + identifier: 'S1', + component: MIDTERM, + grade: 105, + max: 100, + kind: 'above', + }, + ], + sample: [{ identifier: 'S1', grades: { Midterm: 105 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + renderWizard({ weightedViewEnabled: false }); + await advanceToVerifyStep(); + expect( + screen.getByText(/S1 - Midterm: 105 \(max 100\)/), + ).toBeInTheDocument(); + expect(screen.queryByText(/weighted total/i)).not.toBeInTheDocument(); + }); + + it('shows a "did you mean" suggestion for a renamed column, not raw header dumps', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { + message: 'bad_header', + missing: [], + unrecognized: [], + suggestions: [{ expected: MIDTERMS, didYouMean: MIDTERM }], + duplicates: [], + }, + }, + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERMS); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + // Actionable detail is shown (not the generic "Could not verify" toast) + expect( + await screen.findByText(/did you mean ['‘]Midterms['’]\?/i), + ).toBeInTheDocument(); + // The old eye-diff "Found: …" / "do not match" dump is gone + expect(screen.queryByText(/Found:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/do not match/i)).not.toBeInTheDocument(); + // Stays on the upload step — no preview/confirm advance + expect( + screen.queryByRole('button', { name: /confirm import/i }), + ).not.toBeInTheDocument(); + }); + + it('shows only the duplicate-header line when duplicates are the only problem', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { + message: 'bad_header', + missing: [], + unrecognized: [], + suggestions: [], + duplicates: [{ name: MIDTERM, count: 2 }], + }, + }, + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM},${MIDTERM}\nA001,1,2\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + expect( + await screen.findByText(/appears more than once:.*Midterm \(×2\)/i), + ).toBeInTheDocument(); + // No bogus missing/unrecognized lines when every column is present + expect(screen.queryByText(/is missing/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/recognized/i)).not.toBeInTheDocument(); + }); + + it('uses singular copy for a single missing / unrecognized / duplicate column', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { + message: 'bad_header', + missing: [MIDTERM], + unrecognized: ['Wrong'], + suggestions: [], + duplicates: [{ name: 'Quiz', count: 2 }], + }, + }, + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},Wrong\nA001,41\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + expect( + await screen.findByText(/is missing this column:.*Midterm\b/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/This column isn['’]t recognized:.*Wrong/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/This column appears more than once:.*Quiz \(×2\)/i), + ).toBeInTheDocument(); + }); + + it('uses plural copy for multiple missing / unrecognized / duplicate columns', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { + message: 'bad_header', + missing: [MIDTERM, 'Final Exam'], + unrecognized: ['Wrong', 'Extra'], + suggestions: [], + duplicates: [ + { name: 'Quiz', count: 2 }, + { name: 'Project', count: 3 }, + ], + }, + }, + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},Wrong,Extra\nA001,41,5\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + expect( + await screen.findByText( + /is missing these columns:.*Midterm, Final Exam/i, + ), + ).toBeInTheDocument(); + expect( + screen.getByText(/These columns aren['’]t recognized:.*Wrong, Extra/i), + ).toBeInTheDocument(); + expect( + screen.getByText( + /These columns appear more than once:.*Quiz \(×2\), Project \(×3\)/i, + ), + ).toBeInTheDocument(); + }); + + it('shows preview subtitle with total row count when preview has more than 5 rows', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + totalRows: 6, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [ + { identifier: A001, grades: { Midterm: 41 } }, + { identifier: 'A002', grades: { Midterm: 42 } }, + { identifier: 'A003', grades: { Midterm: 43 } }, + { identifier: 'A004', grades: { Midterm: 44 } }, + { identifier: 'A005', grades: { Midterm: 45 } }, + ], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + }, + }); + + renderWizard(); + + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + + await userEvent.upload( + screen.getByLabelText(/upload/i), + file( + [ + `${EXTERNAL_ID},${MIDTERM}`, + 'A001,41', + 'A002,42', + 'A003,43', + 'A004,44', + 'A005,45', + 'A006,46', + ].join('\n'), + ), + ); + + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + expect(await screen.findByText(A001)).toBeVisible(); + + expect( + screen.getByText( + /Previewing the first 5 of 6 rows. Check that this preview matches your CSV before continuing./i, + ), + ).toBeInTheDocument(); + }); + + it('shows all rows subtitle variant when totalRows is 5 or fewer', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + totalRows: 5, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [ + { identifier: A001, grades: { Midterm: 41 } }, + { identifier: 'A002', grades: { Midterm: 42 } }, + { identifier: 'A003', grades: { Midterm: 43 } }, + { identifier: 'A004', grades: { Midterm: 44 } }, + { identifier: 'A005', grades: { Midterm: 45 } }, + ], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + }, + }); + + renderWizard(); + + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + + await userEvent.upload( + screen.getByLabelText(/upload/i), + file( + [ + `${EXTERNAL_ID},${MIDTERM}`, + 'A001,41', + 'A002,42', + 'A003,43', + 'A004,44', + 'A005,45', + ].join('\n'), + ), + ); + + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + expect(await screen.findByText(A001)).toBeVisible(); + + expect( + screen.getByText( + /Previewing all 5 rows. Check that this preview matches your CSV before continuing./i, + ), + ).toBeInTheDocument(); + }); + + it('renders the Verify preview columns in the CSV column order', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: 'A001', grades: { Final: 80, Midterm: 41 } }], + conflictRows: [], + reassignments: [], + totalRows: 1, + columnOrder: ['Final', 'Midterm'], + }, + }); + + renderWizard(); + // define Midterm first, then Final (opposite of the CSV order) + await userEvent.type(componentNameInput(), 'Midterm'); + await userEvent.click( + screen.getByRole('button', { name: /add component/i }), + ); + const nameInputs = screen.getAllByRole('textbox', { + name: 'Component name', + }); + await userEvent.type(nameInputs[1], 'Final'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file('Final,External ID,Midterm\n80,A001,41\n'), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await screen.findByRole('button', { name: /confirm import/i }); + + const headerCells = screen + .getAllByRole('columnheader') + .map((c) => c.textContent); + // identifier header first, then CSV order Final, Midterm + expect(headerCells).toEqual(['External ID', 'Final', 'Midterm']); + }); + + it('states grades import as-is, with the clamping hint only when weighted view is on', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + conflictRows: [], + reassignments: [], + totalRows: 1, + columnOrder: [MIDTERM], + sample: [{ identifier: 'A1', grades: { Midterm: 105 } }], + outOfRange: [ + { + identifier: 'A1', + component: MIDTERM, + grade: 105, + max: 100, + kind: 'above', + }, + ], + }, + }); + renderWizard({ weightedViewEnabled: true }); + await advanceToVerifyStep(); + expect( + screen.getByText( + /Grades will be imported exactly as entered\. This is only a warning; you can turn off this warning in Manage External Assessments\. Out-of-range grades are only floored or capped in the weighted total/, + ), + ).toBeInTheDocument(); + }); + + it('omits the clamping hint when weighted view is off', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + conflictRows: [], + reassignments: [], + totalRows: 1, + sample: [{ identifier: 'A1', grades: { Midterm: 105 } }], + outOfRange: [ + { + identifier: 'A1', + component: MIDTERM, + grade: 105, + max: 100, + kind: 'above', + }, + ], + columnOrder: [MIDTERM], + }, + }); + renderWizard({ weightedViewEnabled: false }); + await advanceToVerifyStep(); + expect( + screen.getByText( + 'Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. If these out-of-range grades are intentional, continue.', + ), + ).toBeInTheDocument(); + + expect(screen.queryByText(/floored or capped/)).not.toBeInTheDocument(); + }); + + it('wraps the preview table in a horizontally scrollable container', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + conflictRows: [], + reassignments: [], + outOfRange: [], + totalRows: 1, + sample: [{ identifier: 'A1', grades: { Midterm: 80 } }], + columnOrder: [MIDTERM], + }, + }); + renderWizard(); + await advanceToVerifyStep(); + const table = screen.getByRole('table'); + expect(table.parentElement).toHaveClass('overflow-x-auto'); + }); + + it('cues the pending change set on the Verify step before the confirm modal', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + columnOrder: [MIDTERM], + sample: [{ identifier: A001, grades: { Midterm: 20 } }], + conflictRows: [ + { + identifier: A001, + studentName: 'Alice', + cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, + }, + ], + reassignments: [], + totalRows: 1, + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + + // Cue appears on the Verify step, before any confirm click + expect( + await screen.findByText( + /1 row contains changes to existing grades\. After checking this preview, click Confirm import to review these conflicts before anything is imported\./i, + ), + ).toBeInTheDocument(); + }); + + it('shows a spinner on Replace while the commit is in flight', async () => { + let resolveCommit: (v: unknown) => void = () => {}; + (CourseAPI.gradebook.importCommit as jest.Mock).mockReturnValue( + new Promise((res) => { + resolveCommit = res; + }), + ); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ data: {} }); + + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: 'A001', grades: { Midterm: 41 } }], + conflictRows: [ + { + identifier: 'A001', + studentName: 'student', + identifierMismatch: false, + cells: { Midterm: { existing: 10, inFile: 41, changed: true } }, + }, + ], + reassignments: [], + totalRows: 1, + columnOrder: [MIDTERM], + }, + }); + + renderWizard(); + await advanceToVerifyStep(); + await userEvent.click( + screen.getByRole('button', { name: /confirm import/i }), + ); + // conflict prompt is open + const replaceBtn = await screen.findByRole('button', { name: /replace/i }); + await userEvent.click(replaceBtn); + + // MUI LoadingButton sets aria-disabled and renders a progressbar while loading + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + + resolveCommit({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 1 }, + }); + }); + + it('renders the diagnostic heading and a single closing instruction', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { + message: 'bad_header', + missing: ['Quiz 2'], + unrecognized: [], + suggestions: [], + duplicates: [], + identifierNotFirst: false, + }, + }, + }, + }); + render(, { + state: studentsState([]), + }); + await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`); + + expect(await screen.findByText('These headers need fixing:')).toBeVisible(); + expect( + screen.getByText('Correct these in your CSV, then re-upload.'), + ).toBeVisible(); + }); + + it('shows the identifier-first bullet when identifierNotFirst is set', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { + message: 'bad_header', + missing: [], + unrecognized: [], + suggestions: [], + duplicates: [], + identifierNotFirst: true, + }, + }, + }, + }); + render(, { + state: studentsState([]), + }); + await advanceToVerifyFailure(`${MIDTERM},${EXTERNAL_ID}\n41,A001\n`); + + expect( + await screen.findByText('‘External ID’ must be the first column.'), + ).toBeVisible(); + }); + + it('shows a reassignment warning when an identifier now matches a different student', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: A001, grades: { Midterm: 77 } }], + conflictRows: [], + reassignments: [ + { + identifier: A001, + currentStudent: 'Carol', + previousStudents: ['Alice'], + }, + ], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + + render(, { + state: studentsState([]), + }); + await advanceToVerifyStep(); + + expect( + await screen.findByText(/now match a different student/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/A001: now Carol \(was Alice\)/), + ).toBeInTheDocument(); + }); + + it('shows the External ID matching hint when every student has one', async () => { + render(, { + state: studentsState([ + student({ id: 1, name: 'Alice', externalId: 'E1' }), + ]), + }); + await fillOneComponent(); + expect( + screen.getByText(/Matching uses each student's External ID/), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); + }); + + it('hides the External ID hint when matching by Email', async () => { + render(, { + state: studentsState([ + student({ id: 1, name: 'Alice', externalId: 'E1' }), + ]), + }); + await fillOneComponent(); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + expect( + screen.queryByText(/Matching uses each student's External ID/), + ).not.toBeInTheDocument(); + }); + + it('does not cue pending changes when no rows conflict', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: A001, grades: { Midterm: 41 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + // Use an isolated store (not the shared singleton, which an earlier commit + // test may have left without a students slice). + render(, { + state: studentsState([]), + }); + await advanceToVerifyStep(); + expect( + screen.queryByText(/contains? changes to existing grades/i), + ).not.toBeInTheDocument(); + }); + + it('renders the change matrix: changed cells as old→new, unchanged as-is, missing as em-dash', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [ + { identifier: A001, grades: { Midterm: 20, Finals: 88 } }, + { identifier: 'A002', grades: { Midterm: 30 } }, + ], + conflictRows: [ + { + identifier: A001, + studentName: 'Alice', + identifierMismatch: false, + cells: { + Midterm: { existing: 10, inFile: 20, changed: true }, + Finals: { existing: 88, inFile: 88, changed: false }, + }, + }, + { + identifier: 'A002', + studentName: 'Bob', + identifierMismatch: false, + // Finals absent for this row → em-dash in matrix + cells: { Midterm: { existing: 5, inFile: 30, changed: true } }, + }, + ], + reassignments: [], + columnOrder: [MIDTERM, 'Finals'], + totalRows: 2, + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ + data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 2 }, + }); + render( + , + { state: studentsState([]) }, + ); + // Fill the initial blank row with Midterm (locks it as existing), then add + // Finals from its chip — two components, so Next is enabled. + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.click(screen.getByRole('button', { name: 'Finals' })); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM},Finals\n${A001},20,88\nA002,30,\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /confirm import/i }), + ); + + const dialog = await screen.findByRole('dialog', { + name: /resolve grade conflicts/i, + }); + // changed cell: struck old + bold new + const struck = within(dialog).getByText('10'); + expect(struck).toHaveStyle('text-decoration: line-through'); + expect(within(dialog).getByText('20')).toHaveStyle('font-weight: 700'); + // unchanged cell (Finals 88 → 88): shows the stored value, not struck + expect(within(dialog).getByText('88')).not.toHaveStyle( + 'text-decoration: line-through', + ); + // missing Finals cell on A002 → em-dash + expect(within(dialog).getByText('—')).toBeInTheDocument(); + }); + + it('formats below-minimum out-of-range grades with a min-0 bound', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [ + { + identifier: 'S1', + component: MIDTERM, + grade: -5, + max: 100, + kind: 'below', + }, + ], + sample: [{ identifier: 'S1', grades: { Midterm: -5 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + renderWizard({ weightedViewEnabled: true }); + await advanceToVerifyStep(); + expect(screen.getByText(/S1 - Midterm: -5 \(min 0\)/)).toBeInTheDocument(); + }); + + it('summarises out-of-range cells beyond the first ten', async () => { + const outOfRange = Array.from({ length: 12 }, (_, i) => ({ + identifier: `S${i + 1}`, + component: MIDTERM, + grade: 105, + max: 100, + kind: 'above' as const, + })); + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange, + sample: [{ identifier: 'S1', grades: { Midterm: 105 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 12, + }, + }); + renderWizard({ weightedViewEnabled: true }); + await advanceToVerifyStep(); + expect( + screen.getByText(/S10 - Midterm: 105 \(max 100\)/), + ).toBeInTheDocument(); + expect(screen.queryByText(/S11 - Midterm/)).not.toBeInTheDocument(); + expect(screen.getByText('+2 more')).toBeInTheDocument(); + }); + + it('uses email copy for unresolved identifiers when matching by Email', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: false, + unresolved: ['nope@x.com'], + malformed: [], + outOfRange: [], + sample: [], + conflictRows: [], + reassignments: [], + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`Email,${MIDTERM}\nnope@x.com,1\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + expect( + await screen.findByText( + /This email address was not found in the course: nope@x.com/, + ), + ).toBeInTheDocument(); + }); + + it('disables Next when two components share the same name', async () => { + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click( + screen.getByRole('button', { name: /add component/i }), + ); + const names = screen.getAllByRole('textbox', { name: 'Component name' }); + await userEvent.type(names[1], MIDTERM); + expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + }); + + it('lists the Email header when matching by Email', async () => { + render(, { + state: studentsState([ + student({ id: 1, name: 'Alice', externalId: 'E1' }), + ]), + }); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + await userEvent.click(screen.getByRole('button', { name: 'Next' })); + expect( + screen.getByText(/Your CSV needs these column headers: Email, Midterm/), + ).toBeInTheDocument(); + }); + + it('shows the header mismatch without a suggestion list when none is returned', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { + message: 'bad_header', + missing: [MIDTERMS], + unrecognized: ['Quiz'], + suggestions: [], + duplicates: [], + }, + }, + }, + }); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERMS); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},Quiz\nA001,41\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + // The header diagnostic lists the mismatched columns, but with no + // suggestions returned it omits the "did you mean" line entirely. + expect( + await screen.findByText('These headers need fixing:'), + ).toBeInTheDocument(); + expect( + screen.getByText(/This column isn['’]t recognized:.*Quiz/i), + ).toBeInTheDocument(); + expect(screen.queryByText(/did you mean/i)).not.toBeInTheDocument(); + }); + + it('shows a specific error when the CSV has no data rows', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { data: { errors: { message: 'empty_csv' } } }, + }); + renderWizard(); + await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\n`); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + expect.stringMatching(/no data rows|empty/i), + ), + ); + }); + + it('shows the duplicated identifiers when a row identifier repeats', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ + response: { + data: { + errors: { message: 'duplicate_identifier', identifiers: ['A001'] }, + }, + }, + }); + renderWizard(); + await advanceToVerifyFailure( + `${EXTERNAL_ID},${MIDTERM}\n${A001},40\n${A001},50\n`, + ); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/A001/)), + ); + }); + + it('toasts a generic error when the preview fails without a header diagnosis', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue( + new Error('network'), + ); + renderWizard(); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'Could not verify the file. Please try again.', + ), + ); + // stays on upload step + expect( + screen.queryByRole('button', { name: /confirm import/i }), + ).not.toBeInTheDocument(); + }); + + it('toasts failure and stays open when the commit request rejects', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: A001, grades: { Midterm: 41 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + }, + }); + (CourseAPI.gradebook.importCommit as jest.Mock).mockRejectedValue( + new Error('boom'), + ); + const onClose = jest.fn(); + render( + , + { state: studentsState([]) }, + ); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`), + ); + await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await userEvent.click( + await screen.findByRole('button', { name: /confirm import/i }), + ); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'Import failed. Nothing was saved.', + ), + ); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('resets to the define step when reopened after a close', async () => { + const { rerender } = render( + , + ); + await userEvent.type(componentNameInput(), MIDTERM); + await userEvent.type(screen.getByLabelText(/weightage/i), '30'); + await userEvent.type(screen.getByLabelText(/max marks/i), '50'); + await userEvent.click(screen.getByRole('button', { name: /next/i })); + expect(screen.getByLabelText(/upload/i)).toBeInTheDocument(); // on step 1 + // rerender re-renders the root element directly, so re-wrap in TestApp to + // keep the Redux provider; reusing the singleton store keeps the same store + // instance, exercising the open-prop reset effect rather than a remount. + rerender( + + + , + ); + rerender( + + + , + ); + // back on define step with a blank component + expect(componentNameInput()).toHaveValue(''); + expect(screen.queryByLabelText(/upload/i)).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx index 7033339d00..0e7fa39f3d 100644 --- a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx @@ -1,7 +1,7 @@ import type { DropResult } from '@hello-pangea/dnd'; import userEvent from '@testing-library/user-event'; import type { AppDispatch } from 'store'; -import { render, screen, waitFor } from 'test-utils'; +import { render, screen, waitFor, within } from 'test-utils'; import ManageExternalAssessmentsPanel, { handleDragEnd, @@ -21,6 +21,35 @@ const dropResult = (from: number, to: number | null): DropResult => to === null ? null : { index: to, droppableId: 'external-assessments' }, }) as DropResult; +jest.mock('../components/import/ImportExternalAssessmentsWizard', () => ({ + __esModule: true, + default: ({ + existingAssessments, + onClose, + open, + }: { + existingAssessments: { name: string }[]; + onClose: () => void; + open: boolean; + }): JSX.Element | null => + open ? ( +
+

Import external assessments

+ {existingAssessments.map((assessment) => ( + + ))} + + +
+ ) : null, +})); + jest.mock('../operations', () => ({ __esModule: true, ...jest.requireActual('../operations'), @@ -312,6 +341,96 @@ it('hides both bound chips when an external is neither floored nor capped', asyn expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); }); +it('passes existing external names as chips to the import wizard', async () => { + const stateWithExternal = { + gradebook: { + categories: [], + tabs: [{ id: 1, title: 'External', categoryId: 0, gradebookWeight: 30 }], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: 1, + maxGrade: 50, + gradebookWeight: 30, + external: true, + }, + ], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + clamp: true, + }, + courseMaxLevel: 0, + }, + }; + render(, { + state: stateWithExternal, + }); + + // Open the import wizard + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + + // The "Midterm" chip must appear in the define step + expect( + within(await screen.findByTestId('import-wizard')).getByText('Midterm'), + ).toBeInTheDocument(); +}); + +it('hides the external assessments panel while importing and reopens it on cancel', async () => { + render(, { + state: preloadedState, + }); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); + + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + + expect(await screen.findByText('Import external assessments')).toBeVisible(); + await waitFor(() => + expect(screen.queryByText('External assessments')).not.toBeInTheDocument(), + ); + + await userEvent.click( + within(screen.getByTestId('import-wizard')).getByText('Cancel'), + ); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); +}); + +it('reopens the external assessments panel after a successful import', async () => { + render(, { + state: preloadedState, + }); + + await userEvent.click( + await screen.findByRole('button', { name: /import csv/i }), + ); + expect(await screen.findByText('Import external assessments')).toBeVisible(); + await userEvent.click( + within(screen.getByTestId('import-wizard')).getByText('Confirm import'), + ); + + expect( + await screen.findByRole('heading', { name: 'External assessments' }), + ).toBeVisible(); +}); + it('renders a drag handle per external assessment', async () => { const page = render( , diff --git a/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts new file mode 100644 index 0000000000..279e3cc68e --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts @@ -0,0 +1,79 @@ +import { + buildTemplateCsv, + identifierHeader, +} from '../components/import/buildTemplate'; + +describe('identifierHeader', () => { + it('maps mode to the concrete header', () => { + expect(identifierHeader('external_id')).toBe('External ID'); + expect(identifierHeader('email')).toBe('Email'); + }); +}); + +describe('buildTemplateCsv', () => { + const components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }]; + + it('uses the External ID header in external_id mode', () => { + expect(buildTemplateCsv(components, 'external_id')).toBe( + 'External ID,Midterm\n', + ); + }); + + it('uses the Email header in email mode', () => { + expect(buildTemplateCsv(components, 'email')).toBe('Email,Midterm\n'); + }); + + it('quotes a component name containing a comma', () => { + const csv = buildTemplateCsv( + [{ name: 'Lab, week 1', weightage: 10, maximumGrade: 20 }], + 'external_id', + ); + expect(csv.split('\n')[0]).toBe('External ID,"Lab, week 1"'); + }); + + it('returns "External ID\\n" for empty components array in external_id mode', () => { + expect(buildTemplateCsv([], 'external_id')).toBe('External ID\n'); + }); + + it('quotes a component name containing a double-quote', () => { + const csv = buildTemplateCsv( + [{ name: 'My "Best" Quiz', weightage: 10, maximumGrade: 20 }], + 'external_id', + ); + expect(csv.split('\n')[0]).toBe('External ID,"My ""Best"" Quiz"'); + }); + + it('quotes a component name containing a newline', () => { + const csv = buildTemplateCsv( + [{ name: 'Lab\nWeek1', weightage: 10, maximumGrade: 20 }], + 'external_id', + ); + // The quoted cell spans two lines; verify the full header row content. + expect(csv.startsWith('External ID,"Lab\nWeek1"')).toBe(true); + }); + + it('always ends with exactly one newline', () => { + const csv = buildTemplateCsv( + [{ name: 'A', weightage: 0, maximumGrade: 100 }], + 'external_id', + ); + expect(csv.endsWith('\n')).toBe(true); + expect(csv.split('\n')).toHaveLength(2); // header line + empty string after trailing \n + }); + + it('emits one column per component, in input order', () => { + const csv = buildTemplateCsv( + [ + { name: 'Midterm', weightage: 30, maximumGrade: 50 }, + { name: 'Final', weightage: 50, maximumGrade: 100 }, + { name: 'Lab', weightage: 20, maximumGrade: 20 }, + ], + 'external_id', + ); + expect(csv).toBe('External ID,Midterm,Final,Lab\n'); + }); + + it('returns "Email\\n" for empty components array in email mode', () => { + expect(buildTemplateCsv([], 'email')).toBe('Email\n'); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/operations.test.ts b/client/app/bundles/course/gradebook/__tests__/operations.test.ts index 1e9813cd42..f17e35775d 100644 --- a/client/app/bundles/course/gradebook/__tests__/operations.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/operations.test.ts @@ -3,9 +3,11 @@ import type { AppDispatch } from 'store'; import CourseAPI from 'api/course'; import fetchGradebook, { + commitImport, createExternalAssessment, deleteExternalAssessment, editExternalAssessment, + previewImport, reorderExternalAssessments, setExternalGrade, updateGradebookWeights, @@ -302,6 +304,36 @@ describe('id-negating thunks', () => { }); }); +describe('commitImport', () => { + afterEach(() => jest.restoreAllMocks()); + + it('commits, refreshes the gradebook, and returns the commit summary', async () => { + const dispatched: { type: string; payload: unknown }[] = []; + const dispatch = ((a: { type: string; payload: unknown }) => { + dispatched.push(a); + return a; + }) as unknown as AppDispatch; + jest + .spyOn(CourseAPI.gradebook, 'importCommit') + .mockResolvedValue({ data: { inserted: 2 } } as never); + jest + .spyOn(CourseAPI.gradebook, 'index') + .mockResolvedValue({ data: { refreshed: true } } as never); + + const summary = await commitImport({ onConflict: 'replace' } as never)( + dispatch, + (() => ({})) as never, + {}, + ); + + expect(summary).toEqual({ inserted: 2 }); + expect(dispatched).toContainEqual({ + type: 'course/gradebook/SAVE_GRADEBOOK', + payload: { refreshed: true }, + }); + }); +}); + describe('simple pass-through thunks', () => { afterEach(() => jest.restoreAllMocks()); @@ -394,4 +426,16 @@ describe('simple pass-through thunks', () => { }; expect(action.payload.levelContribution.formulaAst).toEqual(formulaAst); }); + + it('previewImport returns the API data without dispatching', async () => { + const { dispatched, dispatch, getState } = passHarness(); + jest + .spyOn(CourseAPI.gradebook, 'importPreview') + .mockResolvedValue({ data: { conflicts: [] } } as never); + + const result = await previewImport({} as never)(dispatch, getState, {}); + + expect(result).toEqual({ conflicts: [] }); + expect(dispatched).toHaveLength(0); + }); }); diff --git a/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx new file mode 100644 index 0000000000..cd8ea7fbc5 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictPrompt.tsx @@ -0,0 +1,128 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { LoadingButton } from '@mui/lab'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Typography, +} from '@mui/material'; +import type { ConflictRow } from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import ExternalGradeConflictTable from './ExternalGradeConflictTable'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ExternalGradeConflictPrompt.title', + defaultMessage: 'Resolve grade conflicts', + }, + body: { + id: 'course.gradebook.ExternalGradeConflictPrompt.body', + defaultMessage: + 'Some students already have grades for these components that differ from the values in your file. Replace will overwrite the existing grades with the values from your file. Keep Existing will leave the existing grades unchanged.', + }, + goBack: { + id: 'course.gradebook.ExternalGradeConflictPrompt.goBack', + defaultMessage: 'Go Back', + }, + keepExisting: { + id: 'course.gradebook.ExternalGradeConflictPrompt.keepExisting', + defaultMessage: 'Keep Existing', + }, + replace: { + id: 'course.gradebook.ExternalGradeConflictPrompt.replace', + defaultMessage: 'Replace', + }, + changesSummary: { + id: 'course.gradebook.ExternalGradeConflictPrompt.changesSummary', + defaultMessage: '{changed} of {total} rows have changes', + }, +}); + +interface Props { + open: boolean; + rows: ConflictRow[]; + componentNames: string[]; + identifierLabel: string; + totalRows: number; + disabled?: boolean; + keepLoading?: boolean; + replaceLoading?: boolean; + onKeepExisting: () => void; + onReplaceAll: () => void; + onCancel: () => void; +} + +const ExternalGradeConflictPrompt: FC = ({ + open, + rows, + componentNames, + identifierLabel, + totalRows, + disabled = false, + keepLoading = false, + replaceLoading = false, + onKeepExisting, + onReplaceAll, + onCancel, +}) => { + const { t } = useTranslation(); + return ( + { + if (reason === 'backdropClick') return; + onCancel(); + }} + open={open} + > + {t(translations.title)} + + {t(translations.body)} + + {t(translations.changesSummary, { + changed: rows.length, + total: totalRows, + })} + + + + + + + + + {t(translations.keepExisting)} + + + {t(translations.replace)} + + + + ); +}; + +export default ExternalGradeConflictPrompt; diff --git a/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx new file mode 100644 index 0000000000..97867067a0 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ExternalGradeConflictTable.tsx @@ -0,0 +1,103 @@ +import { FC, memo } from 'react'; +import { defineMessages } from 'react-intl'; +import { ArrowForward } from '@mui/icons-material'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; +import type { ConflictRow } from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + name: { + id: 'course.gradebook.ExternalGradeConflictTable.name', + defaultMessage: 'Name', + }, +}); + +interface Props { + rows: ConflictRow[]; + componentNames: string[]; + identifierLabel: string; +} + +const formatGrade = (value: number | null): string => + value == null ? '—' : String(value); + +const ExternalGradeConflictTable: FC = ({ + rows, + componentNames, + identifierLabel, +}) => { + const { t } = useTranslation(); + return ( + + + + {identifierLabel} + {t(translations.name)} + {componentNames.map((name) => ( + + {name} + + ))} + + + + {rows.map((row) => ( + + + {row.identifier} + + {row.studentName} + {componentNames.map((name) => { + const cell = row.cells[name]; + if (cell?.changed) { + return ( + + + {formatGrade(cell.existing)} + + + + {formatGrade(cell.inFile)} + + + ); + } + // unchanged / new-fill / blank: show the value that will be stored + const value = cell == null ? null : cell.inFile ?? cell.existing; + return ( + + {formatGrade(value)} + + ); + })} + + ))} + +
+ ); +}; + +export default memo(ExternalGradeConflictTable); diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx new file mode 100644 index 0000000000..b92a3944a1 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx @@ -0,0 +1,951 @@ +import { FC, useEffect, useMemo, useState } from 'react'; +import Dropzone from 'react-dropzone'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { Add, Delete } from '@mui/icons-material'; +import { LoadingButton } from '@mui/lab'; +import { + Alert, + AlertTitle, + Button, + Chip, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Link as MuiLink, + Step, + StepLabel, + Stepper, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TextField, + Typography, +} from '@mui/material'; +import type { + ExistingExternalAssessment, + IdentifierMode, + ImportComponent, + ImportPreviewResult, +} from 'types/course/gradebook'; + +import SegmentedSwitch from 'lib/components/core/buttons/SegmentedSwitch'; +import { FilePreview } from 'lib/components/form/fields/SingleFileInput'; +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { commitImport, previewImport } from '../../operations'; +import { getStudents } from '../../selectors'; + +import { + downloadTemplate, + identifierHeader, + readFileText, +} from './buildTemplate'; +import ExternalGradeConflictPrompt from './ExternalGradeConflictPrompt'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.ImportWizard.title', + defaultMessage: 'Import external assessments', + }, + stepDefine: { + id: 'course.gradebook.ImportWizard.stepDefine', + defaultMessage: 'Define components', + }, + stepUpload: { + id: 'course.gradebook.ImportWizard.stepUpload', + defaultMessage: 'Template & upload', + }, + stepVerify: { + id: 'course.gradebook.ImportWizard.stepVerify', + defaultMessage: 'Verify', + }, + componentName: { + id: 'course.gradebook.ImportWizard.componentName', + defaultMessage: 'Component name', + }, + weightage: { + id: 'course.gradebook.ImportWizard.weightage', + defaultMessage: 'Weightage', + }, + maxMarks: { + id: 'course.gradebook.ImportWizard.maxMarks', + defaultMessage: 'Max marks', + }, + addComponent: { + id: 'course.gradebook.ImportWizard.addComponent', + defaultMessage: 'Add component', + }, + updatesExisting: { + id: 'course.gradebook.ImportWizard.updatesExisting', + defaultMessage: 'Updates existing — managed in the gradebook', + }, + fromExisting: { + id: 'course.gradebook.ImportWizard.fromExisting', + defaultMessage: 'From existing', + }, + identifierMode: { + id: 'course.gradebook.ImportWizard.identifierMode', + defaultMessage: 'Match students by', + }, + externalId: { + id: 'course.gradebook.ImportWizard.externalId', + defaultMessage: 'External ID', + }, + email: { id: 'course.gradebook.ImportWizard.email', defaultMessage: 'Email' }, + requiredHeaders: { + id: 'course.gradebook.ImportWizard.requiredHeaders', + defaultMessage: + 'Your CSV needs these column headers: {headers}. ‘{identifier}’ must be the first column.', + }, + headerErrorsHeading: { + id: 'course.gradebook.ImportWizard.headerErrorsHeading', + defaultMessage: 'These headers need fixing:', + }, + headerErrorsClosing: { + id: 'course.gradebook.ImportWizard.headerErrorsClosing', + defaultMessage: 'Correct these in your CSV, then re-upload.', + }, + identifierNotFirst: { + id: 'course.gradebook.ImportWizard.identifierNotFirst', + defaultMessage: '‘{identifier}’ must be the first column.', + }, + dropzone: { + id: 'course.gradebook.ImportWizard.dropzone', + defaultMessage: 'Drag a CSV here, or click to choose a file', + }, + downloadTemplate: { + id: 'course.gradebook.ImportWizard.downloadTemplate', + defaultMessage: 'Download template', + }, + upload: { + id: 'course.gradebook.ImportWizard.upload', + defaultMessage: 'Upload filled CSV', + }, + back: { id: 'course.gradebook.ImportWizard.back', defaultMessage: 'Back' }, + next: { id: 'course.gradebook.ImportWizard.next', defaultMessage: 'Next' }, + verify: { + id: 'course.gradebook.ImportWizard.verify', + defaultMessage: 'Verify', + }, + cancel: { + id: 'course.gradebook.ImportWizard.cancel', + defaultMessage: 'Cancel', + }, + continue: { + id: 'course.gradebook.ImportWizard.continue', + defaultMessage: 'Confirm import', + }, + unresolvedEmail: { + id: 'course.gradebook.ImportWizard.unresolvedEmail', + defaultMessage: + '{count, plural, one {This email address was not found in the course: {ids}} other {These email addresses were not found in the course: {ids}}}', + }, + unresolvedExternalId: { + id: 'course.gradebook.ImportWizard.unresolvedExternalId', + defaultMessage: + '{count, plural, one {This external ID was not found in the course: {ids}} other {These external IDs were not found in the course: {ids}}}', + }, + malformed: { + id: 'course.gradebook.ImportWizard.malformed', + defaultMessage: 'These cells do not contain valid numbers:', + }, + malformedMore: { + id: 'course.gradebook.ImportWizard.malformedMore', + defaultMessage: 'and {count} more', + }, + outOfRangeTitle: { + id: 'course.gradebook.ImportWizard.outOfRangeTitle', + defaultMessage: 'Some grades are outside their valid range.', + }, + outOfRangeSubtitle: { + id: 'course.gradebook.ImportWizard.outOfRangeSubtitle', + defaultMessage: + 'Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. If these out-of-range grades are intentional, continue.', + }, + outOfRangeWeightedSubtitle: { + id: 'course.gradebook.ImportWizard.outOfRangeWeightedSubtitle', + defaultMessage: + 'Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. Out-of-range grades are only floored or capped in the weighted total. If these out-of-range grades are intentional, continue.', + }, + reassignmentTitle: { + id: 'course.gradebook.ImportWizard.reassignmentTitle', + defaultMessage: 'Some identifiers now match a different student', + }, + reassignmentSubtitle: { + id: 'course.gradebook.ImportWizard.reassignmentSubtitle', + defaultMessage: + 'These identifiers were previously imported for another student. Grades are matched by the current student, not the identifier — confirm these are the people you intend before importing.', + }, + committed: { + id: 'course.gradebook.ImportWizard.committed', + defaultMessage: 'Import complete.', + }, + headerSuggestion: { + id: 'course.gradebook.ImportWizard.headerSuggestion', + defaultMessage: + 'No column named ‘{suggestion}’ — did you mean ‘{expected}’?', + }, + duplicateHeaders: { + id: 'course.gradebook.ImportWizard.duplicateHeaders', + defaultMessage: + '{count, plural, one {This column appears more than once: {dupes}.} other {These columns appear more than once: {dupes}.}}', + }, + missingHeaders: { + id: 'course.gradebook.ImportWizard.missingHeaders', + defaultMessage: + '{count, plural, one {Your CSV is missing this column: {missing}.} other {Your CSV is missing these columns: {missing}.}}', + }, + unrecognizedHeaders: { + id: 'course.gradebook.ImportWizard.unrecognizedHeaders', + defaultMessage: + '{count, plural, one {This column isn’t recognized: {unrecognized}.} other {These columns aren’t recognized: {unrecognized}.}}', + }, + commitError: { + id: 'course.gradebook.ImportWizard.commitError', + defaultMessage: 'Import failed. Nothing was saved.', + }, + previewError: { + id: 'course.gradebook.ImportWizard.previewError', + defaultMessage: 'Could not verify the file. Please try again.', + }, + emptyCsv: { + id: 'course.gradebook.ImportWizard.emptyCsv', + defaultMessage: + 'The uploaded file has no data rows. Add at least one student row and try again.', + }, + duplicateIdentifier: { + id: 'course.gradebook.ImportWizard.duplicateIdentifier', + defaultMessage: + 'The file lists {count, plural, one {an identifier} other {identifiers}} more than once: {ids}. Each student should appear on a single row.', + }, + externalIdHint: { + id: 'course.gradebook.ImportWizard.externalIdHint', + defaultMessage: + "Matching uses each student's External ID. Keep External IDs up to date in Manage Users.", + }, + externalIdBlocked: { + id: 'course.gradebook.ImportWizard.externalIdBlocked', + defaultMessage: + '{count, plural, =0 {{name} has no External ID} one {{name} and one other student have no External ID} other {{name} and # other students have no External ID}}. Add the missing IDs in Manage Users to import by External ID.', + }, + previewRows: { + id: 'course.gradebook.ImportWizard.previewRows', + defaultMessage: + 'Previewing the first 5 of {totalRows} rows. Check that this preview matches your CSV before continuing.', + }, + previewFewRows: { + id: 'course.gradebook.ImportWizard.previewFewRows', + defaultMessage: + 'Previewing all {totalRows} rows. Check that this preview matches your CSV before continuing.', + }, + willChangeExisting: { + id: 'course.gradebook.ImportWizard.willChangeExisting', + defaultMessage: + '{count, plural, one {# row contains} other {# rows contain}} changes to existing grades. After checking this preview, click Confirm import to review these conflicts before anything is imported.', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; + weightedViewEnabled: boolean; + existingAssessments: ExistingExternalAssessment[]; +} + +let rowId = 0; +const blankComponent = (): ImportComponent & { id: number } => { + rowId += 1; + return { id: rowId, name: '', weightage: 0, maximumGrade: 0 }; +}; + +interface BadHeaderError { + message: string; + missing: string[]; + unrecognized: string[]; + suggestions: { expected: string; didYouMean: string }[]; + duplicates: { name: string; count: number }[]; + identifierNotFirst: boolean; +} + +const badHeaderFromError = (error: unknown): BadHeaderError | null => { + const body = ( + error as { response?: { data?: { errors?: Partial } } } + )?.response?.data?.errors; + return body?.message === 'bad_header' + ? { + message: body.message, + missing: body.missing ?? [], + unrecognized: body.unrecognized ?? [], + suggestions: body.suggestions ?? [], + duplicates: body.duplicates ?? [], + identifierNotFirst: body.identifierNotFirst ?? false, + } + : null; +}; + +const importErrorCode = ( + error: unknown, +): { message: string; identifiers?: string[] } | null => { + const msg = ( + error as { + response?: { + data?: { errors?: { message?: string; identifiers?: string[] } }; + }; + } + )?.response?.data?.errors?.message; + if (!msg) return null; + return ( + error as { + response: { + data: { errors: { message: string; identifiers?: string[] } }; + }; + } + ).response.data.errors; +}; + +const ImportExternalAssessmentsWizard: FC = ({ + open, + onClose, + weightedViewEnabled, + existingAssessments, +}) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const { courseId: courseIdParam } = useParams(); + const courseId = courseIdParam ?? ''; + const [step, setStep] = useState(0); + const [components, setComponents] = useState< + (ImportComponent & { id: number })[] + >([blankComponent()]); + const [mode, setMode] = useState('external_id'); + const [file, setFile] = useState(null); + const [csvData, setCsvData] = useState(''); + const [preview, setPreview] = useState(null); + const [conflictOpen, setConflictOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [headerError, setHeaderError] = useState(null); + const [pendingCommit, setPendingCommit] = useState<'keep' | 'replace' | null>( + null, + ); + + const students = useAppSelector(getStudents); + const missingStudents = useMemo( + () => students.filter((s) => s.externalId == null || s.externalId === ''), + [students], + ); + const identifierReady = mode === 'email' || missingStudents.length === 0; + const identifierModeLabel = t( + mode === 'email' ? translations.email : translations.externalId, + ); + + useEffect(() => { + if (!open) { + setStep(0); + setComponents([blankComponent()]); + setFile(null); + setCsvData(''); + setPreview(null); + setConflictOpen(false); + setPendingCommit(null); + setBusy(false); + setHeaderError(null); + } + }, [open]); + + const existingMap = useMemo( + () => new Map(existingAssessments.map((a) => [a.name, a])), + [existingAssessments], + ); + const isExisting = (name: string): boolean => existingMap.has(name.trim()); + + const addedNames = useMemo( + () => new Set(components.map((c) => c.name.trim())), + [components], + ); + + const availableChips = useMemo( + () => existingAssessments.filter((a) => !addedNames.has(a.name)), + [existingAssessments, addedNames], + ); + + const updateComponent = ( + i: number, + patch: Partial, + ): void => + setComponents((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c))); + + const insertFromExisting = (a: ExistingExternalAssessment): void => { + rowId += 1; + setComponents((cs) => [ + ...cs, + { + id: rowId, + name: a.name, + weightage: a.weightage, + maximumGrade: a.maximumGrade, + }, + ]); + }; + + const defineValid = + components.length > 0 && + components.every((c) => c.name.trim() !== '') && + new Set(components.map((c) => c.name.trim())).size === components.length; + + const runPreview = async (): Promise => { + setBusy(true); + try { + const result = await dispatch( + previewImport({ + components: components.map(({ id: _, ...rest }) => rest), + identifierMode: mode, + csvData, + }), + ); + setPreview(result); + if (result.ok) setStep(2); + } catch (error) { + const badHeader = badHeaderFromError(error); + if (badHeader) { + setHeaderError(badHeader); + } else { + const known = importErrorCode(error); + if (known?.message === 'empty_csv') { + toast.error(t(translations.emptyCsv)); + } else if (known?.message === 'duplicate_identifier') { + const ids = known.identifiers ?? []; + toast.error( + t(translations.duplicateIdentifier, { + count: ids.length, + ids: ids.join(', '), + }), + ); + } else { + toast.error(t(translations.previewError)); + } + } + } finally { + setBusy(false); + } + }; + + const doCommit = async (onConflict: 'keep' | 'replace'): Promise => { + setBusy(true); + setPendingCommit(onConflict); + try { + await dispatch( + commitImport({ + components: components.map(({ id: _, ...rest }) => rest), + identifierMode: mode, + csvData, + onConflict, + }), + ); + toast.success(t(translations.committed)); + setConflictOpen(false); + onClose(); + } catch { + toast.error(t(translations.commitError)); + } finally { + setBusy(false); + setPendingCommit(null); + } + }; + + const onConfirm = (): void => { + if (preview && preview.conflictRows.length > 0) setConflictOpen(true); + else doCommit('replace'); + }; + + const renderAlerts = (includeBlocking: boolean): JSX.Element | null => { + if (!preview) return null; + + return ( + <> + {includeBlocking && !preview.ok && preview.unresolved.length > 0 && ( + + + {t( + mode === 'email' + ? translations.unresolvedEmail + : translations.unresolvedExternalId, + { + count: preview.unresolved.length, + ids: preview.unresolved.join(', '), + }, + )} + + + )} + + {includeBlocking && !preview.ok && preview.malformed.length > 0 && ( + + {t(translations.malformed)} +
    + {preview.malformed.slice(0, 5).map((cell) => ( + + {cell} + + ))} + {preview.malformed.length > 5 && ( + + {t(translations.malformedMore, { + count: preview.malformed.length - 5, + })} + + )} +
+
+ )} + + {preview.outOfRange.length > 0 && ( + + {t(translations.outOfRangeTitle)} + +
    + {preview.outOfRange.slice(0, 10).map((cell) => ( + + {cell.kind === 'above' + ? `${cell.identifier} - ${cell.component}: ${cell.grade} (max ${cell.max})` + : `${cell.identifier} - ${cell.component}: ${cell.grade} (min 0)`} + + ))} + + {preview.outOfRange.length > 10 && ( + + +{preview.outOfRange.length - 10} more + + )} +
+ + {weightedViewEnabled && ( + + {t(translations.outOfRangeWeightedSubtitle)} + + )} + + {!weightedViewEnabled && ( + {t(translations.outOfRangeSubtitle)} + )} +
+ )} + + {preview.reassignments.length > 0 && ( + + {t(translations.reassignmentTitle)} + +
    + {preview.reassignments.slice(0, 5).map((entry) => ( + + {`${entry.identifier}: now ${entry.currentStudent} (was ${entry.previousStudents.join( + ', ', + )})`} + + ))} + + {preview.reassignments.length > 5 && ( + + +{preview.reassignments.length - 5} more + + )} +
+ + {t(translations.reassignmentSubtitle)} +
+ )} + + ); + }; + + return ( + { + if (reason === 'backdropClick') return; + onClose(); + }} + open={open} + > + {t(translations.title)} + + + + {t(translations.stepDefine)} + + + {t(translations.stepUpload)} + + + {t(translations.stepVerify)} + + + + {step === 0 && ( + <> + {availableChips.length > 0 && ( +
+ + {t(translations.fromExisting)} + +
+ {availableChips.map((a) => ( + insertFromExisting(a)} + variant="outlined" + /> + ))} +
+
+ )} + + {components.map((c, i) => { + const locked = isExisting(c.name); + const existing = locked + ? existingMap.get(c.name.trim()) + : undefined; + return ( +
+ + updateComponent(i, { name: e.target.value }) + } + size="small" + value={c.name} + /> + {weightedViewEnabled && ( + + updateComponent(i, { + weightage: Number(e.target.value), + }) + } + size="small" + type="number" + value={ + locked && existing ? existing.weightage : c.weightage + } + /> + )} + + updateComponent(i, { + maximumGrade: Number(e.target.value), + }) + } + size="small" + type="number" + value={ + locked && existing + ? existing.maximumGrade + : c.maximumGrade + } + /> + {locked && ( + + {t(translations.updatesExisting)} + + )} + + setComponents((cs) => cs.filter((_, j) => j !== i)) + } + size="small" + > + + +
+ ); + })} + + +
+ + {t(translations.identifierMode)} + + +
+ + {mode === 'external_id' && ( + + {identifierReady + ? t(translations.externalIdHint, { + link: (chunks) => ( + + {chunks} + + ), + }) + : t(translations.externalIdBlocked, { + name: missingStudents[0]?.name ?? '', + count: missingStudents.length - 1, + link: (chunks) => ( + + {chunks} + + ), + })} + + )} + + )} + + {step === 1 && ( +
+ + {t(translations.requiredHeaders, { + headers: [ + identifierHeader(mode), + ...components.map((c) => c.name), + ].join(', '), + identifier: identifierHeader(mode), + })} + + {headerError && ( + + + {t(translations.headerErrorsHeading)} + +
    + {headerError.identifierNotFirst && ( + + {t(translations.identifierNotFirst, { + identifier: identifierHeader(mode), + })} + + )} + {headerError.suggestions.map((s) => ( + + {t(translations.headerSuggestion, { + expected: s.expected, + suggestion: s.didYouMean, + })} + + ))} + {headerError.missing.length > 0 && ( + + {t(translations.missingHeaders, { + count: headerError.missing.length, + missing: headerError.missing.join(', '), + })} + + )} + {headerError.unrecognized.length > 0 && ( + + {t(translations.unrecognizedHeaders, { + count: headerError.unrecognized.length, + unrecognized: headerError.unrecognized.join(', '), + })} + + )} + {headerError.duplicates.length > 0 && ( + + {t(translations.duplicateHeaders, { + count: headerError.duplicates.length, + dupes: headerError.duplicates + .map((d) => `${d.name} (×${d.count})`) + .join(', '), + })} + + )} +
+ + {t(translations.headerErrorsClosing)} + +
+ )} + {preview && !preview.ok && renderAlerts(true)} + + { + const f = files[0]; + if (f) { + setFile(f); + setHeaderError(null); + setPreview(null); + setCsvData(await readFileText(f)); + } + }} + > + {({ getRootProps, getInputProps }) => ( +
+ + {file ? ( + + ) : ( +
{t(translations.dropzone)}
+ )} +
+ )} +
+
+ )} + + {step === 2 && preview?.ok && ( + <> + {renderAlerts(false)} + {preview.conflictRows.length > 0 && ( + + {t(translations.willChangeExisting, { + count: preview.conflictRows.length, + })} + + )} +
+ + + + {identifierModeLabel} + {preview.columnOrder.map((name) => ( + {name} + ))} + + + + {preview.sample.map((row) => ( + + {row.identifier} + {preview.columnOrder.map((name) => ( + + {row.grades[name] ?? '—'} + + ))} + + ))} + +
+
+ {preview.totalRows > 5 ? ( + + {t(translations.previewRows, { + totalRows: preview.totalRows, + })} + + ) : ( + + {t(translations.previewFewRows, { + totalRows: preview.totalRows, + })} + + )} + + )} +
+ + + {step > 0 && ( + + )} + {step === 0 && ( + + )} + {step === 1 && ( + + {t(translations.verify)} + + )} + {step === 2 && preview?.ok && ( + + {t(translations.continue)} + + )} + + + setConflictOpen(false)} + onKeepExisting={() => doCommit('keep')} + onReplaceAll={() => doCommit('replace')} + open={conflictOpen} + replaceLoading={pendingCommit === 'replace'} + rows={preview?.conflictRows ?? []} + totalRows={preview?.totalRows ?? 0} + /> +
+ ); +}; + +export default ImportExternalAssessmentsWizard; diff --git a/client/app/bundles/course/gradebook/components/import/__tests__/ExternalGradeConflictTable.test.tsx b/client/app/bundles/course/gradebook/components/import/__tests__/ExternalGradeConflictTable.test.tsx new file mode 100644 index 0000000000..83007cd189 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/__tests__/ExternalGradeConflictTable.test.tsx @@ -0,0 +1,155 @@ +import { render, screen } from 'test-utils'; + +import ExternalGradeConflictTable from '../ExternalGradeConflictTable'; + +jest.mock('lib/components/wrappers/I18nProvider'); + +const rows = [ + { + identifier: 'S1000', + studentName: 'student1000', + cells: { + Midterm: { existing: 83.55, inFile: 90.05, changed: true }, + }, + }, +]; + +it('renders a changed grade cell on a single line (no wrap)', () => { + render( + , + ); + + const oldValue = screen.getByText('83.55'); + // the change cell (the ancestor) must not wrap to two lines + const cell = oldValue.closest('td') as HTMLElement; + expect(cell).toHaveStyle({ whiteSpace: 'nowrap' }); +}); + +it('renders a changed cell as struck old value, arrow, then bold new value', () => { + render( + , + ); + + const oldValue = screen.getByText('83.55'); + expect(oldValue).toHaveStyle({ textDecoration: 'line-through' }); + + const newValue = screen.getByText('90.05'); + expect(newValue).toHaveStyle({ fontWeight: 700 }); + + // arrow separator sits between the two values, struck old before bold new + const cellText = oldValue.closest('td')?.textContent ?? ''; + expect(cellText.indexOf('83.55')).toBeLessThan(cellText.indexOf('90.05')); + expect(screen.getByTestId('ArrowForwardIcon')).toBeInTheDocument(); +}); + +it('renders a new-fill cell (no existing) as a single plain value', () => { + render( + , + ); + + const value = screen.getByText('77'); + expect(value).not.toHaveStyle({ textDecoration: 'line-through' }); + expect(screen.queryByTestId('ArrowForwardIcon')).not.toBeInTheDocument(); +}); + +it('falls back to the existing value when an unchanged cell has no inFile', () => { + render( + , + ); + + expect(screen.getByText('60')).toBeInTheDocument(); +}); + +it('renders an em-dash for a component the row has no cell for', () => { + render( + , + ); + + // Final column has no cell for this row → em-dash + expect(screen.getByText('—')).toBeInTheDocument(); +}); + +it('shows an em-dash for the missing side of a changed cell', () => { + render( + , + ); + + expect(screen.getByText('—')).toBeInTheDocument(); + expect(screen.getByText('88')).toHaveStyle({ fontWeight: 700 }); +}); + +it('renders the identifier label, Name header, and a header per component', () => { + render( + , + ); + + expect(screen.getByText('External ID')).toBeInTheDocument(); + expect(screen.getByText('Name')).toBeInTheDocument(); + expect(screen.getByText('Midterm')).toBeInTheDocument(); + expect(screen.getByText('Final')).toBeInTheDocument(); +}); + +it('renders each row identifier and student name', () => { + render( + , + ); + + expect(screen.getByText('S1000')).toBeInTheDocument(); + expect(screen.getByText('student1000')).toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/components/import/buildTemplate.ts b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts new file mode 100644 index 0000000000..4754820aa2 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts @@ -0,0 +1,43 @@ +import type { IdentifierMode, ImportComponent } from 'types/course/gradebook'; + +const csvCell = (value: string): string => + /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; + +export const identifierHeader = (mode: IdentifierMode): string => + mode === 'email' ? 'Email' : 'External ID'; + +// Header-only template: per-mode identifier header + one column per component. +export const buildTemplateCsv = ( + components: ImportComponent[], + mode: IdentifierMode, +): string => { + const header = [identifierHeader(mode), ...components.map((c) => c.name)] + .map(csvCell) + .join(','); + return `${header}\n`; +}; + +// Triggers a client-side download of the template. +export const downloadTemplate = ( + components: ImportComponent[], + mode: IdentifierMode, +): void => { + const blob = new Blob([buildTemplateCsv(components, mode)], { + type: 'text/csv;charset=utf-8;', + }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'external_assessments_template.csv'; + link.click(); + URL.revokeObjectURL(url); +}; + +// Reads an uploaded File to text (raw CSV; the server parses authoritatively). +export const readFileText = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (): void => resolve(String(reader.result)); + reader.onerror = (): void => reject(reader.error); + reader.readAsText(file); + }); diff --git a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx index 18a80e7bfa..60a564da42 100644 --- a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx +++ b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx @@ -6,7 +6,7 @@ import { Droppable, DropResult, } from '@hello-pangea/dnd'; -import { Add, Delete, DragIndicator, Edit } from '@mui/icons-material'; +import { Add, Delete, DragIndicator, Edit, Upload } from '@mui/icons-material'; import { Button, Chip, @@ -19,7 +19,10 @@ import { Typography, } from '@mui/material'; import type { AppDispatch } from 'store'; -import type { AssessmentData } from 'types/course/gradebook'; +import type { + AssessmentData, + ExistingExternalAssessment, +} from 'types/course/gradebook'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; @@ -33,6 +36,7 @@ import { } from '../../selectors'; import AddExternalColumnPrompt from '../AddExternalColumnPrompt'; import DeleteExternalColumnPrompt from '../DeleteExternalColumnPrompt'; +import ImportExternalAssessmentsWizard from '../import/ImportExternalAssessmentsWizard'; import EditExternalAssessmentPrompt from './EditExternalAssessmentPrompt'; @@ -45,6 +49,10 @@ const translations = defineMessages({ id: 'course.gradebook.ManageExternalPanel.add', defaultMessage: 'Add', }, + import: { + id: 'course.gradebook.ManageExternalPanel.import', + defaultMessage: 'Import CSV', + }, name: { id: 'course.gradebook.ManageExternalPanel.name', defaultMessage: 'Name', @@ -79,7 +87,8 @@ const translations = defineMessages({ }, emptyHint: { id: 'course.gradebook.ManageExternalPanel.emptyHint', - defaultMessage: 'Add one to track grades earned outside Coursemology.', + defaultMessage: + 'Add one manually, or import a CSV of grades earned outside Coursemology.', }, close: { id: 'course.gradebook.ManageExternalPanel.close', @@ -131,12 +140,20 @@ const ManageExternalAssessmentsPanel: FC = ({ open, onClose }) => { const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); const dispatch = useAppDispatch(); const [addOpen, setAddOpen] = useState(false); + const [importOpen, setImportOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); const tabWeights = Object.fromEntries( tabs.map((tab) => [tab.id, tab.gradebookWeight ?? 0]), ); + const existingAssessments: ExistingExternalAssessment[] = externals.map( + (a) => ({ + name: a.title, + maximumGrade: a.maxGrade, + weightage: tabWeights[a.tabId] ?? 0, + }), + ); const onDragEnd = (result: DropResult): void => handleDragEnd( @@ -156,163 +173,186 @@ const ManageExternalAssessmentsPanel: FC = ({ open, onClose }) => { const reorderable = externals.length > 1; return ( - - {t(translations.title)} - - - - - - {externals.length === 0 ? ( -
- - {t(translations.empty)} - - - {t(translations.emptyHint)} - -
- ) : ( - <> -
+ + {t(translations.title)} + + + + + + + {externals.length === 0 ? ( +
+ + {t(translations.empty)} + + + {t(translations.emptyHint)} +
+ ) : ( + <> +
+ + {t(translations.name)} + {t(translations.max)} + {weightedViewEnabled && {t(translations.weight)}} + {t(translations.bounds)} + {t(translations.actions)} +
- - - {(dropProvided) => ( -
- {externals.map((a, index) => ( - - {(dragProvided, { isDragging }) => ( -
- {reorderable ? ( - - - - ) : ( - - )} - - {a.title} - - {a.maxGrade} - {weightedViewEnabled && ( - {tabWeights[a.tabId] ?? 0} - )} - - - {(a.floorAtZero ?? true) && ( - + + {(dropProvided) => ( +
+ {externals.map((a, index) => ( + + {(dragProvided, { isDragging }) => ( +
+ {reorderable ? ( + + - )} - {(a.capAtMaximum ?? true) && ( - - )} - - - - setEditing(a)} - size="small" - > - - - setDeleting(a)} - size="small" + + ) : ( + + )} + - - - -
- )} -
- ))} - {dropProvided.placeholder} -
- )} -
- - - )} - - - - + {a.title} +
+ {a.maxGrade} + {weightedViewEnabled && ( + {tabWeights[a.tabId] ?? 0} + )} + + + {(a.floorAtZero ?? true) && ( + + )} + {(a.capAtMaximum ?? true) && ( + + )} + + + + setEditing(a)} + size="small" + > + + + setDeleting(a)} + size="small" + > + + + +
+ )} +
+ ))} + {dropProvided.placeholder} +
+ )} +
+
+ + )} +
+ + + - setAddOpen(false)} - open={addOpen} - weightedViewEnabled={weightedViewEnabled} - /> - {editing && ( - setEditing(null)} - open={Boolean(editing)} + setAddOpen(false)} + open={addOpen} weightedViewEnabled={weightedViewEnabled} /> - )} - {deleting && ( - setDeleting(null)} - open={Boolean(deleting)} - title={deleting.title} - /> - )} -
+ {editing && ( + setEditing(null)} + open={Boolean(editing)} + weightedViewEnabled={weightedViewEnabled} + /> + )} + {deleting && ( + setDeleting(null)} + open={Boolean(deleting)} + title={deleting.title} + /> + )} +
+ setImportOpen(false)} + open={open && importOpen} + weightedViewEnabled={weightedViewEnabled} + /> + ); }; diff --git a/client/app/bundles/course/gradebook/operations.ts b/client/app/bundles/course/gradebook/operations.ts index 7b65c4100c..c3e3a38abb 100644 --- a/client/app/bundles/course/gradebook/operations.ts +++ b/client/app/bundles/course/gradebook/operations.ts @@ -1,5 +1,8 @@ import type { Operation } from 'store'; import type { + ImportCommitSummary, + ImportPreviewRequest, + ImportPreviewResult, LevelContributionSaveData, UpdateWeightsPayload, } from 'types/course/gradebook'; @@ -127,4 +130,22 @@ export const setExternalGrade = } }; +export const previewImport = + (payload: ImportPreviewRequest): Operation => + async () => { + const response = await CourseAPI.gradebook.importPreview(payload); + return response.data; + }; + +export const commitImport = + ( + payload: ImportPreviewRequest & { onConflict: 'keep' | 'replace' }, + ): Operation => + async (dispatch) => { + const response = await CourseAPI.gradebook.importCommit(payload); + const refreshed = await CourseAPI.gradebook.index(); + dispatch(actions.saveGradebook(refreshed.data)); + return response.data; + }; + export default fetchGradebook; diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts index ec970289bc..537b1031c4 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -107,3 +107,68 @@ export interface ExternalGradePayload { assessmentId: number; grade: number | null; } + +export type IdentifierMode = 'email' | 'external_id'; + +export interface ImportComponent { + name: string; + weightage: number; + maximumGrade: number; +} + +export interface ExistingExternalAssessment { + name: string; + maximumGrade: number; + weightage: number; +} + +export interface ImportPreviewRequest { + components: ImportComponent[]; + identifierMode: IdentifierMode; + csvData: string; +} + +export interface ConflictCell { + existing: number | null; + inFile: number | null; + changed: boolean; +} + +export interface ConflictRow { + identifier: string; + studentName: string; + cells: Record; +} + +export interface ReassignedIdentifier { + // Advisory: this identifier was previously imported as the binding key for a + // grade now owned by a DIFFERENT student (e.g. an External ID recycled). The + // grade is still matched by the current student; this flags it for confirmation. + identifier: string; + currentStudent: string; + previousStudents: string[]; +} + +export interface ImportPreviewResult { + ok: boolean; + unresolved: string[]; + malformed: string[]; + outOfRange: { + identifier: string; + component: string; + grade: number; + max: number; + kind: 'below' | 'above'; + }[]; + sample: { identifier: string; grades: Record }[]; + conflictRows: ConflictRow[]; + reassignments: ReassignedIdentifier[]; + totalRows: number; + columnOrder: string[]; +} + +export interface ImportCommitSummary { + createdComponents: number; + updatedComponents: number; + gradesWritten: number; +} diff --git a/client/locales/en.json b/client/locales/en.json index 1b0cb56289..22eacf3b25 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9882,7 +9882,7 @@ "defaultMessage": "No external assessments yet" }, "course.gradebook.ManageExternalPanel.emptyHint": { - "defaultMessage": "Add one to track grades earned outside Coursemology." + "defaultMessage": "Add one manually, or import a CSV of grades earned outside Coursemology." }, "course.gradebook.ManageExternalPanel.floored": { "defaultMessage": "≥ 0" @@ -9937,5 +9937,173 @@ }, "course.gradebook.OutOfRangeAlert.warningWeighted": { "defaultMessage": "{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range and {gradeCount, plural, one {is} other {are}} being capped or floored in the weighted total. Review before exporting." + }, + "course.gradebook.ExternalGradeConflictPrompt.body": { + "defaultMessage": "Some students already have grades for these components that differ from the values in your file. Replace will overwrite the existing grades with the values from your file. Keep Existing will leave the existing grades unchanged." + }, + "course.gradebook.ExternalGradeConflictPrompt.changesSummary": { + "defaultMessage": "{changed} of {total} rows have changes" + }, + "course.gradebook.ExternalGradeConflictPrompt.goBack": { + "defaultMessage": "Go Back" + }, + "course.gradebook.ExternalGradeConflictPrompt.keepExisting": { + "defaultMessage": "Keep Existing" + }, + "course.gradebook.ExternalGradeConflictPrompt.replace": { + "defaultMessage": "Replace" + }, + "course.gradebook.ExternalGradeConflictPrompt.title": { + "defaultMessage": "Resolve grade conflicts" + }, + "course.gradebook.ExternalGradeConflictTable.name": { + "defaultMessage": "Name" + }, + "course.gradebook.ImportWizard.addComponent": { + "defaultMessage": "Add component" + }, + "course.gradebook.ImportWizard.back": { + "defaultMessage": "Back" + }, + "course.gradebook.ImportWizard.cancel": { + "defaultMessage": "Cancel" + }, + "course.gradebook.ImportWizard.commitError": { + "defaultMessage": "Import failed. Nothing was saved." + }, + "course.gradebook.ImportWizard.committed": { + "defaultMessage": "Import complete." + }, + "course.gradebook.ImportWizard.componentName": { + "defaultMessage": "Component name" + }, + "course.gradebook.ImportWizard.continue": { + "defaultMessage": "Confirm import" + }, + "course.gradebook.ImportWizard.downloadTemplate": { + "defaultMessage": "Download template" + }, + "course.gradebook.ImportWizard.dropzone": { + "defaultMessage": "Drag a CSV here, or click to choose a file" + }, + "course.gradebook.ImportWizard.duplicateHeaders": { + "defaultMessage": "{count, plural, one {This column appears more than once: {dupes}.} other {These columns appear more than once: {dupes}.}}" + }, + "course.gradebook.ImportWizard.duplicateIdentifier": { + "defaultMessage": "The file lists {count, plural, one {an identifier} other {identifiers}} more than once: {ids}. Each student should appear on a single row." + }, + "course.gradebook.ImportWizard.email": { + "defaultMessage": "Email" + }, + "course.gradebook.ImportWizard.emptyCsv": { + "defaultMessage": "The uploaded file has no data rows. Add at least one student row and try again." + }, + "course.gradebook.ImportWizard.externalId": { + "defaultMessage": "External ID" + }, + "course.gradebook.ImportWizard.externalIdBlocked": { + "defaultMessage": "{count, plural, =0 {{name} has no External ID} one {{name} and one other student have no External ID} other {{name} and # other students have no External ID}}. Add the missing IDs in Manage Users to import by External ID." + }, + "course.gradebook.ImportWizard.externalIdHint": { + "defaultMessage": "Matching uses each student's External ID. Keep External IDs up to date in Manage Users." + }, + "course.gradebook.ImportWizard.fromExisting": { + "defaultMessage": "From existing" + }, + "course.gradebook.ImportWizard.headerErrorsClosing": { + "defaultMessage": "Correct these in your CSV, then re-upload." + }, + "course.gradebook.ImportWizard.headerErrorsHeading": { + "defaultMessage": "These headers need fixing:" + }, + "course.gradebook.ImportWizard.headerSuggestion": { + "defaultMessage": "No column named ‘{suggestion}’ — did you mean ‘{expected}’?" + }, + "course.gradebook.ImportWizard.identifierMode": { + "defaultMessage": "Match students by" + }, + "course.gradebook.ImportWizard.identifierNotFirst": { + "defaultMessage": "‘{identifier}’ must be the first column." + }, + "course.gradebook.ImportWizard.malformed": { + "defaultMessage": "These cells do not contain valid numbers:" + }, + "course.gradebook.ImportWizard.malformedMore": { + "defaultMessage": "and {count} more" + }, + "course.gradebook.ImportWizard.maxMarks": { + "defaultMessage": "Max marks" + }, + "course.gradebook.ImportWizard.missingHeaders": { + "defaultMessage": "{count, plural, one {Your CSV is missing this column: {missing}.} other {Your CSV is missing these columns: {missing}.}}" + }, + "course.gradebook.ImportWizard.next": { + "defaultMessage": "Next" + }, + "course.gradebook.ImportWizard.outOfRangeSubtitle": { + "defaultMessage": "Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. If these out-of-range grades are intentional, continue." + }, + "course.gradebook.ImportWizard.outOfRangeTitle": { + "defaultMessage": "Some grades are outside their valid range." + }, + "course.gradebook.ImportWizard.outOfRangeWeightedSubtitle": { + "defaultMessage": "Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. Out-of-range grades are only floored or capped in the weighted total. If these out-of-range grades are intentional, continue." + }, + "course.gradebook.ImportWizard.previewError": { + "defaultMessage": "Could not verify the file. Please try again." + }, + "course.gradebook.ImportWizard.previewFewRows": { + "defaultMessage": "Previewing all {totalRows} rows. Check that this preview matches your CSV before continuing." + }, + "course.gradebook.ImportWizard.previewRows": { + "defaultMessage": "Previewing the first 5 of {totalRows} rows. Check that this preview matches your CSV before continuing." + }, + "course.gradebook.ImportWizard.reassignmentSubtitle": { + "defaultMessage": "These identifiers were previously imported for another student. Grades are matched by the current student, not the identifier — confirm these are the people you intend before importing." + }, + "course.gradebook.ImportWizard.reassignmentTitle": { + "defaultMessage": "Some identifiers now match a different student" + }, + "course.gradebook.ImportWizard.requiredHeaders": { + "defaultMessage": "Your CSV needs these column headers: {headers}. ‘{identifier}’ must be the first column." + }, + "course.gradebook.ImportWizard.stepDefine": { + "defaultMessage": "Define components" + }, + "course.gradebook.ImportWizard.stepUpload": { + "defaultMessage": "Template & upload" + }, + "course.gradebook.ImportWizard.stepVerify": { + "defaultMessage": "Verify" + }, + "course.gradebook.ImportWizard.title": { + "defaultMessage": "Import external assessments" + }, + "course.gradebook.ImportWizard.unrecognizedHeaders": { + "defaultMessage": "{count, plural, one {This column isn’t recognized: {unrecognized}.} other {These columns aren’t recognized: {unrecognized}.}}" + }, + "course.gradebook.ImportWizard.unresolvedEmail": { + "defaultMessage": "{count, plural, one {This email address was not found in the course: {ids}} other {These email addresses were not found in the course: {ids}}}" + }, + "course.gradebook.ImportWizard.unresolvedExternalId": { + "defaultMessage": "{count, plural, one {This external ID was not found in the course: {ids}} other {These external IDs were not found in the course: {ids}}}" + }, + "course.gradebook.ImportWizard.updatesExisting": { + "defaultMessage": "Updates existing — managed in the gradebook" + }, + "course.gradebook.ImportWizard.upload": { + "defaultMessage": "Upload filled CSV" + }, + "course.gradebook.ImportWizard.verify": { + "defaultMessage": "Verify" + }, + "course.gradebook.ImportWizard.weightage": { + "defaultMessage": "Weightage" + }, + "course.gradebook.ImportWizard.willChangeExisting": { + "defaultMessage": "{count, plural, one {# row contains} other {# rows contain}} changes to existing grades. After checking this preview, click Confirm import to review these conflicts before anything is imported." + }, + "course.gradebook.ManageExternalPanel.import": { + "defaultMessage": "Import CSV" } } diff --git a/client/locales/ko.json b/client/locales/ko.json index 59d4434a95..5fffc422d7 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9876,7 +9876,7 @@ "defaultMessage": "아직 외부 평가가 없습니다" }, "course.gradebook.ManageExternalPanel.emptyHint": { - "defaultMessage": "Coursemology 외부에서 받은 성적을 추적하려면 하나를 추가하세요." + "defaultMessage": "직접 추가하거나, Coursemology 외부에서 받은 성적을 CSV로 가져오세요." }, "course.gradebook.ManageExternalPanel.floored": { "defaultMessage": "≥ 0" @@ -9928,5 +9928,173 @@ }, "course.gradebook.OutOfRangeAlert.warningWeighted": { "defaultMessage": "외부 평가 {assessmentNames}에서 {gradeCount, plural, other {성적 #개}}이(가) 범위를 벗어나 가중 총점에서 제한되거나 하한 처리됩니다. 내보내기 전에 검토하세요." + }, + "course.gradebook.ExternalGradeConflictPrompt.body": { + "defaultMessage": "일부 학생은 이미 이 구성요소에 대한 성적이 있으며 파일의 값과 다릅니다. 덮어쓰기를 선택하면 기존 성적이 파일의 값으로 덮어쓰여집니다. 기존 값 유지를 선택하면 기존 성적이 변경되지 않습니다." + }, + "course.gradebook.ExternalGradeConflictPrompt.changesSummary": { + "defaultMessage": "전체 {total}개 행 중 {changed}개 행에 변경 사항이 있습니다" + }, + "course.gradebook.ExternalGradeConflictPrompt.goBack": { + "defaultMessage": "뒤로 가기" + }, + "course.gradebook.ExternalGradeConflictPrompt.keepExisting": { + "defaultMessage": "기존 값 유지" + }, + "course.gradebook.ExternalGradeConflictPrompt.replace": { + "defaultMessage": "덮어쓰기" + }, + "course.gradebook.ExternalGradeConflictPrompt.title": { + "defaultMessage": "성적 충돌 해결" + }, + "course.gradebook.ExternalGradeConflictTable.name": { + "defaultMessage": "이름" + }, + "course.gradebook.ImportWizard.addComponent": { + "defaultMessage": "구성요소 추가" + }, + "course.gradebook.ImportWizard.back": { + "defaultMessage": "뒤로" + }, + "course.gradebook.ImportWizard.cancel": { + "defaultMessage": "취소" + }, + "course.gradebook.ImportWizard.commitError": { + "defaultMessage": "가져오기에 실패했습니다. 저장된 내용이 없습니다." + }, + "course.gradebook.ImportWizard.committed": { + "defaultMessage": "가져오기가 완료되었습니다." + }, + "course.gradebook.ImportWizard.componentName": { + "defaultMessage": "구성요소 이름" + }, + "course.gradebook.ImportWizard.continue": { + "defaultMessage": "가져오기 확인" + }, + "course.gradebook.ImportWizard.downloadTemplate": { + "defaultMessage": "템플릿 다운로드" + }, + "course.gradebook.ImportWizard.dropzone": { + "defaultMessage": "여기에 CSV 파일을 끌어다 놓거나 클릭하여 파일을 선택하세요" + }, + "course.gradebook.ImportWizard.duplicateHeaders": { + "defaultMessage": "{count, plural, one {다음 열이 두 번 이상 나타납니다: {dupes}.} other {다음 열들이 두 번 이상 나타납니다: {dupes}.}}" + }, + "course.gradebook.ImportWizard.duplicateIdentifier": { + "defaultMessage": "파일에 {count, plural, one {식별자가} other {식별자가}} 두 번 이상 나열되어 있습니다: {ids}. 각 학생은 하나의 행에만 나타나야 합니다." + }, + "course.gradebook.ImportWizard.email": { + "defaultMessage": "이메일" + }, + "course.gradebook.ImportWizard.emptyCsv": { + "defaultMessage": "업로드한 파일에 데이터 행이 없습니다. 학생 행을 하나 이상 추가한 후 다시 시도하세요." + }, + "course.gradebook.ImportWizard.externalId": { + "defaultMessage": "외부 ID" + }, + "course.gradebook.ImportWizard.externalIdBlocked": { + "defaultMessage": "{count, plural, =0 {{name}에게 외부 ID가 없습니다} one {{name} 및 다른 학생 한 명에게 외부 ID가 없습니다} other {{name} 및 다른 학생 #명에게 외부 ID가 없습니다}}. 외부 ID로 가져오려면 사용자 관리에서 누락된 ID를 추가하세요." + }, + "course.gradebook.ImportWizard.externalIdHint": { + "defaultMessage": "각 학생의 외부 ID를 사용하여 매칭합니다. 사용자 관리에서 외부 ID를 최신 상태로 유지하세요." + }, + "course.gradebook.ImportWizard.fromExisting": { + "defaultMessage": "기존 항목에서" + }, + "course.gradebook.ImportWizard.headerErrorsClosing": { + "defaultMessage": "CSV에서 이 항목들을 수정한 후 다시 업로드하세요." + }, + "course.gradebook.ImportWizard.headerErrorsHeading": { + "defaultMessage": "다음 헤더를 수정해야 합니다:" + }, + "course.gradebook.ImportWizard.headerSuggestion": { + "defaultMessage": "‘{suggestion}’(이)라는 이름의 열이 없습니다 — ‘{expected}’을(를) 의도하셨나요?" + }, + "course.gradebook.ImportWizard.identifierMode": { + "defaultMessage": "학생 매칭 기준" + }, + "course.gradebook.ImportWizard.identifierNotFirst": { + "defaultMessage": "‘{identifier}’은(는) 첫 번째 열이어야 합니다." + }, + "course.gradebook.ImportWizard.malformed": { + "defaultMessage": "다음 셀에는 유효한 숫자가 포함되어 있지 않습니다:" + }, + "course.gradebook.ImportWizard.malformedMore": { + "defaultMessage": "외 {count}개" + }, + "course.gradebook.ImportWizard.maxMarks": { + "defaultMessage": "최대 점수" + }, + "course.gradebook.ImportWizard.missingHeaders": { + "defaultMessage": "{count, plural, one {CSV에 다음 열이 없습니다: {missing}.} other {CSV에 다음 열들이 없습니다: {missing}.}}" + }, + "course.gradebook.ImportWizard.next": { + "defaultMessage": "다음" + }, + "course.gradebook.ImportWizard.outOfRangeSubtitle": { + "defaultMessage": "성적은 입력한 그대로 가져옵니다. 이는 경고일 뿐이며, 외부 평가 관리에서 이 경고를 끌 수 있습니다. 이러한 범위를 벗어난 성적이 의도된 것이라면 계속 진행하세요." + }, + "course.gradebook.ImportWizard.outOfRangeTitle": { + "defaultMessage": "일부 성적이 유효 범위를 벗어났습니다." + }, + "course.gradebook.ImportWizard.outOfRangeWeightedSubtitle": { + "defaultMessage": "성적은 입력한 그대로 가져옵니다. 이는 경고일 뿐이며, 외부 평가 관리에서 이 경고를 끌 수 있습니다. 범위를 벗어난 성적은 가중 총점에서만 하한 또는 상한으로 조정됩니다. 이러한 범위를 벗어난 성적이 의도된 것이라면 계속 진행하세요." + }, + "course.gradebook.ImportWizard.previewError": { + "defaultMessage": "파일을 확인할 수 없습니다. 다시 시도하세요." + }, + "course.gradebook.ImportWizard.previewFewRows": { + "defaultMessage": "전체 {totalRows}개 행을 미리 봅니다. 계속하기 전에 이 미리보기가 CSV와 일치하는지 확인하세요." + }, + "course.gradebook.ImportWizard.previewRows": { + "defaultMessage": "전체 {totalRows}개 행 중 처음 5개 행을 미리 봅니다. 계속하기 전에 이 미리보기가 CSV와 일치하는지 확인하세요." + }, + "course.gradebook.ImportWizard.reassignmentSubtitle": { + "defaultMessage": "이 식별자들은 이전에 다른 학생에 대해 가져온 적이 있습니다. 성적은 식별자가 아니라 현재 학생을 기준으로 매칭됩니다 — 가져오기 전에 의도한 사람이 맞는지 확인하세요." + }, + "course.gradebook.ImportWizard.reassignmentTitle": { + "defaultMessage": "일부 식별자가 이제 다른 학생과 일치합니다" + }, + "course.gradebook.ImportWizard.requiredHeaders": { + "defaultMessage": "CSV에는 다음 열 헤더가 필요합니다: {headers}. ‘{identifier}’은(는) 첫 번째 열이어야 합니다." + }, + "course.gradebook.ImportWizard.stepDefine": { + "defaultMessage": "구성요소 정의" + }, + "course.gradebook.ImportWizard.stepUpload": { + "defaultMessage": "템플릿 및 업로드" + }, + "course.gradebook.ImportWizard.stepVerify": { + "defaultMessage": "확인" + }, + "course.gradebook.ImportWizard.title": { + "defaultMessage": "외부 평가 가져오기" + }, + "course.gradebook.ImportWizard.unrecognizedHeaders": { + "defaultMessage": "{count, plural, one {다음 열을 인식할 수 없습니다: {unrecognized}.} other {다음 열들을 인식할 수 없습니다: {unrecognized}.}}" + }, + "course.gradebook.ImportWizard.unresolvedEmail": { + "defaultMessage": "{count, plural, one {이 이메일 주소를 과목에서 찾을 수 없습니다: {ids}} other {이 이메일 주소들을 과목에서 찾을 수 없습니다: {ids}}}" + }, + "course.gradebook.ImportWizard.unresolvedExternalId": { + "defaultMessage": "{count, plural, one {이 외부 ID를 과목에서 찾을 수 없습니다: {ids}} other {이 외부 ID들을 과목에서 찾을 수 없습니다: {ids}}}" + }, + "course.gradebook.ImportWizard.updatesExisting": { + "defaultMessage": "기존 항목 업데이트 — 성적표에서 관리됨" + }, + "course.gradebook.ImportWizard.upload": { + "defaultMessage": "작성한 CSV 업로드" + }, + "course.gradebook.ImportWizard.verify": { + "defaultMessage": "확인" + }, + "course.gradebook.ImportWizard.weightage": { + "defaultMessage": "가중치" + }, + "course.gradebook.ImportWizard.willChangeExisting": { + "defaultMessage": "{count, plural, one {#개 행에} other {#개 행에}} 기존 성적에 대한 변경 사항이 포함되어 있습니다. 이 미리보기를 확인한 후, 가져오기 확인을 클릭하여 가져오기 전에 이러한 충돌을 검토하세요." + }, + "course.gradebook.ManageExternalPanel.import": { + "defaultMessage": "CSV 가져오기" } } diff --git a/client/locales/zh.json b/client/locales/zh.json index a64d095551..b4e9d4faa5 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9870,7 +9870,7 @@ "defaultMessage": "暂无外部评估" }, "course.gradebook.ManageExternalPanel.emptyHint": { - "defaultMessage": "添加一个以跟踪在 Coursemology 之外获得的成绩。" + "defaultMessage": "手动添加,或导入在 Coursemology 之外获得的成绩的 CSV 文件。" }, "course.gradebook.ManageExternalPanel.floored": { "defaultMessage": "≥ 0" @@ -9922,5 +9922,173 @@ }, "course.gradebook.OutOfRangeAlert.warningWeighted": { "defaultMessage": "外部评估 {assessmentNames} 中有 {gradeCount, plural, other {# 个成绩}}超出范围,将在加权总分中被限制或下调。导出前请检查。" + }, + "course.gradebook.ExternalGradeConflictPrompt.body": { + "defaultMessage": "部分学生在这些组件上已有成绩,且与您文件中的数值不同。覆盖将用文件中的数值替换现有成绩。保留现有则保持现有成绩不变。" + }, + "course.gradebook.ExternalGradeConflictPrompt.changesSummary": { + "defaultMessage": "{total} 行中有 {changed} 行发生变更" + }, + "course.gradebook.ExternalGradeConflictPrompt.goBack": { + "defaultMessage": "返回" + }, + "course.gradebook.ExternalGradeConflictPrompt.keepExisting": { + "defaultMessage": "保留现有" + }, + "course.gradebook.ExternalGradeConflictPrompt.replace": { + "defaultMessage": "覆盖" + }, + "course.gradebook.ExternalGradeConflictPrompt.title": { + "defaultMessage": "解决成绩冲突" + }, + "course.gradebook.ExternalGradeConflictTable.name": { + "defaultMessage": "名称" + }, + "course.gradebook.ImportWizard.addComponent": { + "defaultMessage": "添加组件" + }, + "course.gradebook.ImportWizard.back": { + "defaultMessage": "返回" + }, + "course.gradebook.ImportWizard.cancel": { + "defaultMessage": "取消" + }, + "course.gradebook.ImportWizard.commitError": { + "defaultMessage": "导入失败。未保存任何内容。" + }, + "course.gradebook.ImportWizard.committed": { + "defaultMessage": "导入完成。" + }, + "course.gradebook.ImportWizard.componentName": { + "defaultMessage": "组件名称" + }, + "course.gradebook.ImportWizard.continue": { + "defaultMessage": "确认导入" + }, + "course.gradebook.ImportWizard.downloadTemplate": { + "defaultMessage": "下载模板" + }, + "course.gradebook.ImportWizard.dropzone": { + "defaultMessage": "将 CSV 拖到此处,或点击选择文件" + }, + "course.gradebook.ImportWizard.duplicateHeaders": { + "defaultMessage": "{count, plural, one {此列出现多次:{dupes}。} other {以下列出现多次:{dupes}。}}" + }, + "course.gradebook.ImportWizard.duplicateIdentifier": { + "defaultMessage": "文件中{count, plural, one {某个标识符} other {标识符}}出现多次:{ids}。每名学生应仅出现在一行中。" + }, + "course.gradebook.ImportWizard.email": { + "defaultMessage": "邮箱" + }, + "course.gradebook.ImportWizard.emptyCsv": { + "defaultMessage": "上传的文件没有数据行。请至少添加一行学生数据后重试。" + }, + "course.gradebook.ImportWizard.externalId": { + "defaultMessage": "外部 ID" + }, + "course.gradebook.ImportWizard.externalIdBlocked": { + "defaultMessage": "{count, plural, =0 {{name} 没有外部 ID} one {{name} 和另外一名学生没有外部 ID} other {{name} 和另外 # 名学生没有外部 ID}}。请在用户管理中补充缺失的 ID,以便按外部 ID 导入。" + }, + "course.gradebook.ImportWizard.externalIdHint": { + "defaultMessage": "匹配将使用每名学生的外部 ID。请在用户管理中及时更新外部 ID。" + }, + "course.gradebook.ImportWizard.fromExisting": { + "defaultMessage": "从现有创建" + }, + "course.gradebook.ImportWizard.headerErrorsClosing": { + "defaultMessage": "请在 CSV 中更正这些问题,然后重新上传。" + }, + "course.gradebook.ImportWizard.headerErrorsHeading": { + "defaultMessage": "以下表头需要修正:" + }, + "course.gradebook.ImportWizard.headerSuggestion": { + "defaultMessage": "没有名为 ‘{suggestion}’ 的列 — 您是否想输入 ‘{expected}’?" + }, + "course.gradebook.ImportWizard.identifierMode": { + "defaultMessage": "学生匹配方式" + }, + "course.gradebook.ImportWizard.identifierNotFirst": { + "defaultMessage": "‘{identifier}’ 必须是第一列。" + }, + "course.gradebook.ImportWizard.malformed": { + "defaultMessage": "以下单元格不包含有效数字:" + }, + "course.gradebook.ImportWizard.malformedMore": { + "defaultMessage": "以及另外 {count} 个" + }, + "course.gradebook.ImportWizard.maxMarks": { + "defaultMessage": "最高分" + }, + "course.gradebook.ImportWizard.missingHeaders": { + "defaultMessage": "{count, plural, one {您的 CSV 缺少此列:{missing}。} other {您的 CSV 缺少以下列:{missing}。}}" + }, + "course.gradebook.ImportWizard.next": { + "defaultMessage": "下一步" + }, + "course.gradebook.ImportWizard.outOfRangeSubtitle": { + "defaultMessage": "成绩将完全按输入的内容导入。这只是一条警告;您可以在管理外部评估中关闭此警告。如果这些超出范围的成绩是有意为之,请继续。" + }, + "course.gradebook.ImportWizard.outOfRangeTitle": { + "defaultMessage": "部分成绩超出其有效范围。" + }, + "course.gradebook.ImportWizard.outOfRangeWeightedSubtitle": { + "defaultMessage": "成绩将完全按输入的内容导入。这只是一条警告;您可以在管理外部评估中关闭此警告。超出范围的成绩仅在加权总分中被取下限或封顶。如果这些超出范围的成绩是有意为之,请继续。" + }, + "course.gradebook.ImportWizard.previewError": { + "defaultMessage": "无法验证该文件。请重试。" + }, + "course.gradebook.ImportWizard.previewFewRows": { + "defaultMessage": "正在预览全部 {totalRows} 行。请在继续前确认此预览与您的 CSV 一致。" + }, + "course.gradebook.ImportWizard.previewRows": { + "defaultMessage": "正在预览 {totalRows} 行中的前 5 行。请在继续前确认此预览与您的 CSV 一致。" + }, + "course.gradebook.ImportWizard.reassignmentSubtitle": { + "defaultMessage": "这些标识符此前曾为另一名学生导入。成绩按当前学生匹配,而非按标识符 — 请在导入前确认这些是您想要的人员。" + }, + "course.gradebook.ImportWizard.reassignmentTitle": { + "defaultMessage": "部分标识符现在匹配到了另一名学生" + }, + "course.gradebook.ImportWizard.requiredHeaders": { + "defaultMessage": "您的 CSV 需要以下列表头:{headers}。‘{identifier}’ 必须是第一列。" + }, + "course.gradebook.ImportWizard.stepDefine": { + "defaultMessage": "定义组件" + }, + "course.gradebook.ImportWizard.stepUpload": { + "defaultMessage": "模板与上传" + }, + "course.gradebook.ImportWizard.stepVerify": { + "defaultMessage": "验证" + }, + "course.gradebook.ImportWizard.title": { + "defaultMessage": "导入外部评估" + }, + "course.gradebook.ImportWizard.unrecognizedHeaders": { + "defaultMessage": "{count, plural, one {无法识别此列:{unrecognized}。} other {无法识别以下列:{unrecognized}。}}" + }, + "course.gradebook.ImportWizard.unresolvedEmail": { + "defaultMessage": "{count, plural, one {在课程中未找到此邮箱地址:{ids}} other {在课程中未找到以下邮箱地址:{ids}}}" + }, + "course.gradebook.ImportWizard.unresolvedExternalId": { + "defaultMessage": "{count, plural, one {在课程中未找到此外部 ID:{ids}} other {在课程中未找到以下外部 ID:{ids}}}" + }, + "course.gradebook.ImportWizard.updatesExisting": { + "defaultMessage": "更新现有 — 在成绩册中管理" + }, + "course.gradebook.ImportWizard.upload": { + "defaultMessage": "上传已填写的 CSV" + }, + "course.gradebook.ImportWizard.verify": { + "defaultMessage": "验证" + }, + "course.gradebook.ImportWizard.weightage": { + "defaultMessage": "权重" + }, + "course.gradebook.ImportWizard.willChangeExisting": { + "defaultMessage": "{count, plural, one {# 行包含} other {# 行包含}}对现有成绩的变更。检查此预览后,点击确认导入以在任何内容导入前查看这些冲突。" + }, + "course.gradebook.ManageExternalPanel.import": { + "defaultMessage": "导入 CSV" } } diff --git a/config/routes.rb b/config/routes.rb index 6e19cb396f..e57841f9fb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -510,6 +510,11 @@ put 'reorder' => 'external_assessments#reorder' end end + resources :external_assessment_imports, only: [:create] do + collection do + post 'preview' + end + end end scope module: :discussion do diff --git a/spec/controllers/course/external_assessment_imports_controller_spec.rb b/spec/controllers/course/external_assessment_imports_controller_spec.rb new file mode 100644 index 0000000000..fb2bec27b6 --- /dev/null +++ b/spec/controllers/course/external_assessment_imports_controller_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::ExternalAssessmentImportsController, type: :controller do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + let(:ta) { create(:course_teaching_assistant, course: course) } + let!(:alice) { create(:course_student, course: course, external_id: 'A001') } + let!(:bob) { create(:course_student, course: course, external_id: 'A002') } + + let(:components) { [name: 'Midterm', weightage: 30, maximumGrade: 50] } + let(:csv_data) { "External ID,Midterm\nA001,41\n" } + let(:base_params) do + { course_id: course.id, format: :json, + components: components, identifierMode: 'student_id', csvData: csv_data } + end + + describe '#preview' do + render_views + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'returns ok with a sample and writes nothing' do + expect { post :preview, params: base_params }. + not_to(change { Course::ExternalAssessmentGrade.count }) + data = JSON.parse(response.body) + expect(data['ok']).to be(true) + expect(data['sample'].first['identifier']).to eq('A001') + end + + it 'returns ok:false with unresolved identifiers' do + post :preview, params: base_params.merge(csvData: "External ID,Midterm\nZZZ,1\n") + data = JSON.parse(response.body) + expect(data['ok']).to be(false) + expect(data['unresolved']).to include('ZZZ') + end + + it 'returns 422 on a malformed header' do + post :preview, params: base_params.merge(csvData: "Wrong,Midterm\nA001,1\n") + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)['errors']['message']).to eq('bad_header') + end + + it 'returns conflicts when a grade already exists' do + # Seed an existing grade for alice + service = Course::Gradebook::ExternalAssessmentImportService.new( + course: course, actor: manager.user, + components: [name: 'Midterm', weightage: 30, maximum_grade: 50], + identifier_mode: 'student_id', csv_data: "External ID,Midterm\nA001,10\n" + ) + service.commit(on_conflict: 'replace') + + post :preview, params: base_params.merge(csvData: "External ID,Midterm\nA001,20\n") + data = JSON.parse(response.body) + expect(data['conflictRows'].size).to eq(1) + expect(data['conflictRows'].first['studentName']).to eq(alice.name) + end + + it 'returns ok:false with malformed grade cells' do + post :preview, params: base_params.merge(csvData: "External ID,Midterm\nA001,oops\n") + data = JSON.parse(response.body) + expect(data['ok']).to be(false) + expect(data['malformed']).to be_present + end + + it 'returns 422 on duplicate component names' do + dup_components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }, + { name: 'Midterm', weightage: 20, maximumGrade: 40 }] + post :preview, params: base_params.merge( + components: dup_components, + csvData: "External ID,Midterm,Midterm\nA001,1,2\n" + ) + expect(JSON.parse(response.body)['errors']['message']).to eq('duplicate_component_name') + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns out-of-range cells in the preview payload' do + out_of_range_params = base_params.merge( + components: [name: 'Midterm', weightage: 30, maximumGrade: 50], + csvData: "External ID,Midterm\nA001,105\n" + ) + post :preview, params: out_of_range_params, format: :json + body = JSON.parse(response.body) + expect(body['outOfRange']).to be_present + end + + it 'resolves by email when identifierMode is email' do + post :preview, params: base_params.merge( + identifierMode: 'email', + csvData: "Email,Midterm\n#{alice.user.email},41\n" + ) + data = JSON.parse(response.body) + expect(data['ok']).to be(true) + expect(data['sample'].first['identifier']).to eq(alice.user.email) + end + end + + context 'as a teaching assistant' do + before { controller_sign_in(controller, ta.user) } + + it 'is denied' do + expect { post :preview, params: base_params }.to raise_error(CanCan::AccessDenied) + end + end + end + + describe '#create (commit)' do + render_views + context 'as a manager' do + before { controller_sign_in(controller, manager.user) } + + it 'commits and returns a summary' do + expect { post :create, params: base_params.merge(onConflict: 'replace') }. + to change { Course::ExternalAssessmentGrade.count }.by(1) + data = JSON.parse(response.body) + expect(data['createdComponents']).to eq(1) + expect(data['gradesWritten']).to eq(1) + end + + it 'returns 422 and writes nothing on an unresolved identifier' do + expect do + post :create, params: base_params.merge( + csvData: "External ID,Midterm\nZZZ,1\n", onConflict: 'replace' + ) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'commits with onConflict keep and returns updatedComponents' do + # Seed first + post :create, params: base_params.merge(onConflict: 'replace') + # Re-import with keep + expect do + post :create, params: base_params.merge(onConflict: 'keep', + csvData: "External ID,Midterm\nA001,99\n") + end.not_to(change { Course::ExternalAssessmentGrade.count }) + data = JSON.parse(response.body) + expect(data['updatedComponents']).to eq(1) + expect(data['createdComponents']).to eq(0) + kept = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm'). + external_assessment_grades.find_by(course_user: alice) + expect(kept.grade).to eq(41) + end + + it 'overwrites an existing grade when onConflict is replace' do + post :create, params: base_params.merge(onConflict: 'replace') + post :create, params: base_params.merge(onConflict: 'replace', + csvData: "External ID,Midterm\nA001,99\n") + data = JSON.parse(response.body) + expect(data['updatedComponents']).to eq(1) + expect(data['createdComponents']).to eq(0) + grade = Course::ExternalAssessmentGrade. + joins(:external_assessment). + find_by(course_external_assessments: { course_id: course.id }, + course_user_id: alice.id) + expect(grade.grade).to eq(99) + end + + it 'returns 422 on duplicate component names' do + dup_components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }, + { name: 'Midterm', weightage: 20, maximumGrade: 40 }] + expect do + post :create, params: base_params.merge( + components: dup_components, + csvData: "External ID,Midterm,Midterm\nA001,1,2\n", + onConflict: 'replace' + ) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + expect(response).to have_http_status(:unprocessable_entity) + end + + it 'returns 422 and writes nothing on malformed grade cells' do + expect do + post :create, params: base_params.merge( + csvData: "External ID,Midterm\nA001,oops\n", onConflict: 'replace' + ) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + expect(response).to have_http_status(:unprocessable_entity) + end + end + + context 'as a teaching assistant' do + before { controller_sign_in(controller, ta.user) } + + it 'is denied' do + expect { post :create, params: base_params.merge(onConflict: 'keep') }. + to raise_error(CanCan::AccessDenied) + end + end + end + end +end diff --git a/spec/services/course/gradebook/external_assessment_import_service_spec.rb b/spec/services/course/gradebook/external_assessment_import_service_spec.rb new file mode 100644 index 0000000000..8152661594 --- /dev/null +++ b/spec/services/course/gradebook/external_assessment_import_service_spec.rb @@ -0,0 +1,777 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Gradebook::ExternalAssessmentImportService, type: :service do + let(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:actor) { create(:course_manager, course: course).user } + let!(:alice) { create(:course_student, course: course, external_id: 'A001') } + let!(:bob) { create(:course_student, course: course, external_id: 'A002') } + + def service(csv_data:, components:, identifier_mode: 'student_id') + described_class.new( + course: course, actor: actor, components: components, + identifier_mode: identifier_mode, csv_data: csv_data + ) + end + + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + describe '#preview' do + it 'writes nothing (dry-run)' do + csv = "External ID,Midterm\nA001,41\nA002,37\n" + expect { service(csv_data: csv, components: components).preview }. + to not_change { Course::ExternalAssessmentGrade.count }. + and(not_change { Course::ExternalAssessment.count }) + end + + it 'returns ok with the first 5 resolved rows (External IDs)' do + csv = "External ID,Midterm\nA001,41\nA002,37\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:unresolved]).to be_empty + expect(result[:sample].size).to eq(2) + expect(result[:sample].map { |r| r[:identifier] }).to include(alice.external_id, bob.external_id) + expect(result[:sample].first[:grades]['Midterm']).to eq(41.0) + end + + it 'caps the sample at 5 rows but reports the true total in total_rows' do + extra = (1..5).map { |i| create(:course_student, course: course, external_id: "X00#{i}") } + ids = ['A001', 'A002'] + extra.map(&:external_id) + csv = "External ID,Midterm\n#{ids.map { |id| "#{id},10" }.join("\n")}\n" + result = service(csv_data: csv, components: components).preview + expect(result[:sample].size).to eq(5) + expect(result[:total_rows]).to eq(7) + end + + it 'normalizes preview identifiers to the roster email when resolving by email' do + csv = "Email,Midterm\n#{alice.user.email.upcase},41\n" + result = service(csv_data: csv, components: components, identifier_mode: 'email').preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:identifier]).to eq(alice.user.email) + end + + it 'fails the whole batch on any unresolved identifier' do + csv = "External ID,Midterm\nA001,41\nZZZZ,37\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(false) + expect(result[:unresolved]).to include('ZZZZ') + end + + it 'flags a malformed (non-numeric) cell' do + csv = "External ID,Midterm\nA001,oops\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(false) + expect(result[:malformed]).to be_present + end + + it 'rejects an in-file duplicate component name' do + dup = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Midterm', weightage: 20, maximum_grade: 40 }] + csv = "External ID,Midterm,Midterm\nA001,1,2\n" + expect { service(csv_data: csv, components: dup).preview }. + to raise_error(described_class::ImportError) + end + + it 'raises ImportError on wrong CSV header' do + csv = "Wrong,Midterm\nA001,41\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) + end + + it 'raises duplicate_component_name (not bad_header) for an in-file duplicate component' do + dup = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Midterm', weightage: 20, maximum_grade: 40 }] + csv = "External ID,Midterm,Midterm\nA001,1,2\n" + expect { service(csv_data: csv, components: dup).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('duplicate_component_name') + end + end + + it 'rejects an otherwise-valid CSV with no data rows as empty_csv' do + csv = "External ID,Midterm\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('empty_csv') + end + end + + it 'writes nothing and raises empty_csv on commit of a header-only CSV' do + csv = "External ID,Midterm\n" + expect do + expect { service(csv_data: csv, components: components).commit(on_conflict: 'replace') }. + to raise_error(described_class::ImportError) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + end + + it 'treats a whitespace-only cell as ungraded, not malformed' do + csv = "External ID,Midterm\nA001, \n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:malformed]).to be_empty + expect(result[:sample].first[:grades]['Midterm']).to be_nil + end + + it 'rejects duplicate identifiers even if unresolvable' do + csv = "External ID,Midterm\nZZZZ,1\nZZZZ,2\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('duplicate_identifier') + expect(error.payload[:identifiers]).to include('ZZZZ') + end + end + + it 'reports the malformed cell with its 1-based data-row number and component' do + csv = "External ID,Midterm\nA001,41\nA002,oops\n" + result = service(csv_data: csv, components: components).preview + expect(result[:malformed]).to include('row 3, Midterm: oops') + end + + it 'accumulates every malformed cell across rows and components' do + comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 50, maximum_grade: 100 }] + csv = "External ID,Midterm,Final\nA001,bad,worse\n" + result = service(csv_data: csv, components: comps).preview + expect(result[:malformed].size).to eq(2) + end + + it 'treats a blank cell as ungraded in the sample' do + csv = "External ID,Midterm\nA001,\n" + result = service(csv_data: csv, components: components).preview + expect(result[:sample].first[:grades]['Midterm']).to be_nil + end + + it 'accepts the External ID header in student_id mode' do + csv = "External ID,Midterm\nA001,41\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:identifier]).to eq(alice.external_id) + end + + it 'accepts the Email header in email mode' do + csv = "Email,Midterm\n#{alice.user.email},41\n" + result = service(csv_data: csv, components: components, identifier_mode: 'email').preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:identifier]).to eq(alice.user.email) + end + + it 'rejects the External ID header when resolving by email' do + csv = "External ID,Midterm\n#{alice.user.email},41\n" + expect { service(csv_data: csv, components: components, identifier_mode: 'email').preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('bad_header') + end + end + + it 'reports duplicate headers in the bad_header payload' do + csv = "External ID,Midterm,Midterm\nA001,1,2\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('bad_header') + expect(error.payload[:duplicates]).to include(name: 'Midterm', count: 2) + end + end + + it 'leaves duplicates empty for a non-duplicate header mismatch' do + csv = "Wrong,Midterm\nA001,41\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:duplicates]).to eq([]) + end + end + + it 'reports only duplicates when every expected column is present but repeated' do + csv = "External ID,Midterm,Midterm\nA001,1,2\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:duplicates]).to include(name: 'Midterm', count: 2) + expect(error.payload[:missing]).to eq([]) + expect(error.payload[:unrecognized]).to eq([]) + end + end + + it 'reports missing and unrecognized columns for a header mismatch' do + csv = "External ID,Wrong\nA001,41\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:missing]).to eq(['Midterm']) + expect(error.payload[:unrecognized]).to eq(['Wrong']) + end + end + + it 'returns the component columns in the CSV header order, not the defined order' do + two_components = [ + { name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 70, maximum_grade: 100 } + ] + # CSV lists Final before Midterm;. + csv = "External ID,Final,Midterm\n80,A001,41\n" + result = service(csv_data: csv, components: two_components).preview + expect(result[:column_order]).to eq(['Final', 'Midterm']) + end + end + + describe '#preview out-of-range detection' do + let(:oor_components) { [name: 'Midterms', maximum_grade: 100, weightage: 0] } + let!(:charlie) { create(:course_student, course: course, external_id: 'S123') } + + it 'lists grades below 0 or above the component max without failing the preview' do + csv = "External ID,Midterms\nS123,105\n" + result = service(csv_data: csv, components: oor_components).preview + expect(result[:ok]).to be(true) # out-of-range is advisory, not a block + expect(result[:out_of_range]).to include( + a_hash_including( + component: 'Midterms', + identifier: 'S123', + grade: 105.0, + kind: 'above', + max: 100 + ) + ) + end + + it 'flags grades below 0' do + csv = "External ID,Midterms\nS123,-2\n" + result = service(csv_data: csv, components: oor_components).preview + expect(result[:ok]).to be(true) + expect(result[:out_of_range]).to include( + a_hash_including( + component: 'Midterms', + identifier: 'S123', + grade: -2.0, + kind: 'below', + max: 100 + ) + ) + end + + it 'does not flag a grade exactly at the maximum or at zero' do + csv = "External ID,Midterms\nS123,100\nA001,0\n" + result = service(csv_data: csv, components: oor_components).preview + expect(result[:ok]).to be(true) + expect(result[:out_of_range]).to be_empty + end + + it 'ignores blank cells for out-of-range detection' do + csv = "External ID,Midterms\nS123,\n" + result = service(csv_data: csv, components: oor_components).preview + expect(result[:out_of_range]).to be_empty + end + end + + describe '#commit (fresh import)' do + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + it 'creates the external in the External Assessments category with the typed weight' do + csv = "External ID,Midterm\nA001,41\nA002,37\n" + summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') + external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + expect(external).to be_present + expect(external.maximum_grade).to eq(50) + expect(external.gradebook_contribution.weight).to eq(30) + expect(summary[:createdComponents]).to eq(1) + expect(summary[:gradesWritten]).to eq(2) + end + + it 'writes one grade row per resolved student bound to course_user' do + csv = "External ID,Midterm\nA001,41\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') + grade = external.external_assessment_grades.find_by!(course_user: alice) + expect(grade.course_user_id).to eq(alice.id) + expect(grade.grade).to eq(41) + expect(grade.imported_identifier).to eq('A001') + end + + it 'skips a blank cell on a fresh import (no grade row created)' do + csv = "External ID,Midterm\nA001,\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + # After fix: blank cell on fresh import does NOT create a grade row (filter_map skips nil) + external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + expect(external.external_assessment_grades.count).to eq(0) + end + + it 'accepts a grade greater than the max (no ceiling)' do + csv = "External ID,Midterm\nA001,60\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') + expect(external.external_assessment_grades.find_by!(course_user: alice).grade).to eq(60) + end + + it 'creates multiple components as separate externals' do + comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 50, maximum_grade: 100 }] + csv = "External ID,Midterm,Final\nA001,40,80\n" + service(csv_data: csv, components: comps).commit(on_conflict: 'replace') + expect(Course::ExternalAssessment.for_course(course).pluck(:title)).to contain_exactly('Midterm', 'Final') + expect(Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm'). + external_assessment_grades.count).to eq(1) + expect(Course::ExternalAssessment.for_course(course).find_by!(title: 'Final'). + external_assessment_grades.count).to eq(1) + end + + it 'writes nothing when an identifier does not resolve' do + csv = "External ID,Midterm\nA001,41\nZZZ,9\n" + expect do + expect do + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + end.to raise_error(described_class::ImportError) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + end + + it 'raises validation_failed with the unresolved identifiers in the payload' do + csv = "External ID,Midterm\nA001,41\nZZZ,9\n" + expect { service(csv_data: csv, components: components).commit(on_conflict: 'replace') }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('validation_failed') + expect(error.payload[:unresolved]).to include('ZZZ') + end + end + + it 'aborts the commit and writes nothing when a cell is malformed' do + csv = "External ID,Midterm\nA001,41\nA002,oops\n" + expect do + expect do + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + end.to raise_error(described_class::ImportError) { |e| expect(e.payload[:malformed]).to be_present } + end.not_to(change { Course::ExternalAssessmentGrade.count }) + end + + it 'rolls back all components when a later component fails mid-write' do + # Pre-create "Final" outside the service WITHOUT a gradebook_contribution so + # the service treats it as new (existing_external matches by title) — instead, + # create a title collision the service cannot reconcile. + comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Quiz', weightage: 20, maximum_grade: 20 }] + csv = "External ID,Midterm,Quiz\nA001,40,10\n" + # Sabotage: make the second create! blow up by stubbing it to raise after the first wrote. + call = 0 + allow(Course::ExternalAssessment).to receive(:create_for_course!).and_wrap_original do |orig, **kwargs| + call += 1 + raise ActiveRecord::RecordInvalid if call == 2 + + orig.call(**kwargs) + end + expect do + expect do + service(csv_data: csv, components: comps).commit(on_conflict: 'replace') + end.to raise_error(ActiveRecord::RecordInvalid) + end.to change { Course::ExternalAssessment.for_course(course).count }.by(0). + and(change { Course::ExternalAssessmentGrade.count }.by(0)) + end + end + + describe '#commit (upsert into existing component)' do + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + def seed_initial! + csv = "External ID,Midterm\nA001,10\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + end + + it 'updates grades into the same component (no second tab)' do + external = seed_initial! + csv = "External ID,Midterm\nA001,20\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + expect(Course::ExternalAssessment.for_course(course).where(title: 'Midterm').count).to eq(1) + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(20) + end + + it "keeps existing grades when on_conflict is 'keep'" do + external = seed_initial! + csv = "External ID,Midterm\nA001,99\n" + service(csv_data: csv, components: components).commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) + end + + it 'inserts a grade for a brand-new student regardless of on_conflict' do + external = seed_initial! + csv = "External ID,Midterm\nA002,55\n" + service(csv_data: csv, components: components).commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: bob).grade).to eq(55) + end + + it 'skips a blank cell on upsert (existing grade unchanged)' do + external = seed_initial! + csv = "External ID,Midterm\nA001,\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) + end + + it 'never changes the external max or contribution weight on upsert' do + external = seed_initial! + csv = "External ID,Midterm\nA001,20\n" + comps = [name: 'Midterm', weightage: 99, maximum_grade: 999] + service(csv_data: csv, components: comps).commit(on_conflict: 'replace') + expect(external.reload.maximum_grade).to eq(50) + expect(external.gradebook_contribution.reload.weight).to eq(30) + end + + it 'groups changed cells by student and drops unchanged / new-fill students' do + seed_initial! # alice (A001) Midterm=10; bob (A002) has no grade yet + csv = "External ID,Midterm\nA001,20\nA002,33\n" + result = service(csv_data: csv, components: components).preview + + rows = result[:conflict_rows] + expect(rows.map { |r| r[:studentName] }).to contain_exactly(alice.name) + cell = rows.first[:cells]['Midterm'] + expect(cell[:existing]).to eq(10.0) + expect(cell[:inFile]).to eq(20.0) + expect(cell[:changed]).to be(true) + end + + it 'drops a student whose grade is unchanged (equal at 2 dp)' do + seed_initial! # alice Midterm=10 + csv = "External ID,Midterm\nA001,10.00\n" + result = service(csv_data: csv, components: components).preview + expect(result[:conflict_rows]).to be_empty + end + + it 'flags a reassignment when an identifier now resolves to a different student than it was imported under' do + seed_initial! # alice imported under 'A001' (snapshot 'A001' on alice's grade) + alice.update!(external_id: 'AOLD') # free up A001 + carol = create(:course_student, course: course, external_id: 'A001') # A001 recycled to carol + csv = "External ID,Midterm\nA001,77\n" + result = service(csv_data: csv, components: components).preview + + entry = result[:reassignments].find { |r| r[:identifier] == 'A001' } + expect(entry[:currentStudent]).to eq(carol.name) + expect(entry[:previousStudents]).to include(alice.name) + # carol is a brand-new insert, so she is NOT in the conflict table + expect(result[:conflict_rows].map { |r| r[:studentName] }).not_to include(carol.name) + end + + it 'does not flag a reassignment when only the same student\'s own identifier changed' do + seed_initial! # alice imported under 'A001' + bob.update!(external_id: 'A777') # free A002 + alice.update!(external_id: 'A002') # alice's OWN id drifted A001 -> A002 + csv = "External ID,Midterm\nA002,20\n" + result = service(csv_data: csv, components: components).preview + expect(result[:reassignments]).to be_empty + end + + it 'does not flag a reassignment when switching identifier mode between imports' do + seed_initial! # alice imported under External ID 'A001' + csv = "Email,Midterm\n#{alice.user.email},20\n" + result = service(csv_data: csv, components: components, identifier_mode: 'email').preview + expect(result[:reassignments]).to be_empty + end + + it 'reports changed cells across multiple components on one student row' do + comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 50, maximum_grade: 100 }] + service(csv_data: "External ID,Midterm,Final\nA001,10,80\n", components: comps). + commit(on_conflict: 'replace') + csv = "External ID,Midterm,Final\nA001,20,90\n" + result = service(csv_data: csv, components: comps).preview + + expect(result[:conflict_rows].length).to eq(1) + cells = result[:conflict_rows].first[:cells] + expect(cells['Midterm'][:changed]).to be(true) + expect(cells['Final'][:changed]).to be(true) + end + + it 'returns updatedComponents: 1 after an upsert' do + seed_initial! + csv = "External ID,Midterm\nA001,20\n" + summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') + expect(summary[:updatedComponents]).to eq(1) + expect(summary[:createdComponents]).to eq(0) + end + + it 'updates a nil existing grade even when on_conflict is keep' do + external = seed_initial! + # Manually clear the grade to nil (simulates a partial import that wrote the row but not the value) + external.external_assessment_grades.find_by(course_user: alice).update_column(:grade, nil) + csv = "External ID,Midterm\nA001,50\n" + service(csv_data: csv, components: components).commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(50) + end + + it 'refreshes imported_identifier on upsert while preserving the original creator' do + external = seed_initial! # alice imported under 'A001', value 10 + grade = external.external_assessment_grades.find_by(course_user: alice) + original_creator_id = grade.creator_id + alice.update!(external_id: 'A001-NEW') + csv = "External ID,Midterm\nA001-NEW,22\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + grade.reload + expect(grade.grade).to eq(22) + expect(grade.imported_identifier).to eq('A001-NEW') + expect(grade.creator_id).to eq(original_creator_id) + end + + it 'inserts new students and upserts existing ones in a single replace commit' do + external = seed_initial! # alice has Midterm=10, bob has none + csv = "External ID,Midterm\nA001,15\nA002,20\n" + summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(15) + expect(external.external_assessment_grades.find_by(course_user: bob).grade).to eq(20) + expect(summary[:gradesWritten]).to eq(2) + end + + it 'includes the unchanged cell with its real existing value when another cell changed' do + comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 50, maximum_grade: 100 }] + service(csv_data: "External ID,Midterm,Final\nA001,10,80\n", components: comps). + commit(on_conflict: 'replace') + csv = "External ID,Midterm,Final\nA001,20,80\n" # only Midterm changes + result = service(csv_data: csv, components: comps).preview + cells = result[:conflict_rows].first[:cells] + expect(cells['Midterm']).to include(existing: 10.0, inFile: 20.0, changed: true) + expect(cells['Final']).to include(existing: 80.0, inFile: 80.0, changed: false) + end + end + + describe 'determinacy' do + let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } + + it 'does not move a grade when the student external_id changes after import' do + csv = "External ID,Midterm\nA001,41\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + grade = Course::ExternalAssessmentGrade.last + alice.update!(external_id: 'CHANGED') + expect(grade.reload.course_user_id).to eq(alice.id) + expect(grade.grade).to eq(41) + end + end + + describe 'order-free assessment columns' do + let(:components) do + [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Finals', weightage: 70, maximum_grade: 100 }] + end + + it 'accepts assessment columns in any order, with the identifier first' do + csv = "External ID,Finals,Midterm\nA001,1,2\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:grades]['Midterm']).to eq(2.0) + expect(result[:sample].first[:grades]['Finals']).to eq(1.0) + + csv = "External ID,Midterm,Finals\nA001,1,2\n" + result = service(csv_data: csv, components: components).preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:grades]['Midterm']).to eq(1.0) + expect(result[:sample].first[:grades]['Finals']).to eq(2.0) + end + + it 'rejects an unexpected extra column' do + csv = "External ID,Midterm,Finals,Notes\nA001,41,61,x\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) { |e| + expect(e.payload[:message]).to eq('bad_header') + } + end + + it 'rejects an unexpected extra column even when the number of headers are correct' do + csv = "External ID,Finals,Notes\nA001,1,x\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) { |e| + expect(e.payload[:message]).to eq('bad_header') + } + end + + it 'rejects a duplicated column' do + csv = "External ID,Midterm,Midterm,Finals\nA001,41,41,61\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) + end + + it 'rejects a duplicated column even when the number of headers are correct' do + csv = "External ID,Midterm,Midterm\nA001,41,41\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) + end + + it 'rejects a file missing the identifier column' do + csv = "Midterm\n41\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('bad_header') + expect(error.payload[:missing]).to include('External ID') + end + end + end + + describe 'duplicate identifiers' do + it 'rejects a CSV with the same identifier in two rows' do + csv = "External ID,Midterm\nA001,40\nA001,50\n" + expect { service(csv_data: csv, components: components).preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('duplicate_identifier') + expect(error.payload[:identifiers]).to include('A001') + end + end + + it 'treats case-different emails as the same duplicated identifier' do + csv = "Email,Midterm\n#{alice.user.email},40\n#{alice.user.email.upcase},50\n" + expect { service(csv_data: csv, components: components, identifier_mode: 'email').preview }. + to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('duplicate_identifier') + end + end + + it 'writes nothing when an identifier is duplicated' do + csv = "External ID,Midterm\nA001,40\nA001,50\n" + expect do + expect { service(csv_data: csv, components: components).commit(on_conflict: 'replace') }. + to raise_error(described_class::ImportError) + end.not_to(change { Course::ExternalAssessmentGrade.count }) + end + end + + describe 'bad-header suggestions' do + let(:components) { [name: 'Midterms', weightage: 30, maximum_grade: 50] } + + it 'suggests the near-miss uploaded header for a missing expected header' do + csv = "External ID,Midterm\nA001,41\n" # header "Midterm" vs component "Midterms" + error = nil + begin + service(csv_data: csv, components: components).preview + rescue described_class::ImportError => e + error = e + end + expect(error).to be_present + expect(error.payload[:message]).to eq('bad_header') + expect(error.payload[:suggestions]).to include( + a_hash_including(expected: 'Midterms', didYouMean: 'Midterm') + ) + # The typo pair is surfaced as a suggestion, not double-reported as a + # plain missing/unrecognized column. + expect(error.payload[:missing]).to eq([]) + expect(error.payload[:unrecognized]).to eq([]) + end + + it 'omits a suggestion when no uploaded header is within the edit-distance threshold' do + csv = "External ID,Homework\nA001,41\n" # "Homework" is far from "Midterms" + error = nil + begin + service(csv_data: csv, components: components).preview + rescue described_class::ImportError => e + error = e + end + expect(error.payload[:suggestions]).to be_empty + end + end + + describe 'commit write batching' do + let(:roster) { [alice, bob] + create_list(:course_student, 8, course: course) } + + def write_query_count(&block) + count = 0 + table = Course::ExternalAssessmentGrade.table_name + + counter = lambda do |*, payload| + sql = payload[:sql].to_s + + count += 1 if sql =~ /\A\s*(INSERT|UPDATE)/i && + sql.include?(table) + end + + ActiveSupport::Notifications.subscribed(counter, 'sql.active_record', &block) + + count + end + + def csv_for(students, value) + rows = students.map { |s| "#{s.external_id},#{value}" }.join("\n") + "External ID,Midterm\n#{rows}\n" + end + + it 'inserts many brand-new grades with a single INSERT statement' do + students = roster + students.each { |s| s.update!(external_id: "E#{s.id}") } + csv = csv_for(students, 41) + + writes = write_query_count do + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + end + + expect(Course::ExternalAssessmentGrade.where(course_user: students).count).to eq(students.size) + expect(writes).to be <= 2 # one INSERT for the grades (+ at most the component create) + end + + it 'replaces many existing grades without one UPDATE per row' do + students = roster + students.each { |s| s.update!(external_id: "E#{s.id}") } + # First import seeds existing grades. + service(csv_data: csv_for(students, 41), components: components).commit(on_conflict: 'replace') + + writes = write_query_count do + service(csv_data: csv_for(students, 99), components: components).commit(on_conflict: 'replace') + end + + grades = Course::ExternalAssessmentGrade.where(course_user: students).where.not(grade: nil).pluck(:grade) + expect(grades).to all(eq(99)) + expect(writes).to be <= 1 # single upsert statement for all rows + end + + it 'keeps existing non-null grades but fills nil ones under keep' do + students = roster + students.each { |s| s.update!(external_id: "E#{s.id}") } + service(csv_data: csv_for(students, 41), components: components).commit(on_conflict: 'replace') + external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') + blanked = students.first + external.external_assessment_grades.find_by(course_user: blanked).update_column(:grade, nil) + + service(csv_data: csv_for(students, 99), components: components).commit(on_conflict: 'keep') + grades = external.external_assessment_grades.where.not(course_user: blanked).pluck(:grade) + expect(grades).to all(eq(41)) + expect(external.external_assessment_grades.find_by(course_user: blanked).grade).to eq(99) + end + end + + describe 'header ordering' do + let(:components) do + [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, + { name: 'Final', weightage: 70, maximum_grade: 100 }] + end + + it 'returns column_order following the uploaded CSV header order (faithful preview)' do + ordered = "External ID,Midterm,Final\nA001,41,80\n" + shuffled = "External ID,Final,Midterm\nA001,80,41\n" + a = service(csv_data: ordered, components: components).preview[:column_order] + b = service(csv_data: shuffled, components: components).preview[:column_order] + expect(a).to eq(%w[Midterm Final]) + expect(b).to eq(%w[Final Midterm]) + end + + it 'keeps canonical (position) order in define-step order, not CSV header order' do + # CSV columns are in the opposite order to the defined components; the + # gradebook's canonical order must follow the defined (append) order. + csv = "External ID,Final,Midterm\nA001,80,41\n" + service(csv_data: csv, components: components).commit(on_conflict: 'replace') + titles = Course::ExternalAssessment.for_course(course).order(:position).pluck(:title) + expect(titles).to eq(%w[Midterm Final]) + end + + it 'blocks when the identifier is present but not the first column' do + csv = "Midterm,External ID,Final\n41,A001,80\n" + error = service(csv_data: csv, components: components).preview rescue $ERROR_INFO # rubocop:disable Style/RescueModifier + expect(error).to be_a(described_class::ImportError) + expect(error.payload[:message]).to eq('bad_header') + expect(error.payload[:identifierNotFirst]).to be(true) + end + + it 'does not flag identifierNotFirst when the identifier is first' do + csv = "External ID,Final,Midterm\nA001,80,41\n" + expect { service(csv_data: csv, components: components).preview }.not_to raise_error + end + + it 'treats a missing identifier as missing, not identifierNotFirst' do + csv = "Midterm,Final\n41,80\n" + begin + service(csv_data: csv, components: components).preview + rescue described_class::ImportError => e + expect(e.payload[:identifierNotFirst]).to be(false) + expect(e.payload[:missing]).to include('External ID') + end + end + end + end +end From 2fca414cda253ca944004667921d237c1964e086 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 2 Jul 2026 23:55:10 +0800 Subject: [PATCH 2/4] feat(gradebook): add option to cap weighted total at 100 --- .../course/gradebook_controller.rb | 11 +++ .../course/settings/gradebook_component.rb | 15 ++++ .../course/gradebook/index.json.jbuilder | 1 + .../__tests__/ConfigureWeightsPrompt.test.tsx | 57 +++++++++++++ .../__tests__/GradebookIndex.test.tsx | 3 + .../ManageExternalAssessmentsButton.test.tsx | 1 + .../ManageExternalAssessmentsPanel.test.tsx | 2 + .../__tests__/WeightedGradebookTable.test.tsx | 55 +++++++++++++ .../gradebook/__tests__/operations.test.ts | 33 ++++++++ .../course/gradebook/__tests__/store.test.ts | 48 +++++++++++ .../components/ConfigureWeightsPrompt.tsx | 79 +++++++++++++++++-- .../components/WeightedGradebookTable.tsx | 66 +++++++++++----- .../bundles/course/gradebook/operations.ts | 8 +- .../gradebook/pages/GradebookIndex/index.tsx | 3 + .../app/bundles/course/gradebook/selectors.ts | 2 + client/app/bundles/course/gradebook/store.ts | 6 ++ client/app/types/course/gradebook.ts | 4 + .../course/gradebook_controller_spec.rb | 12 +++ .../settings/gradebook_component_spec.rb | 24 ++++++ 19 files changed, 401 insertions(+), 29 deletions(-) diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb index d632592d45..e6b70ce2c7 100644 --- a/app/controllers/course/gradebook_controller.rb +++ b/app/controllers/course/gradebook_controller.rb @@ -26,6 +26,7 @@ def update_weights level_config = persist_weight_updates(updates) response_body = { weights: serialize_weight_updates(updates) } response_body[:levelContribution] = serialize_level_contribution(level_config) if level_config + response_body[:capTotal] = @settings.cap_weighted_total render json: response_body rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound => e render json: { errors: { base: e.message } }, status: :unprocessable_entity @@ -43,10 +44,20 @@ def persist_weight_updates(updates) Course::Gradebook::TabContribution.bulk_update(course: current_course, updates: tab_updates) Course::Gradebook::ExternalContribution.bulk_update(course: current_course, updates: external_updates) level_config = persist_level_contribution + persist_cap_total end level_config end + # Persists the gradebook-wide cap-at-100 policy flag. Skipped when the client + # didn't send it, so unrelated weight saves leave the setting untouched. + def persist_cap_total + return unless params.key?(:capTotal) + + @settings.cap_weighted_total = params[:capTotal] + current_course.save! + end + def authorize_read_gradebook! authorize! :read_gradebook, current_course end diff --git a/app/models/course/settings/gradebook_component.rb b/app/models/course/settings/gradebook_component.rb index 0f78808606..2f5047d6b6 100644 --- a/app/models/course/settings/gradebook_component.rb +++ b/app/models/course/settings/gradebook_component.rb @@ -13,4 +13,19 @@ def weighted_view_enabled def weighted_view_enabled=(value) settings.weighted_view_enabled = ActiveRecord::Type::Boolean.new.cast(value) end + + # Returns whether the weighted total is capped at 100% (disabled by default). A + # gradebook-wide grading policy, so it lives beside weighted_view_enabled. + # + # @return [Boolean] Setting on whether the weighted total is capped at 100%. + def cap_weighted_total + ActiveRecord::Type::Boolean.new.cast(settings.cap_weighted_total) || false + end + + # Enable or disable capping the weighted total at 100%. + # + # @param [Boolean|Integer|String] value Setting on whether the weighted total is capped. + def cap_weighted_total=(value) + settings.cap_weighted_total = ActiveRecord::Type::Boolean.new.cast(value) + end end diff --git a/app/views/course/gradebook/index.json.jbuilder b/app/views/course/gradebook/index.json.jbuilder index 383454faed..74fca97a1b 100644 --- a/app/views/course/gradebook/index.json.jbuilder +++ b/app/views/course/gradebook/index.json.jbuilder @@ -1,6 +1,7 @@ # frozen_string_literal: true json.weightedViewEnabled @weighted_view_enabled json.canManageWeights can?(:manage_gradebook_weights, current_course) +json.capTotal @settings.cap_weighted_total json.categories do json.array!(@categories) do |cat| diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx index 207c8ac008..ef5a8e1cb3 100644 --- a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx @@ -64,6 +64,7 @@ const setup = (overrides = {}): ReturnType => render( => />, ); +// Weights summing to 140 → the cap toggle is meaningful (enabled). +const over100Tabs = [ + { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 70 }, + { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 70 }, +]; + const modeGroup = (tabTitle: string): HTMLElement => screen.getByRole('radiogroup', { name: `${tabTitle} weight mode` }); @@ -279,6 +286,7 @@ describe('', () => { }, ], expect.objectContaining({ enabled: false }), + false, // capTotal (default off) ); }); }); @@ -1085,3 +1093,52 @@ describe('level contribution section', () => { expect(screen.getByText(/set to 0/i)).toBeInTheDocument(); }); }); + +describe(' cap-at-100 toggle', () => { + beforeEach(() => jest.clearAllMocks()); + + const capToggle = (): HTMLElement => + screen.getByRole('checkbox', { name: /cap at 100/i }); + + it('is enabled when the weight sum exceeds 100', () => { + setup({ tabs: over100Tabs }); + expect(capToggle()).toBeEnabled(); + }); + + it('is disabled but retains its checked state when the sum is <= 100', () => { + setup({ capTotal: true }); // default tabs sum to exactly 100 + expect(capToggle()).toBeDisabled(); + expect(capToggle()).toBeChecked(); + }); + + it('hides the weights-do-not-sum alert when the cap is on and sum > 100', () => { + setup({ tabs: over100Tabs, capTotal: true }); + expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument(); + }); + + it('still warns when the sum exceeds 100 but the cap is off', () => { + setup({ tabs: over100Tabs, capTotal: false }); + expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument(); + }); + + it('sends the toggled capTotal as the third save argument', async () => { + setup({ tabs: over100Tabs }); + fireEvent.click(capToggle()); // turn the cap on + fireEvent.click(screen.getByRole('button', { name: /save/i })); + await waitFor(() => + expect(operations.updateGradebookWeights).toHaveBeenCalled(), + ); + const call = (operations.updateGradebookWeights as jest.Mock).mock.calls[0]; + expect(call[2]).toBe(true); + }); + + it('opens the info modal when the ⓘ button is clicked', () => { + setup({ tabs: over100Tabs }); + fireEvent.click( + screen.getByRole('button', { name: /about capping the total/i }), + ); + expect( + screen.getByText(/shown — and exported — as 100%/i), + ).toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index 06e12297e8..275e7a7951 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -45,6 +45,7 @@ const emptyState = { weightedViewEnabled: false, canManageWeights: false, courseMaxLevel: 0, + capTotal: false, levelContribution: { enabled: false, formula: '', @@ -66,6 +67,7 @@ const noStudentsState = { weightedViewEnabled: false, canManageWeights: false, courseMaxLevel: 0, + capTotal: false, levelContribution: { enabled: false, formula: '', @@ -99,6 +101,7 @@ const populatedState = { weightedViewEnabled: false, canManageWeights: false, courseMaxLevel: 0, + capTotal: false, levelContribution: { enabled: false, formula: '', diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx index c66d7593ce..ab9d1fb3d5 100644 --- a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx @@ -20,6 +20,7 @@ const state = { show: false, clamp: true, }, + capTotal: false, }, }; diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx index 0e7fa39f3d..365ee5af52 100644 --- a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx @@ -99,6 +99,7 @@ const preloadedState = { }, { id: 7, title: 'Native quiz', tabId: 3, maxGrade: 10 }, ], + capTotal: false, }, }; @@ -369,6 +370,7 @@ it('passes existing external names as chips to the import wizard', async () => { clamp: true, }, courseMaxLevel: 0, + capTotal: false, }, }; render(, { diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx index f27ca38a5f..5aa84aa26b 100644 --- a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx @@ -98,6 +98,7 @@ interface RenderWeightedOptions { gamificationEnabled?: boolean; courseMaxLevel?: number; levelContribution?: typeof defaultLevelContribution; + capTotal?: boolean; toolbarAction?: JSX.Element; } @@ -115,6 +116,7 @@ const renderWeighted = ( { students: [makeStudent(1, 'Alice')], submissions: [], tabs: [makeTab(10, 'Tab 1', 1, 100)], + capTotal: false, }; const { rerender } = render( { students: [makeStudent(1, 'Alice')], submissions: [], tabs: [makeTab(10, 'Tab 1', 1, 100)], + capTotal: false, }; const { rerender } = render( { ).not.toBeInTheDocument(); }); }); + +describe('cap the weighted total at 100%', () => { + beforeEach(() => localStorage.clear()); + + // Two 70-weight tabs → weight sum 140; a perfect student's tab cells read 70 + // each (uncapped) and the total reads 140, capped to 100. + const over100 = { + tabs: [makeTab(10, 'Tab 1', 1, 70), makeTab(11, 'Tab 2', 1, 70)], + assessments: [ + makeAssessment(100, 'Q1', 10, 100), + makeAssessment(101, 'Q2', 11, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 100), makeSub(1, 101, 100)], + }; + + it('caps the displayed total at 100 when capTotal is on and weight > 100', () => { + renderWeighted({ ...over100, capTotal: true }); + expect(screen.getAllByText('100').length).toBeGreaterThanOrEqual(1); + expect(screen.queryByText('140')).not.toBeInTheDocument(); + }); + + it('shows the raw total when capTotal is off', () => { + renderWeighted({ ...over100, capTotal: false }); + expect(screen.getByText('140')).toBeInTheDocument(); + }); + + it('does not cap when capTotal is on but weight <= 100', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [makeAssessment(100, 'Q1', 10, 100)], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 90)], + capTotal: true, + }); + expect(screen.getAllByText('90').length).toBeGreaterThanOrEqual(1); + }); + + it('shows a calm "Capped at 100%" label and hides the ≠100 warning when active', () => { + renderWeighted({ ...over100, capTotal: true }); + expect(screen.getByText(/capped at 100/i)).toBeInTheDocument(); + expect(screen.queryByText(/do not sum/i)).not.toBeInTheDocument(); + }); + + it('renders no interactive cap control in the table', () => { + renderWeighted({ ...over100, capTotal: true }); + expect( + screen.queryByRole('checkbox', { name: /cap.*100/i }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/operations.test.ts b/client/app/bundles/course/gradebook/__tests__/operations.test.ts index f17e35775d..74604ce537 100644 --- a/client/app/bundles/course/gradebook/__tests__/operations.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/operations.test.ts @@ -439,3 +439,36 @@ describe('simple pass-through thunks', () => { expect(dispatched).toHaveLength(0); }); }); + +describe('updateGradebookWeights capTotal', () => { + afterEach(() => jest.restoreAllMocks()); + const UPDATE = 'course/gradebook/UPDATE_TAB_WEIGHTS'; + + it('sends capTotal in the request and dispatches the echoed value', async () => { + const { dispatched, dispatch, getState } = harness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [], capTotal: true } } as never); + + await updateGradebookWeights([], undefined, true)(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith( + expect.objectContaining({ capTotal: true }), + ); + const updates = dispatched.filter((a) => a.type === UPDATE); + expect((updates[0].payload as { capTotal?: boolean }).capTotal).toBe(true); + }); + + it('omits capTotal from the request when it is not provided', async () => { + const { dispatch, getState } = harness(); + const spy = jest + .spyOn(CourseAPI.gradebook, 'updateWeights') + .mockResolvedValue({ data: { weights: [] } } as never); + + await updateGradebookWeights([])(dispatch, getState, {}); + + expect(spy).toHaveBeenCalledWith( + expect.not.objectContaining({ capTotal: expect.anything() }), + ); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/store.test.ts b/client/app/bundles/course/gradebook/__tests__/store.test.ts index 6eeb7e83ab..18123af365 100644 --- a/client/app/bundles/course/gradebook/__tests__/store.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/store.test.ts @@ -25,6 +25,7 @@ const baseState = { show: false, clamp: true, }, + capTotal: false, }; describe('SAVE_GRADEBOOK reducer', () => { @@ -47,6 +48,7 @@ describe('SAVE_GRADEBOOK reducer', () => { show: false, clamp: true, }, + capTotal: false, }); }); @@ -70,6 +72,7 @@ describe('SAVE_GRADEBOOK reducer', () => { show: false, clamp: true, }, + capTotal: true, }), ); expect(next.categories).toHaveLength(1); @@ -79,6 +82,50 @@ describe('SAVE_GRADEBOOK reducer', () => { expect(next.gamificationEnabled).toBe(true); expect(next.weightedViewEnabled).toBe(true); expect(next.canManageWeights).toBe(true); + expect(next.capTotal).toBe(true); + }); + + it('defaults capTotal to false when the payload omits it', () => { + const next = reducer( + undefined, + actions.saveGradebook({ + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + clamp: true, + }, + }), + ); + expect(next.capTotal).toBe(false); + }); +}); + +describe('capTotal via UPDATE_TAB_WEIGHTS', () => { + it('updates capTotal when the payload carries it', () => { + const next = reducer( + baseState as never, + actions.updateTabWeights({ weights: [], capTotal: true }), + ); + expect(next.capTotal).toBe(true); + }); + + it('leaves capTotal unchanged when the payload omits it', () => { + const next = reducer( + { ...baseState, capTotal: true } as never, + actions.updateTabWeights({ weights: [] }), + ); + expect(next.capTotal).toBe(true); }); }); @@ -629,6 +676,7 @@ describe('external assessment reducers', () => { clamp: true, }, courseMaxLevel: 0, + capTotal: false, }; it('applyCreatedExternal adds category, tab and assessment', () => { diff --git a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx index 1a865ff582..6caf8305b1 100644 --- a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx @@ -1,10 +1,19 @@ import { FC, useEffect, useState } from 'react'; import { defineMessages } from 'react-intl'; -import { KeyboardArrowDown, KeyboardArrowRight } from '@mui/icons-material'; +import { + InfoOutlined, + KeyboardArrowDown, + KeyboardArrowRight, +} from '@mui/icons-material'; import { Alert, + Button, Checkbox, Collapse, + Dialog, + DialogActions, + DialogContent, + DialogTitle, FormControlLabel, IconButton, Stack, @@ -106,6 +115,29 @@ const translations = defineMessages({ defaultMessage: 'Weights do not sum to 100. Saving is allowed; Total may be inaccurate.', }, + capToggle: { + id: 'course.gradebook.ConfigureWeightsPrompt.capToggle', + defaultMessage: 'Cap at 100%', + }, + capInfo: { + id: 'course.gradebook.ConfigureWeightsPrompt.capInfo', + defaultMessage: 'About capping the total at 100%', + }, + capInfoTitle: { + id: 'course.gradebook.ConfigureWeightsPrompt.capInfoTitle', + defaultMessage: 'Capping the weighted total at 100%', + }, + capInfoBody: { + id: 'course.gradebook.ConfigureWeightsPrompt.capInfoBody', + defaultMessage: + 'When on, any weighted total above 100% is shown — and exported — as 100%. ' + + 'Per-tab percentages still show what each student earned. Turn this off to ' + + 'see raw totals, including extra credit. Available only when weights sum above 100%.', + }, + capInfoClose: { + id: 'course.gradebook.ConfigureWeightsPrompt.capInfoClose', + defaultMessage: 'Got it', + }, valueTooLow: { id: 'course.gradebook.ConfigureWeightsPrompt.valueTooLow', defaultMessage: 'Value must be at least 0', @@ -269,6 +301,7 @@ interface Props { gamificationEnabled: boolean; courseMaxLevel: number; levelContribution: LevelContributionData; + capTotal: boolean; students: StudentData[]; } @@ -281,6 +314,7 @@ const ConfigureWeightsPrompt: FC = ({ gamificationEnabled, courseMaxLevel, levelContribution, + capTotal, students, }) => { const { t } = useTranslation(); @@ -342,6 +376,8 @@ const ConfigureWeightsPrompt: FC = ({ ); const [levelShow, setLevelShow] = useState(levelContribution.show); const [levelClamp, setLevelClamp] = useState(levelContribution.clamp); + const [capEnabled, setCapEnabled] = useState(capTotal); + const [capInfoOpen, setCapInfoOpen] = useState(false); useEffect(() => { if (open) { @@ -357,6 +393,7 @@ const ConfigureWeightsPrompt: FC = ({ setLevelWeight(levelContribution.weight || courseMaxLevel); setLevelShow(levelContribution.show); setLevelClamp(levelContribution.clamp); + setCapEnabled(capTotal); } }, [open]); @@ -533,6 +570,7 @@ const ConfigureWeightsPrompt: FC = ({ return entry; }), lcPayload, + capEnabled, ), ); onClose(); @@ -1124,14 +1162,45 @@ const ConfigureWeightsPrompt: FC = ({ )} )} - - {t(translations.total, { sum })} - - {sum !== 100 && ( + + + {t(translations.total, { sum })} + + setCapEnabled(e.target.checked)} + size="small" + /> + } + label={t(translations.capToggle)} + /> + setCapInfoOpen(true)} + size="small" + > + + + + {sum !== 100 && !(capEnabled && sum > 100) && ( {t(translations.weightsDoNotSum)} )} + setCapInfoOpen(false)} open={capInfoOpen}> + {t(translations.capInfoTitle)} + + {t(translations.capInfoBody)} + + + + + ); }; diff --git a/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx index 3911d27268..514a09c44b 100644 --- a/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx @@ -1,4 +1,11 @@ -import { Fragment, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + Fragment, + type ReactNode, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; import { defineMessages, MessageDescriptor } from 'react-intl'; import { Download, @@ -139,6 +146,10 @@ const translations = defineMessages({ id: 'course.gradebook.WeightedGradebookTable.weightsDoNotSum', defaultMessage: 'Weights do not sum to 100. Total may be inaccurate.', }, + totalCapped: { + id: 'course.gradebook.WeightedGradebookTable.totalCapped', + defaultMessage: 'Capped at 100%', + }, searchStudents: { id: 'course.gradebook.WeightedGradebookTable.searchStudents', defaultMessage: 'Search students', @@ -283,6 +294,7 @@ interface Props { gamificationEnabled: boolean; courseMaxLevel: number; levelContribution: LevelContributionData; + capTotal: boolean; /** Optional action rendered in the toolbar, left of the column picker. */ toolbarAction?: JSX.Element; } @@ -334,6 +346,7 @@ const WeightedGradebookTable = ({ gamificationEnabled, courseMaxLevel, levelContribution, + capTotal, toolbarAction, }: Props): JSX.Element => { const { t } = useTranslation(); @@ -489,7 +502,14 @@ const WeightedGradebookTable = ({ ); const allWeightsZero = totalWeight === 0; - const totalDisplayValue = (total: number | null): number | null => total; + // The cap only does anything when weights exceed 100 (Option A — tied to total + // weight, not per-student overflow). Display-only: the stored row.total stays raw. + const capActive = capTotal && totalWeight > 100; + + const totalDisplayValue = (total: number | null): number | null => { + if (total === null) return null; + return capActive ? Math.min(total, 100) : total; + }; const fmtDisplay = (v: number | null, prec: 0 | 1 | 2): string => { if (v === null) return '—'; @@ -900,6 +920,27 @@ const WeightedGradebookTable = ({ ? t(translations.percentTotalExact) : t(translations.outOfWeight, { weight: totalWeight }); + let totalWeightHeaderContent: ReactNode; + if (capActive) { + totalWeightHeaderContent = ( + + {t(translations.totalCapped)} + + ); + } else if (totalWeight === 100) { + totalWeightHeaderContent = totalWeightHeaderLabel; + } else { + totalWeightHeaderContent = ( + + + {displayMode === 'percent' + ? t(translations.percentTotalWarning, { weight: totalWeight }) + : t(translations.outOfWeight, { weight: totalWeight })} + + + ); + } + const levelBudgetLabel = displayMode === 'percent' ? t(translations.percentOfGrade, { weight: levelContribution.weight }) @@ -1284,25 +1325,7 @@ const WeightedGradebookTable = ({ ); })} - {totalWeight === 100 ? ( - totalWeightHeaderLabel - ) : ( - - - {displayMode === 'percent' - ? t(translations.percentTotalWarning, { - weight: totalWeight, - }) - : t(translations.outOfWeight, { - weight: totalWeight, - })} - - - )} + {totalWeightHeaderContent} @@ -1742,6 +1765,7 @@ const WeightedGradebookTable = ({ {canManageWeights && ( async (dispatch) => { - const response = await CourseAPI.gradebook.updateWeights( - levelContribution ? { weights, levelContribution } : { weights }, - ); + const payload: UpdateWeightsPayload = { weights }; + if (levelContribution) payload.levelContribution = levelContribution; + if (capTotal !== undefined) payload.capTotal = capTotal; + const response = await CourseAPI.gradebook.updateWeights(payload); // BE response does not echo formulaAst; merge it back so the store reducer // can optimistically recompute per-student levelContribution without a refetch. const responseData = { ...response.data }; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx index 8de817ff9b..154fb13f8a 100644 --- a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -22,6 +22,7 @@ import { outOfRangeSummary } from '../../outOfRange'; import { getAssessments, getCanManageWeights, + getCapTotal, getCategories, getCourseMaxLevel, getGamificationEnabled, @@ -82,6 +83,7 @@ const GradebookIndex: FC = () => { const canManageWeights = useAppSelector(getCanManageWeights); const courseMaxLevel = useAppSelector(getCourseMaxLevel); const levelContribution = useAppSelector(getLevelContribution); + const capTotal = useAppSelector(getCapTotal); const rangeSummary = useMemo( () => outOfRangeSummary(assessments, submissions), @@ -114,6 +116,7 @@ const GradebookIndex: FC = () => { getLocalState(state).courseMaxLevel; +export const getCapTotal = (state: AppState): boolean => + getLocalState(state).capTotal; export const getExternalAssessments = ( state: AppState, ): GradebookState['assessments'] => diff --git a/client/app/bundles/course/gradebook/store.ts b/client/app/bundles/course/gradebook/store.ts index 113a9adf2e..a76b42db16 100644 --- a/client/app/bundles/course/gradebook/store.ts +++ b/client/app/bundles/course/gradebook/store.ts @@ -39,6 +39,7 @@ interface GradebookState { canManageWeights: boolean; levelContribution: LevelContributionData; courseMaxLevel: number; + capTotal: boolean; } interface SaveGradebookAction { @@ -89,6 +90,7 @@ const initialState: GradebookState = { clamp: true, }, courseMaxLevel: 0, + capTotal: false, }; const reducer = produce( @@ -116,6 +118,7 @@ const reducer = produce( draft.levelContribution = action.payload.levelContribution ?? initialState.levelContribution; draft.courseMaxLevel = action.payload.courseMaxLevel ?? 0; + draft.capTotal = action.payload.capTotal ?? false; break; } case UPDATE_TAB_WEIGHTS: { @@ -173,6 +176,9 @@ const reducer = produce( }); } } + if (action.payload.capTotal !== undefined) { + draft.capTotal = action.payload.capTotal; + } break; } case APPLY_CREATED_EXTERNAL: { diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts index 537b1031c4..c5c7d2fc8e 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -60,6 +60,9 @@ export interface GradebookData { canManageWeights: boolean; courseMaxLevel: number; levelContribution: LevelContributionData; + // Gradebook-wide policy: cap each student's weighted total at 100%. + // Optional on the wire; the store defaults it to false when absent. + capTotal?: boolean; } export interface UpdateWeightsPayload { @@ -72,6 +75,7 @@ export interface UpdateWeightsPayload { assessmentWeights?: { assessmentId: number; weight: number }[]; }[]; levelContribution?: LevelContributionSaveData; + capTotal?: boolean; } export type FormulaNode = diff --git a/spec/controllers/course/gradebook_controller_spec.rb b/spec/controllers/course/gradebook_controller_spec.rb index b5d45f2e53..33720de0f7 100644 --- a/spec/controllers/course/gradebook_controller_spec.rb +++ b/spec/controllers/course/gradebook_controller_spec.rb @@ -476,6 +476,18 @@ def weight_for(tab) expect(response).to have_http_status(:ok) end + it 'echoes capTotal, persists it, and leaves it untouched when later omitted' do + patch :update_weights, + params: { course_id: course.id, weights: [], capTotal: true }, + format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['capTotal']).to eq(true) + + # A later save that omits capTotal must not reset the persisted flag. + patch :update_weights, params: { course_id: course.id, **valid_payload }, format: :json + expect(response.parsed_body['capTotal']).to eq(true) + end + it 'rejects negative with 422 and no partial write' do create(:course_gradebook_tab_contribution, tab: tab1, course: course, weight: 10) patch :update_weights, diff --git a/spec/models/course/settings/gradebook_component_spec.rb b/spec/models/course/settings/gradebook_component_spec.rb index 5b4fb09665..b938fc56f7 100644 --- a/spec/models/course/settings/gradebook_component_spec.rb +++ b/spec/models/course/settings/gradebook_component_spec.rb @@ -71,5 +71,29 @@ expect(settings.weighted_view_enabled).to eq(false) end end + + describe '#cap_weighted_total' do + it 'returns false by default' do + expect(settings.cap_weighted_total).to eq(false) + end + end + + describe '#cap_weighted_total=' do + it 'persists true when set to true' do + settings.cap_weighted_total = true + course.save! + expect(settings.cap_weighted_total).to eq(true) + end + + it 'handles string "1" as truthy' do + settings.cap_weighted_total = '1' + expect(settings.cap_weighted_total).to eq(true) + end + + it 'handles string "false" as falsy' do + settings.cap_weighted_total = 'false' + expect(settings.cap_weighted_total).to eq(false) + end + end end end From c8aecfebda9043035d2c2b38426efae354234b10 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 3 Jul 2026 15:23:32 +0800 Subject: [PATCH 3/4] feat(gradebook): add multiple small fixes: - levelFormula: make negative rounding consistent with ruby, fix error message when more than 2 arguments are used for min and max - WeightedGradebookTable: add cell tooltip for capping total at 100% - ConfigureWeightsPrompt: change capTotal to no longer be gated to when total weightage adds to over 100% - ImportWizard: successful import toast now shows how many grades got replaced/kept if import resolved from conflictPrompt - update to locales based on the above changes --- .../__tests__/ConfigureWeightsPrompt.test.tsx | 36 ++++--- .../ImportExternalAssessmentsWizard.test.tsx | 4 +- .../__tests__/WeightedGradebookTable.test.tsx | 55 +++++++++- .../gradebook/__tests__/levelFormula.test.ts | 19 ++++ .../components/ConfigureWeightsPrompt.tsx | 62 ++++------- .../components/WeightedGradebookTable.tsx | 100 ++++++++++++++---- .../ImportExternalAssessmentsWizard.tsx | 55 ++++++++-- .../bundles/course/gradebook/levelFormula.ts | 13 ++- client/locales/en.json | 15 ++- client/locales/ko.json | 15 ++- client/locales/zh.json | 15 ++- 11 files changed, 292 insertions(+), 97 deletions(-) diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx index ef5a8e1cb3..c71019aac6 100644 --- a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx @@ -1,3 +1,4 @@ +import userEvent from '@testing-library/user-event'; import { fireEvent, render, screen, waitFor, within } from 'test-utils'; import toast from 'lib/hooks/toast'; @@ -99,7 +100,9 @@ describe('', () => { 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(); + expect( + screen.queryByText(/weights sum to (?:more|less) than 100/i), + ).not.toBeInTheDocument(); }); it('shows Total: 100% (not the raw float) when 2dp weights sum to 100', () => { @@ -121,7 +124,9 @@ describe('', () => { setup({ tabs: sevenTabs, assessments: sevenAssessments }); expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); expect(screen.queryByText(/99\.999/)).not.toBeInTheDocument(); - expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/weights sum to (?:more|less) than 100/i), + ).not.toBeInTheDocument(); }); it('shows warning when sum != 100', () => { @@ -130,7 +135,9 @@ describe('', () => { target: { value: '30' }, }); expect(screen.getByText(/Total:\s*80%/)).toBeInTheDocument(); - expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument(); + expect( + screen.getByText(/weights sum to less than 100/i), + ).toBeInTheDocument(); }); it('shows inline error for tab total > 100', () => { @@ -1105,20 +1112,27 @@ describe(' cap-at-100 toggle', () => { expect(capToggle()).toBeEnabled(); }); - it('is disabled but retains its checked state when the sum is <= 100', () => { + it('stays enabled and checked when the sum is <= 100', () => { + // The cap is a course policy, not a reaction to the weight sum: extra credit + // can push a total past 100 even when weights sum to exactly 100, so the + // toggle must remain usable regardless of the sum. setup({ capTotal: true }); // default tabs sum to exactly 100 - expect(capToggle()).toBeDisabled(); + expect(capToggle()).toBeEnabled(); expect(capToggle()).toBeChecked(); }); it('hides the weights-do-not-sum alert when the cap is on and sum > 100', () => { setup({ tabs: over100Tabs, capTotal: true }); - expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/weights sum to (?:more|less) than 100/i), + ).not.toBeInTheDocument(); }); it('still warns when the sum exceeds 100 but the cap is off', () => { setup({ tabs: over100Tabs, capTotal: false }); - expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument(); + expect( + screen.getByText(/weights sum to more than 100/i), + ).toBeInTheDocument(); }); it('sends the toggled capTotal as the third save argument', async () => { @@ -1132,13 +1146,11 @@ describe(' cap-at-100 toggle', () => { expect(call[2]).toBe(true); }); - it('opens the info modal when the ⓘ button is clicked', () => { + it('explains the cap in a hover tooltip on the ⓘ icon', async () => { setup({ tabs: over100Tabs }); - fireEvent.click( - screen.getByRole('button', { name: /about capping the total/i }), - ); + await userEvent.hover(screen.getByLabelText(/about capping the total/i)); expect( - screen.getByText(/shown — and exported — as 100%/i), + await screen.findByText(/shown and exported as 100%/i), ).toBeInTheDocument(); }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx index d193ba0628..1f1a7d1516 100644 --- a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx @@ -604,8 +604,8 @@ describe('ImportExternalAssessmentsWizard', () => { expect(screen.getByDisplayValue('50')).toBeDisabled(); expect(screen.getByDisplayValue('30')).toBeDisabled(); - // "Updates existing" label is shown - expect(screen.getByText(/updates existing/i)).toBeInTheDocument(); + // The "existing assessment" locked-row label is shown + expect(screen.getByText(/existing assessment/i)).toBeInTheDocument(); }); it('hides a chip once the corresponding external has been added to the component list', async () => { diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx index 5aa84aa26b..cceee6fd8e 100644 --- a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx @@ -2450,7 +2450,7 @@ describe('cap the weighted total at 100%', () => { expect(screen.getByText('140')).toBeInTheDocument(); }); - it('does not cap when capTotal is on but weight <= 100', () => { + it('caps but shows no indicator when nothing exceeds 100 (weight = 100)', () => { renderWeighted({ tabs: [makeTab(10, 'Tab 1', 1, 100)], assessments: [makeAssessment(100, 'Q1', 10, 100)], @@ -2459,12 +2459,59 @@ describe('cap the weighted total at 100%', () => { capTotal: true, }); expect(screen.getAllByText('90').length).toBeGreaterThanOrEqual(1); + expect(screen.queryByLabelText(/shown as 100%/i)).not.toBeInTheDocument(); }); - it('shows a calm "Capped at 100%" label and hides the ≠100 warning when active', () => { + // Weights sum to exactly 100, but an uncapped assessment scored 150/100 pushes + // Alice's total to 130 — the overflow a weight-sum gate would miss. + const extraCredit = { + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 40)], + assessments: [ + makeAssessment(100, 'Q1', 10, 100), + makeAssessment(101, 'Q2', 11, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 150), makeSub(1, 101, 100)], + }; + + it('caps and flags an extra-credit total that exceeds 100 at weight = 100', () => { + renderWeighted({ ...extraCredit, capTotal: true }); + expect(screen.getAllByText('100').length).toBeGreaterThanOrEqual(1); // total 130 → 100 + expect(screen.queryByText('130')).not.toBeInTheDocument(); + expect(screen.getByText('/100')).toBeInTheDocument(); // scale label stays neutral + expect(screen.getByLabelText(/shown as 100%/i)).toBeInTheDocument(); + }); + + it('marks a capped cell and exposes the raw total', () => { + renderWeighted({ ...extraCredit, capTotal: true }); + const capped = document.querySelector('[aria-label*="capped to"]'); + expect(capped?.getAttribute('aria-label')).toMatch( + /actual 130, capped to 100/i, + ); // raw total exposed + expect(capped?.querySelector('sup')?.textContent).toBe('*'); // visible marker + }); + + it('keeps the weight-scale label and surfaces the cap as an info tooltip when clamping', () => { renderWeighted({ ...over100, capTotal: true }); - expect(screen.getByText(/capped at 100/i)).toBeInTheDocument(); - expect(screen.queryByText(/do not sum/i)).not.toBeInTheDocument(); + expect(screen.getByText('/140')).toBeInTheDocument(); // scale label not replaced + expect(screen.getByLabelText(/shown as 100%/i)).toBeInTheDocument(); + }); + + it('shows the scale label but no cap indicator when the cap cannot bite (weight < 100)', () => { + // Tabs 50 + 30 → total weight 80, distinct from every tab subheader so the + // "/80" total label is unambiguous. + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 50), makeTab(11, 'Tab 2', 1, 30)], + assessments: [ + makeAssessment(100, 'Q1', 10, 100), + makeAssessment(101, 'Q2', 11, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 50), makeSub(1, 101, 50)], + capTotal: true, + }); + expect(screen.getByText('/80')).toBeInTheDocument(); + expect(screen.queryByLabelText(/shown as 100%/i)).not.toBeInTheDocument(); }); it('renders no interactive cap control in the table', () => { diff --git a/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts b/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts index 4ea820b2a6..a2d8042269 100644 --- a/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts @@ -42,6 +42,16 @@ describe('parseFormula', () => { expect(evalOk('round(level / 3)', 5)).toBe(2); // 1.66 -> 2 }); + // round must round halves AWAY FROM ZERO to match Ruby's Float#round on the + // backend (evaluate_for). JS Math.round rounds halves toward +Infinity, so a + // negative x.5 would diverge: the value shown after save (FE) would differ + // from the value re-derived on reload (BE). Positive halves already agree. + it('rounds negative halves away from zero, matching the backend', () => { + expect(evalOk('round(0.5 - level)', 1)).toBe(-1); // round(-0.5) -> -1, not 0 + expect(evalOk('round(1.5 - level)', 3)).toBe(-2); // round(-1.5) -> -2, not -1 + expect(evalOk('round(level - 0.5)', 1)).toBe(1); // positive half unchanged + }); + it('supports unary minus', () => { expect(evalOk('-level + 10', 3)).toBe(7); }); @@ -130,6 +140,15 @@ describe('parseFormula — plain-language errors', () => { expect(errorOf('min(level, 20')).toMatch(/closing bracket/i); }); + it('explains too many arguments to min/max with an example', () => { + expect(errorOf('min(level, 25, 30)')).toMatch(/min takes exactly two values/i); + expect(errorOf('min(level, 25, 30)')).toMatch(/min\(level, 25\)/); + expect(errorOf('max(0, level, 5)')).toMatch(/max takes exactly two values/i); + expect(errorOf('max(0, level, 5)')).toMatch(/max\(level, 25\)/); + // and never falls back to the misleading closing-bracket message + expect(errorOf('min(level, 25, 30)')).not.toMatch(/closing bracket/i); + }); + it('explains a stray symbol', () => { expect(errorOf('level % 2')).toContain('"%" cannot be used'); }); diff --git a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx index 6caf8305b1..2059660ee3 100644 --- a/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx +++ b/client/app/bundles/course/gradebook/components/ConfigureWeightsPrompt.tsx @@ -7,17 +7,13 @@ import { } from '@mui/icons-material'; import { Alert, - Button, Checkbox, Collapse, - Dialog, - DialogActions, - DialogContent, - DialogTitle, FormControlLabel, IconButton, Stack, TextField, + Tooltip, Typography, } from '@mui/material'; import type { @@ -110,10 +106,15 @@ const translations = defineMessages({ id: 'course.gradebook.ConfigureWeightsPrompt.total', defaultMessage: 'Total: {sum}%', }, - weightsDoNotSum: { - id: 'course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum', + weightsOverHundred: { + id: 'course.gradebook.ConfigureWeightsPrompt.weightsOverHundred', defaultMessage: - 'Weights do not sum to 100. Saving is allowed; Total may be inaccurate.', + "Weights sum to more than 100%, so student totals can exceed 100%. Turn on 'Cap at 100%' to show and export them as 100%, or set weights to sum to 100%. Saving is still allowed.", + }, + weightsUnderHundred: { + id: 'course.gradebook.ConfigureWeightsPrompt.weightsUnderHundred', + defaultMessage: + 'Weights sum to less than 100%, so no student can reach a 100% total. Set weights to sum to 100% if that is unintended. Saving is still allowed.', }, capToggle: { id: 'course.gradebook.ConfigureWeightsPrompt.capToggle', @@ -123,20 +124,12 @@ const translations = defineMessages({ id: 'course.gradebook.ConfigureWeightsPrompt.capInfo', defaultMessage: 'About capping the total at 100%', }, - capInfoTitle: { - id: 'course.gradebook.ConfigureWeightsPrompt.capInfoTitle', - defaultMessage: 'Capping the weighted total at 100%', - }, capInfoBody: { id: 'course.gradebook.ConfigureWeightsPrompt.capInfoBody', defaultMessage: - 'When on, any weighted total above 100% is shown — and exported — as 100%. ' + + 'When on, any weighted total above 100% is shown and exported as 100%. ' + 'Per-tab percentages still show what each student earned. Turn this off to ' + - 'see raw totals, including extra credit. Available only when weights sum above 100%.', - }, - capInfoClose: { - id: 'course.gradebook.ConfigureWeightsPrompt.capInfoClose', - defaultMessage: 'Got it', + 'see raw totals.', }, valueTooLow: { id: 'course.gradebook.ConfigureWeightsPrompt.valueTooLow', @@ -377,7 +370,6 @@ const ConfigureWeightsPrompt: FC = ({ const [levelShow, setLevelShow] = useState(levelContribution.show); const [levelClamp, setLevelClamp] = useState(levelContribution.clamp); const [capEnabled, setCapEnabled] = useState(capTotal); - const [capInfoOpen, setCapInfoOpen] = useState(false); useEffect(() => { if (open) { @@ -1170,37 +1162,29 @@ const ConfigureWeightsPrompt: FC = ({ control={ setCapEnabled(e.target.checked)} size="small" /> } label={t(translations.capToggle)} /> - setCapInfoOpen(true)} - size="small" - > - - + + + {sum !== 100 && !(capEnabled && sum > 100) && ( - {t(translations.weightsDoNotSum)} + {t( + sum > 100 + ? translations.weightsOverHundred + : translations.weightsUnderHundred, + )} )} - setCapInfoOpen(false)} open={capInfoOpen}> - {t(translations.capInfoTitle)} - - {t(translations.capInfoBody)} - - - - - ); }; diff --git a/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx index 514a09c44b..86119e99e6 100644 --- a/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/WeightedGradebookTable.tsx @@ -20,6 +20,7 @@ import { Checkbox, IconButton, Paper, + Stack, Table, TableBody, TableCell, @@ -146,9 +147,13 @@ const translations = defineMessages({ id: 'course.gradebook.WeightedGradebookTable.weightsDoNotSum', defaultMessage: 'Weights do not sum to 100. Total may be inaccurate.', }, - totalCapped: { - id: 'course.gradebook.WeightedGradebookTable.totalCapped', - defaultMessage: 'Capped at 100%', + totalCappedTooltip: { + id: 'course.gradebook.WeightedGradebookTable.totalCappedTooltip', + defaultMessage: 'Totals above 100% are shown as 100%.', + }, + totalCappedCell: { + id: 'course.gradebook.WeightedGradebookTable.totalCappedCell', + defaultMessage: 'Actual {raw}, capped to {max}.', }, searchStudents: { id: 'course.gradebook.WeightedGradebookTable.searchStudents', @@ -502,13 +507,9 @@ const WeightedGradebookTable = ({ ); const allWeightsZero = totalWeight === 0; - // The cap only does anything when weights exceed 100 (Option A — tied to total - // weight, not per-student overflow). Display-only: the stored row.total stays raw. - const capActive = capTotal && totalWeight > 100; - const totalDisplayValue = (total: number | null): number | null => { if (total === null) return null; - return capActive ? Math.min(total, 100) : total; + return capTotal ? Math.min(total, 100) : total; }; const fmtDisplay = (v: number | null, prec: 0 | 1 | 2): string => { @@ -739,6 +740,36 @@ const WeightedGradebookTable = ({ }; }, [rows, resolvedTabs, displayMode, totalWeight, levelContribution.weight]); + // Renders a student's weighted-total cell. When the cap is on and the raw total + // exceeds 100, the displayed 100 is indistinguishable from a genuine 100 — so + // mark it and expose the raw value on hover: quiet by default (no alert), + // detail on demand, and it keeps a wrong-maxGrade anomaly (a raw 300%) + // discoverable rather than silently flattened. + const renderTotalCell = (raw: number | null): ReactNode => { + const display = fmtDisplay(totalDisplayValue(raw), columnPrecisions.total); + if (!capTotal || raw === null || raw <= 100) return display; + const rawPrec = Number.isInteger(raw) ? 0 : 2; + const cappedLabel = t(translations.totalCappedCell, { + raw: fmtDisplay(raw, rawPrec), + max: fmtDisplay(100, 0), + }); + return ( + + + {display} + + * + + + + ); + }; + const hasExternalIds = useMemo( () => students.some((s) => s.externalId != null && s.externalId !== ''), [students], @@ -920,17 +951,23 @@ const WeightedGradebookTable = ({ ? t(translations.percentTotalExact) : t(translations.outOfWeight, { weight: totalWeight }); - let totalWeightHeaderContent: ReactNode; - if (capActive) { - totalWeightHeaderContent = ( - - {t(translations.totalCapped)} + // Two independent facts share this header. The weight-sum label (neutral at + // 100, warning otherwise) is primary and always shows — it is the scale totals + // are read on, which matters even under the cap (weights summing to 80 still + // top out at 80, so "Capped at 100%" alone would mislead). The cap is secondary + // metadata: an info affordance shown only when it actually cuts a total down + // (some raw total > 100), so it never claims "capped" on a column where nothing + // reaches 100 — and it does surface in the extra-credit case, where weights sum + // to exactly 100 yet a total still exceeds it. + const capClamping = + capTotal && rows.some((r) => r.total !== null && r.total > 100); + + const weightLabelContent: ReactNode = + totalWeight === 100 ? ( + + {totalWeightHeaderLabel} - ); - } else if (totalWeight === 100) { - totalWeightHeaderContent = totalWeightHeaderLabel; - } else { - totalWeightHeaderContent = ( + ) : ( {displayMode === 'percent' @@ -939,7 +976,27 @@ const WeightedGradebookTable = ({ ); - } + + const totalWeightHeaderContent: ReactNode = capClamping ? ( + + {weightLabelContent} + + + + + ) : ( + weightLabelContent + ); const levelBudgetLabel = displayMode === 'percent' @@ -1523,10 +1580,7 @@ const WeightedGradebookTable = ({ ); })} - {fmtDisplay( - totalDisplayValue(row.original.total), - columnPrecisions.total, - )} + {renderTotalCell(row.original.total)} {isExpanded && diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx index b92a3944a1..f702751cca 100644 --- a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx +++ b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx @@ -26,6 +26,7 @@ import { TextField, Typography, } from '@mui/material'; +import type { SxProps, Theme } from '@mui/material'; import type { ExistingExternalAssessment, IdentifierMode, @@ -84,7 +85,8 @@ const translations = defineMessages({ }, updatesExisting: { id: 'course.gradebook.ImportWizard.updatesExisting', - defaultMessage: 'Updates existing — managed in the gradebook', + defaultMessage: + 'Existing assessment - settings managed in Manage External Assessments', }, fromExisting: { id: 'course.gradebook.ImportWizard.fromExisting', @@ -187,6 +189,16 @@ const translations = defineMessages({ id: 'course.gradebook.ImportWizard.committed', defaultMessage: 'Import complete.', }, + committedReplaced: { + id: 'course.gradebook.ImportWizard.committedReplaced', + defaultMessage: + 'Import complete. {count, plural, one {# existing grade replaced} other {# existing grades replaced}}.', + }, + committedKept: { + id: 'course.gradebook.ImportWizard.committedKept', + defaultMessage: + 'Import complete. {count, plural, one {# existing grade kept} other {# existing grades kept}}.', + }, headerSuggestion: { id: 'course.gradebook.ImportWizard.headerSuggestion', defaultMessage: @@ -310,6 +322,15 @@ const importErrorCode = ( ).response.data.errors; }; +// Keep every alert's text at body2 size and black so the error (red) and +// warning (amber) blocks read identically; the severity icon keeps its color. +const alertSx: SxProps = { + mb: 1, + color: 'common.black', + '& .MuiAlertTitle-root': { typography: 'body2' }, + '& .MuiAlert-message .MuiTypography-root': { typography: 'body2' }, +}; + const ImportExternalAssessmentsWizard: FC = ({ open, onClose, @@ -448,7 +469,27 @@ const ImportExternalAssessmentsWizard: FC = ({ onConflict, }), ); - toast.success(t(translations.committed)); + // Count the grade cells that actually differed, so the success toast can + // report how many existing grades were replaced vs kept. When nothing + // conflicted (the clean-import path also calls doCommit('replace')), fall + // back to the plain message so we never claim a replace/keep that never happened. + const changedGrades = (preview?.conflictRows ?? []).reduce( + (sum, row) => + sum + Object.values(row.cells).filter((cell) => cell.changed).length, + 0, + ); + if (changedGrades > 0) { + toast.success( + t( + onConflict === 'keep' + ? translations.committedKept + : translations.committedReplaced, + { count: changedGrades }, + ), + ); + } else { + toast.success(t(translations.committed)); + } setConflictOpen(false); onClose(); } catch { @@ -470,7 +511,7 @@ const ImportExternalAssessmentsWizard: FC = ({ return ( <> {includeBlocking && !preview.ok && preview.unresolved.length > 0 && ( - + {t( mode === 'email' @@ -486,7 +527,7 @@ const ImportExternalAssessmentsWizard: FC = ({ )} {includeBlocking && !preview.ok && preview.malformed.length > 0 && ( - + {t(translations.malformed)}
    {preview.malformed.slice(0, 5).map((cell) => ( @@ -506,7 +547,7 @@ const ImportExternalAssessmentsWizard: FC = ({ )} {preview.outOfRange.length > 0 && ( - + {t(translations.outOfRangeTitle)}
      @@ -541,7 +582,7 @@ const ImportExternalAssessmentsWizard: FC = ({ )} {preview.reassignments.length > 0 && ( - + {t(translations.reassignmentTitle)}
        @@ -664,7 +705,7 @@ const ImportExternalAssessmentsWizard: FC = ({ } /> {locked && ( - + {t(translations.updatesExisting)} )} diff --git a/client/app/bundles/course/gradebook/levelFormula.ts b/client/app/bundles/course/gradebook/levelFormula.ts index bbeb8793b8..37765fc14e 100644 --- a/client/app/bundles/course/gradebook/levelFormula.ts +++ b/client/app/bundles/course/gradebook/levelFormula.ts @@ -26,10 +26,17 @@ interface Scope { level: number; } +// Ruby's Float#round (used by the backend's evaluate_for) rounds halves away +// from zero, whereas JS Math.round rounds them toward +Infinity — they diverge +// on negative x.5. Match Ruby so the value shown right after save equals the +// value re-derived on the next page load. +const roundHalfAwayFromZero = (x: number): number => + Math.sign(x) * Math.round(Math.abs(x)); + const FUNCTIONS_1: Record number> = { floor: Math.floor, ceil: Math.ceil, - round: Math.round, + round: roundHalfAwayFromZero, }; const FUNCTIONS_2: Record number> = { min: Math.min, @@ -193,6 +200,10 @@ const buildAst = (tokens: Token[]): FormulaNode => { const a = parseExpr(); expect('comma'); const b = parseExpr(); + if (peek()?.type === 'comma') + throw new Error( + `${name} takes exactly two values - for example ${name}(level, 25).`, + ); expect('rparen'); return { type: 'call2', fn: name as 'min' | 'max', a, b }; } diff --git a/client/locales/en.json b/client/locales/en.json index 22eacf3b25..b707d141ab 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9662,8 +9662,11 @@ "course.gradebook.ConfigureWeightsPrompt.valueTooLow": { "defaultMessage": "Value must be at least 0" }, - "course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum": { - "defaultMessage": "Weights do not sum to 100. Saving is allowed; Total may be inaccurate." + "course.gradebook.ConfigureWeightsPrompt.weightsOverHundred": { + "defaultMessage": "Weights sum to more than 100%, so student totals can exceed 100%. Turn on 'Cap at 100%' to show and export them as 100%, or set weights to sum to 100%. Saving is still allowed." + }, + "course.gradebook.ConfigureWeightsPrompt.weightsUnderHundred": { + "defaultMessage": "Weights sum to less than 100%, so no student can reach a 100% total. Set weights to sum to 100% if that is unintended. Saving is still allowed." }, "course.gradebook.GradebookIndex.allAssessments": { "defaultMessage": "All Assessments" @@ -9974,6 +9977,12 @@ "course.gradebook.ImportWizard.committed": { "defaultMessage": "Import complete." }, + "course.gradebook.ImportWizard.committedKept": { + "defaultMessage": "Import complete. {count, plural, one {# existing grade kept} other {# existing grades kept}}." + }, + "course.gradebook.ImportWizard.committedReplaced": { + "defaultMessage": "Import complete. {count, plural, one {# existing grade replaced} other {# existing grades replaced}}." + }, "course.gradebook.ImportWizard.componentName": { "defaultMessage": "Component name" }, @@ -10089,7 +10098,7 @@ "defaultMessage": "{count, plural, one {This external ID was not found in the course: {ids}} other {These external IDs were not found in the course: {ids}}}" }, "course.gradebook.ImportWizard.updatesExisting": { - "defaultMessage": "Updates existing — managed in the gradebook" + "defaultMessage": "Existing assessment - settings managed in Manage External Assessments" }, "course.gradebook.ImportWizard.upload": { "defaultMessage": "Upload filled CSV" diff --git a/client/locales/ko.json b/client/locales/ko.json index 5fffc422d7..d497167813 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9623,8 +9623,11 @@ "course.gradebook.ConfigureWeightsPrompt.valueTooLow": { "defaultMessage": "값은 최소 0이어야 합니다" }, - "course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum": { - "defaultMessage": "가중치 합계가 100이 아닙니다. 저장은 가능하지만 총점이 정확하지 않을 수 있습니다." + "course.gradebook.ConfigureWeightsPrompt.weightsOverHundred": { + "defaultMessage": "가중치 합계가 100%를 초과하므로 학생 총점이 100%를 넘을 수 있습니다. '100%로 제한'을 켜면 표시 및 내보내기 시 100%로 표시되며, 또는 가중치 합계를 100%로 설정하세요. 저장은 계속 허용됩니다." + }, + "course.gradebook.ConfigureWeightsPrompt.weightsUnderHundred": { + "defaultMessage": "가중치 합계가 100% 미만이므로 어떤 학생도 총점 100%에 도달할 수 없습니다. 의도한 것이 아니라면 가중치 합계를 100%로 설정하세요. 저장은 계속 허용됩니다." }, "course.gradebook.GradebookIndex.allAssessments": { "defaultMessage": "전체 평가" @@ -9965,6 +9968,12 @@ "course.gradebook.ImportWizard.committed": { "defaultMessage": "가져오기가 완료되었습니다." }, + "course.gradebook.ImportWizard.committedKept": { + "defaultMessage": "가져오기가 완료되었습니다. {count, plural, one {기존 성적 #개가 유지되었습니다} other {기존 성적 #개가 유지되었습니다}}." + }, + "course.gradebook.ImportWizard.committedReplaced": { + "defaultMessage": "가져오기가 완료되었습니다. {count, plural, one {기존 성적 #개가 대체되었습니다} other {기존 성적 #개가 대체되었습니다}}." + }, "course.gradebook.ImportWizard.componentName": { "defaultMessage": "구성요소 이름" }, @@ -10080,7 +10089,7 @@ "defaultMessage": "{count, plural, one {이 외부 ID를 과목에서 찾을 수 없습니다: {ids}} other {이 외부 ID들을 과목에서 찾을 수 없습니다: {ids}}}" }, "course.gradebook.ImportWizard.updatesExisting": { - "defaultMessage": "기존 항목 업데이트 — 성적표에서 관리됨" + "defaultMessage": "기존 평가 - 설정은 외부 평가 관리에서 관리됩니다" }, "course.gradebook.ImportWizard.upload": { "defaultMessage": "작성한 CSV 업로드" diff --git a/client/locales/zh.json b/client/locales/zh.json index b4e9d4faa5..6fadf25c24 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9620,8 +9620,11 @@ "course.gradebook.ConfigureWeightsPrompt.valueTooLow": { "defaultMessage": "数值至少为 0" }, - "course.gradebook.ConfigureWeightsPrompt.weightsDoNotSum": { - "defaultMessage": "权重合计不为 100。仍可保存;总成绩可能不准确。" + "course.gradebook.ConfigureWeightsPrompt.weightsOverHundred": { + "defaultMessage": "权重总和超过 100%,因此学生总分可能超过 100%。开启“限制为 100%”可在显示和导出时将其显示为 100%,或将权重总和设为 100%。仍允许保存。" + }, + "course.gradebook.ConfigureWeightsPrompt.weightsUnderHundred": { + "defaultMessage": "权重总和低于 100%,因此没有学生可以达到 100% 的总分。如果这不是预期结果,请将权重总和设为 100%。仍允许保存。" }, "course.gradebook.GradebookIndex.allAssessments": { "defaultMessage": "全部评估" @@ -9959,6 +9962,12 @@ "course.gradebook.ImportWizard.committed": { "defaultMessage": "导入完成。" }, + "course.gradebook.ImportWizard.committedKept": { + "defaultMessage": "导入完成。{count, plural, one {已保留 # 个现有成绩} other {已保留 # 个现有成绩}}。" + }, + "course.gradebook.ImportWizard.committedReplaced": { + "defaultMessage": "导入完成。{count, plural, one {已替换 # 个现有成绩} other {已替换 # 个现有成绩}}。" + }, "course.gradebook.ImportWizard.componentName": { "defaultMessage": "组件名称" }, @@ -10074,7 +10083,7 @@ "defaultMessage": "{count, plural, one {在课程中未找到此外部 ID:{ids}} other {在课程中未找到以下外部 ID:{ids}}}" }, "course.gradebook.ImportWizard.updatesExisting": { - "defaultMessage": "更新现有 — 在成绩册中管理" + "defaultMessage": "现有评估 - 设置在管理外部评估中管理" }, "course.gradebook.ImportWizard.upload": { "defaultMessage": "上传已填写的 CSV" From 686d92ca83760fe63077a92493ade68725741a66 Mon Sep 17 00:00:00 2001 From: lws49 Date: Fri, 3 Jul 2026 17:27:00 +0800 Subject: [PATCH 4/4] refactor(gradebook): restructure external assessment management + import UI - unify manage and configure-weights into a GradebookSettingsDialog - decompose import wizard into ColumnMappingTable + useImportMapping hook - extract importValidation module with dedicated unit tests - rework ExternalAssessmentImportService header/identifier handling - refresh en/ko/zh locales for the new settings surface --- .../external_assessment_imports_controller.rb | 23 +- .../course/gradebook_controller.rb | 5 +- .../external_assessment_import_service.rb | 266 +-- .../AddExternalColumnPrompt.test.tsx | 61 +- .../ConfigureWeightsContent.test.tsx | 56 + .../__tests__/ConfigureWeightsPrompt.test.tsx | 1156 ---------- .../EditExternalAssessmentPrompt.test.tsx | 97 +- .../__tests__/GradebookIndex.test.tsx | 35 +- .../GradebookSettingsButton.test.tsx | 65 + .../GradebookSettingsDialog.test.tsx | 84 + .../__tests__/GradebookTable.test.tsx | 23 +- .../ImportExternalAssessmentsWizard.test.tsx | 2050 ++++++----------- .../ManageExternalAssessmentsButton.test.tsx | 38 - .../ManageExternalAssessmentsContent.test.tsx | 294 +++ .../ManageExternalAssessmentsPanel.test.tsx | 481 ---- .../__tests__/WeightedGradebookTable.test.tsx | 28 +- .../gradebook/__tests__/buildTemplate.test.ts | 77 +- .../gradebook/__tests__/importTypes.test.ts | 20 + .../gradebook/__tests__/levelFormula.test.ts | 8 +- .../components/AddExternalColumnPrompt.tsx | 109 +- ...Prompt.tsx => ConfigureWeightsContent.tsx} | 68 +- .../gradebook/components/GradebookTable.tsx | 45 +- .../components/WeightedGradebookTable.tsx | 55 +- .../components/import/ColumnMappingTable.tsx | 368 +++ .../components/import/ImportCsvButton.tsx | 65 + .../ImportExternalAssessmentsWizard.tsx | 1010 +++----- .../__tests__/ColumnMappingTable.test.tsx | 215 ++ .../import/__tests__/importValidation.test.ts | 230 ++ .../import/__tests__/useImportMapping.test.ts | 325 +++ .../components/import/buildTemplate.ts | 48 +- .../components/import/importValidation.ts | 193 ++ .../components/import/useImportMapping.ts | 311 +++ .../manage/EditExternalAssessmentPrompt.tsx | 149 +- .../manage/GradebookSettingsButton.tsx | 61 + .../manage/GradebookSettingsDialog.tsx | 134 ++ .../ManageExternalAssessmentsButton.tsx | 38 - .../ManageExternalAssessmentsContent.tsx | 324 +++ .../manage/ManageExternalAssessmentsPanel.tsx | 359 --- .../gradebook/pages/GradebookIndex/index.tsx | 40 +- .../core/buttons/SegmentedSwitch.tsx | 166 +- .../table/MuiTableAdapter/MuiTableToolbar.tsx | 41 +- .../table/__tests__/MuiTableToolbar.test.tsx | 16 + .../table/builder/ColumnPickerTemplate.ts | 3 + client/app/types/course/gradebook.ts | 17 +- client/app/utilities/test-utils.tsx | 45 +- client/locales/en.json | 236 +- client/locales/ko.json | 108 +- client/locales/zh.json | 108 +- ...rnal_assessment_imports_controller_spec.rb | 59 +- ...external_assessment_import_service_spec.rb | 776 ++----- 50 files changed, 5114 insertions(+), 5475 deletions(-) create mode 100644 client/app/bundles/course/gradebook/__tests__/ConfigureWeightsContent.test.tsx delete mode 100644 client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookSettingsButton.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/GradebookSettingsDialog.test.tsx delete mode 100644 client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsContent.test.tsx delete mode 100644 client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx create mode 100644 client/app/bundles/course/gradebook/__tests__/importTypes.test.ts rename client/app/bundles/course/gradebook/components/{ConfigureWeightsPrompt.tsx => ConfigureWeightsContent.tsx} (97%) create mode 100644 client/app/bundles/course/gradebook/components/import/ColumnMappingTable.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/ImportCsvButton.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/__tests__/ColumnMappingTable.test.tsx create mode 100644 client/app/bundles/course/gradebook/components/import/__tests__/importValidation.test.ts create mode 100644 client/app/bundles/course/gradebook/components/import/__tests__/useImportMapping.test.ts create mode 100644 client/app/bundles/course/gradebook/components/import/importValidation.ts create mode 100644 client/app/bundles/course/gradebook/components/import/useImportMapping.ts create mode 100644 client/app/bundles/course/gradebook/components/manage/GradebookSettingsButton.tsx create mode 100644 client/app/bundles/course/gradebook/components/manage/GradebookSettingsDialog.tsx delete mode 100644 client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx create mode 100644 client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsContent.tsx delete mode 100644 client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx diff --git a/app/controllers/course/external_assessment_imports_controller.rb b/app/controllers/course/external_assessment_imports_controller.rb index a85d636e74..e3f5661ace 100644 --- a/app/controllers/course/external_assessment_imports_controller.rb +++ b/app/controllers/course/external_assessment_imports_controller.rb @@ -26,28 +26,23 @@ def component def build_service permitted_params = import_params - weighted_view_enabled = gradebook_settings.weighted_view_enabled Service.new( course: current_course, actor: current_user, - components: permitted_params[:components].map do |c| - { name: c[:name], - weightage: weighted_view_enabled ? c[:weightage].to_i : 0, - maximum_grade: c[:maximumGrade].to_f } - end, identifier_mode: permitted_params[:identifierMode], - csv_data: permitted_params[:csvData] + identifier_column: permitted_params[:identifierColumn], + csv_data: permitted_params[:csvData], + mappings: (permitted_params[:mappings] || []).map do |m| + { header: m[:header], action: m[:action], target: m[:target], + max_grade: m[:maxGrade].presence&.to_f, weight: m[:weight].presence&.to_f } + end ) end - def gradebook_settings - @gradebook_settings ||= Course::Settings::GradebookComponent.new(component) - end - def import_params - @import_params ||= params.slice(:identifierMode, :csvData, :onConflict, :components).permit( - :identifierMode, :csvData, :onConflict, - components: [:name, :weightage, :maximumGrade] + @import_params ||= params.permit( + :identifierMode, :identifierColumn, :csvData, :onConflict, + mappings: [:header, :action, :target, :maxGrade, :weight] ) end end diff --git a/app/controllers/course/gradebook_controller.rb b/app/controllers/course/gradebook_controller.rb index e6b70ce2c7..affe45da1a 100644 --- a/app/controllers/course/gradebook_controller.rb +++ b/app/controllers/course/gradebook_controller.rb @@ -54,7 +54,10 @@ def persist_weight_updates(updates) def persist_cap_total return unless params.key?(:capTotal) - @settings.cap_weighted_total = params[:capTotal] + new_value = ActiveRecord::Type::Boolean.new.cast(params[:capTotal]) + return if @settings.cap_weighted_total == new_value + + @settings.cap_weighted_total = new_value current_course.save! end diff --git a/app/services/course/gradebook/external_assessment_import_service.rb b/app/services/course/gradebook/external_assessment_import_service.rb index 934bd94a1c..867ce39b26 100644 --- a/app/services/course/gradebook/external_assessment_import_service.rb +++ b/app/services/course/gradebook/external_assessment_import_service.rb @@ -11,14 +11,21 @@ def initialize(payload) end end - HEADER_SUGGESTION_MAX_DISTANCE = 2 + BLANKISH = ['', '-', '–', '—'].freeze - def initialize(course:, actor:, components:, identifier_mode:, csv_data:) + # rubocop:disable Metrics/ParameterLists -- mirrors the controller's request shape; named kwargs are clearer than a struct + def initialize(course:, actor:, identifier_mode:, identifier_column:, csv_data:, mappings:) @course = course @actor = actor - @components = components.map(&:symbolize_keys) @identifier_mode = identifier_mode.to_s + @identifier_column = identifier_column.to_s @csv_data = csv_data + @mappings = mappings.map(&:symbolize_keys) + end + # rubocop:enable Metrics/ParameterLists + + def targets + @mappings.map { |m| m[:target] } end def preview @@ -54,48 +61,71 @@ def commit(on_conflict:) private def write_components(summary, resolved, on_conflict) - @components.each do |component| - external = existing_external(component[:name]) - if external + @mappings.each do |mapping| + if mapping[:action] == 'existing' + external = existing_external(mapping[:target]) summary[:updatedComponents] += 1 - summary[:gradesWritten] += upsert_grades(external, component, resolved, + summary[:gradesWritten] += upsert_grades(external, mapping, resolved, on_conflict: on_conflict) else summary[:createdComponents] += 1 - summary[:gradesWritten] += create_component(component, resolved) + summary[:gradesWritten] += create_component(mapping, resolved) end end end def parsed_rows - guard_no_duplicate_components! table = CSV.parse(@csv_data.to_s, headers: true) guard_header!(table.headers) + guard_targets! guard_not_empty!(table) guard_unique_identifiers!(table) table end - def guard_no_duplicate_components! - names = @components.map { |c| c[:name] } - return if names.uniq.length == names.length + def guard_header!(headers) + present = headers.compact + guard_identifier_column_present!(present) + guard_mapped_headers_present!(present) + guard_no_duplicate_headers!(present) + end - raise ImportError, { message: 'duplicate_component_name' } + def guard_identifier_column_present!(present) + return if present.include?(@identifier_column) + + raise ImportError, { message: 'bad_header', missing: [@identifier_column] } end - def guard_header!(headers) - expected = [identifier_header] + @components.map { |c| c[:name] } - identifier_not_first = headers.include?(identifier_header) && - headers.compact.first != identifier_header - return if headers.tally == expected.tally && !identifier_not_first + def guard_mapped_headers_present!(present) + missing = @mappings.map { |m| m[:header] }.reject { |h| present.include?(h) } + raise ImportError, { message: 'bad_header', missing: missing } if missing.any? + end - suggestions = header_suggestions(expected, headers) - raise ImportError, { message: 'bad_header', - missing: missing_headers(expected, headers, suggestions), - unrecognized: unrecognized_headers(expected, headers, suggestions), - suggestions: suggestions, - duplicates: duplicate_headers(headers), - identifierNotFirst: identifier_not_first } + def guard_no_duplicate_headers!(present) + mapped_or_identifier = present.select { |h| h == @identifier_column || @mappings.any? { |m| m[:header] == h } } + dupes = duplicate_headers(mapped_or_identifier) + raise ImportError, { message: 'bad_header', duplicates: dupes } if dupes.any? + end + + # Defensive backend counterpart to the frontend's own collision guards: two + # "create" mappings cannot share a target, nor may a "create" target collide + # with an existing assessment's title (both case-insensitive). + def guard_targets! + create_targets = @mappings.select { |m| m[:action] == 'create' }.map { |m| m[:target] } + dupes = duplicate_targets(create_targets) + collisions = colliding_targets(create_targets) + return if dupes.empty? && collisions.empty? + + raise ImportError, { message: 'duplicate_target', duplicates: dupes, collisions: collisions } + end + + def duplicate_targets(create_targets) + create_targets.group_by(&:downcase).select { |_key, group| group.size > 1 }.keys + end + + def colliding_targets(create_targets) + existing_titles = Course::ExternalAssessment.for_course(@course).pluck(:title) + create_targets.select { |target| existing_titles.any? { |title| title.casecmp?(target) } } end def guard_not_empty!(table) @@ -106,7 +136,7 @@ def guard_not_empty!(table) def guard_unique_identifiers!(table) keys = table.filter_map do |row| - key = lookup_key(row[identifier_header].to_s.strip) + key = lookup_key(row[@identifier_column].to_s.strip) key unless key.empty? end duplicates = keys.tally.select { |_key, count| count > 1 }.keys @@ -121,80 +151,6 @@ def duplicate_headers(headers) map { |name, count| { name: name, count: count } } end - # Expected columns absent from the upload. A column we can pair to a likely - # typo'd upload is reported via suggestions ("did you mean"), not here. - def missing_headers(expected, headers, suggestions) - (expected - headers) - suggestions.map { |s| s[:expected] } - end - - # Uploaded columns that are not expected. The upload side of a typo pair is - # reported via suggestions, so it is excluded here. - def unrecognized_headers(expected, headers, suggestions) - (headers.compact.uniq - expected) - suggestions.map { |s| s[:didYouMean] } - end - - # For each expected header absent from the uploaded file, suggest the closest - # *unused* uploaded header within HEADER_SUGGESTION_MAX_DISTANCE edits (catches - # typos and singular/plural, e.g. required "Midterms" vs uploaded "Midterm"). - def header_suggestions(expected, got) - available = (got - expected).compact - (expected - got).filter_map do |want| - near = available.min_by { |have| levenshtein(want, have) } - next if near.nil? || levenshtein(want, near) > HEADER_SUGGESTION_MAX_DISTANCE - - available.delete(near) # never suggest the same uploaded column twice - { expected: want, didYouMean: near } - end - end - - def levenshtein(source, target) - return target.length if source.empty? - return source.length if target.empty? - - distances = (0..target.length).to_a - - source.each_char.with_index(1) do |source_char, source_index| - distances = next_levenshtein_row( - distances, - target, - source_char, - source_index - ) - end - - distances[target.length] - end - - def next_levenshtein_row(previous, target, source_char, source_index) - current = [source_index] - - target.each_char.with_index(1) do |target_char, target_index| - current << levenshtein_cell( - previous, - current, - source_char, - target_char, - target_index - ) - end - - current - end - - def levenshtein_cell(previous, current, source_char, target_char, target_index) - substitution_cost = (source_char == target_char) ? 0 : 1 - - [ - previous[target_index] + 1, - current[target_index - 1] + 1, - previous[target_index - 1] + substitution_cost - ].min - end - - def identifier_header - (@identifier_mode == 'email') ? 'Email' : 'External ID' - end - def roster_lookup @roster_lookup ||= if @identifier_mode == 'email' @@ -213,7 +169,7 @@ def resolve(table) unresolved = [] malformed = [] table.each_with_index do |row, idx| - identifier = row[identifier_header].to_s.strip + identifier = row[@identifier_column].to_s.strip course_user = roster_lookup[lookup_key(identifier)] if course_user.nil? unresolved << identifier @@ -235,19 +191,25 @@ def normalized_email(identifier, course_user) def parse_grades(row, row_idx) grades = {} malformed = [] - @components.each do |component| - raw = row[component[:name]] - if raw.nil? || raw.to_s.strip.empty? - grades[component[:name]] = nil + @mappings.each do |m| + raw = row[m[:header]] + key = m[:target] + if blankish?(raw) + grades[key] = nil elsif numeric?(raw) - grades[component[:name]] = Float(raw) + grades[key] = Float(raw) else - malformed << "row #{row_idx + 2}, #{component[:name]}: #{raw}" + malformed << "row #{row_idx + 2}, #{m[:header]}: #{raw}" end end [grades, malformed] end + def blankish?(raw) + s = raw.to_s.strip + raw.nil? || BLANKISH.include?(s) || s.match?(/\An\/?a\z/i) + end + def numeric?(value) Float(value) true @@ -257,25 +219,24 @@ def numeric?(value) # Advisory: grades outside [0, max]. Reported but never blocks the import. def out_of_range(resolved) - cells = [] - resolved.each do |row| - @components.each do |component| - grade = row[:grades][component[:name]] - next if grade.nil? - - max = component[:maximum_grade] - next unless grade < 0 || grade > max - - cells << { - identifier: row[:identifier], - component: component[:name], - grade: grade, - max: max, - kind: (grade < 0) ? 'below' : 'above' - } - end + resolved.each_with_object([]) do |row, cells| + @mappings.each { |m| out_of_range_cell(row, m)&.tap { |cell| cells << cell } } end - cells + end + + def mapping_max(mapping) + (mapping[:action] == 'existing') ? existing_external(mapping[:target]).maximum_grade : (mapping[:max_grade] || 100) + end + + def out_of_range_cell(row, mapping) + grade = row[:grades][mapping[:target]] + return if grade.nil? + + max = mapping_max(mapping) + return unless grade < 0 || grade > max + + { identifier: row[:identifier], component: mapping[:target], grade: grade, max: max, + kind: (grade < 0) ? 'below' : 'above' } end # A row is "reassigned" when its CSV identifier was previously imported as the @@ -338,32 +299,35 @@ def sample(resolved) end end - # Component column headers in the order they appear in the uploaded CSV, - # with the identifier header removed. guard_header! has already ensured the - # header set matches @components, so the remainder are exactly the component - # names in CSV order. - def column_order(table) - table.headers.compact - [identifier_header] + # Mapping targets, one per mapping, in the order the mappings were given. + # The frontend builds mappings in the order columns appear in the uploaded + # CSV, so this is effectively the CSV column order restricted to mapped + # (non-ignored) columns. We key by target (the gradebook component name), not + # the CSV header, so the preview labels match the gradebook and align with the + # grades/conflict hashes (both keyed by target) — a header-keyed order renders + # renamed columns blank. + def column_order(_table) + @mappings.map { |m| m[:target] } end def conflict_rows(resolved) - grades_by_component = @components.to_h do |component| - external = existing_external(component[:name]) + grades_by_target = @mappings.to_h do |m| + external = existing_external(m[:target]) grades = external ? external.external_assessment_grades.index_by(&:course_user_id) : {} - [component[:name], grades] + [m[:target], grades] end - resolved.filter_map { |row| build_conflict_row(row, grades_by_component) } + resolved.filter_map { |row| build_conflict_row(row, grades_by_target) } end - def build_conflict_row(row, grades_by_component) + def build_conflict_row(row, grades_by_target) cells = {} changed_any = false - @components.each do |component| - cell, changed = conflict_cell(component, row, grades_by_component) + @mappings.each do |m| + cell, changed = conflict_cell(m, row, grades_by_target) changed_any ||= changed - cells[component[:name]] = cell + cells[m[:target]] = cell end return nil unless changed_any @@ -371,10 +335,10 @@ def build_conflict_row(row, grades_by_component) { identifier: row[:identifier], studentName: row[:course_user].name, cells: cells } end - def conflict_cell(component, row, grades_by_component) - name = component[:name] - in_file = row[:grades][name] - existing_record = grades_by_component[name][row[:course_user].id] + def conflict_cell(mapping, row, grades_by_target) + target = mapping[:target] + in_file = row[:grades][target] + existing_record = grades_by_target[target][row[:course_user].id] existing = existing_record&.grade changed = grade_changed?(existing, in_file) [{ existing: existing&.to_f, inFile: in_file, changed: changed }, changed] @@ -393,23 +357,23 @@ def existing_external(name) @existing_externals[name] ||= Course::ExternalAssessment.for_course(@course).find_by(title: name) end - def create_component(component, resolved) + def create_component(mapping, resolved) external = User.with_stamper(@actor) do Course::ExternalAssessment.create_for_course!( - course: @course, title: component[:name], - maximum_grade: component[:maximum_grade], weight: component[:weightage] + course: @course, title: mapping[:target], + maximum_grade: mapping[:max_grade] || 100, weight: mapping[:weight] || 0 ) end rows = resolved.filter_map do |r| - build_grade(external, r) unless r[:grades][component[:name]].nil? + build_grade(external, r) unless r[:grades][mapping[:target]].nil? end bulk_insert(rows) rows.size end - def upsert_grades(external, component, resolved, on_conflict:) + def upsert_grades(external, mapping, resolved, on_conflict:) inserts, upserts = partition_grade_writes( - external, component[:name], resolved, on_conflict + external, mapping[:target], resolved, on_conflict ) bulk_insert(inserts) @@ -418,10 +382,10 @@ def upsert_grades(external, component, resolved, on_conflict:) inserts.size + upserts.size end - def partition_grade_writes(external, component_name, resolved, on_conflict) + def partition_grade_writes(external, target, resolved, on_conflict) context = { external: external, - component_name: component_name, + component_name: target, existing_by_cu: external.external_assessment_grades.index_by(&:course_user_id) } diff --git a/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx index cd8fd577fe..68fa42722c 100644 --- a/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/AddExternalColumnPrompt.test.tsx @@ -1,3 +1,4 @@ +import userEvent from '@testing-library/user-event'; import { fireEvent, render, screen, waitFor } from 'test-utils'; import AddExternalColumnPrompt from '../components/AddExternalColumnPrompt'; @@ -30,7 +31,6 @@ it('submits with both bound flags on by default', async () => { 50, true, true, - undefined, ), ); }); @@ -44,6 +44,9 @@ it('submits floorAtZero false when the floor toggle is switched off', async () = fireEvent.change(screen.getByLabelText('Max marks'), { target: { value: '50' }, }); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); fireEvent.click(screen.getByRole('checkbox', { name: 'Floor grades at 0' })); fireEvent.click(screen.getByRole('button', { name: 'Create' })); await waitFor(() => @@ -52,7 +55,6 @@ it('submits floorAtZero false when the floor toggle is switched off', async () = 50, false, true, - undefined, ), ); }); @@ -66,6 +68,9 @@ it('submits capAtMaximum false when the cap toggle is switched off', async () => fireEvent.change(screen.getByLabelText('Max marks'), { target: { value: '50' }, }); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); fireEvent.click(screen.getByRole('button', { name: 'Create' })); await waitFor(() => @@ -74,7 +79,6 @@ it('submits capAtMaximum false when the cap toggle is switched off', async () => 50, true, false, - undefined, ), ); }); @@ -144,52 +148,35 @@ it('keeps the dialog open when create fails', async () => { it('explains the floor and cap toggles, stressing the grade is unchanged', async () => { render(); await waitFor(() => screen.getByLabelText('Name')); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); expect( screen.getByLabelText( - /Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged./i, + /Counts negative grades as 0 when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged./i, ), ).toBeInTheDocument(); expect( screen.getByLabelText( - /Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged./i, + /Counts grades above the maximum as the maximum when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged./i, ), ).toBeInTheDocument(); }); -it('passes the typed weightage when weighted view is on', async () => { - render( - , - ); +it('never renders a weight field (weight lives in the Weights tab)', async () => { + render(); await waitFor(() => screen.getByLabelText('Name')); - fireEvent.change(screen.getByLabelText('Name'), { - target: { value: 'Midterm' }, - }); - fireEvent.change(screen.getByLabelText('Max marks'), { - target: { value: '50' }, - }); - fireEvent.change(screen.getByLabelText('Weightage'), { - target: { value: '20' }, - }); - fireEvent.click(screen.getByRole('button', { name: 'Create' })); - await waitFor(() => - expect(createExternalAssessment).toHaveBeenCalledWith( - 'Midterm', - 50, - true, - true, - 20, - ), - ); + expect(screen.queryByLabelText('Weight')).not.toBeInTheDocument(); }); -it('hides the weightage field when weighted view is off', async () => { - render( - , - ); +it('hides floor/cap behind Advanced settings, collapsed by default', async () => { + render(); await waitFor(() => screen.getByLabelText('Name')); - expect(screen.queryByLabelText('Weightage')).not.toBeInTheDocument(); + expect( + screen.queryByRole('checkbox', { name: /floor grades at 0/i }), + ).toBeNull(); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); + expect(screen.getByLabelText(/floor grades at 0/i)).toBeInTheDocument(); }); diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsContent.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsContent.test.tsx new file mode 100644 index 0000000000..3fbeabd8ea --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsContent.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from 'test-utils'; + +import ConfigureWeightsContent from '../components/ConfigureWeightsContent'; + +// 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 props = { + categories: [], + tabs: [], + assessments: [], + gamificationEnabled: false, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + weight: 0, + formula: '', + show: false, + clamp: true, + }, + capTotal: false, + students: [], + onSaved: jest.fn(), + registerSave: jest.fn(), +}; + +const state = { + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + courseMaxLevel: 0, + capTotal: false, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + clamp: true, + }, + }, +}; + +test('renders weight configuration body and registers a save handler', () => { + render(, { state }); + // Host can wire its footer to the body's save handler. + expect(props.registerSave).toHaveBeenCalled(); + // The total row from the body is present. + expect(screen.getByText(/^Total: \d/i)).toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx deleted file mode 100644 index c71019aac6..0000000000 --- a/client/app/bundles/course/gradebook/__tests__/ConfigureWeightsPrompt.test.tsx +++ /dev/null @@ -1,1156 +0,0 @@ -import userEvent from '@testing-library/user-event'; -import { fireEvent, render, screen, waitFor, within } from 'test-utils'; - -import toast from 'lib/hooks/toast'; - -import ConfigureWeightsPrompt from '../components/ConfigureWeightsPrompt'; -import * as operations from '../operations'; - -// Render synchronously without the real provider's locale-loading spinner -// (uses the manual mock at lib/components/wrappers/__mocks__/I18nProvider). -jest.mock('lib/components/wrappers/I18nProvider'); -jest.mock('lib/hooks/toast', () => ({ - __esModule: true, - default: { error: jest.fn() }, -})); - -jest - .spyOn(operations, 'updateGradebookWeights') - .mockReturnValue(async () => {}); - -const A1 = 'Assignment 1'; -const A2 = 'Assignment 2'; -const ASSIGN_A1 = 'Assignments: Assignment 1'; -const INCLUDE_A1 = 'Include Assignment 1 in grade'; -const 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, - clamp: true, -}; - -const enabledLevel = (over = {}): Record => ({ - enabled: true, - formula: LEVEL_FORMULA, - weight: 8, - show: true, - clamp: 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 }, -]; - -const levelZeroStudent = [ - { id: 1, name: 'A', email: 'a@x', externalId: null, level: 0, totalXp: 0 }, -]; - -const setup = (overrides = {}): ReturnType => - render( - , - ); - -// Weights summing to 140 → the cap toggle is meaningful (enabled). -const over100Tabs = [ - { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 70 }, - { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 70 }, -]; - -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(/weights sum to (?:more|less) than 100/i), - ).not.toBeInTheDocument(); - }); - - it('shows Total: 100% (not the raw float) when 2dp weights sum to 100', () => { - // 14.29×6 + 14.26 = 99.99999999999999 in IEEE-754; the total must round to 100. - const sevenTabs = [14.29, 14.29, 14.29, 14.29, 14.29, 14.29, 14.26].map( - (w, i) => ({ - id: 10 + i, - title: `T${i}`, - categoryId: 1, - gradebookWeight: w, - }), - ); - const sevenAssessments = sevenTabs.map((t, i) => ({ - id: 200 + i, - title: `Q${i}`, - tabId: t.id, - maxGrade: 100, - })); - setup({ tabs: sevenTabs, assessments: sevenAssessments }); - expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); - expect(screen.queryByText(/99\.999/)).not.toBeInTheDocument(); - expect( - screen.queryByText(/weights sum to (?:more|less) than 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(/weights sum to less than 100/i), - ).toBeInTheDocument(); - }); - - it('shows inline error for tab total > 100', () => { - setup(); - fireEvent.change(screen.getByLabelText('Assignments'), { - target: { value: '101' }, - }); - expect(screen.getByText(/must be at most 100/i)).toBeInTheDocument(); - }); - - it('shows inline error for negative tab total', () => { - setup(); - fireEvent.change(screen.getByLabelText('Optional'), { - target: { value: '-1' }, - }); - expect(screen.getByText(/must be at least 0/i)).toBeInTheDocument(); - }); - - it('accepts a 2dp tab total without error', () => { - setup(); - fireEvent.change(screen.getByLabelText('Assignments'), { - target: { value: '49.55' }, - }); - expect(screen.queryByText(/decimal places/i)).not.toBeInTheDocument(); - }); - - it('auto-rounds to 2 decimal places on blur', () => { - setup(); - const input = screen.getByLabelText('Assignments'); - fireEvent.change(input, { target: { value: '49.555' } }); - fireEvent.blur(input); - expect(input).toHaveValue(49.56); - expect(screen.queryByText(/decimal places/i)).not.toBeInTheDocument(); - }); - - it('defaults every tab to Equal mode', () => { - setup(); - expect( - within(modeGroup('Assignments')).getByRole('radio', { name: /equal/i }), - ).toHaveAttribute('aria-checked', 'true'); - }); - - it('equal mode preview shows tabTotal / n per assessment (ignores max grade)', () => { - setup(); - const expandBtns = screen.getAllByRole('button', { name: '' }); - fireEvent.click(expandBtns[0]); // expand Assignments (weight 50, n=2) - // 1/n => 25.00 each; proportional-to-maxGrade would be 33.33 / 16.67. - expect(screen.getAllByText('25.00% of grade')).toHaveLength(2); - }); - - it('equal mode preview rebalances % of grade across remaining included assessments', async () => { - setup(); - fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); // expand Assignments (50, n=2) - expect(screen.getAllByText('25.00% of grade')).toHaveLength(2); - // Exclude Assignment 2 -> the single remaining assessment now carries the full 50. - fireEvent.click( - await screen.findByRole('checkbox', { - name: 'Include Assignment 2 in grade', - }), - ); - expect(screen.getByText('50.00% of grade')).toBeInTheDocument(); - expect(screen.queryByText('25.00% of grade')).not.toBeInTheDocument(); - }); - - it('switching to Custom reveals per-assessment inputs seeded to sum the tab total', () => { - setup(); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - expect(screen.getByLabelText(ASSIGN_A1)).toHaveValue(25); - expect(screen.getByLabelText('Assignments: Assignment 2')).toHaveValue(25); - }); - - it('switching to Custom preserves existing non-zero assessment weights instead of reseeding', () => { - setup({ - assessments: [ - { id: 101, title: A1, tabId: 10, maxGrade: 100, gradebookWeight: 40 }, - { id: 102, title: A2, tabId: 10, maxGrade: 50, gradebookWeight: 10 }, - ], - }); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - // allZero === false -> seeds left untouched (NOT redistributed to 25/25). - expect(screen.getByLabelText(ASSIGN_A1)).toHaveValue(40); - expect(screen.getByLabelText('Assignments: Assignment 2')).toHaveValue(10); - }); - - it('shows an inline error Alert and disables Save when a custom tab is unbalanced', () => { - setup(); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - fireEvent.change(screen.getByLabelText(ASSIGN_A1), { - target: { value: '10' }, // 10 + 25 = 35 != 50 - }); - expect(screen.getByText(/must sum to its tab total/i)).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); - }); - - it('disables Save and flags the formula when Level contribution is enabled but the formula is cleared', () => { - setup({ - gamificationEnabled: true, - levelContribution: enabledLevel(), - students, - }); - fireEvent.change(screen.getByLabelText(/formula/i), { - target: { value: '' }, - }); - expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); - expect(screen.getByText('Enter a formula.')).toBeInTheDocument(); - }); - - it('shows the running assessment-weight sum against the tab total', () => { - setup(); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - fireEvent.change(screen.getByLabelText(ASSIGN_A1), { - target: { value: '10' }, // 10 + 25 = 35 of 50 - }); - expect( - screen.getByText('Assessment weights: 35.00 / 50.00'), - ).toBeInTheDocument(); - }); - - it('Save sends weightMode for equal tabs and assessmentWeights for custom tabs', async () => { - setup(); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - fireEvent.click(screen.getByRole('button', { name: /save/i })); - await waitFor(() => { - expect(operations.updateGradebookWeights).toHaveBeenCalledWith( - [ - { - tabId: 10, - weight: 50, - weightMode: 'custom', - keepHighest: 0, - excludedAssessmentIds: [], - assessmentWeights: [ - { assessmentId: 101, weight: 25 }, - { assessmentId: 102, weight: 25 }, - ], - }, - { - tabId: 11, - weight: 50, - weightMode: 'equal', - keepHighest: 0, - excludedAssessmentIds: [], - }, - ], - expect.objectContaining({ enabled: false }), - false, // capTotal (default off) - ); - }); - }); - - it('calls onClose after a successful save', async () => { - const onClose = jest.fn(); - setup({ onClose }); - fireEvent.click(screen.getByRole('button', { name: /save/i })); - await waitFor(() => expect(onClose).toHaveBeenCalled()); - }); - - it('shows an error toast and keeps the dialog open when save fails', async () => { - (operations.updateGradebookWeights as jest.Mock).mockReturnValueOnce( - async () => { - throw new Error('boom'); - }, - ); - const onClose = jest.fn(); - setup({ onClose }); - fireEvent.click(screen.getByRole('button', { name: /save/i })); - await waitFor(() => - expect(toast.error).toHaveBeenCalledWith( - 'Failed to save weights. Please try again.', - ), - ); - expect(onClose).not.toHaveBeenCalled(); - }); - - it('seeds odd splits so they still sum exactly to the tab total', () => { - setup({ - tabs: [ - { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 }, - ], - assessments: [ - { id: 101, title: 'A1', tabId: 10, maxGrade: 100 }, - { id: 102, title: 'A2', tabId: 10, maxGrade: 100 }, - { id: 103, title: 'A3', tabId: 10, maxGrade: 100 }, - ], - }); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - expect(screen.getByLabelText('Assignments: A1')).toHaveValue(16.67); - expect(screen.getByLabelText('Assignments: A2')).toHaveValue(16.67); - expect(screen.getByLabelText('Assignments: A3')).toHaveValue(16.66); - expect( - screen.queryByText(/must sum to its tab total/i), - ).not.toBeInTheDocument(); - }); - - it('Cancel does not dispatch', () => { - setup(); - fireEvent.click(screen.getByRole('button', { name: /cancel/i })); - expect(operations.updateGradebookWeights).not.toHaveBeenCalled(); - }); - - it('disables the mode toggle + expand for tabs with no assessments', () => { - setup(); - const expandBtns = screen.getAllByRole('button', { name: '' }); - expect(expandBtns[1]).toBeDisabled(); - expect( - within(modeGroup('Optional')).getByRole('radio', { name: /custom/i }), - ).toBeDisabled(); - }); - - it('defaults the clamp toggle to checked', () => { - setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); - expect( - screen.getByRole('checkbox', { name: /Keep results between/i }), - ).toBeChecked(); - }); - - it('sends clamp in the save payload', async () => { - setup({ gamificationEnabled: true, levelContribution: enabledLevel() }); - fireEvent.click( - screen.getByRole('checkbox', { name: /Keep results between/i }), - ); - fireEvent.click(screen.getByRole('button', { name: /save/i })); - await waitFor(() => - expect(operations.updateGradebookWeights).toHaveBeenCalled(), - ); - const arg = (operations.updateGradebookWeights as jest.Mock).mock - .calls[0][1]; - expect(arg.clamp).toBe(false); - }); - - it('suppresses the range alert when clamp is off', () => { - setup({ - gamificationEnabled: true, - students, // levels 5, 12 - levelContribution: enabledLevel({ - formula: 'level * 5', - weight: 10, - clamp: false, - }), - // raw 25 and 60 are above 10, but clamp is off -> no alert - }); - expect(screen.queryByText(/above 10/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, - keepHighest: 0, - excludedAssessmentIds: [101, 102], - }); - }); - - it('seeds checkboxes from gradebookExcluded and restores weight on re-include', async () => { - setup({ - assessments: [ - { - id: 101, - title: A1, - tabId: 10, - maxGrade: 100, - gradebookWeight: 50, - gradebookExcluded: true, - }, - { - id: 102, - title: A2, - tabId: 10, - maxGrade: 50, - gradebookWeight: 0, - }, - ], - tabs: [ - { - id: 10, - title: 'Assignments', - categoryId: 1, - gradebookWeight: 50, - weightMode: 'custom', - }, - ], - }); - fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); - const cb = await screen.findByRole('checkbox', { - name: INCLUDE_A1, - }); - expect(cb).not.toBeChecked(); - // re-include -> its retained weight (50) is still in the input - fireEvent.click(cb); - expect(screen.getByLabelText(ASSIGN_A1)).toHaveValue(50); - }); - - describe('default weights when unconfigured', () => { - const zeroTabs = [ - { id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 0 }, - { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 0 }, - ]; - const bothPopulated = [ - { id: 101, title: 'Graded item', tabId: 10, maxGrade: 100 }, - { id: 201, title: 'Bonus item', tabId: 11, maxGrade: 100 }, - ]; - - it('pre-fills an equal split summing to 100 and shows the defaults hint', () => { - setup({ tabs: zeroTabs, assessments: bothPopulated }); - expect(screen.getByText(/no weights set yet/i)).toBeInTheDocument(); - expect(screen.getByLabelText('Assignments')).toHaveValue(50); - expect(screen.getByLabelText('Optional')).toHaveValue(50); - expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); - }); - - it('gives empty tabs 0% and the full default to the populated tab', () => { - // Only tab 10 has an assessment (shared fixture), so it absorbs all 100. - setup({ tabs: zeroTabs }); - expect(screen.getByLabelText('Assignments')).toHaveValue(100); - expect(screen.getByLabelText('Optional')).toHaveValue(0); - }); - - it('does not show the defaults hint once a weight is configured', () => { - setup(); // shared fixture tabs carry 50/50 - expect(screen.queryByText(/no weights set yet/i)).not.toBeInTheDocument(); - }); - }); - - it('shows assessment weights sum footer in custom mode', () => { - setup(); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - // Expand to see the footer - // custom mode auto-expands, so just check the footer - expect(screen.getByText(/Assessment weights:/i)).toBeInTheDocument(); - }); -}); - -describe('per-assessment exclusion (extended)', () => { - beforeEach(() => jest.clearAllMocks()); - - it('shows "Excluded" label in custom mode for excluded assessment', async () => { - setup({ - assessments: [ - { - id: 101, - title: A1, - tabId: 10, - maxGrade: 100, - gradebookExcluded: true, - }, - { id: 102, title: A2, tabId: 10, maxGrade: 50 }, - ], - tabs: [ - { - id: 10, - title: 'Assignments', - categoryId: 1, - gradebookWeight: 50, - weightMode: 'custom', - }, - ], - }); - fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); - expect(await screen.findByText('Excluded')).toBeInTheDocument(); - }); - - it('does not show "Excluded" label in equal mode for excluded assessment', async () => { - setup({ - assessments: [ - { - id: 101, - title: A1, - tabId: 10, - maxGrade: 100, - gradebookExcluded: true, - }, - { id: 102, title: A2, tabId: 10, maxGrade: 50 }, - ], - }); - fireEvent.click(screen.getAllByRole('button', { name: '' })[0]); - // In equal mode excluded shows "Excluded" text too - expect(await screen.findByText('Excluded')).toBeInTheDocument(); - }); -}); - -describe('keep-highest control', () => { - const TOGGLE = 'Enable keep highest for Assignments'; - const INPUT = 'Keep highest for Assignments'; - const three = [ - { id: 101, title: A1, tabId: 10, maxGrade: 100 }, - { id: 102, title: A2, tabId: 10, maxGrade: 50 }, - { id: 103, title: 'Assignment 3', tabId: 10, maxGrade: 80 }, - ]; - - beforeEach(() => jest.clearAllMocks()); - - it('renders a keep-highest checkbox; number field hidden until checked', () => { - setup({ assessments: three }); - expect(screen.getByRole('checkbox', { name: TOGGLE })).toBeInTheDocument(); - expect( - screen.queryByRole('spinbutton', { name: INPUT }), - ).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE })); - expect(screen.getByRole('spinbutton', { name: INPUT })).toBeInTheDocument(); - }); - - it('shows a visible "Keep highest" text label next to the checkbox', () => { - setup({ assessments: three }); - expect(screen.getByText('Keep highest')).toBeInTheDocument(); - }); - - it('defaults the count to included − 1 when checked', () => { - setup({ assessments: three }); // 3 assessments -> default to 2 - fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE })); - expect(screen.getByRole('spinbutton', { name: INPUT })).toHaveValue(2); - }); - - it('hides checkbox + field in custom mode', () => { - setup({ assessments: three }); - fireEvent.click( - within(modeGroup('Assignments')).getByRole('radio', { name: /custom/i }), - ); - expect( - screen.queryByRole('checkbox', { name: TOGGLE }), - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('spinbutton', { name: INPUT }), - ).not.toBeInTheDocument(); - }); - - it('seeds the field (checkbox pre-checked) from tab.keepHighest', () => { - setup({ - assessments: three, - tabs: [ - { - id: 10, - title: 'Assignments', - categoryId: 1, - gradebookWeight: 50, - keepHighest: 2, - }, - { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 }, - ], - }); - expect(screen.getByRole('checkbox', { name: TOGGLE })).toBeChecked(); - expect(screen.getByRole('spinbutton', { name: INPUT })).toHaveValue(2); - }); - - it('disables the checkbox when only one assessment is included', () => { - setup({ - assessments: [{ id: 101, title: A1, tabId: 10, maxGrade: 100 }], - }); - expect(screen.getByRole('checkbox', { name: TOGGLE })).toBeDisabled(); - }); - - it('sends keepHighest in the save payload', async () => { - setup({ assessments: three }); - fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE })); - fireEvent.click(screen.getByRole('button', { name: /save/i })); - 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.keepHighest).toBe(2); - }); - - it('unchecking sends keepHighest 0', async () => { - setup({ - assessments: three, - tabs: [ - { - id: 10, - title: 'Assignments', - categoryId: 1, - gradebookWeight: 50, - keepHighest: 2, - }, - { id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 }, - ], - }); - // checkbox is pre-checked; uncheck it - fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE })); - fireEvent.click(screen.getByRole('button', { name: /save/i })); - 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.keepHighest).toBe(0); - }); - - it('blocks saving on non-integer input but not on keep > included (overflow)', async () => { - setup({ assessments: three }); - fireEvent.click(screen.getByRole('checkbox', { name: TOGGLE })); - const input = screen.getByRole('spinbutton', { name: INPUT }); - - // overflow: keep=5 > included=3 -> warning but save NOT blocked - fireEvent.change(input, { target: { value: '5' } }); - expect(screen.getByRole('button', { name: /save/i })).not.toBeDisabled(); - expect( - screen.getByText(/keeps more assessments than it contains/i), - ).toBeInTheDocument(); - - // non-integer (0): should block saving - fireEvent.change(input, { target: { value: '0' } }); - expect(screen.getByRole('button', { name: /save/i })).toBeDisabled(); - expect(screen.getByText(/keep at least 1/i)).toBeInTheDocument(); - }); -}); - -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); - // Exact name: the clamp label ("...max level contributions") also matches - // /level contribution/i, so an exact string isolates the enable toggle. - expect( - screen.getByRole('checkbox', { name: 'Level contribution' }), - ).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(); - // The removed control was a numeric field; scope to spinbutton so the clamp - // checkbox ("...max level contributions") isn't mistaken for it. - expect( - screen.queryByRole('spinbutton', { name: /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('always shows the formula syntax reference, even when the formula is valid', () => { - setup({ - gamificationEnabled: true, - students, // valid default formula min(level, 10) for these students - levelContribution: enabledLevel(), - }); - expect( - screen.getByText(/floor, ceil, round, min and max/i), - ).toBeInTheDocument(); - expect(screen.getByText(/arithmetic 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(/These contributions will be set to 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(/These contributions will be set to 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( - /Contributions below 0 will be set to 0, and contributions above 10 will be set to 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(); - }); - - it('excludes the level weight from the Total when gamification is disabled', () => { - // A persisted enabled level (weight 8) must not count once gamification is - // off — the section is hidden, so it is treated as disabled. Total stays 100. - setup({ gamificationEnabled: false, levelContribution: enabledLevel() }); - expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument(); - }); - - it('warns and names students when the formula divides by zero for them', async () => { - setup({ - gamificationEnabled: true, - students: levelZeroStudent, - levelContribution: enabledLevel({ formula: '100 / level' }), - }); - // ...render with the level contribution enabled and a student at level 0... - // ...set the formula field to '100 / level'... - expect(await screen.findByText(/divides by zero/i)).toBeInTheDocument(); - expect(screen.getByText(/set to 0/i)).toBeInTheDocument(); - }); -}); - -describe(' cap-at-100 toggle', () => { - beforeEach(() => jest.clearAllMocks()); - - const capToggle = (): HTMLElement => - screen.getByRole('checkbox', { name: /cap at 100/i }); - - it('is enabled when the weight sum exceeds 100', () => { - setup({ tabs: over100Tabs }); - expect(capToggle()).toBeEnabled(); - }); - - it('stays enabled and checked when the sum is <= 100', () => { - // The cap is a course policy, not a reaction to the weight sum: extra credit - // can push a total past 100 even when weights sum to exactly 100, so the - // toggle must remain usable regardless of the sum. - setup({ capTotal: true }); // default tabs sum to exactly 100 - expect(capToggle()).toBeEnabled(); - expect(capToggle()).toBeChecked(); - }); - - it('hides the weights-do-not-sum alert when the cap is on and sum > 100', () => { - setup({ tabs: over100Tabs, capTotal: true }); - expect( - screen.queryByText(/weights sum to (?:more|less) than 100/i), - ).not.toBeInTheDocument(); - }); - - it('still warns when the sum exceeds 100 but the cap is off', () => { - setup({ tabs: over100Tabs, capTotal: false }); - expect( - screen.getByText(/weights sum to more than 100/i), - ).toBeInTheDocument(); - }); - - it('sends the toggled capTotal as the third save argument', async () => { - setup({ tabs: over100Tabs }); - fireEvent.click(capToggle()); // turn the cap on - fireEvent.click(screen.getByRole('button', { name: /save/i })); - await waitFor(() => - expect(operations.updateGradebookWeights).toHaveBeenCalled(), - ); - const call = (operations.updateGradebookWeights as jest.Mock).mock.calls[0]; - expect(call[2]).toBe(true); - }); - - it('explains the cap in a hover tooltip on the ⓘ icon', async () => { - setup({ tabs: over100Tabs }); - await userEvent.hover(screen.getByLabelText(/about capping the total/i)); - expect( - await screen.findByText(/shown and exported as 100%/i), - ).toBeInTheDocument(); - }); -}); diff --git a/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx index 1b76a774c0..b51265a3b3 100644 --- a/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/EditExternalAssessmentPrompt.test.tsx @@ -36,6 +36,9 @@ it('saves the edited name and a toggled cap flag', async () => { const name = await screen.findByLabelText('Name'); await userEvent.clear(name); await userEvent.type(name, 'Quiz 1'); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); fireEvent.click(screen.getByRole('checkbox', { name: 'Cap grades at max' })); fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => @@ -79,6 +82,9 @@ it('saves a toggled floor flag', async () => { />, ); await screen.findByLabelText('Name'); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); fireEvent.click(screen.getByRole('checkbox', { name: 'Floor grades at 0' })); fireEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => @@ -128,6 +134,9 @@ it('defaults floor and cap switches to checked when the assessment omits them', />, ); await screen.findByLabelText('Name'); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); expect( screen.getByRole('checkbox', { name: 'Floor grades at 0' }), ).toBeChecked(); @@ -145,77 +154,31 @@ it('explains the floor and cap toggles, explaining the grade is unchanged', asyn />, ); await screen.findByLabelText('Name'); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); expect( screen.getByLabelText( - /Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged./i, + /Counts negative grades as 0 when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged./i, ), ).toBeInTheDocument(); expect( screen.getByLabelText( - /Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged./i, + /Counts grades above the maximum as the maximum when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged./i, ), ).toBeInTheDocument(); }); -it('shows a weightage field seeded from the current weight and includes it when saving (weighted view on)', async () => { - render( - , - ); - const weight = await screen.findByLabelText('Weightage'); - expect(weight).toHaveValue(30); - fireEvent.change(weight, { target: { value: '45' } }); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - await waitFor(() => - expect(editExternalAssessment).toHaveBeenCalledWith(-3, { - title: 'Quiz', - maximumGrade: 20, - floorAtZero: true, - capAtMaximum: true, - weight: 45, - }), - ); -}); - -it('defaults weightage to 0 when no currentWeight is provided (weighted view on)', async () => { - render( - , - ); - const weight = await screen.findByLabelText('Weightage'); - expect(weight).toHaveValue(0); - fireEvent.click(screen.getByRole('button', { name: 'Save' })); - await waitFor(() => - expect(editExternalAssessment).toHaveBeenCalledWith(-3, { - title: 'Quiz', - maximumGrade: 20, - floorAtZero: true, - capAtMaximum: true, - weight: 0, - }), - ); -}); - -it('omits the weightage field when weighted view is off', async () => { +it('never renders a weight field (weight lives in the Weights tab)', async () => { render( , ); await screen.findByLabelText('Name'); - expect(screen.queryByLabelText('Weightage')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Weight')).not.toBeInTheDocument(); }); it('disables Save when the name is blank', async () => { @@ -305,3 +268,31 @@ it('shows an error toast and keeps the dialog open when saving fails', async () await waitFor(() => expect(toast.error).toHaveBeenCalled()); expect(onClose).not.toHaveBeenCalled(); }); + +it('hides floor/cap behind Advanced settings, collapsed by default', async () => { + render( + , + ); + await screen.findByLabelText('Name'); + expect( + screen.queryByRole('checkbox', { name: /floor grades at 0/i }), + ).toBeNull(); + await userEvent.click( + screen.getByRole('button', { name: /advanced settings/i }), + ); + // The Advanced settings accordion expands via a CSS height transition, so + // the switch isn't in the accessibility tree on the same tick the click + // handler returns. Query by role (as above) rather than toBeVisible() — + // MUI's Switch always sets opacity: 0 on its native input by design (the + // visible thumb/track are separate sibling elements), so toBeVisible() + // never passes for it regardless of the accordion's state. + await waitFor(() => + expect( + screen.getByRole('checkbox', { name: /floor grades at 0/i }), + ).toBeInTheDocument(), + ); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index 275e7a7951..456b5e0a60 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -284,9 +284,7 @@ describe('GradebookIndex', () => { 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 }), - ); + fireEvent.click(await screen.findByRole('button', { name: /columns/i })); expect( await screen.findByText( 'No grade columns selected - export will include student info only.', @@ -296,9 +294,7 @@ describe('GradebookIndex', () => { it('shows grade-and-gamification hint in column picker after enabling a gamification column with no grade columns selected', async () => { render(, { state: populatedStateWithGamification }); - fireEvent.click( - await screen.findByRole('button', { name: /select columns/i }), - ); + fireEvent.click(await screen.findByRole('button', { name: /columns/i })); fireEvent.click( await screen.findByRole('checkbox', { name: /gamification/i }), ); @@ -364,21 +360,23 @@ describe('GradebookIndex', () => { const byWeightButton = await screen.findByText(/weighted total/i); fireEvent.click(byWeightButton); await screen.findByTestId(WEIGHTED_TABLE_TESTID); - fireEvent.click( - await screen.findByRole('button', { name: /select columns/i }), - ); + fireEvent.click(await screen.findByRole('button', { name: /columns/i })); const dialog = await screen.findByRole('dialog'); expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); }); - it('shows the manage button and not the old import/add buttons', async () => { + it('shows the settings gear and import button, and drops the old manage button', async () => { render(, { state: populatedStateManagerWeightedOff }); expect( - await screen.findByRole('button', { - name: 'Manage external assessments', - }), + await screen.findByRole('button', { name: /gradebook settings/i }), ).toBeVisible(); + expect( + screen.getByRole('button', { name: /import csv/i }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Manage external assessments' }), + ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'Import external assessments' }), ).not.toBeInTheDocument(); @@ -387,21 +385,24 @@ describe('GradebookIndex', () => { ).not.toBeInTheDocument(); }); - it('shows the manage button in the weighted-total view for managers', async () => { + it('shows the settings gear in the weighted-total view for managers', async () => { render(, { state: populatedStateManagerWeightedOn }); const byWeightButton = await screen.findByText(/weighted total/i); fireEvent.click(byWeightButton); await screen.findByTestId(WEIGHTED_TABLE_TESTID); expect( - screen.getByRole('button', { name: 'Manage external assessments' }), + screen.getByRole('button', { name: /gradebook settings/i }), ).toBeVisible(); }); - it('does not show the manage button to staff who cannot manage weights', async () => { + it('does not show the settings gear to staff who cannot manage weights', async () => { render(, { state: populatedState }); await screen.findByRole('button', { name: /export/i }); // wait for load expect( - screen.queryByRole('button', { name: 'Manage external assessments' }), + screen.queryByRole('button', { name: /gradebook settings/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /import csv/i }), ).not.toBeInTheDocument(); }); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookSettingsButton.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookSettingsButton.test.tsx new file mode 100644 index 0000000000..e1f19df857 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookSettingsButton.test.tsx @@ -0,0 +1,65 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen } from 'test-utils'; + +import GradebookSettingsButton from '../components/manage/GradebookSettingsButton'; + +jest.mock('../operations', () => ({ + __esModule: true, + ...jest.requireActual('../operations'), + reorderExternalAssessments: jest.fn( + () => (): Promise => Promise.resolve(), + ), +})); + +const props = { + weightedViewEnabled: false, + categories: [], + tabs: [], + assessments: [], + gamificationEnabled: false, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + weight: 0, + formula: '', + show: false, + clamp: true, + }, + capTotal: false, + students: [], +}; + +const state = { + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + courseMaxLevel: 0, + capTotal: false, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + clamp: true, + }, + }, +}; + +it('opens the settings dialog on click', async () => { + render(, { state }); + await userEvent.click( + await screen.findByRole('button', { name: /gradebook settings/i }), + ); + expect(await screen.findByRole('dialog')).toBeVisible(); +}); + +it('does not open the settings dialog by default', () => { + render(, { state }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookSettingsDialog.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookSettingsDialog.test.tsx new file mode 100644 index 0000000000..98950e3564 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/GradebookSettingsDialog.test.tsx @@ -0,0 +1,84 @@ +import { AppState } from 'store'; +import { render, screen } from 'test-utils'; + +import GradebookSettingsDialog from '../components/manage/GradebookSettingsDialog'; + +// Render synchronously without the real provider's locale-loading spinner +// (uses the manual mock at lib/components/wrappers/__mocks__/I18nProvider). +jest.mock('lib/components/wrappers/I18nProvider'); + +jest.mock('../operations', () => ({ + __esModule: true, + ...jest.requireActual('../operations'), + reorderExternalAssessments: jest.fn( + () => (): Promise => Promise.resolve(), + ), +})); + +const weightsProps = { + categories: [], + tabs: [], + assessments: [], + gamificationEnabled: false, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + weight: 0, + formula: '', + show: false, + clamp: true, + }, + capTotal: false, + students: [], +}; + +const stateWith = (weightedViewEnabled: boolean): Partial => ({ + gradebook: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled, + canManageWeights: true, + courseMaxLevel: 0, + capTotal: false, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + clamp: true, + }, + }, +}); + +test('weighted: shows Weights and External assessments tabs, Weights first', () => { + render( + , + { state: stateWith(true) }, + ); + expect(screen.getByRole('tab', { name: /weights/i })).toBeInTheDocument(); + expect( + screen.getByRole('tab', { name: /external assessments/i }), + ).toBeInTheDocument(); +}); + +test('non-weighted: no tab strip, renders external assessments directly', () => { + render( + , + { state: stateWith(false) }, + ); + expect(screen.queryByRole('tab')).toBeNull(); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx index f08d238e0c..f431e55988 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -172,7 +172,7 @@ describe('GradebookTable', () => { renderTableWithAssessmentVisible(); await screen.findByText('Alice'); expect( - screen.getByRole('button', { name: /select columns/i }), + screen.getByRole('button', { name: /columns/i }), ).toBeInTheDocument(); expect(screen.getByRole('button', { name: /export/i })).toBeInTheDocument(); }); @@ -315,7 +315,7 @@ describe('GradebookTable', () => { const user = userEvent.setup(); renderTable({ gamificationEnabled: true }); const selectColumnsBtn = await screen.findByRole('button', { - name: /select columns/i, + name: /columns/i, }); await user.click(selectColumnsBtn); const dialog = await screen.findByRole('dialog'); @@ -466,7 +466,7 @@ describe('GradebookTable', () => { ); renderTable({ gamificationEnabled: true }); await screen.findByText('Alice'); - await user.click(screen.getByRole('button', { name: /select columns/i })); + await user.click(screen.getByRole('button', { name: /columns/i })); expect( await screen.findByText(/no grade or gamification columns selected/i), ).toBeInTheDocument(); @@ -477,7 +477,7 @@ describe('GradebookTable', () => { localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); renderTable({ gamificationEnabled: false }); await screen.findByText('Alice'); - await user.click(screen.getByRole('button', { name: /select columns/i })); + await user.click(screen.getByRole('button', { name: /columns/i })); expect( await screen.findByText(/no grade columns selected/i), ).toBeInTheDocument(); @@ -559,7 +559,7 @@ describe('GradebookTable', () => { const user = userEvent.setup(); renderWith(students); const btn = await screen.findByRole('button', { - name: /select columns/i, + name: /columns/i, }); await user.click(btn); const dialog = await screen.findByRole('dialog'); @@ -663,7 +663,7 @@ describe('GradebookTable', () => { 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 })); + await user.click(screen.getByRole('button', { name: /columns/i })); const dialog = await screen.findByRole('dialog'); await user.click( within(dialog).getByRole('checkbox', { name: /quiz 1/i }), @@ -720,7 +720,7 @@ describe('GradebookTable', () => { 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 })); + await user.click(screen.getByRole('button', { name: /columns/i })); const dialog = await screen.findByRole('dialog'); await user.click( within(dialog).getByRole('checkbox', { name: /quiz 1/i }), @@ -1361,6 +1361,15 @@ describe('GradebookTable', () => { screen.queryByLabelText(/weighted total/i), ).not.toBeInTheDocument(); }); + + it('shows an edit affordance (pencil) on an external grade cell', async () => { + renderForExternal({ grade: 30 }); + const value = await screen.findByText('30'); + const cell = value.closest('[role="button"]'); + expect( + cell?.querySelector('[data-testid="edit-affordance"]'), + ).not.toBeNull(); + }); }); describe('cross-page selection', () => { diff --git a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx index 1f1a7d1516..42803bfc3b 100644 --- a/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx @@ -1,6 +1,6 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, within } from 'test-utils'; -import type { StudentData } from 'types/course/gradebook'; +import type { AssessmentData, StudentData } from 'types/course/gradebook'; import TestApp from 'utilities/TestApp'; import CourseAPI from 'api/course'; @@ -15,7 +15,6 @@ jest.mock('lib/hooks/toast'); const EXTERNAL_ID = 'External ID'; const MIDTERM = 'Midterm'; -const MIDTERMS = 'Midterms'; const A001 = 'A001'; const defaultProps = { @@ -24,9 +23,6 @@ const defaultProps = { weightedViewEnabled: true, }; -const componentNameInput = (): HTMLElement => - screen.getByRole('textbox', { name: 'Component name' }); - const file = (text: string): File => new File([text], 'marks.csv', { type: 'text/csv' }); @@ -54,43 +50,101 @@ const studentsState = (students: StudentData[]): object => ({ }, }); +// Like studentsState, but also seeds existing external assessments so +// useImportMapping's own header-matching (against getExternalAssessments, +// not the wizard's `existingAssessments` prop) can auto-map a column. +const assessmentsState = ( + assessments: AssessmentData[], + students: StudentData[] = [], +): object => ({ + gradebook: { + categories: [], + tabs: [], + submissions: [], + assessments, + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + students, + }, +}); + +const okPreview = (over: Partial> = {}): unknown => ({ + data: { + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [{ identifier: A001, grades: { Midterm: 41 } }], + conflictRows: [], + reassignments: [], + columnOrder: [MIDTERM], + totalRows: 1, + ...over, + }, +}); + +const okCommit = (over: Partial> = {}): unknown => ({ + data: { + createdComponents: 1, + updatedComponents: 0, + gradesWritten: 1, + ...over, + }, +}); + +const okIndex = (): unknown => ({ + data: { + categories: [], + tabs: [], + assessments: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: true, + canManageWeights: true, + }, +}); + // Render against an isolated store (not the shared singleton, which a commit // test can leave without a students slice and crash getStudents()). const renderWizard = ( - props: Partial<{ weightedViewEnabled: boolean }> = {}, + props: Partial<{ + weightedViewEnabled: boolean; + existingAssessments: typeof defaultProps.existingAssessments; + }> = {}, + students: StudentData[] = [student({ externalId: A001 })], ): void => { render( , - { state: studentsState([]) }, + { state: studentsState(students) }, ); }; -const advanceToVerifyStep = async (): Promise => { - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`), - ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - await screen.findByRole('button', { name: /confirm import/i }); -}; +const nextButton = (): HTMLElement => + screen.getByRole('button', { name: /^next$/i }); -const advanceToVerifyFailure = async (csv: string): Promise => { - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); +const dropCsv = async (csv: string): Promise => { await userEvent.upload(screen.getByLabelText(/upload/i), file(csv)); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); + await waitFor(() => expect(nextButton()).toBeEnabled()); +}; + +// upload -> map +const advanceToMap = async (csv: string): Promise => { + await dropCsv(csv); + await userEvent.click(nextButton()); }; -const fillOneComponent = async (): Promise => { - await userEvent.type(componentNameInput(), MIDTERM); +// upload -> map -> preview +const advanceToPreview = async (csv: string): Promise => { + await advanceToMap(csv); + await waitFor(() => expect(nextButton()).toBeEnabled()); + await userEvent.click(nextButton()); }; describe('dialog dismissal guards', () => { @@ -146,57 +200,38 @@ describe('dialog dismissal guards', () => { describe('ImportExternalAssessmentsWizard', () => { beforeEach(() => jest.clearAllMocks()); - it('walks define → upload → verify → commit with no conflicts', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - sample: [{ identifier: A001, grades: { Midterm: 41 } }], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ - data: { createdComponents: 1, updatedComponents: 0, gradesWritten: 1 }, - }); - (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ - data: { - categories: [], - tabs: [], - assessments: [], - students: [], - submissions: [], - gamificationEnabled: false, - weightedViewEnabled: true, - canManageWeights: true, - }, - }); - + it('drops a CSV, advances to Map, and shows the flat mapping table', async () => { renderWizard(); - // Step 1: type a component - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); + await advanceToMap(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); - // Step 2: upload - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`), + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByText(MIDTERM)).toBeInTheDocument(); + // The identifier is a static label now, not a Select — it always reads + // headers[0] and shows the active match mode alongside it. + expect( + screen.getByText(`${EXTERNAL_ID} (${EXTERNAL_ID})`), + ).toBeInTheDocument(); + }); + + it('walks upload -> map -> preview -> commit with no conflicts', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview(), + ); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue( + okCommit({ createdComponents: 1 }), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue(okIndex()); + + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); - // Step 3: preview shows the sample expect(await screen.findByText(A001)).toBeVisible(); expect( screen.getByRole('columnheader', { name: EXTERNAL_ID }), ).toBeInTheDocument(); + await userEvent.click( - screen.getByRole('button', { name: /continue|confirm/i }), + screen.getByRole('button', { name: /confirm import/i }), ); await waitFor(() => @@ -206,166 +241,174 @@ describe('ImportExternalAssessmentsWizard', () => { .calls[0][0]; expect(payload.onConflict).toBe('replace'); expect(payload.identifierMode).toBe('external_id'); - expect(payload.components[0]).toMatchObject({ - name: MIDTERM, - weightage: 30, - maximumGrade: 50, + expect(payload.identifierColumn).toBe(EXTERNAL_ID); + expect(payload.mappings).toContainEqual({ + header: MIDTERM, + action: 'create', + target: MIDTERM, + maxGrade: 100, + weight: 0, }); - // success side-effects await waitFor(() => - expect(toast.success).toHaveBeenCalledWith('Import complete.'), + expect(toast.success).toHaveBeenCalledWith( + 'Imported grades. Created 1 external assessment.', + { autoClose: false }, + ), ); }); - it('uses singular copy for a single unresolved external ID', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: false, - unresolved: ['ZZZ'], - malformed: [], - outOfRange: [], - sample: [], - conflictRows: [], - reassignments: [], - }, - }); - renderWizard(); - await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\n`); - expect( - await screen.findByText( - /This external ID was not found in the course: ZZZ/, - ), - ).toBeInTheDocument(); - }); + it('does not toast success when createdComponents is 0', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview(), + ); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue( + okCommit({ createdComponents: 0, updatedComponents: 1 }), + ); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue(okIndex()); - it('uses plural copy for multiple unresolved external IDs', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: false, - unresolved: ['ZZZ', 'YYY'], - malformed: [], - outOfRange: [], - sample: [], - conflictRows: [], - reassignments: [], - }, - }); renderWizard(); - await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\nYYY,2\n`); - expect( - await screen.findByText( - /These external IDs were not found in the course: ZZZ, YYY/, - ), - ).toBeInTheDocument(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + await screen.findByText(A001); + await userEvent.click( + screen.getByRole('button', { name: /confirm import/i }), + ); + + await waitFor(() => + expect(CourseAPI.gradebook.importCommit).toHaveBeenCalled(), + ); + expect(toast.success).not.toHaveBeenCalled(); }); - it('lists up to five malformed cells then summarises the rest', async () => { - const malformed = [ - 'row 2, Midterm: a', - 'row 3, Midterm: b', - 'row 4, Midterm: c', - 'row 5, Midterm: d', - 'row 6, Midterm: e', - 'row 7, Midterm: f', - 'row 8, Midterm: g', - ]; - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: false, - unresolved: [], - malformed, - outOfRange: [], - sample: [], - conflictRows: [], - reassignments: [], - }, - }); - renderWizard(); - await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\n${A001},a\n`); - expect(await screen.findByText('row 2, Midterm: a')).toBeInTheDocument(); - expect(screen.getByText('row 6, Midterm: e')).toBeInTheDocument(); - expect(screen.queryByText('row 7, Midterm: f')).not.toBeInTheDocument(); - expect(screen.getByText('and 2 more')).toBeInTheDocument(); + it('disables Next/Preview on the Map step while a column has a non-numeric grade', async () => { + renderWizard({}, [ + student({ id: 1, externalId: A001 }), + student({ id: 2, externalId: 'A002' }), + ]); + // A numeric first row auto-maps the column to "create"; the second row's + // non-numeric cell then trips the mapping's nonNumeric error. + await advanceToMap(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\nA002,abc\n`); + + expect(nextButton()).toBeDisabled(); + expect(screen.getByText(/isn.t a number/i)).toBeInTheDocument(); }); - it('shows unresolved identifiers and stays on the upload step', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: false, - unresolved: ['ZZZ'], - malformed: [], - outOfRange: [], - sample: [], - conflictRows: [], - reassignments: [], - }, - }); + it('re-previews when a column mapping dropdown changes', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ columnOrder: [MIDTERM, 'Finals'] }), + ); renderWizard(); + await advanceToMap(`${EXTERNAL_ID},${MIDTERM},Finals\n${A001},41,88\n`); - await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\n`); - expect(await screen.findByText(/ZZZ/)).toBeVisible(); - expect(CourseAPI.gradebook.importCommit).not.toHaveBeenCalled(); - }); + await waitFor(() => + expect(CourseAPI.gradebook.importPreview).toHaveBeenCalledTimes(1), + ); - it('hides weightage field when weightedViewEnabled is false', async () => { - render( - , + // Flip "Finals" (currently auto-mapped to create) to "Don't import". + const comboboxes = screen.getAllByRole('combobox'); + const finalsSelect = comboboxes[comboboxes.length - 1]; + await userEvent.click(finalsSelect); + await userEvent.click( + screen.getByRole('option', { name: /don't import/i }), + ); + + await waitFor(() => + expect(CourseAPI.gradebook.importPreview).toHaveBeenCalledTimes(2), ); - await userEvent.type(componentNameInput(), MIDTERM); - expect(screen.queryByLabelText(/weightage/i)).not.toBeInTheDocument(); - expect(screen.getByLabelText(/max marks/i)).toBeInTheDocument(); }); - it('disables Next when component name is empty', () => { - renderWizard(); - expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); + it('links the External ID hint to the students page, not /users', async () => { + renderWizard({}, [student({ id: 1, name: 'Alice', externalId: 'E1' })]); + const link = screen.getByRole('link', { name: /manage users/i }); + expect(link).toHaveAttribute('href', expect.stringContaining('/students')); + expect(link).not.toHaveAttribute('href', expect.stringContaining('/users')); }); - it('labels the identifier toggle "External ID"', () => { - render( - , - ); + it('blocks Next on the Upload step while a student has no External ID', async () => { + renderWizard({}, [ + student({ id: 1, name: 'Alice Lim', externalId: null }), + student({ id: 2, name: 'Bob Tan', externalId: 'E2' }), + ]); expect( - screen.getByRole('radio', { name: EXTERNAL_ID }), + screen.getByText(/Alice Lim has no External ID/), ).toBeInTheDocument(); + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`), + ); + await screen.findByText('marks.csv'); // file parsed + expect(nextButton()).toBeDisabled(); + }); + + it('enables Next once matching by Email instead', async () => { + renderWizard({}, [ + student({ id: 1, name: 'Alice Lim', externalId: null, email: A001 }), + ]); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + await dropCsv(`Email,${MIDTERM}\n${A001},41\n`); + expect(nextButton()).toBeEnabled(); + }); + + it('states the identifier must be the first column on the upload step, reacting to the toggle', async () => { + renderWizard({}, [student({ id: 1, name: 'Alice', externalId: 'E1' })]); expect( - screen.queryByRole('radio', { name: 'Student ID' }), - ).not.toBeInTheDocument(); + screen.getByText(/first column must be External ID/i), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + expect(screen.getByText(/first column must be Email/i)).toBeInTheDocument(); }); - it('keeps the component input label independent of the selected identifier mode', async () => { + it('shows the External ID example template by default with a downloadable CSV', () => { + renderWizard(); + + const example = screen.getByText(/External ID,Assessment 1,Assessment 2/); + expect(example).toHaveTextContent('A0123456,85,90'); + expect(example).toHaveTextContent('A0123457,78,88'); + + const link = screen.getByRole('link', { name: /template file/i }); + expect(link).toHaveAttribute('download', 'template.csv'); + expect(link.getAttribute('href')).toMatch(/^data:text\/csv/); + }); + + it('switches the example template to Email when the identifier toggle flips', async () => { renderWizard(); - expect(componentNameInput()).toBeInTheDocument(); - expect( - screen.queryByRole('textbox', { name: EXTERNAL_ID }), - ).not.toBeInTheDocument(); await userEvent.click(screen.getByRole('radio', { name: 'Email' })); - expect(componentNameInput()).toBeInTheDocument(); - expect( - screen.queryByRole('textbox', { name: 'Email' }), - ).not.toBeInTheDocument(); + const example = screen.getByText(/Email,Assessment 1,Assessment 2/); + expect(example).toHaveTextContent('test1@example.com,85,90'); + + const link = screen.getByRole('link', { name: /template file/i }); + expect(decodeURIComponent(link.getAttribute('href') ?? '')).toContain( + 'test1@example.com,85,90', + ); }); - it('commits with keep when Keep Existing is clicked on the conflict prompt', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], + // NOTE: this test's original premise — that an unrecognized identifier + // header falls through to Map with a blank identifier Select for the user + // to fix — no longer applies. The identifier is now always headers[0] + // (there is no more Select to pick it from), and detectUploadBlock now + // structurally blocks the upload when the first column doesn't match the + // selected mode's canonical header. So the equivalent current behavior is + // the opposite of "enables Next": Next stays disabled on Upload, gated by + // uploadBlock, with the block alert explaining why. + it('blocks Next on the Upload step when the identifier header is not first', async () => { + renderWizard(); + // "Student ID" does not case-insensitively match the canonical "External + // ID" header for external_id mode. + await userEvent.upload( + screen.getByLabelText(/upload/i), + file(`Student ID,${MIDTERM}\n${A001},41\n`), + ); + await screen.findByText('marks.csv'); + expect(nextButton()).toBeDisabled(); + expect(screen.getByText(/but it is .*Student ID/i)).toBeInTheDocument(); + }); + + it('opens the conflict prompt and commits with keep/replace', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ sample: [{ identifier: A001, grades: { Midterm: 20 } }], conflictRows: [ { @@ -374,138 +417,49 @@ describe('ImportExternalAssessmentsWizard', () => { cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, }, ], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ - data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 0 }, - }); - (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ - data: { - categories: [], - tabs: [], - assessments: [], - students: [], - submissions: [], - gamificationEnabled: false, - weightedViewEnabled: true, - canManageWeights: true, - }, - }); - renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`), + }), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - await userEvent.click( - await screen.findByRole('button', { name: /confirm import/i }), + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue( + okCommit({ createdComponents: 0, updatedComponents: 1 }), ); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue(okIndex()); + + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`); + await screen.findByText(A001); + + // The pending-change cue appears before the confirm modal. expect( - await screen.findByText(/1 of 1 rows have changes/i), + await screen.findByText( + /1 row contains changes to existing grades\. After checking this preview, click Resolve Conflicts to review these conflicts before anything is imported\./i, + ), ).toBeInTheDocument(); - // The struck old value (10) is unique to the matrix; the new value (20) - // also appears in the Verify sample table behind the dialog, so assert - // only the header + old value to avoid a multiple-match error. - expect(screen.getByText('10')).toBeInTheDocument(); // struck old value + + await userEvent.click( + screen.getByRole('button', { name: /resolve conflicts/i }), + ); + + const dialog = await screen.findByRole('dialog', { + name: /resolve grade conflicts/i, + }); + expect(within(dialog).getByText('10')).toHaveStyle( + 'text-decoration: line-through', + ); + await userEvent.click( - await screen.findByRole('button', { name: /keep existing/i }), + within(dialog).getByRole('button', { name: /replace/i }), ); await waitFor(() => expect( (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] .onConflict, - ).toBe('keep'), + ).toBe('replace'), ); }); - it('blocks Next in External ID mode while a student has no External ID', async () => { - render(, { - state: studentsState([ - student({ id: 1, name: 'Alice Lim', externalId: null }), - student({ id: 2, name: 'Bob Tan', externalId: 'E2' }), - ]), - }); - await fillOneComponent(); - expect( - screen.getByText(/Alice Lim has no External ID/), - ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); - }); - - it('enables Next once matching by Email instead', async () => { - render(, { - state: studentsState([ - student({ id: 1, name: 'Alice Lim', externalId: null }), - ]), - }); - await fillOneComponent(); - await userEvent.click(screen.getByRole('radio', { name: 'Email' })); - expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); - }); - - it('lists the exact required headers on the upload step', async () => { - render(, { - state: studentsState([ - student({ id: 1, name: 'Alice', externalId: 'E1' }), - ]), - }); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.click(screen.getByRole('button', { name: 'Next' })); - expect( - screen.getByText( - /Your CSV needs these column headers: External ID, Midterm/, - ), - ).toBeInTheDocument(); - }); - - it('summarises the count when several students lack an External ID', async () => { - render(, { - state: studentsState([ - student({ id: 1, name: 'Alice Lim', externalId: null }), - student({ id: 2, name: 'Bob Tan', externalId: '' }), - student({ id: 3, name: 'Carol Low', externalId: '' }), - ]), - }); - await fillOneComponent(); - expect( - screen.getByText(/Alice Lim and 2 other students have no External ID/), - ).toBeInTheDocument(); - }); - - it('does not show Confirm import button when preview has errors', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: false, - unresolved: ['ZZZ'], - malformed: [], - outOfRange: [], - sample: [], - conflictRows: [], - reassignments: [], - }, - }); - renderWizard(); - await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\n`); - await screen.findByText(/ZZZ/); - expect( - screen.queryByRole('button', { name: /confirm import/i }), - ).not.toBeInTheDocument(); - }); - - it('opens the conflict prompt and commits with keep/replace', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], + it('commits with keep when Keep Existing is clicked on the conflict prompt', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ sample: [{ identifier: A001, grades: { Midterm: 20 } }], conflictRows: [ { @@ -514,136 +468,191 @@ describe('ImportExternalAssessmentsWizard', () => { cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, }, ], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ - data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 1 }, - }); - (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ - data: { - categories: [], - tabs: [], - assessments: [], - students: [], - submissions: [], - gamificationEnabled: false, - weightedViewEnabled: true, - canManageWeights: true, - }, - }); - render( - , + }), ); - // Step 1: MIDTERM matches existing → max/weightage locked; just Next. - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`), + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue( + okCommit({ createdComponents: 0, updatedComponents: 1 }), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue(okIndex()); + + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`); + await screen.findByText(A001); await userEvent.click( - await screen.findByRole('button', { name: /continue|confirm/i }), + screen.getByRole('button', { name: /resolve conflicts/i }), ); - // conflict prompt + const dialog = await screen.findByRole('dialog', { + name: /resolve grade conflicts/i, + }); await userEvent.click( - await screen.findByRole('button', { name: /replace/i }), + within(dialog).getByRole('button', { name: /keep existing/i }), ); await waitFor(() => expect( (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] .onConflict, - ).toBe('replace'), + ).toBe('keep'), ); }); - it('renders existing external chips in the define step', () => { - render( - , + it('does not open the conflict prompt when the preview reports no existing grades', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview(), ); - expect(screen.getByRole('button', { name: MIDTERM })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Finals' })).toBeInTheDocument(); - }); - - it('clicking an existing chip inserts a locked row pre-filled with correct max and weight', async () => { - render( - , + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue( + okCommit(), ); - await userEvent.click(screen.getByRole('button', { name: MIDTERM })); - - // The chip-inserted row's name field is read-only (disabled input) - const nameInput = screen.getByDisplayValue(MIDTERM); - expect(nameInput).toBeDisabled(); - - // Max and weight are pre-filled with the existing values - expect(screen.getByDisplayValue('50')).toBeDisabled(); - expect(screen.getByDisplayValue('30')).toBeDisabled(); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue(okIndex()); - // The "existing assessment" locked-row label is shown - expect(screen.getByText(/existing assessment/i)).toBeInTheDocument(); - }); + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + await screen.findByText(A001); + expect( + screen.queryByText(/contains? changes to existing grades/i), + ).not.toBeInTheDocument(); - it('hides a chip once the corresponding external has been added to the component list', async () => { - render( - , + await userEvent.click( + screen.getByRole('button', { name: /confirm import/i }), ); - await userEvent.click(screen.getByRole('button', { name: MIDTERM })); - // After clicking, chip disappears (already in the list) + expect( - screen.queryByRole('button', { name: MIDTERM }), + screen.queryByRole('dialog', { name: /resolve grade conflicts/i }), ).not.toBeInTheDocument(); + await waitFor(() => + expect(CourseAPI.gradebook.importCommit).toHaveBeenCalled(), + ); + expect( + (CourseAPI.gradebook.importCommit as jest.Mock).mock.calls[0][0] + .onConflict, + ).toBe('replace'); }); - it('does not render the From existing section when there are no existing externals', () => { - render( - new, unchanged as-is, missing as em-dash', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ + sample: [ + { identifier: A001, grades: { Midterm: 20, Finals: 88 } }, + { identifier: 'A002', grades: { Midterm: 30 } }, + ], + conflictRows: [ + { + identifier: A001, + studentName: 'Alice', + cells: { + Midterm: { existing: 10, inFile: 20, changed: true }, + Finals: { existing: 88, inFile: 88, changed: false }, + }, + }, + { + identifier: 'A002', + studentName: 'Bob', + cells: { Midterm: { existing: 5, inFile: 30, changed: true } }, + }, + ], + columnOrder: [MIDTERM, 'Finals'], + totalRows: 2, + }), + ); + (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue( + okCommit({ createdComponents: 0, updatedComponents: 1 }), + ); + + renderWizard({}, [ + student({ id: 1, externalId: A001 }), + student({ id: 2, externalId: 'A002' }), + ]); + await advanceToPreview( + `${EXTERNAL_ID},${MIDTERM},Finals\n${A001},20,88\nA002,30,\n`, + ); + await screen.findByText(A001); + await userEvent.click( + screen.getByRole('button', { name: /resolve conflicts/i }), + ); + + const dialog = await screen.findByRole('dialog', { + name: /resolve grade conflicts/i, + }); + const struck = within(dialog).getByText('10'); + expect(struck).toHaveStyle('text-decoration: line-through'); + expect(within(dialog).getByText('20')).toHaveStyle('font-weight: 700'); + expect(within(dialog).getByText('88')).not.toHaveStyle( + 'text-decoration: line-through', + ); + expect(within(dialog).getByText('—')).toBeInTheDocument(); + }); + + it('shows a spinner on Replace while the commit is in flight', async () => { + let resolveCommit: (v: unknown) => void = () => {}; + (CourseAPI.gradebook.importCommit as jest.Mock).mockReturnValue( + new Promise((res) => { + resolveCommit = res; + }), + ); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ data: {} }); + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ + conflictRows: [ + { + identifier: A001, + studentName: 'student', + cells: { Midterm: { existing: 10, inFile: 41, changed: true } }, + }, + ], + }), + ); + + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + await screen.findByText(A001); + await userEvent.click( + screen.getByRole('button', { name: /resolve conflicts/i }), + ); + const dialog = await screen.findByRole('dialog', { + name: /resolve grade conflicts/i, + }); + const replaceBtn = within(dialog).getByRole('button', { + name: /replace/i, + }); + await userEvent.click(replaceBtn); + + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + + resolveCommit(okCommit({ createdComponents: 0, updatedComponents: 1 })); + }); + + it('toasts failure and stays open when the commit request rejects', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview(), + ); + (CourseAPI.gradebook.importCommit as jest.Mock).mockRejectedValue( + new Error('boom'), + ); + const onClose = jest.fn(); + render( + , + { state: studentsState([student({ externalId: A001 })]) }, + ); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + await screen.findByText(A001); + await userEvent.click( + screen.getByRole('button', { name: /confirm import/i }), + ); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'Import failed. Nothing was saved.', + ), ); - expect(screen.queryByText(/from existing/i)).not.toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); }); - it('warns about out-of-range grades at Verify without blocking import', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], + it('warns about out-of-range grades at Preview without blocking import', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ outOfRange: [ { identifier: 'S1', @@ -654,32 +663,26 @@ describe('ImportExternalAssessmentsWizard', () => { }, ], sample: [{ identifier: 'S1', grades: { Midterm: 105 } }], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - renderWizard({ weightedViewEnabled: true }); - await advanceToVerifyStep(); + }), + ); + renderWizard({ weightedViewEnabled: true }, [ + student({ externalId: 'S1' }), + ]); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\nS1,105\n`); expect( - screen.getByText(/S1 - Midterm: 105 \(max 100\)/), + await screen.findByText(/S1 - Midterm: 105 \(max 100\)/), ).toBeInTheDocument(); expect( screen.getByText(/floored or capped in the weighted total/i), ).toBeInTheDocument(); - // non-blocking: Confirm import still enabled expect( screen.getByRole('button', { name: /Confirm import/i }), ).toBeEnabled(); }); it('omits the weighted-total wording when weighted view is off', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ outOfRange: [ { identifier: 'S1', @@ -690,178 +693,189 @@ describe('ImportExternalAssessmentsWizard', () => { }, ], sample: [{ identifier: 'S1', grades: { Midterm: 105 } }], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - renderWizard({ weightedViewEnabled: false }); - await advanceToVerifyStep(); + }), + ); + renderWizard({ weightedViewEnabled: false }, [ + student({ externalId: 'S1' }), + ]); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\nS1,105\n`); expect( - screen.getByText(/S1 - Midterm: 105 \(max 100\)/), + await screen.findByText(/S1 - Midterm: 105 \(max 100\)/), ).toBeInTheDocument(); expect(screen.queryByText(/weighted total/i)).not.toBeInTheDocument(); }); - it('shows a "did you mean" suggestion for a renamed column, not raw header dumps', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { - message: 'bad_header', - missing: [], - unrecognized: [], - suggestions: [{ expected: MIDTERMS, didYouMean: MIDTERM }], - duplicates: [], + it('formats below-minimum out-of-range grades with a min-0 bound', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ + outOfRange: [ + { + identifier: 'S1', + component: MIDTERM, + grade: -5, + max: 100, + kind: 'below', }, - }, - }, - }); - renderWizard(); - await userEvent.type(componentNameInput(), MIDTERMS); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`), + ], + sample: [{ identifier: 'S1', grades: { Midterm: -5 } }], + }), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - - // Actionable detail is shown (not the generic "Could not verify" toast) + renderWizard({ weightedViewEnabled: true }, [ + student({ externalId: 'S1' }), + ]); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\nS1,-5\n`); expect( - await screen.findByText(/did you mean ['‘]Midterms['’]\?/i), + await screen.findByText(/S1 - Midterm: -5 \(min 0\)/), ).toBeInTheDocument(); - // The old eye-diff "Found: …" / "do not match" dump is gone - expect(screen.queryByText(/Found:/)).not.toBeInTheDocument(); - expect(screen.queryByText(/do not match/i)).not.toBeInTheDocument(); - // Stays on the upload step — no preview/confirm advance + }); + + it('summarises out-of-range cells beyond the first ten', async () => { + const outOfRange = Array.from({ length: 12 }, (_, i) => ({ + identifier: `S${i + 1}`, + component: MIDTERM, + grade: 105, + max: 100, + kind: 'above' as const, + })); + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ + outOfRange, + sample: [{ identifier: 'S1', grades: { Midterm: 105 } }], + }), + ); + renderWizard({ weightedViewEnabled: true }, [ + student({ externalId: 'S1' }), + ]); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\nS1,105\n`); expect( - screen.queryByRole('button', { name: /confirm import/i }), - ).not.toBeInTheDocument(); + await screen.findByText(/S10 - Midterm: 105 \(max 100\)/), + ).toBeInTheDocument(); + expect(screen.queryByText(/S11 - Midterm/)).not.toBeInTheDocument(); + expect(screen.getByText('+2 more')).toBeInTheDocument(); }); - it('shows only the duplicate-header line when duplicates are the only problem', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { - message: 'bad_header', - missing: [], - unrecognized: [], - suggestions: [], - duplicates: [{ name: MIDTERM, count: 2 }], + it('shows a reassignment warning when an identifier now matches a different student', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ + reassignments: [ + { + identifier: A001, + currentStudent: 'Carol', + previousStudents: ['Alice'], }, - }, - }, - }); - renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM},${MIDTERM}\nA001,1,2\n`), + ], + }), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); expect( - await screen.findByText(/appears more than once:.*Midterm \(×2\)/i), + await screen.findByText(/now match a different student/i), + ).toBeInTheDocument(); + expect( + screen.getByText(/A001: now Carol \(was Alice\)/), ).toBeInTheDocument(); - // No bogus missing/unrecognized lines when every column is present - expect(screen.queryByText(/is missing/i)).not.toBeInTheDocument(); - expect(screen.queryByText(/recognized/i)).not.toBeInTheDocument(); }); - it('uses singular copy for a single missing / unrecognized / duplicate column', async () => { + it('toasts a meaningful message and hides Confirm import when the preview request itself rejects', async () => { (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { - message: 'bad_header', - missing: [MIDTERM], - unrecognized: ['Wrong'], - suggestions: [], - duplicates: [{ name: 'Quiz', count: 2 }], - }, - }, - }, + response: { data: { errors: { message: 'empty_csv' } } }, }); renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'The uploaded file has no data rows. Add at least one student row and try again.', + ), + ); + expect( + screen.queryByRole('button', { name: /confirm import/i }), + ).not.toBeInTheDocument(); + expect(CourseAPI.gradebook.importCommit).not.toHaveBeenCalled(); + }); + + // The client now blocks a CSV identifier with no matching student at the + // Upload step, before the server is ever consulted — so an unrecognized + // identifier can no longer reach Preview via the client-side path. These + // two cases assert that Upload-step block instead (same copy as before). + it('blocks Next on the Upload step and shows unresolved external IDs', async () => { + renderWizard(); await userEvent.upload( screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},Wrong\nA001,41\n`), + file(`${EXTERNAL_ID},${MIDTERM}\nZZZ,1\n`), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - - expect( - await screen.findByText(/is missing this column:.*Midterm\b/i), - ).toBeInTheDocument(); - expect( - screen.getByText(/This column isn['’]t recognized:.*Wrong/i), - ).toBeInTheDocument(); + await screen.findByText('marks.csv'); + expect(nextButton()).toBeDisabled(); expect( - screen.getByText(/This column appears more than once:.*Quiz \(×2\)/i), + screen.getByText(/This external ID was not found in the course: ZZZ/), ).toBeInTheDocument(); + expect(CourseAPI.gradebook.importPreview).not.toHaveBeenCalled(); }); - it('uses plural copy for multiple missing / unrecognized / duplicate columns', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { - message: 'bad_header', - missing: [MIDTERM, 'Final Exam'], - unrecognized: ['Wrong', 'Extra'], - suggestions: [], - duplicates: [ - { name: 'Quiz', count: 2 }, - { name: 'Project', count: 3 }, - ], - }, - }, - }, - }); + it('uses email copy for unresolved identifiers when matching by Email', async () => { renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); await userEvent.upload( screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},Wrong,Extra\nA001,41,5\n`), + file(`Email,${MIDTERM}\nnope@x.com,1\n`), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - + await screen.findByText('marks.csv'); + expect(nextButton()).toBeDisabled(); expect( - await screen.findByText( - /is missing these columns:.*Midterm, Final Exam/i, + screen.getByText( + /This email address was not found in the course: nope@x.com/, ), ).toBeInTheDocument(); + expect(CourseAPI.gradebook.importPreview).not.toHaveBeenCalled(); + }); + + // Backstop: the server preview stays the authoritative check even for an + // identifier the client's roster snapshot accepted (e.g. stale client + // state, or a server-side rule the client doesn't replicate). + it('shows the Preview alert when the server rejects an identifier the client accepted', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ ok: false, unresolved: [A001], sample: [] }), + ); + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},1\n`); expect( - screen.getByText(/These columns aren['’]t recognized:.*Wrong, Extra/i), - ).toBeInTheDocument(); - expect( - screen.getByText( - /These columns appear more than once:.*Quiz \(×2\), Project \(×3\)/i, + await screen.findByText( + new RegExp(`This external ID was not found in the course: ${A001}`), ), ).toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /confirm import/i }), + ).not.toBeInTheDocument(); + expect(CourseAPI.gradebook.importCommit).not.toHaveBeenCalled(); + }); + + it('lists up to five malformed cells then summarises the rest', async () => { + const malformed = [ + 'row 2, Midterm: a', + 'row 3, Midterm: b', + 'row 4, Midterm: c', + 'row 5, Midterm: d', + 'row 6, Midterm: e', + 'row 7, Midterm: f', + ]; + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ ok: false, malformed, sample: [] }), + ); + renderWizard(); + // The mocked server response is what actually reports these cells as + // malformed; the local CSV just needs a numeric cell so the column + // auto-maps to "create" and Map step's mapping validation passes. + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + expect(await screen.findByText('row 2, Midterm: a')).toBeInTheDocument(); + expect(screen.getByText('row 6, Midterm: e')).toBeInTheDocument(); + expect(screen.queryByText('row 7, Midterm: f')).not.toBeInTheDocument(); + expect(screen.getByText('and 1 more')).toBeInTheDocument(); }); it('shows preview subtitle with total row count when preview has more than 5 rows', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ totalRows: 6, - unresolved: [], - malformed: [], - outOfRange: [], sample: [ { identifier: A001, grades: { Midterm: 41 } }, { identifier: 'A002', grades: { Midterm: 42 } }, @@ -869,812 +883,268 @@ describe('ImportExternalAssessmentsWizard', () => { { identifier: 'A004', grades: { Midterm: 44 } }, { identifier: 'A005', grades: { Midterm: 45 } }, ], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - }, - }); - - renderWizard(); - - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - - await userEvent.upload( - screen.getByLabelText(/upload/i), - file( - [ - `${EXTERNAL_ID},${MIDTERM}`, - 'A001,41', - 'A002,42', - 'A003,43', - 'A004,44', - 'A005,45', - 'A006,46', - ].join('\n'), + }), + ); + renderWizard( + {}, + ['A001', 'A002', 'A003', 'A004', 'A005', 'A006'].map((id, i) => + student({ id: i + 1, externalId: id }), ), ); - - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - + await advanceToPreview( + [ + `${EXTERNAL_ID},${MIDTERM}`, + 'A001,41', + 'A002,42', + 'A003,43', + 'A004,44', + 'A005,45', + 'A006,46', + ].join('\n'), + ); expect(await screen.findByText(A001)).toBeVisible(); - expect( screen.getByText( - /Previewing the first 5 of 6 rows. Check that this preview matches your CSV before continuing./i, + /Previewing the first 5 of 6 rows. Check that these grades match your CSV before continuing./i, ), ).toBeInTheDocument(); }); it('shows all rows subtitle variant when totalRows is 5 or fewer', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ totalRows: 5, - unresolved: [], - malformed: [], - outOfRange: [], sample: [ { identifier: A001, grades: { Midterm: 41 } }, { identifier: 'A002', grades: { Midterm: 42 } }, - { identifier: 'A003', grades: { Midterm: 43 } }, - { identifier: 'A004', grades: { Midterm: 44 } }, - { identifier: 'A005', grades: { Midterm: 45 } }, ], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - }, - }); - - renderWizard(); - - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - - await userEvent.upload( - screen.getByLabelText(/upload/i), - file( - [ - `${EXTERNAL_ID},${MIDTERM}`, - 'A001,41', - 'A002,42', - 'A003,43', - 'A004,44', - 'A005,45', - ].join('\n'), - ), + }), + ); + renderWizard({}, [ + student({ id: 1, externalId: A001 }), + student({ id: 2, externalId: 'A002' }), + ]); + await advanceToPreview( + [`${EXTERNAL_ID},${MIDTERM}`, 'A001,41', 'A002,42'].join('\n'), ); - - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - expect(await screen.findByText(A001)).toBeVisible(); - expect( screen.getByText( - /Previewing all 5 rows. Check that this preview matches your CSV before continuing./i, + /Previewing all 5 rows. Check that these grades match your CSV before continuing./i, ), ).toBeInTheDocument(); }); - it('renders the Verify preview columns in the CSV column order', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - sample: [{ identifier: 'A001', grades: { Final: 80, Midterm: 41 } }], - conflictRows: [], - reassignments: [], - totalRows: 1, - columnOrder: ['Final', 'Midterm'], - }, - }); - - renderWizard(); - // define Midterm first, then Final (opposite of the CSV order) - await userEvent.type(componentNameInput(), 'Midterm'); - await userEvent.click( - screen.getByRole('button', { name: /add component/i }), - ); - const nameInputs = screen.getAllByRole('textbox', { - name: 'Component name', - }); - await userEvent.type(nameInputs[1], 'Final'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file('Final,External ID,Midterm\n80,A001,41\n'), + it('wraps the preview table in a horizontally scrollable container', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview(), ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - await screen.findByRole('button', { name: /confirm import/i }); - - const headerCells = screen - .getAllByRole('columnheader') - .map((c) => c.textContent); - // identifier header first, then CSV order Final, Midterm - expect(headerCells).toEqual(['External ID', 'Final', 'Midterm']); + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + const table = await screen.findByRole('table'); + expect(table.parentElement).toHaveClass('overflow-x-auto'); }); - it('states grades import as-is, with the clamping hint only when weighted view is on', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - conflictRows: [], - reassignments: [], - totalRows: 1, - columnOrder: [MIDTERM], - sample: [{ identifier: 'A1', grades: { Midterm: 105 } }], - outOfRange: [ - { - identifier: 'A1', - component: MIDTERM, - grade: 105, - max: 100, - kind: 'above', - }, - ], - }, - }); - renderWizard({ weightedViewEnabled: true }); - await advanceToVerifyStep(); - expect( - screen.getByText( - /Grades will be imported exactly as entered\. This is only a warning; you can turn off this warning in Manage External Assessments\. Out-of-range grades are only floored or capped in the weighted total/, - ), - ).toBeInTheDocument(); - }); - - it('omits the clamping hint when weighted view is off', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - conflictRows: [], - reassignments: [], - totalRows: 1, - sample: [{ identifier: 'A1', grades: { Midterm: 105 } }], - outOfRange: [ - { - identifier: 'A1', - component: MIDTERM, - grade: 105, - max: 100, - kind: 'above', - }, - ], - columnOrder: [MIDTERM], - }, - }); - renderWizard({ weightedViewEnabled: false }); - await advanceToVerifyStep(); - expect( - screen.getByText( - 'Grades will be imported exactly as entered. This is only a warning; you can turn off this warning in Manage External Assessments. If these out-of-range grades are intentional, continue.', - ), - ).toBeInTheDocument(); - - expect(screen.queryByText(/floored or capped/)).not.toBeInTheDocument(); - }); - - it('wraps the preview table in a horizontally scrollable container', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - conflictRows: [], - reassignments: [], - outOfRange: [], - totalRows: 1, - sample: [{ identifier: 'A1', grades: { Midterm: 80 } }], - columnOrder: [MIDTERM], - }, - }); - renderWizard(); - await advanceToVerifyStep(); - const table = screen.getByRole('table'); - expect(table.parentElement).toHaveClass('overflow-x-auto'); - }); - - it('cues the pending change set on the Verify step before the confirm modal', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - columnOrder: [MIDTERM], - sample: [{ identifier: A001, grades: { Midterm: 20 } }], - conflictRows: [ - { - identifier: A001, - studentName: 'Alice', - cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, - }, - ], - reassignments: [], - totalRows: 1, - }, - }); - renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`), + it('resets to the Upload step when reopened after a close', async () => { + const { rerender } = render( + , + { state: studentsState([student({ externalId: A001 })]) }, ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - - // Cue appears on the Verify step, before any confirm click - expect( - await screen.findByText( - /1 row contains changes to existing grades\. After checking this preview, click Confirm import to review these conflicts before anything is imported\./i, - ), - ).toBeInTheDocument(); - }); + await advanceToMap(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + expect(screen.getByRole('table')).toBeInTheDocument(); // on Map - it('shows a spinner on Replace while the commit is in flight', async () => { - let resolveCommit: (v: unknown) => void = () => {}; - (CourseAPI.gradebook.importCommit as jest.Mock).mockReturnValue( - new Promise((res) => { - resolveCommit = res; - }), + rerender( + + + , ); - (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue({ data: {} }); - - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - sample: [{ identifier: 'A001', grades: { Midterm: 41 } }], - conflictRows: [ - { - identifier: 'A001', - studentName: 'student', - identifierMismatch: false, - cells: { Midterm: { existing: 10, inFile: 41, changed: true } }, - }, - ], - reassignments: [], - totalRows: 1, - columnOrder: [MIDTERM], - }, - }); - - renderWizard(); - await advanceToVerifyStep(); - await userEvent.click( - screen.getByRole('button', { name: /confirm import/i }), + rerender( + + + , ); - // conflict prompt is open - const replaceBtn = await screen.findByRole('button', { name: /replace/i }); - await userEvent.click(replaceBtn); - // MUI LoadingButton sets aria-disabled and renders a progressbar while loading - expect(await screen.findByRole('progressbar')).toBeInTheDocument(); - - resolveCommit({ - data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 1 }, - }); + // back on the Upload step with no file and a blank identifier column + expect(screen.getByLabelText(/upload/i)).toBeInTheDocument(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); + expect(screen.getByText(/Drag a CSV here/)).toBeInTheDocument(); }); - it('renders the diagnostic heading and a single closing instruction', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { - message: 'bad_header', - missing: ['Quiz 2'], - unrecognized: [], - suggestions: [], - duplicates: [], - identifierNotFirst: false, - }, - }, - }, - }); - render(, { - state: studentsState([]), - }); - await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`); - - expect(await screen.findByText('These headers need fixing:')).toBeVisible(); - expect( - screen.getByText('Correct these in your CSV, then re-upload.'), - ).toBeVisible(); + it('preserves the dropped file and identifier mode when Back returns from Map to Upload', async () => { + renderWizard({}, [student({ email: A001 })]); + await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + await advanceToMap(`Email,${MIDTERM}\n${A001},41\n`); + expect(screen.getByRole('table')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /^back$/i })); + + // Back on Upload: the previously dropped file and the Email toggle are + // still there — Back must not reset either. + expect(screen.getByText('marks.csv')).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Email' })).toBeChecked(); + // Next re-enables immediately: the file's already-parsed headers were + // not thrown away either. + expect(nextButton()).toBeEnabled(); }); - it('shows the identifier-first bullet when identifierNotFirst is set', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { - message: 'bad_header', - missing: [], - unrecognized: [], - suggestions: [], - duplicates: [], - identifierNotFirst: true, - }, - }, - }, - }); - render(, { - state: studentsState([]), - }); - await advanceToVerifyFailure(`${MIDTERM},${EXTERNAL_ID}\n41,A001\n`); + it("re-drops a new file and reseeds column mappings from that file's own headers, not the previous file's", async () => { + renderWizard({}, [ + student({ id: 1, externalId: A001 }), + student({ id: 2, externalId: 'A002' }), + ]); + await advanceToMap(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + // Flip Midterm away from its auto-detected "create" default. + const midtermSelect = screen.getAllByRole('combobox').at(-1)!; + await userEvent.click(midtermSelect); + await userEvent.click( + screen.getByRole('option', { name: /don't import/i }), + ); + // With the only column set to "Don't import", the flat table's footer + // hint switches to the nothing-imported variant. + expect(screen.getByText(/set at least one column/i)).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', { name: /^back$/i })); + await dropCsv(`${EXTERNAL_ID},${MIDTERM}\nA002,55\n`); + await userEvent.click(nextButton()); + + // A fresh parseFile re-seeds Midterm's default action ("create") from + // the new file's own data; the earlier "Don't import" override for the + // old file must not leak across files. + expect(screen.getByText(MIDTERM)).toBeInTheDocument(); expect( - await screen.findByText('‘External ID’ must be the first column.'), - ).toBeVisible(); + screen.queryByText(/set at least one column/i), + ).not.toBeInTheDocument(); }); - it('shows a reassignment warning when an identifier now matches a different student', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - sample: [{ identifier: A001, grades: { Midterm: 77 } }], - conflictRows: [], - reassignments: [ - { - identifier: A001, - currentStudent: 'Carol', - previousStudents: ['Alice'], - }, - ], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - + it('renders the Map step with the flat table and no footer hint when every column is already mapped', async () => { render(, { - state: studentsState([]), + state: assessmentsState( + [{ id: 1, title: MIDTERM, tabId: 1, maxGrade: 100, external: true }], + [student({ externalId: A001 })], + ), }); - await advanceToVerifyStep(); - expect( - await screen.findByText(/now match a different student/i), - ).toBeInTheDocument(); - expect( - screen.getByText(/A001: now Carol \(was Alice\)/), - ).toBeInTheDocument(); - }); + await advanceToMap(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); - it('shows the External ID matching hint when every student has one', async () => { - render(, { - state: studentsState([ - student({ id: 1, name: 'Alice', externalId: 'E1' }), - ]), - }); - await fillOneComponent(); + // Still on Map — a fully-mapped column does not auto-skip to Preview; + // it just renders in the (always-visible) flat table with no + // incomplete/nothing-imported footer hint. expect( - screen.getByText(/Matching uses each student's External ID/), + screen.getByText(`${EXTERNAL_ID} (${EXTERNAL_ID})`), ).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Next' })).toBeEnabled(); - }); - - it('hides the External ID hint when matching by Email', async () => { - render(, { - state: studentsState([ - student({ id: 1, name: 'Alice', externalId: 'E1' }), - ]), - }); - await fillOneComponent(); - await userEvent.click(screen.getByRole('radio', { name: 'Email' })); + expect(screen.getByRole('table')).toBeInTheDocument(); expect( - screen.queryByText(/Matching uses each student's External ID/), + screen.queryByText(/finish mapping|set at least one column/i), ).not.toBeInTheDocument(); + expect(nextButton()).toBeEnabled(); }); - it('does not cue pending changes when no rows conflict', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - sample: [{ identifier: A001, grades: { Midterm: 41 } }], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - // Use an isolated store (not the shared singleton, which an earlier commit - // test may have left without a students slice). - render(, { - state: studentsState([]), - }); - await advanceToVerifyStep(); + it('preserves column mappings and the identifier column when Back returns from Preview to Map', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview(), + ); + renderWizard(); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`); + expect(await screen.findByText(A001)).toBeVisible(); + + await userEvent.click(screen.getByRole('button', { name: /^back$/i })); + + expect(screen.getByRole('table')).toBeInTheDocument(); + expect(screen.getByText(MIDTERM)).toBeInTheDocument(); expect( - screen.queryByText(/contains? changes to existing grades/i), - ).not.toBeInTheDocument(); + screen.getByText(`${EXTERNAL_ID} (${EXTERNAL_ID})`), + ).toBeInTheDocument(); + // canPreview was already satisfied before Back — Next re-enables without + // the user having to redo any mapping. + expect(nextButton()).toBeEnabled(); }); - it('renders the change matrix: changed cells as old→new, unchanged as-is, missing as em-dash', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - sample: [ - { identifier: A001, grades: { Midterm: 20, Finals: 88 } }, - { identifier: 'A002', grades: { Midterm: 30 } }, - ], + it('hides the wizard while the conflict prompt is open', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ + sample: [{ identifier: A001, grades: { Midterm: 20 } }], conflictRows: [ { identifier: A001, studentName: 'Alice', - identifierMismatch: false, - cells: { - Midterm: { existing: 10, inFile: 20, changed: true }, - Finals: { existing: 88, inFile: 88, changed: false }, - }, - }, - { - identifier: 'A002', - studentName: 'Bob', - identifierMismatch: false, - // Finals absent for this row → em-dash in matrix - cells: { Midterm: { existing: 5, inFile: 30, changed: true } }, + cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, }, ], - reassignments: [], - columnOrder: [MIDTERM, 'Finals'], - totalRows: 2, - }, - }); - (CourseAPI.gradebook.importCommit as jest.Mock).mockResolvedValue({ - data: { createdComponents: 0, updatedComponents: 1, gradesWritten: 2 }, - }); - render( - , - { state: studentsState([]) }, - ); - // Fill the initial blank row with Midterm (locks it as existing), then add - // Finals from its chip — two components, so Next is enabled. - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.click(screen.getByRole('button', { name: 'Finals' })); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM},Finals\n${A001},20,88\nA002,30,\n`), - ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - await userEvent.click( - await screen.findByRole('button', { name: /confirm import/i }), - ); - - const dialog = await screen.findByRole('dialog', { - name: /resolve grade conflicts/i, - }); - // changed cell: struck old + bold new - const struck = within(dialog).getByText('10'); - expect(struck).toHaveStyle('text-decoration: line-through'); - expect(within(dialog).getByText('20')).toHaveStyle('font-weight: 700'); - // unchanged cell (Finals 88 → 88): shows the stored value, not struck - expect(within(dialog).getByText('88')).not.toHaveStyle( - 'text-decoration: line-through', + }), ); - // missing Finals cell on A002 → em-dash - expect(within(dialog).getByText('—')).toBeInTheDocument(); - }); - - it('formats below-minimum out-of-range grades with a min-0 bound', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [ - { - identifier: 'S1', - component: MIDTERM, - grade: -5, - max: 100, - kind: 'below', - }, - ], - sample: [{ identifier: 'S1', grades: { Midterm: -5 } }], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, - }); - renderWizard({ weightedViewEnabled: true }); - await advanceToVerifyStep(); - expect(screen.getByText(/S1 - Midterm: -5 \(min 0\)/)).toBeInTheDocument(); - }); - - it('summarises out-of-range cells beyond the first ten', async () => { - const outOfRange = Array.from({ length: 12 }, (_, i) => ({ - identifier: `S${i + 1}`, - component: MIDTERM, - grade: 105, - max: 100, - kind: 'above' as const, - })); - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange, - sample: [{ identifier: 'S1', grades: { Midterm: 105 } }], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 12, - }, - }); - renderWizard({ weightedViewEnabled: true }); - await advanceToVerifyStep(); - expect( - screen.getByText(/S10 - Midterm: 105 \(max 100\)/), - ).toBeInTheDocument(); - expect(screen.queryByText(/S11 - Midterm/)).not.toBeInTheDocument(); - expect(screen.getByText('+2 more')).toBeInTheDocument(); - }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue(okIndex()); - it('uses email copy for unresolved identifiers when matching by Email', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: false, - unresolved: ['nope@x.com'], - malformed: [], - outOfRange: [], - sample: [], - conflictRows: [], - reassignments: [], - }, - }); renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('radio', { name: 'Email' })); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`Email,${MIDTERM}\nnope@x.com,1\n`), - ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - expect( - await screen.findByText( - /This email address was not found in the course: nope@x.com/, - ), - ).toBeInTheDocument(); - }); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`); + await screen.findByText(A001); - it('disables Next when two components share the same name', async () => { - renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); await userEvent.click( - screen.getByRole('button', { name: /add component/i }), + screen.getByRole('button', { name: /resolve conflicts/i }), ); - const names = screen.getAllByRole('textbox', { name: 'Component name' }); - await userEvent.type(names[1], MIDTERM); - expect(screen.getByRole('button', { name: /next/i })).toBeDisabled(); - }); - it('lists the Email header when matching by Email', async () => { - render(, { - state: studentsState([ - student({ id: 1, name: 'Alice', externalId: 'E1' }), - ]), - }); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.click(screen.getByRole('radio', { name: 'Email' })); - await userEvent.click(screen.getByRole('button', { name: 'Next' })); + // The conflict prompt is the only dialog on screen; the wizard is hidden. + await screen.findByRole('dialog', { name: /resolve grade conflicts/i }); expect( - screen.getByText(/Your CSV needs these column headers: Email, Midterm/), - ).toBeInTheDocument(); + screen.queryByRole('dialog', { name: /import external assessments/i }), + ).not.toBeInTheDocument(); }); - it('shows the header mismatch without a suggestion list when none is returned', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { - message: 'bad_header', - missing: [MIDTERMS], - unrecognized: ['Quiz'], - suggestions: [], - duplicates: [], + it('re-shows the wizard when Go Back is clicked on the conflict prompt', async () => { + (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue( + okPreview({ + sample: [{ identifier: A001, grades: { Midterm: 20 } }], + conflictRows: [ + { + identifier: A001, + studentName: 'Alice', + cells: { Midterm: { existing: 10, inFile: 20, changed: true } }, }, - }, - }, - }); - renderWizard(); - await userEvent.type(componentNameInput(), MIDTERMS); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},Quiz\nA001,41\n`), - ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - // The header diagnostic lists the mismatched columns, but with no - // suggestions returned it omits the "did you mean" line entirely. - expect( - await screen.findByText('These headers need fixing:'), - ).toBeInTheDocument(); - expect( - screen.getByText(/This column isn['’]t recognized:.*Quiz/i), - ).toBeInTheDocument(); - expect(screen.queryByText(/did you mean/i)).not.toBeInTheDocument(); - }); - - it('shows a specific error when the CSV has no data rows', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { data: { errors: { message: 'empty_csv' } } }, - }); - renderWizard(); - await advanceToVerifyFailure(`${EXTERNAL_ID},${MIDTERM}\n`); - await waitFor(() => - expect(toast.error).toHaveBeenCalledWith( - expect.stringMatching(/no data rows|empty/i), - ), + ], + }), ); - }); + (CourseAPI.gradebook.index as jest.Mock).mockResolvedValue(okIndex()); - it('shows the duplicated identifiers when a row identifier repeats', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue({ - response: { - data: { - errors: { message: 'duplicate_identifier', identifiers: ['A001'] }, - }, - }, - }); renderWizard(); - await advanceToVerifyFailure( - `${EXTERNAL_ID},${MIDTERM}\n${A001},40\n${A001},50\n`, - ); - await waitFor(() => - expect(toast.error).toHaveBeenCalledWith(expect.stringMatching(/A001/)), - ); - }); + await advanceToPreview(`${EXTERNAL_ID},${MIDTERM}\n${A001},20\n`); + await screen.findByText(A001); - it('toasts a generic error when the preview fails without a header diagnosis', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockRejectedValue( - new Error('network'), - ); - renderWizard(); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\nA001,41\n`), - ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); - await waitFor(() => - expect(toast.error).toHaveBeenCalledWith( - 'Could not verify the file. Please try again.', - ), + await userEvent.click( + screen.getByRole('button', { name: /resolve conflicts/i }), ); - // stays on upload step - expect( - screen.queryByRole('button', { name: /confirm import/i }), - ).not.toBeInTheDocument(); - }); - - it('toasts failure and stays open when the commit request rejects', async () => { - (CourseAPI.gradebook.importPreview as jest.Mock).mockResolvedValue({ - data: { - ok: true, - unresolved: [], - malformed: [], - outOfRange: [], - sample: [{ identifier: A001, grades: { Midterm: 41 } }], - conflictRows: [], - reassignments: [], - columnOrder: [MIDTERM], - totalRows: 1, - }, + const prompt = await screen.findByRole('dialog', { + name: /resolve grade conflicts/i, }); - (CourseAPI.gradebook.importCommit as jest.Mock).mockRejectedValue( - new Error('boom'), - ); - const onClose = jest.fn(); - render( - , - { state: studentsState([]) }, - ); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - await userEvent.upload( - screen.getByLabelText(/upload/i), - file(`${EXTERNAL_ID},${MIDTERM}\n${A001},41\n`), - ); - await userEvent.click(screen.getByRole('button', { name: /verify/i })); await userEvent.click( - await screen.findByRole('button', { name: /confirm import/i }), - ); - await waitFor(() => - expect(toast.error).toHaveBeenCalledWith( - 'Import failed. Nothing was saved.', - ), + within(prompt).getByRole('button', { name: /go back/i }), ); - expect(onClose).not.toHaveBeenCalled(); - }); - it('resets to the define step when reopened after a close', async () => { - const { rerender } = render( - , - ); - await userEvent.type(componentNameInput(), MIDTERM); - await userEvent.type(screen.getByLabelText(/weightage/i), '30'); - await userEvent.type(screen.getByLabelText(/max marks/i), '50'); - await userEvent.click(screen.getByRole('button', { name: /next/i })); - expect(screen.getByLabelText(/upload/i)).toBeInTheDocument(); // on step 1 - // rerender re-renders the root element directly, so re-wrap in TestApp to - // keep the Redux provider; reusing the singleton store keeps the same store - // instance, exercising the open-prop reset effect rather than a remount. - rerender( - - - , - ); - rerender( - - - , - ); - // back on define step with a blank component - expect(componentNameInput()).toHaveValue(''); - expect(screen.queryByLabelText(/upload/i)).not.toBeInTheDocument(); + // Wizard is back on the Preview step; the prompt is gone. + expect( + await screen.findByRole('dialog', { + name: /import external assessments/i, + }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('dialog', { name: /resolve grade conflicts/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByRole('button', { name: /resolve conflicts/i }), + ).toBeInTheDocument(); }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx deleted file mode 100644 index ab9d1fb3d5..0000000000 --- a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsButton.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { fireEvent, render, screen } from 'test-utils'; - -import ManageExternalAssessmentsButton from '../components/manage/ManageExternalAssessmentsButton'; - -const state = { - gradebook: { - categories: [], - tabs: [], - students: [], - submissions: [], - assessments: [], - gamificationEnabled: false, - weightedViewEnabled: false, - canManageWeights: true, - courseMaxLevel: 0, - levelContribution: { - enabled: false, - formula: '', - weight: 0, - show: false, - clamp: true, - }, - capTotal: false, - }, -}; - -it('opens the panel on click', async () => { - render(, { state }); - fireEvent.click( - await screen.findByRole('button', { name: 'Manage external assessments' }), - ); - expect(await screen.findByText('External assessments')).toBeVisible(); -}); - -it('does not open the external assessments panel by default', () => { - render(, { state }); - expect(screen.queryByText('External assessments')).not.toBeInTheDocument(); -}); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsContent.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsContent.test.tsx new file mode 100644 index 0000000000..55b6421909 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsContent.test.tsx @@ -0,0 +1,294 @@ +import type { DropResult } from '@hello-pangea/dnd'; +import userEvent from '@testing-library/user-event'; +import type { AppDispatch } from 'store'; +import { render, screen, waitFor } from 'test-utils'; + +import ManageExternalAssessmentsContent, { + handleDragEnd, + moveItem, +} from '../components/manage/ManageExternalAssessmentsContent'; +import { reorderExternalAssessments } from '../operations'; + +const dropResult = (from: number, to: number | null): DropResult => + ({ + draggableId: 'x', + type: 'DEFAULT', + mode: 'FLUID', + reason: 'DROP', + combine: null, + source: { index: from, droppableId: 'external-assessments' }, + destination: + to === null ? null : { index: to, droppableId: 'external-assessments' }, + }) as DropResult; + +jest.mock('../operations', () => ({ + __esModule: true, + ...jest.requireActual('../operations'), + reorderExternalAssessments: jest.fn( + () => (): Promise => Promise.resolve(), + ), +})); + +const preloadedState = { + gradebook: { + categories: [], + students: [], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + clamp: true, + }, + tabs: [ + { id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }, + { id: -2, title: 'Final', categoryId: -1, gradebookWeight: 50 }, + ], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + { + id: -2, + title: 'Final', + tabId: -2, + maxGrade: 100, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + { id: 7, title: 'Native quiz', tabId: 3, maxGrade: 10 }, + ], + capTotal: false, + }, +}; + +const externalWith = (overrides: { + floorAtZero: boolean; + capAtMaximum: boolean; +}): typeof preloadedState => ({ + gradebook: { + ...preloadedState.gradebook, + tabs: [{ id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }], + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + ...overrides, + }, + ], + }, +}); + +describe('handleDragEnd', () => { + beforeEach(() => { + (reorderExternalAssessments as jest.Mock).mockClear(); + }); + + it('does nothing when the row is dropped outside the list', () => { + const dispatch = jest.fn() as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(0, null), dispatch, jest.fn()); + expect(reorderExternalAssessments).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('does nothing when the row is dropped back in its original position', () => { + const dispatch = jest.fn() as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(1, 1), dispatch, jest.fn()); + expect(reorderExternalAssessments).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('dispatches the reordered ids on a valid move', () => { + const dispatch = jest.fn(() => Promise.resolve()) as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(2, 0), dispatch, jest.fn()); + expect(reorderExternalAssessments).toHaveBeenCalledWith([-3, -1, -2]); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + + it('invokes the error callback when the reorder dispatch rejects', async () => { + const onError = jest.fn(); + const dispatch = jest.fn(() => + Promise.reject(new Error('nope')), + ) as unknown as AppDispatch; + handleDragEnd([-1, -2, -3], dropResult(0, 2), dispatch, onError); + await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); + }); +}); + +it('renders the external list body with no dialog shell and no import button', async () => { + render(, { state: preloadedState }); + expect(await screen.findByText('Midterm')).toBeVisible(); + expect( + screen.queryByRole('button', { name: /import csv/i }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: /^close$/i }), + ).not.toBeInTheDocument(); +}); + +it('shows an empty state when there are no externals', async () => { + render(, { + state: { gradebook: { ...preloadedState.gradebook, assessments: [] } }, + }); + expect(await screen.findByText('No external assessments yet')).toBeVisible(); + expect(screen.queryByRole('table')).not.toBeInTheDocument(); +}); + +it('lists only external assessments', async () => { + render(, { state: preloadedState }); + expect(await screen.findByText('Name')).toBeVisible(); + expect(await screen.findByText('Midterm')).toBeVisible(); + expect(screen.queryByText('Native quiz')).not.toBeInTheDocument(); +}); + +it('opens the add dialog', async () => { + render(, { state: preloadedState }); + await userEvent.click(await screen.findByRole('button', { name: 'Add' })); + await waitFor(() => + expect(screen.getByText('Add external assessment')).toBeVisible(), + ); +}); + +it('opens the edit prompt for a row', async () => { + render(, { state: preloadedState }); + await userEvent.click(await screen.findByLabelText('edit Midterm')); + await waitFor(() => + expect(screen.getByText('Edit external assessment')).toBeVisible(), + ); +}); + +it('opens the delete confirmation for a row', async () => { + render(, { state: preloadedState }); + await userEvent.click(await screen.findByLabelText('delete Midterm')); + expect(await screen.findByText('Delete external assessment')).toBeVisible(); + expect( + screen.getByText( + /Delete "Midterm"\? This permanently removes the column and every student grade in it\. This cannot be undone\./, + ), + ).toBeVisible(); +}); + +it('hides weights when weighted view is disabled', async () => { + render(, { state: preloadedState }); + await screen.findByText('Midterm'); + expect(screen.queryByText('Weight')).not.toBeInTheDocument(); + expect(screen.queryByText('25')).not.toBeInTheDocument(); +}); + +it('never shows a Weight column, even when weighted view is enabled', async () => { + render(, { + state: { + gradebook: { ...preloadedState.gradebook, weightedViewEnabled: true }, + }, + }); + await screen.findByText('Midterm'); + expect(screen.queryByText('Weight')).not.toBeInTheDocument(); + expect(screen.queryByText('25')).not.toBeInTheDocument(); +}); + +it('hides the Remarks column when every external is at default bounds', async () => { + render(, { + state: externalWith({ floorAtZero: true, capAtMaximum: true }), + }); + await screen.findByText('Midterm'); + expect(screen.queryByText(/remarks/i)).not.toBeInTheDocument(); + expect(screen.queryByText('≥ 0')).not.toBeInTheDocument(); + expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); +}); + +it('hides the Remarks column when flags are absent (default bounds)', async () => { + render(, { + state: { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { id: -1, title: 'Midterm', tabId: -1, maxGrade: 50, external: true }, + ], + }, + }, + }); + await screen.findByText('Midterm'); + expect(screen.queryByText(/remarks/i)).not.toBeInTheDocument(); +}); + +it('shows Remarks with a No cap chip when capAtMaximum is false', async () => { + render(, { + state: externalWith({ floorAtZero: true, capAtMaximum: false }), + }); + expect(await screen.findByText(/remarks/i)).toBeInTheDocument(); + expect(screen.getByText(/no cap/i)).toBeInTheDocument(); + expect(screen.queryByText(/no floor/i)).not.toBeInTheDocument(); +}); + +it('shows Remarks with No floor and No cap chips when neither bound applies', async () => { + render(, { + state: externalWith({ floorAtZero: false, capAtMaximum: false }), + }); + expect(await screen.findByText(/remarks/i)).toBeInTheDocument(); + expect(screen.getByText(/no floor/i)).toBeInTheDocument(); + expect(screen.getByText(/no cap/i)).toBeInTheDocument(); +}); + +it('shows a hint explaining the No cap chip on hover', async () => { + render(, { + state: externalWith({ floorAtZero: true, capAtMaximum: false }), + }); + await userEvent.hover(await screen.findByText(/no cap/i)); + expect(await screen.findByText(/kept as-is \(not capped\)/i)).toBeVisible(); +}); + +it('renders a drag handle per external assessment', async () => { + const page = render(, { + state: preloadedState, + }); + expect(await page.findByLabelText('reorder Midterm')).toBeVisible(); + expect(await page.findByLabelText('reorder Final')).toBeVisible(); +}); + +it('hides the drag handle when there is only one external assessment', async () => { + const page = render(, { + state: { + gradebook: { + ...preloadedState.gradebook, + assessments: [ + { + id: -1, + title: 'Midterm', + tabId: -1, + maxGrade: 50, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + ], + }, + }, + }); + // The row still renders, with its grade in place... + expect(await page.findByText('Midterm')).toBeVisible(); + expect(page.getByText('50')).toBeVisible(); + // ...but the lone row has no reorder handle, so it cannot be dragged. + expect(page.queryByLabelText('reorder Midterm')).not.toBeInTheDocument(); +}); + +describe('moveItem', () => { + it('moves an item from one index to another, preserving the rest', () => { + expect(moveItem([-1, -2, -3], 2, 0)).toEqual([-3, -1, -2]); + expect(moveItem([-1, -2, -3], 0, 2)).toEqual([-2, -3, -1]); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx b/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx deleted file mode 100644 index 365ee5af52..0000000000 --- a/client/app/bundles/course/gradebook/__tests__/ManageExternalAssessmentsPanel.test.tsx +++ /dev/null @@ -1,481 +0,0 @@ -import type { DropResult } from '@hello-pangea/dnd'; -import userEvent from '@testing-library/user-event'; -import type { AppDispatch } from 'store'; -import { render, screen, waitFor, within } from 'test-utils'; - -import ManageExternalAssessmentsPanel, { - handleDragEnd, - moveItem, -} from '../components/manage/ManageExternalAssessmentsPanel'; -import { reorderExternalAssessments } from '../operations'; - -const dropResult = (from: number, to: number | null): DropResult => - ({ - draggableId: 'x', - type: 'DEFAULT', - mode: 'FLUID', - reason: 'DROP', - combine: null, - source: { index: from, droppableId: 'external-assessments' }, - destination: - to === null ? null : { index: to, droppableId: 'external-assessments' }, - }) as DropResult; - -jest.mock('../components/import/ImportExternalAssessmentsWizard', () => ({ - __esModule: true, - default: ({ - existingAssessments, - onClose, - open, - }: { - existingAssessments: { name: string }[]; - onClose: () => void; - open: boolean; - }): JSX.Element | null => - open ? ( -
        -

        Import external assessments

        - {existingAssessments.map((assessment) => ( - - ))} - - -
        - ) : null, -})); - -jest.mock('../operations', () => ({ - __esModule: true, - ...jest.requireActual('../operations'), - reorderExternalAssessments: jest.fn( - () => (): Promise => Promise.resolve(), - ), -})); - -const preloadedState = { - gradebook: { - categories: [], - students: [], - submissions: [], - gamificationEnabled: false, - weightedViewEnabled: false, - canManageWeights: true, - courseMaxLevel: 0, - levelContribution: { - enabled: false, - formula: '', - weight: 0, - show: false, - clamp: true, - }, - tabs: [ - { id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }, - { id: -2, title: 'Final', categoryId: -1, gradebookWeight: 50 }, - ], - assessments: [ - { - id: -1, - title: 'Midterm', - tabId: -1, - maxGrade: 50, - external: true, - floorAtZero: true, - capAtMaximum: true, - }, - { - id: -2, - title: 'Final', - tabId: -2, - maxGrade: 100, - external: true, - floorAtZero: true, - capAtMaximum: true, - }, - { id: 7, title: 'Native quiz', tabId: 3, maxGrade: 10 }, - ], - capTotal: false, - }, -}; - -const externalWith = (overrides: { - floorAtZero: boolean; - capAtMaximum: boolean; -}): typeof preloadedState => ({ - gradebook: { - ...preloadedState.gradebook, - tabs: [{ id: -1, title: 'Midterm', categoryId: -1, gradebookWeight: 25 }], - assessments: [ - { - id: -1, - title: 'Midterm', - tabId: -1, - maxGrade: 50, - external: true, - ...overrides, - }, - ], - }, -}); - -describe('handleDragEnd', () => { - beforeEach(() => { - (reorderExternalAssessments as jest.Mock).mockClear(); - }); - - it('does nothing when the row is dropped outside the list', () => { - const dispatch = jest.fn() as unknown as AppDispatch; - handleDragEnd([-1, -2, -3], dropResult(0, null), dispatch, jest.fn()); - expect(reorderExternalAssessments).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); - }); - - it('does nothing when the row is dropped back in its original position', () => { - const dispatch = jest.fn() as unknown as AppDispatch; - handleDragEnd([-1, -2, -3], dropResult(1, 1), dispatch, jest.fn()); - expect(reorderExternalAssessments).not.toHaveBeenCalled(); - expect(dispatch).not.toHaveBeenCalled(); - }); - - it('dispatches the reordered ids on a valid move', () => { - const dispatch = jest.fn(() => Promise.resolve()) as unknown as AppDispatch; - handleDragEnd([-1, -2, -3], dropResult(2, 0), dispatch, jest.fn()); - expect(reorderExternalAssessments).toHaveBeenCalledWith([-3, -1, -2]); - expect(dispatch).toHaveBeenCalledTimes(1); - }); - - it('invokes the error callback when the reorder dispatch rejects', async () => { - const onError = jest.fn(); - const dispatch = jest.fn(() => - Promise.reject(new Error('nope')), - ) as unknown as AppDispatch; - handleDragEnd([-1, -2, -3], dropResult(0, 2), dispatch, onError); - await waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - }); -}); - -it('shows an empty state when there are no externals', async () => { - render(, { - state: { gradebook: { ...preloadedState.gradebook, assessments: [] } }, - }); - expect(await screen.findByText('No external assessments yet')).toBeVisible(); - expect(screen.queryByRole('table')).not.toBeInTheDocument(); -}); - -it('lists only external assessments', async () => { - render(, { - state: preloadedState, - }); - expect(await screen.findByText('Name')).toBeVisible(); - expect(await screen.findByText('Midterm')).toBeVisible(); - expect(screen.queryByText('Native quiz')).not.toBeInTheDocument(); -}); - -it('opens the add dialog', async () => { - render(, { - state: preloadedState, - }); - await userEvent.click(await screen.findByRole('button', { name: 'Add' })); - await waitFor(() => - expect(screen.getByText('Add external assessment')).toBeVisible(), - ); -}); - -it('opens the edit prompt for a row', async () => { - render(, { - state: preloadedState, - }); - await userEvent.click( - await screen.findByRole('button', { name: 'edit Midterm' }), - ); - await waitFor(() => - expect(screen.getByText('Edit external assessment')).toBeVisible(), - ); -}); - -it('opens the delete prompt for a row', async () => { - render(, { - state: preloadedState, - }); - await userEvent.click( - await screen.findByRole('button', { name: 'delete Midterm' }), - ); - await waitFor(() => expect(screen.getByRole('dialog')).toBeVisible()); -}); - -it('invokes onClose when the close button is clicked', async () => { - const onClose = jest.fn(); - render(, { - state: preloadedState, - }); - await userEvent.click(await screen.findByRole('button', { name: 'Close' })); - expect(onClose).toHaveBeenCalledTimes(1); -}); - -it('opens the edit dialog for a row', async () => { - render(, { - state: preloadedState, - }); - - await userEvent.click(await screen.findByLabelText('edit Midterm')); - expect(await screen.findByText('Edit external assessment')).toBeVisible(); -}); - -it('seeds the edit dialog weightage from the tab weight (weighted view on)', async () => { - render(, { - state: { - gradebook: { - ...preloadedState.gradebook, - weightedViewEnabled: true, - }, - }, - }); - - await userEvent.click(await screen.findByLabelText('edit Midterm')); - expect(await screen.findByText('Edit external assessment')).toBeVisible(); - expect(await screen.findByLabelText('Weightage')).toHaveValue(25); -}); - -it('opens the delete confirmation for a row', async () => { - render(, { - state: preloadedState, - }); - - await userEvent.click(await screen.findByLabelText('delete Midterm')); - expect(await screen.findByText('Delete external assessment')).toBeVisible(); - // the confirmation body names the assessment being deleted - expect( - screen.getByText( - /Delete "Midterm"\? This permanently removes the column and every student grade in it\. This cannot be undone\./, - ), - ).toBeVisible(); -}); - -it('hides weights when weighted view is disabled', async () => { - render(, { - state: preloadedState, - }); - - await screen.findByText('Midterm'); - expect(screen.queryByText('Weight')).not.toBeInTheDocument(); - expect(screen.queryByText('25')).not.toBeInTheDocument(); -}); - -it('shows external assessment weights when weighted view is enabled', async () => { - render(, { - state: { - gradebook: { - ...preloadedState.gradebook, - weightedViewEnabled: true, - }, - }, - }); - - expect(await screen.findByText('Weight')).toBeVisible(); - expect(screen.getByText('25')).toBeVisible(); -}); - -it('renders bounds chips per the floor/cap flags', async () => { - const stateWithMixedBounds = { - gradebook: { - ...preloadedState.gradebook, - assessments: [ - { - id: -1, - title: 'Midterm', - tabId: -1, - maxGrade: 50, - external: true, - floorAtZero: true, - capAtMaximum: false, - }, - ], - }, - }; - render(, { - state: stateWithMixedBounds, - }); - - expect(await screen.findByText('≥ 0')).toBeVisible(); - expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); -}); - -it('shows both bounds chips by default when flags are absent', async () => { - const stateWithDefaultBounds = { - gradebook: { - ...preloadedState.gradebook, - assessments: [ - { id: -1, title: 'Midterm', tabId: -1, maxGrade: 50, external: true }, - ], - }, - }; - render(, { - state: stateWithDefaultBounds, - }); - - expect(await screen.findByText('≥ 0')).toBeVisible(); - expect(screen.getByText('≤ max')).toBeVisible(); -}); - -it('shows the ≥ 0 and ≤ max chips for a floored and capped external', async () => { - render(, { - state: externalWith({ floorAtZero: true, capAtMaximum: true }), - }); - - expect(await screen.findByText('≥ 0')).toBeVisible(); - expect(screen.getByText('≤ max')).toBeVisible(); - expect(screen.getByText('50')).toBeVisible(); -}); - -it('hides both bound chips when an external is neither floored nor capped', async () => { - render(, { - state: externalWith({ floorAtZero: false, capAtMaximum: false }), - }); - - await screen.findByText('Midterm'); - expect(screen.queryByText('≥ 0')).not.toBeInTheDocument(); - expect(screen.queryByText('≤ max')).not.toBeInTheDocument(); -}); - -it('passes existing external names as chips to the import wizard', async () => { - const stateWithExternal = { - gradebook: { - categories: [], - tabs: [{ id: 1, title: 'External', categoryId: 0, gradebookWeight: 30 }], - assessments: [ - { - id: -1, - title: 'Midterm', - tabId: 1, - maxGrade: 50, - gradebookWeight: 30, - external: true, - }, - ], - students: [], - submissions: [], - gamificationEnabled: false, - weightedViewEnabled: true, - canManageWeights: true, - levelContribution: { - enabled: false, - formula: '', - weight: 0, - show: false, - clamp: true, - }, - courseMaxLevel: 0, - capTotal: false, - }, - }; - render(, { - state: stateWithExternal, - }); - - // Open the import wizard - await userEvent.click( - await screen.findByRole('button', { name: /import csv/i }), - ); - - // The "Midterm" chip must appear in the define step - expect( - within(await screen.findByTestId('import-wizard')).getByText('Midterm'), - ).toBeInTheDocument(); -}); - -it('hides the external assessments panel while importing and reopens it on cancel', async () => { - render(, { - state: preloadedState, - }); - - expect( - await screen.findByRole('heading', { name: 'External assessments' }), - ).toBeVisible(); - - await userEvent.click( - await screen.findByRole('button', { name: /import csv/i }), - ); - - expect(await screen.findByText('Import external assessments')).toBeVisible(); - await waitFor(() => - expect(screen.queryByText('External assessments')).not.toBeInTheDocument(), - ); - - await userEvent.click( - within(screen.getByTestId('import-wizard')).getByText('Cancel'), - ); - - expect( - await screen.findByRole('heading', { name: 'External assessments' }), - ).toBeVisible(); -}); - -it('reopens the external assessments panel after a successful import', async () => { - render(, { - state: preloadedState, - }); - - await userEvent.click( - await screen.findByRole('button', { name: /import csv/i }), - ); - expect(await screen.findByText('Import external assessments')).toBeVisible(); - await userEvent.click( - within(screen.getByTestId('import-wizard')).getByText('Confirm import'), - ); - - expect( - await screen.findByRole('heading', { name: 'External assessments' }), - ).toBeVisible(); -}); - -it('renders a drag handle per external assessment', async () => { - const page = render( - , - { - state: preloadedState, - }, - ); - expect(await page.findByLabelText('reorder Midterm')).toBeVisible(); - expect(await page.findByLabelText('reorder Final')).toBeVisible(); -}); - -it('hides the drag handle when there is only one external assessment', async () => { - const page = render( - , - { - state: { - gradebook: { - ...preloadedState.gradebook, - assessments: [ - { - id: -1, - title: 'Midterm', - tabId: -1, - maxGrade: 50, - external: true, - floorAtZero: true, - capAtMaximum: true, - }, - ], - }, - }, - }, - ); - // The row still renders, with its grade in place... - expect(await page.findByText('Midterm')).toBeVisible(); - expect(page.getByText('50')).toBeVisible(); - // ...but the lone row has no reorder handle, so it cannot be dragged. - expect(page.queryByLabelText('reorder Midterm')).not.toBeInTheDocument(); -}); - -describe('moveItem', () => { - it('moves an item from one index to another, preserving the rest', () => { - expect(moveItem([-1, -2, -3], 2, 0)).toEqual([-3, -1, -2]); - expect(moveItem([-1, -2, -3], 0, 2)).toEqual([-2, -3, -1]); - }); -}); diff --git a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx index cceee6fd8e..276ed59272 100644 --- a/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/WeightedGradebookTable.test.tsx @@ -545,19 +545,15 @@ describe('WeightedGradebookTable', () => { ).not.toBeInTheDocument(); }); - // 11. canManageWeights === false → no "Configure Weights" button - it('does not show Configure Weights button when canManageWeights is false', () => { - renderWeighted({ canManageWeights: false }); + // 11/12. Weights moved into the settings gear: the toolbar no longer has a + // standalone Configure Weights button, and the column picker reads "Select Columns". + it('has no standalone Configure Weights button and labels the picker "Select Columns"', () => { + renderWeighted({ canManageWeights: true }); 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 }); + ).toBeNull(); expect( - screen.getByRole('button', { name: /configure weights/i }), + screen.getByRole('button', { name: /^select columns$/i }), ).toBeInTheDocument(); }); @@ -653,13 +649,13 @@ describe('WeightedGradebookTable', () => { ).toBeInTheDocument(); }); - it('places the toolbar action before the Select Columns button', () => { + it('places the toolbar action before the Columns button', () => { renderWeighted({ toolbarAction: , }); const action = screen.getByText('Manage external'); const selectColumns = screen.getByRole('button', { - name: /select columns/i, + name: /columns/i, }); expect( // eslint-disable-next-line no-bitwise @@ -677,10 +673,10 @@ describe('WeightedGradebookTable', () => { }); describe('column picker', () => { - it('shows Select Columns and Export buttons', () => { + it('shows Columns and Export buttons', () => { renderWeighted(); expect( - screen.getByRole('button', { name: /select columns/i }), + screen.getByRole('button', { name: /columns/i }), ).toBeInTheDocument(); expect( screen.getByRole('button', { name: /export/i }), @@ -773,7 +769,7 @@ describe('WeightedGradebookTable', () => { 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 })); + await user.click(screen.getByRole('button', { name: /columns/i })); const dialog = await screen.findByRole('dialog'); expect(within(dialog).getByText('Email')).toBeInTheDocument(); expect( @@ -1393,7 +1389,7 @@ describe('WeightedGradebookTable', () => { const user = userEvent.setup(); renderWeighted(); await user.click( - await screen.findByRole('button', { name: /select columns/i }), + await screen.findByRole('button', { name: /columns/i }), ); const dialog = await screen.findByRole('dialog'); expect( diff --git a/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts index 279e3cc68e..31b3aa6177 100644 --- a/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/buildTemplate.test.ts @@ -1,6 +1,7 @@ import { - buildTemplateCsv, + exampleCsv, identifierHeader, + templateDataUri, } from '../components/import/buildTemplate'; describe('identifierHeader', () => { @@ -10,70 +11,30 @@ describe('identifierHeader', () => { }); }); -describe('buildTemplateCsv', () => { - const components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }]; - - it('uses the External ID header in external_id mode', () => { - expect(buildTemplateCsv(components, 'external_id')).toBe( - 'External ID,Midterm\n', - ); - }); - - it('uses the Email header in email mode', () => { - expect(buildTemplateCsv(components, 'email')).toBe('Email,Midterm\n'); - }); - - it('quotes a component name containing a comma', () => { - const csv = buildTemplateCsv( - [{ name: 'Lab, week 1', weightage: 10, maximumGrade: 20 }], - 'external_id', - ); - expect(csv.split('\n')[0]).toBe('External ID,"Lab, week 1"'); - }); - - it('returns "External ID\\n" for empty components array in external_id mode', () => { - expect(buildTemplateCsv([], 'external_id')).toBe('External ID\n'); - }); - - it('quotes a component name containing a double-quote', () => { - const csv = buildTemplateCsv( - [{ name: 'My "Best" Quiz', weightage: 10, maximumGrade: 20 }], - 'external_id', - ); - expect(csv.split('\n')[0]).toBe('External ID,"My ""Best"" Quiz"'); - }); - - it('quotes a component name containing a newline', () => { - const csv = buildTemplateCsv( - [{ name: 'Lab\nWeek1', weightage: 10, maximumGrade: 20 }], - 'external_id', +describe('exampleCsv', () => { + it('builds the External ID template with two assessment columns', () => { + expect(exampleCsv('external_id')).toBe( + 'External ID,Assessment 1,Assessment 2\nA0123456,85,90\nA0123457,78,88', ); - // The quoted cell spans two lines; verify the full header row content. - expect(csv.startsWith('External ID,"Lab\nWeek1"')).toBe(true); }); - it('always ends with exactly one newline', () => { - const csv = buildTemplateCsv( - [{ name: 'A', weightage: 0, maximumGrade: 100 }], - 'external_id', + it('builds the Email template', () => { + expect(exampleCsv('email')).toBe( + 'Email,Assessment 1,Assessment 2\ntest1@example.com,85,90\ntest2@example.com,78,88', ); - expect(csv.endsWith('\n')).toBe(true); - expect(csv.split('\n')).toHaveLength(2); // header line + empty string after trailing \n }); +}); - it('emits one column per component, in input order', () => { - const csv = buildTemplateCsv( - [ - { name: 'Midterm', weightage: 30, maximumGrade: 50 }, - { name: 'Final', weightage: 50, maximumGrade: 100 }, - { name: 'Lab', weightage: 20, maximumGrade: 20 }, - ], - 'external_id', - ); - expect(csv).toBe('External ID,Midterm,Final,Lab\n'); +describe('templateDataUri', () => { + it('encodes the CSV (plus a trailing newline) as a text/csv data URI', () => { + const uri = templateDataUri('external_id'); + expect(uri.startsWith('data:text/csv;charset=utf-8,')).toBe(true); + expect( + decodeURIComponent(uri.replace('data:text/csv;charset=utf-8,', '')), + ).toBe(`${exampleCsv('external_id')}\n`); }); - it('returns "Email\\n" for empty components array in email mode', () => { - expect(buildTemplateCsv([], 'email')).toBe('Email\n'); + it('differs between identifier modes', () => { + expect(templateDataUri('email')).not.toBe(templateDataUri('external_id')); }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/importTypes.test.ts b/client/app/bundles/course/gradebook/__tests__/importTypes.test.ts new file mode 100644 index 0000000000..60d3360c42 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/importTypes.test.ts @@ -0,0 +1,20 @@ +import type { ImportPreviewRequest } from 'types/course/gradebook'; + +test('ImportPreviewRequest uses identifierColumn + mappings; create carries maxGrade/weight', () => { + const req: ImportPreviewRequest = { + identifierMode: 'email', + identifierColumn: 'Email', + csvData: '', + mappings: [ + { + header: 'Midterm', + action: 'create', + target: 'Midterm', + maxGrade: 100, + weight: 0, + }, + ], + }; + expect(req.mappings[0].action).toBe('create'); + expect(req.mappings[0].maxGrade).toBe(100); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts b/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts index a2d8042269..334cf0c362 100644 --- a/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts +++ b/client/app/bundles/course/gradebook/__tests__/levelFormula.test.ts @@ -141,9 +141,13 @@ describe('parseFormula — plain-language errors', () => { }); it('explains too many arguments to min/max with an example', () => { - expect(errorOf('min(level, 25, 30)')).toMatch(/min takes exactly two values/i); + expect(errorOf('min(level, 25, 30)')).toMatch( + /min takes exactly two values/i, + ); expect(errorOf('min(level, 25, 30)')).toMatch(/min\(level, 25\)/); - expect(errorOf('max(0, level, 5)')).toMatch(/max takes exactly two values/i); + expect(errorOf('max(0, level, 5)')).toMatch( + /max takes exactly two values/i, + ); expect(errorOf('max(0, level, 5)')).toMatch(/max\(level, 25\)/); // and never falls back to the misleading closing-bracket message expect(errorOf('min(level, 25, 30)')).not.toMatch(/closing bracket/i); diff --git a/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx index f872da82f8..ab409cce1b 100644 --- a/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx +++ b/client/app/bundles/course/gradebook/components/AddExternalColumnPrompt.tsx @@ -1,7 +1,11 @@ import { FC, useState } from 'react'; import { defineMessages } from 'react-intl'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import InfoOutlined from '@mui/icons-material/InfoOutlined'; import { + Accordion, + AccordionDetails, + AccordionSummary, Button, Dialog, DialogActions, @@ -32,9 +36,9 @@ const translations = defineMessages({ id: 'course.gradebook.AddExternalColumnPrompt.maxLabel', defaultMessage: 'Max marks', }, - weightLabel: { - id: 'course.gradebook.AddExternalColumnPrompt.weightLabel', - defaultMessage: 'Weightage', + advancedLabel: { + id: 'course.gradebook.AddExternalColumnPrompt.advancedLabel', + defaultMessage: 'Advanced settings', }, floorLabel: { id: 'course.gradebook.AddExternalColumnPrompt.floorLabel', @@ -47,12 +51,12 @@ const translations = defineMessages({ floorHint: { id: 'course.gradebook.AddExternalColumnPrompt.floorHint', defaultMessage: - 'Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged.', + 'Counts negative grades as 0 when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged.', }, capHint: { id: 'course.gradebook.AddExternalColumnPrompt.capHint', defaultMessage: - 'Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged.', + 'Counts grades above the maximum as the maximum when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged.', }, cancel: { id: 'course.gradebook.AddExternalColumnPrompt.cancel', @@ -74,15 +78,10 @@ const translations = defineMessages({ interface Props { open: boolean; - weightedViewEnabled?: boolean; onClose: () => void; } -const AddExternalColumnPrompt: FC = ({ - open, - weightedViewEnabled = false, - onClose, -}) => { +const AddExternalColumnPrompt: FC = ({ open, onClose }) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const [name, setName] = useState(''); @@ -90,14 +89,12 @@ const AddExternalColumnPrompt: FC = ({ const [floorAtZero, setFloorAtZero] = useState(true); const [capAtMaximum, setCapAtMaximum] = useState(true); const [saving, setSaving] = useState(false); - const [weight, setWeight] = useState('0'); const reset = (): void => { setName(''); setMax(''); setFloorAtZero(true); setCapAtMaximum(true); - setWeight('0'); }; const canSave = @@ -112,7 +109,6 @@ const AddExternalColumnPrompt: FC = ({ Number(max), floorAtZero, capAtMaximum, - weightedViewEnabled ? Number(weight) : undefined, ), ); toast.success(t(translations.success)); @@ -145,52 +141,49 @@ const AddExternalColumnPrompt: FC = ({ type="number" value={max} /> - {weightedViewEnabled && ( - setWeight(e.target.value)} - type="number" - value={weight} - /> - )} -
        - setFloorAtZero(e.target.checked)} + + }> + {t(translations.advancedLabel)} + + +
        + setFloorAtZero(e.target.checked)} + /> + } + label={t(translations.floorLabel)} /> - } - label={t(translations.floorLabel)} - /> - - - -
        -
        - setCapAtMaximum(e.target.checked)} + + + +
        +
        + setCapAtMaximum(e.target.checked)} + /> + } + label={t(translations.capLabel)} /> - } - label={t(translations.capLabel)} - /> - - - -
        + + + +
        + + - )} - + + + {toolbar.onDirectExport && ( - {canManageWeights && ( - setConfigureOpen(false)} - open={configureOpen} - students={students} - tabs={tabs} - /> - )} - {toolbar.columnPicker && toolbar.commitColumnVisibility && ( void; + setCreateTitle: (header: string, title: string) => void; + setExistingTarget: (header: string, name: string) => void; + setCreateMaxGrade: (header: string, max: number) => void; + setCreateWeight: (header: string, weight: number) => void; +} + +const ColumnMappingTable: FC = ({ + columns, + existing, + weightedViewEnabled, + setColumnAction, + setCreateTitle, + setExistingTarget, + setCreateMaxGrade, + setCreateWeight, +}) => { + const { t } = useTranslation(); + + // Real max/weight for existing rows live on this prop (the wizard passes the + // course's actual assessments, with weight) — not on ColumnState. + const existingByName = new Map( + existing.map((a) => [a.name.trim().toLowerCase(), a]), + ); + + const dash = ( + + {DASH} + + ); + + const renderError = (col: ColumnState): JSX.Element | null => { + if (col.status !== 'error') return null; + let message: string; + if (col.error === 'nonNumeric') { + const first = col.badCells[0]; + message = t(translations.nonNumericError, { + row: first?.row ?? 0, + value: first?.value ?? '', + }); + } else if (col.error === 'titleCollision') { + message = t(translations.titleCollisionError); + } else if (col.error === 'duplicateExisting') { + message = t(translations.duplicateExistingError); + } else { + message = t(translations.duplicateTitleError); + } + return {message}; + }; + + const renderAction = (col: ColumnState): JSX.Element => ( + + ); + + const renderTitle = (col: ColumnState): JSX.Element => { + if (col.action === 'ignore') { + if (col.hasNumericData) return dash; + return ( + + {t(translations.nonGradeColumnNote)} + + ); + } + if (col.action === 'create') { + return ( + <> + setCreateTitle(col.header, e.target.value)} + size="small" + value={col.newTitle} + /> + {renderError(col)} + + ); + } + return ( + <> + + {renderError(col)} + + ); + }; + + const renderMax = (col: ColumnState): JSX.Element => { + if (col.action === 'ignore') return dash; + if (col.action === 'create') { + return ( + + setCreateMaxGrade(col.header, Number(e.target.value)) + } + size="small" + type="number" + value={col.newMaxGrade} + /> + ); + } + const resolved = existingByName.get( + col.existingTarget.trim().toLowerCase(), + ); + return ( + + ); + }; + + const renderWeight = (col: ColumnState): JSX.Element => { + if (col.action === 'ignore') return dash; + if (col.action === 'create') { + return ( + setCreateWeight(col.header, Number(e.target.value))} + size="small" + type="number" + value={col.newWeight} + /> + ); + } + const resolved = existingByName.get( + col.existingTarget.trim().toLowerCase(), + ); + return ( + + ); + }; + + const incomplete = columns.some((c) => c.status === 'incomplete'); + const nothingImported = + columns.length > 0 && columns.every((c) => c.action === 'ignore'); + + return ( +
        + + {t(translations.helperLine)} + + + + + + + {t(translations.csvHeader)} + + + {t(translations.actionLabel)} + + + {t(translations.titleLabel)} + + + {t(translations.maxLabel)} + + {weightedViewEnabled && ( + + {t(translations.weightLabel)} + + )} + + + + {columns.map((col) => ( + + + + {col.header} + + + + {renderAction(col)} + + + {renderTitle(col)} + + + {renderMax(col)} + + {weightedViewEnabled && ( + + {renderWeight(col)} + + )} + + ))} + +
        + + {(incomplete || nothingImported) && ( + + {nothingImported + ? t(translations.nothingImportedHint) + : t(translations.incompleteHint)} + + )} +
        + ); +}; + +export default ColumnMappingTable; diff --git a/client/app/bundles/course/gradebook/components/import/ImportCsvButton.tsx b/client/app/bundles/course/gradebook/components/import/ImportCsvButton.tsx new file mode 100644 index 0000000000..b58fe8e36c --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/ImportCsvButton.tsx @@ -0,0 +1,65 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Upload } from '@mui/icons-material'; +import { Button } from '@mui/material'; +import type { ExistingExternalAssessment } from 'types/course/gradebook'; + +import { useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { getExternalAssessments, getTabs } from '../../selectors'; + +import ImportExternalAssessmentsWizard from './ImportExternalAssessmentsWizard'; + +const translations = defineMessages({ + import: { + id: 'course.gradebook.ImportCsvButton.label', + defaultMessage: 'Import CSV', + }, +}); + +interface Props { + /** Match the host toolbar's button size. Defaults to MUI's `medium`. */ + size?: 'small' | 'medium'; + weightedViewEnabled: boolean; +} + +// Top-level CSV import trigger; opens the stepped header-mapping wizard. +const ImportCsvButton: FC = ({ size, weightedViewEnabled }) => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const externals = useAppSelector(getExternalAssessments); + const tabs = useAppSelector(getTabs); + + const tabWeights = Object.fromEntries( + tabs.map((tab) => [tab.id, tab.gradebookWeight ?? 0]), + ); + const existingAssessments: ExistingExternalAssessment[] = externals.map( + (a) => ({ + name: a.title, + maximumGrade: a.maxGrade, + weight: tabWeights[a.tabId] ?? 0, + }), + ); + + return ( + <> + + setOpen(false)} + open={open} + weightedViewEnabled={weightedViewEnabled} + /> + + ); +}; + +export default ImportCsvButton; diff --git a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx index f702751cca..662c8263b7 100644 --- a/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx +++ b/client/app/bundles/course/gradebook/components/import/ImportExternalAssessmentsWizard.tsx @@ -1,19 +1,18 @@ -import { FC, useEffect, useMemo, useState } from 'react'; +import { FC, useEffect, useState } from 'react'; import Dropzone from 'react-dropzone'; import { defineMessages } from 'react-intl'; import { useParams } from 'react-router-dom'; -import { Add, Delete } from '@mui/icons-material'; +import DownloadIcon from '@mui/icons-material/Download'; import { LoadingButton } from '@mui/lab'; +import type { SxProps, Theme } from '@mui/material'; import { Alert, AlertTitle, Button, - Chip, Dialog, DialogActions, DialogContent, DialogTitle, - IconButton, Link as MuiLink, Step, StepLabel, @@ -23,74 +22,46 @@ import { TableCell, TableHead, TableRow, - TextField, Typography, } from '@mui/material'; -import type { SxProps, Theme } from '@mui/material'; -import type { - ExistingExternalAssessment, - IdentifierMode, - ImportComponent, - ImportPreviewResult, -} from 'types/course/gradebook'; +import type { ExistingExternalAssessment } from 'types/course/gradebook'; import SegmentedSwitch from 'lib/components/core/buttons/SegmentedSwitch'; +import Link from 'lib/components/core/Link'; import { FilePreview } from 'lib/components/form/fields/SingleFileInput'; import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; -import { commitImport, previewImport } from '../../operations'; +import { commitImport } from '../../operations'; import { getStudents } from '../../selectors'; import { - downloadTemplate, + exampleCsv, identifierHeader, readFileText, + templateDataUri, } from './buildTemplate'; +import ColumnMappingTable from './ColumnMappingTable'; import ExternalGradeConflictPrompt from './ExternalGradeConflictPrompt'; +import useImportMapping from './useImportMapping'; const translations = defineMessages({ title: { id: 'course.gradebook.ImportWizard.title', defaultMessage: 'Import external assessments', }, - stepDefine: { - id: 'course.gradebook.ImportWizard.stepDefine', - defaultMessage: 'Define components', - }, stepUpload: { id: 'course.gradebook.ImportWizard.stepUpload', - defaultMessage: 'Template & upload', - }, - stepVerify: { - id: 'course.gradebook.ImportWizard.stepVerify', - defaultMessage: 'Verify', - }, - componentName: { - id: 'course.gradebook.ImportWizard.componentName', - defaultMessage: 'Component name', - }, - weightage: { - id: 'course.gradebook.ImportWizard.weightage', - defaultMessage: 'Weightage', + defaultMessage: 'Upload', }, - maxMarks: { - id: 'course.gradebook.ImportWizard.maxMarks', - defaultMessage: 'Max marks', + stepMap: { + id: 'course.gradebook.ImportWizard.stepMap', + defaultMessage: 'Map columns', }, - addComponent: { - id: 'course.gradebook.ImportWizard.addComponent', - defaultMessage: 'Add component', - }, - updatesExisting: { - id: 'course.gradebook.ImportWizard.updatesExisting', - defaultMessage: - 'Existing assessment - settings managed in Manage External Assessments', - }, - fromExisting: { - id: 'course.gradebook.ImportWizard.fromExisting', - defaultMessage: 'From existing', + stepPreview: { + id: 'course.gradebook.ImportWizard.stepPreview', + defaultMessage: 'Preview', }, identifierMode: { id: 'course.gradebook.ImportWizard.identifierMode', @@ -104,38 +75,55 @@ const translations = defineMessages({ requiredHeaders: { id: 'course.gradebook.ImportWizard.requiredHeaders', defaultMessage: - 'Your CSV needs these column headers: {headers}. ‘{identifier}’ must be the first column.', + "Your CSV's first column must be {headers} to identify students.", }, - headerErrorsHeading: { - id: 'course.gradebook.ImportWizard.headerErrorsHeading', - defaultMessage: 'These headers need fixing:', + exampleHeader: { + id: 'course.gradebook.ImportWizard.exampleHeader', + defaultMessage: 'Example ', }, - headerErrorsClosing: { - id: 'course.gradebook.ImportWizard.headerErrorsClosing', - defaultMessage: 'Correct these in your CSV, then re-upload.', + template: { + id: 'course.gradebook.ImportWizard.template', + defaultMessage: '(Template File)', }, - identifierNotFirst: { - id: 'course.gradebook.ImportWizard.identifierNotFirst', - defaultMessage: '‘{identifier}’ must be the first column.', + blockUnreadable: { + id: 'course.gradebook.ImportWizard.blockUnreadable', + defaultMessage: + "We couldn't read any columns from this file. Upload a CSV with a header row.", + }, + blockDuplicateHeaders: { + id: 'course.gradebook.ImportWizard.blockDuplicateHeaders', + defaultMessage: + 'This file has duplicate column names ({headers}). Rename them so each is unique, then re-upload.', + }, + blockIdentifierNotFirst: { + id: 'course.gradebook.ImportWizard.blockIdentifierNotFirst', + defaultMessage: + 'The first column must be “{expected}”, but it is “{found}”. Put the identifier first, or switch how you match students above.', + }, + blockNoGradeColumns: { + id: 'course.gradebook.ImportWizard.blockNoGradeColumns', + defaultMessage: + 'This file only has the identifier column. Add at least one grade column, then re-upload.', + }, + blockNoDataRows: { + id: 'course.gradebook.ImportWizard.blockNoDataRows', + defaultMessage: + 'This file has no data rows. Add at least one student row, then re-upload.', + }, + identifierColumnStatic: { + id: 'course.gradebook.ImportWizard.identifierColumnStatic', + defaultMessage: 'Identifier column', }, dropzone: { id: 'course.gradebook.ImportWizard.dropzone', defaultMessage: 'Drag a CSV here, or click to choose a file', }, - downloadTemplate: { - id: 'course.gradebook.ImportWizard.downloadTemplate', - defaultMessage: 'Download template', - }, upload: { id: 'course.gradebook.ImportWizard.upload', defaultMessage: 'Upload filled CSV', }, back: { id: 'course.gradebook.ImportWizard.back', defaultMessage: 'Back' }, next: { id: 'course.gradebook.ImportWizard.next', defaultMessage: 'Next' }, - verify: { - id: 'course.gradebook.ImportWizard.verify', - defaultMessage: 'Verify', - }, cancel: { id: 'course.gradebook.ImportWizard.cancel', defaultMessage: 'Cancel', @@ -144,6 +132,10 @@ const translations = defineMessages({ id: 'course.gradebook.ImportWizard.continue', defaultMessage: 'Confirm import', }, + resolveConflicts: { + id: 'course.gradebook.ImportWizard.resolveConflicts', + defaultMessage: 'Resolve Conflicts', + }, unresolvedEmail: { id: 'course.gradebook.ImportWizard.unresolvedEmail', defaultMessage: @@ -185,58 +177,6 @@ const translations = defineMessages({ defaultMessage: 'These identifiers were previously imported for another student. Grades are matched by the current student, not the identifier — confirm these are the people you intend before importing.', }, - committed: { - id: 'course.gradebook.ImportWizard.committed', - defaultMessage: 'Import complete.', - }, - committedReplaced: { - id: 'course.gradebook.ImportWizard.committedReplaced', - defaultMessage: - 'Import complete. {count, plural, one {# existing grade replaced} other {# existing grades replaced}}.', - }, - committedKept: { - id: 'course.gradebook.ImportWizard.committedKept', - defaultMessage: - 'Import complete. {count, plural, one {# existing grade kept} other {# existing grades kept}}.', - }, - headerSuggestion: { - id: 'course.gradebook.ImportWizard.headerSuggestion', - defaultMessage: - 'No column named ‘{suggestion}’ — did you mean ‘{expected}’?', - }, - duplicateHeaders: { - id: 'course.gradebook.ImportWizard.duplicateHeaders', - defaultMessage: - '{count, plural, one {This column appears more than once: {dupes}.} other {These columns appear more than once: {dupes}.}}', - }, - missingHeaders: { - id: 'course.gradebook.ImportWizard.missingHeaders', - defaultMessage: - '{count, plural, one {Your CSV is missing this column: {missing}.} other {Your CSV is missing these columns: {missing}.}}', - }, - unrecognizedHeaders: { - id: 'course.gradebook.ImportWizard.unrecognizedHeaders', - defaultMessage: - '{count, plural, one {This column isn’t recognized: {unrecognized}.} other {These columns aren’t recognized: {unrecognized}.}}', - }, - commitError: { - id: 'course.gradebook.ImportWizard.commitError', - defaultMessage: 'Import failed. Nothing was saved.', - }, - previewError: { - id: 'course.gradebook.ImportWizard.previewError', - defaultMessage: 'Could not verify the file. Please try again.', - }, - emptyCsv: { - id: 'course.gradebook.ImportWizard.emptyCsv', - defaultMessage: - 'The uploaded file has no data rows. Add at least one student row and try again.', - }, - duplicateIdentifier: { - id: 'course.gradebook.ImportWizard.duplicateIdentifier', - defaultMessage: - 'The file lists {count, plural, one {an identifier} other {identifiers}} more than once: {ids}. Each student should appear on a single row.', - }, externalIdHint: { id: 'course.gradebook.ImportWizard.externalIdHint', defaultMessage: @@ -250,17 +190,28 @@ const translations = defineMessages({ previewRows: { id: 'course.gradebook.ImportWizard.previewRows', defaultMessage: - 'Previewing the first 5 of {totalRows} rows. Check that this preview matches your CSV before continuing.', + 'Previewing the first 5 of {totalRows} rows. Check that these grades match your CSV before continuing.', }, previewFewRows: { id: 'course.gradebook.ImportWizard.previewFewRows', defaultMessage: - 'Previewing all {totalRows} rows. Check that this preview matches your CSV before continuing.', + 'Previewing all {totalRows} rows. Check that these grades match your CSV before continuing.', }, willChangeExisting: { id: 'course.gradebook.ImportWizard.willChangeExisting', defaultMessage: - '{count, plural, one {# row contains} other {# rows contain}} changes to existing grades. After checking this preview, click Confirm import to review these conflicts before anything is imported.', + '{count, plural, one {# row contains} other {# rows contain}} changes to existing grades. After checking this preview, click Resolve Conflicts to review these conflicts before anything is imported.', + }, + commitError: { + id: 'course.gradebook.ImportWizard.commitError', + defaultMessage: 'Import failed. Nothing was saved.', + }, + // Reuses the exact id + copy B5 already added for ColumnMappingTable's own + // success path; this wizard fires the same toast on a clean commit. + importSuccess: { + id: 'course.gradebook.ImportWizard.importSuccess', + defaultMessage: + 'Imported grades. Created {count, plural, one {# external assessment} other {# external assessments}}.', }, }); @@ -271,56 +222,7 @@ interface Props { existingAssessments: ExistingExternalAssessment[]; } -let rowId = 0; -const blankComponent = (): ImportComponent & { id: number } => { - rowId += 1; - return { id: rowId, name: '', weightage: 0, maximumGrade: 0 }; -}; - -interface BadHeaderError { - message: string; - missing: string[]; - unrecognized: string[]; - suggestions: { expected: string; didYouMean: string }[]; - duplicates: { name: string; count: number }[]; - identifierNotFirst: boolean; -} - -const badHeaderFromError = (error: unknown): BadHeaderError | null => { - const body = ( - error as { response?: { data?: { errors?: Partial } } } - )?.response?.data?.errors; - return body?.message === 'bad_header' - ? { - message: body.message, - missing: body.missing ?? [], - unrecognized: body.unrecognized ?? [], - suggestions: body.suggestions ?? [], - duplicates: body.duplicates ?? [], - identifierNotFirst: body.identifierNotFirst ?? false, - } - : null; -}; - -const importErrorCode = ( - error: unknown, -): { message: string; identifiers?: string[] } | null => { - const msg = ( - error as { - response?: { - data?: { errors?: { message?: string; identifiers?: string[] } }; - }; - } - )?.response?.data?.errors?.message; - if (!msg) return null; - return ( - error as { - response: { - data: { errors: { message: string; identifiers?: string[] } }; - }; - } - ).response.data.errors; -}; +type WizardStep = 'upload' | 'map' | 'preview'; // Keep every alert's text at body2 size and black so the error (red) and // warning (amber) blocks read identically; the severity icon keeps its color. @@ -341,154 +243,117 @@ const ImportExternalAssessmentsWizard: FC = ({ const dispatch = useAppDispatch(); const { courseId: courseIdParam } = useParams(); const courseId = courseIdParam ?? ''; - const [step, setStep] = useState(0); - const [components, setComponents] = useState< - (ImportComponent & { id: number })[] - >([blankComponent()]); - const [mode, setMode] = useState('external_id'); + const [step, setStep] = useState('upload'); const [file, setFile] = useState(null); - const [csvData, setCsvData] = useState(''); - const [preview, setPreview] = useState(null); const [conflictOpen, setConflictOpen] = useState(false); const [busy, setBusy] = useState(false); - const [headerError, setHeaderError] = useState(null); const [pendingCommit, setPendingCommit] = useState<'keep' | 'replace' | null>( null, ); + const { + identifierMode, + setIdentifierMode, + identifierColumn, + columns, + uploadBlock, + setColumnAction, + setCreateTitle, + setExistingTarget, + setCreateMaxGrade, + setCreateWeight, + parseFile, + canPreview, + preview, + previewError, + buildRequest, + } = useImportMapping(); + const students = useAppSelector(getStudents); - const missingStudents = useMemo( - () => students.filter((s) => s.externalId == null || s.externalId === ''), - [students], + const missingStudents = students.filter( + (s) => s.externalId == null || s.externalId === '', ); - const identifierReady = mode === 'email' || missingStudents.length === 0; + const identifierReady = + identifierMode === 'email' || missingStudents.length === 0; const identifierModeLabel = t( - mode === 'email' ? translations.email : translations.externalId, + identifierMode === 'email' ? translations.email : translations.externalId, ); + const uploadBlockMessage = ((): string | null => { + if (!uploadBlock) return null; + switch (uploadBlock.kind) { + case 'unreadable': + return t(translations.blockUnreadable); + case 'duplicateHeaders': + return t(translations.blockDuplicateHeaders, { + headers: uploadBlock.headers.join(', '), + }); + case 'identifierNotFirst': + return t(translations.blockIdentifierNotFirst, { + expected: uploadBlock.expected, + found: uploadBlock.found, + }); + case 'noGradeColumns': + return t(translations.blockNoGradeColumns); + case 'noDataRows': + return t(translations.blockNoDataRows); + case 'unknownIdentifiers': + return t( + identifierMode === 'email' + ? translations.unresolvedEmail + : translations.unresolvedExternalId, + { + count: uploadBlock.ids.length, + ids: uploadBlock.ids.join(', '), + }, + ); + default: + return null; + } + })(); + useEffect(() => { if (!open) { - setStep(0); - setComponents([blankComponent()]); + setStep('upload'); setFile(null); - setCsvData(''); - setPreview(null); setConflictOpen(false); setPendingCommit(null); setBusy(false); - setHeaderError(null); + setIdentifierMode('external_id'); + // Blows away the hook's parsed headers/mappings/preview so reopening + // the wizard starts from a clean slate, the same way a fresh mount + // would — useImportMapping has no explicit reset, but parsing an empty + // string drives it back to its initial shape. + parseFile(''); } }, [open]); - const existingMap = useMemo( - () => new Map(existingAssessments.map((a) => [a.name, a])), - [existingAssessments], - ); - const isExisting = (name: string): boolean => existingMap.has(name.trim()); - - const addedNames = useMemo( - () => new Set(components.map((c) => c.name.trim())), - [components], - ); - - const availableChips = useMemo( - () => existingAssessments.filter((a) => !addedNames.has(a.name)), - [existingAssessments, addedNames], - ); - - const updateComponent = ( - i: number, - patch: Partial, - ): void => - setComponents((cs) => cs.map((c, j) => (j === i ? { ...c, ...patch } : c))); - - const insertFromExisting = (a: ExistingExternalAssessment): void => { - rowId += 1; - setComponents((cs) => [ - ...cs, - { - id: rowId, - name: a.name, - weightage: a.weightage, - maximumGrade: a.maximumGrade, - }, - ]); - }; - - const defineValid = - components.length > 0 && - components.every((c) => c.name.trim() !== '') && - new Set(components.map((c) => c.name.trim())).size === components.length; - - const runPreview = async (): Promise => { - setBusy(true); - try { - const result = await dispatch( - previewImport({ - components: components.map(({ id: _, ...rest }) => rest), - identifierMode: mode, - csvData, - }), - ); - setPreview(result); - if (result.ok) setStep(2); - } catch (error) { - const badHeader = badHeaderFromError(error); - if (badHeader) { - setHeaderError(badHeader); - } else { - const known = importErrorCode(error); - if (known?.message === 'empty_csv') { - toast.error(t(translations.emptyCsv)); - } else if (known?.message === 'duplicate_identifier') { - const ids = known.identifiers ?? []; - toast.error( - t(translations.duplicateIdentifier, { - count: ids.length, - ids: ids.join(', '), - }), - ); - } else { - toast.error(t(translations.previewError)); - } - } - } finally { - setBusy(false); + useEffect(() => { + if (step === 'preview' && previewError) { + toast.error(previewError); } + }, [step, previewError]); + + const onDrop = async (files: File[]): Promise => { + const f = files[0]; + if (!f) return; + setFile(f); + const text = await readFileText(f); + parseFile(text); }; const doCommit = async (onConflict: 'keep' | 'replace'): Promise => { setBusy(true); setPendingCommit(onConflict); try { - await dispatch( - commitImport({ - components: components.map(({ id: _, ...rest }) => rest), - identifierMode: mode, - csvData, - onConflict, - }), + const summary = await dispatch( + commitImport({ ...buildRequest(), onConflict }), ); - // Count the grade cells that actually differed, so the success toast can - // report how many existing grades were replaced vs kept. When nothing - // conflicted (the clean-import path also calls doCommit('replace')), fall - // back to the plain message so we never claim a replace/keep that never happened. - const changedGrades = (preview?.conflictRows ?? []).reduce( - (sum, row) => - sum + Object.values(row.cells).filter((cell) => cell.changed).length, - 0, - ); - if (changedGrades > 0) { + if (summary.createdComponents > 0) { toast.success( - t( - onConflict === 'keep' - ? translations.committedKept - : translations.committedReplaced, - { count: changedGrades }, - ), + t(translations.importSuccess, { count: summary.createdComponents }), + { autoClose: false }, ); - } else { - toast.success(t(translations.committed)); } setConflictOpen(false); onClose(); @@ -514,7 +379,7 @@ const ImportExternalAssessmentsWizard: FC = ({ {t( - mode === 'email' + identifierMode === 'email' ? translations.unresolvedEmail : translations.unresolvedExternalId, { @@ -608,369 +473,256 @@ const ImportExternalAssessmentsWizard: FC = ({ ); }; + const stepIndex = { upload: 0, map: 1, preview: 2 }[step]; + return ( - { - if (reason === 'backdropClick') return; - onClose(); - }} - open={open} - > - {t(translations.title)} - - - - {t(translations.stepDefine)} - - - {t(translations.stepUpload)} - - - {t(translations.stepVerify)} - - - - {step === 0 && ( - <> - {availableChips.length > 0 && ( -
        - - {t(translations.fromExisting)} + <> + { + if (reason === 'backdropClick') return; + onClose(); + }} + open={open && !conflictOpen} + > + {t(translations.title)} + + + + {t(translations.stepUpload)} + + + {t(translations.stepMap)} + + + {t(translations.stepPreview)} + + + + {step === 'upload' && ( +
        +
        + + {t(translations.identifierMode)} -
        - {availableChips.map((a) => ( - insertFromExisting(a)} - variant="outlined" - /> - ))} -
        +
        - )} - {components.map((c, i) => { - const locked = isExisting(c.name); - const existing = locked - ? existingMap.get(c.name.trim()) - : undefined; - return ( -
        - - updateComponent(i, { name: e.target.value }) - } - size="small" - value={c.name} - /> - {weightedViewEnabled && ( - - updateComponent(i, { - weightage: Number(e.target.value), - }) - } - size="small" - type="number" - value={ - locked && existing ? existing.weightage : c.weightage - } - /> - )} - - updateComponent(i, { - maximumGrade: Number(e.target.value), + {identifierMode === 'external_id' && ( + + {identifierReady + ? t(translations.externalIdHint, { + link: (chunks) => ( + + {chunks} + + ), }) - } - size="small" - type="number" - value={ - locked && existing - ? existing.maximumGrade - : c.maximumGrade - } - /> - {locked && ( - - {t(translations.updatesExisting)} - - )} - - setComponents((cs) => cs.filter((_, j) => j !== i)) - } - size="small" - > - - -
        - ); - })} - + : t(translations.externalIdBlocked, { + name: missingStudents[0]?.name ?? '', + count: missingStudents.length - 1, + link: (chunks) => ( + + {chunks} + + ), + })} + + )} -
        - - {t(translations.identifierMode)} + + {t(translations.requiredHeaders, { + headers: identifierHeader(identifierMode), + })} - -
        - {mode === 'external_id' && ( - + + {t(translations.exampleHeader)} + + {t(translations.template)} + + + +
        {exampleCsv(identifierMode)}
        +
        + + {file && uploadBlockMessage && ( + + {uploadBlockMessage} + + )} + + - {identifierReady - ? t(translations.externalIdHint, { - link: (chunks) => ( - - {chunks} - - ), - }) - : t(translations.externalIdBlocked, { - name: missingStudents[0]?.name ?? '', - count: missingStudents.length - 1, - link: (chunks) => ( - - {chunks} - - ), + {({ getRootProps, getInputProps }) => ( +
        - )} - - )} + > + + {file ? ( + + ) : ( +
        {t(translations.dropzone)}
        + )} +
        + )} +
        +
        + )} - {step === 1 && ( -
        - - {t(translations.requiredHeaders, { - headers: [ - identifierHeader(mode), - ...components.map((c) => c.name), - ].join(', '), - identifier: identifierHeader(mode), - })} - - {headerError && ( - + {step === 'map' && ( +
        +
        + + {t(translations.identifierColumnStatic)} + - {t(translations.headerErrorsHeading)} + {identifierColumn} ({identifierModeLabel}) -
          - {headerError.identifierNotFirst && ( - - {t(translations.identifierNotFirst, { - identifier: identifierHeader(mode), - })} - - )} - {headerError.suggestions.map((s) => ( - - {t(translations.headerSuggestion, { - expected: s.expected, - suggestion: s.didYouMean, - })} - - ))} - {headerError.missing.length > 0 && ( - - {t(translations.missingHeaders, { - count: headerError.missing.length, - missing: headerError.missing.join(', '), - })} - - )} - {headerError.unrecognized.length > 0 && ( - - {t(translations.unrecognizedHeaders, { - count: headerError.unrecognized.length, - unrecognized: headerError.unrecognized.join(', '), +
        + + +
        + )} + + {step === 'preview' && ( + <> + {renderAlerts(true)} + {preview?.ok && preview.conflictRows.length > 0 && ( + + {t(translations.willChangeExisting, { + count: preview.conflictRows.length, + })} + + )} + {preview?.ok && ( + <> +
        + + + + {identifierModeLabel} + {preview.columnOrder.map((name) => ( + {name} + ))} + + + + {preview.sample.map((row) => ( + + {row.identifier} + {preview.columnOrder.map((name) => ( + + {row.grades[name] ?? '—'} + + ))} + + ))} + +
        +
        + {preview.totalRows > 5 ? ( + + {t(translations.previewRows, { + totalRows: preview.totalRows, })} - )} - {headerError.duplicates.length > 0 && ( - - {t(translations.duplicateHeaders, { - count: headerError.duplicates.length, - dupes: headerError.duplicates - .map((d) => `${d.name} (×${d.count})`) - .join(', '), + ) : ( + + {t(translations.previewFewRows, { + totalRows: preview.totalRows, })} )} -
      - - {t(translations.headerErrorsClosing)} - -
      - )} - {preview && !preview.ok && renderAlerts(true)} + + )} + + )} + + + + {step !== 'upload' && ( - { - const f = files[0]; - if (f) { - setFile(f); - setHeaderError(null); - setPreview(null); - setCsvData(await readFileText(f)); - } - }} + )} + {step === 'upload' && ( + - {step > 0 && ( - - )} - {step === 0 && ( - - )} - {step === 1 && ( - - {t(translations.verify)} - - )} - {step === 2 && preview?.ok && ( - - {t(translations.continue)} - - )} - + {t(translations.next)} + + )} + {step === 'map' && ( + + )} + {step === 'preview' && preview?.ok && !previewError && ( + + {preview.conflictRows.length > 0 + ? t(translations.resolveConflicts) + : t(translations.continue)} + + )} + + = ({ rows={preview?.conflictRows ?? []} totalRows={preview?.totalRows ?? 0} /> - + ); }; diff --git a/client/app/bundles/course/gradebook/components/import/__tests__/ColumnMappingTable.test.tsx b/client/app/bundles/course/gradebook/components/import/__tests__/ColumnMappingTable.test.tsx new file mode 100644 index 0000000000..217f05c87d --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/__tests__/ColumnMappingTable.test.tsx @@ -0,0 +1,215 @@ +import userEvent from '@testing-library/user-event'; +import { render, screen, within } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +import ColumnMappingTable from '../ColumnMappingTable'; +import type { ColumnState } from '../importValidation'; + +jest.mock('lib/components/wrappers/I18nProvider'); + +const col = (over: Partial): ColumnState => ({ + header: 'H', + action: 'create', + newTitle: 'H', + newMaxGrade: 100, + newWeight: 0, + existingTarget: '', + status: 'ok', + badCells: [], + hasNumericData: true, + ...over, +}); + +const noop = jest.fn(); + +const renderTable = ( + columns: ColumnState[], + over: Record = {}, +): ReturnType => + render( + , + ); + +const rowFor = (header: string): HTMLElement => + screen.getByText(header).closest('tr') as HTMLElement; + +it('renders every column as a row in the given (CSV) order', () => { + renderTable([ + col({ header: 'Project', action: 'ignore' }), + col({ header: 'Quiz 1', action: 'create', newTitle: 'Quiz 1' }), + col({ header: 'Final', action: 'existing', existingTarget: 'Final' }), + ]); + const headers = screen + .getAllByRole('row') + .slice(1) // drop the header row + .map((r) => within(r).getAllByRole('cell')[0].textContent); + expect(headers).toEqual(['Project', 'Quiz 1', 'Final']); +}); + +it('shows a create row with an editable title and max-marks default', () => { + renderTable([ + col({ header: 'Quiz 1', action: 'create', newTitle: 'Quiz 1' }), + ]); + expect(screen.getByDisplayValue('Quiz 1')).toBeInTheDocument(); + expect(screen.getByDisplayValue('100')).toBeInTheDocument(); +}); + +it('shows dashes for a do-not-import row', () => { + renderTable([col({ header: 'Project', action: 'ignore' })]); + const row = rowFor('Project'); + // Title / Max cells show an em-dash placeholder, no inputs + expect(within(row).queryByRole('textbox')).toBeNull(); + expect(within(row).getAllByText('–').length).toBeGreaterThanOrEqual(2); +}); + +it('shows the placeholder for an unmapped existing row without any error text', () => { + renderTable([ + col({ + header: 'Final', + action: 'existing', + existingTarget: '', + status: 'incomplete', + }), + ]); + expect(screen.getByText(/select component/i)).toBeInTheDocument(); + // incomplete is quiet: no red collision/duplicate copy + expect(screen.queryByText(/already used|another column/i)).toBeNull(); +}); + +it('shows the existing component max (looked up from the existing prop) as read-only', () => { + // existing prop has Final -> maximumGrade 80; the table looks it up itself. + renderTable([ + col({ header: 'Final', action: 'existing', existingTarget: 'Final' }), + ]); + const input = screen.getByDisplayValue('80') as HTMLInputElement; + expect(input).toBeDisabled(); +}); + +it('renders the Weight column only when weighted view is on', () => { + const columns = [col({ header: 'Quiz 1', action: 'create' })]; + const { rerender } = renderTable(columns); + expect(screen.queryByText(/weight/i)).toBeNull(); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + rerender( + + + , + ); + expect(screen.getByText(/^weight$/i)).toBeInTheDocument(); +}); + +it('shows an inline error for a title collision', () => { + renderTable([ + col({ + header: 'Score', + action: 'create', + newTitle: 'Final', + status: 'error', + error: 'titleCollision', + }), + ]); + expect( + screen.getByText(/already used by an existing external assessment/i), + ).toBeInTheDocument(); +}); + +it('shows an inline non-numeric error naming the first bad cell', () => { + renderTable([ + col({ + header: 'Midterm', + action: 'create', + status: 'error', + error: 'nonNumeric', + badCells: [{ row: 5, value: 'absnt' }], + }), + ]); + expect(screen.getByText(/absnt/)).toBeInTheDocument(); +}); + +it('shows every existing option even when one is already taken by another row', async () => { + renderTable([ + col({ header: 'Quiz 1', action: 'existing', existingTarget: 'Final' }), + col({ + header: 'Quiz 2', + action: 'existing', + existingTarget: '', + status: 'incomplete', + }), + ]); + // Each existing row has two comboboxes (Action + Title). Scope to Quiz 2's + // row and take its Title select (the second combobox in that row). + const quiz2Row = rowFor('Quiz 2'); + const titleSelect = within(quiz2Row).getAllByRole('combobox')[1]; + await userEvent.click(titleSelect); + // Options render in a portal on the document body, so query via screen. + // Both existing components are offered - even 'Final', taken by Quiz 1 - + // because collisions are caught by validation, not hidden from the list. + expect(screen.getByRole('option', { name: 'Final' })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: 'Other' })).toBeInTheDocument(); +}); + +it('shows a quiet footer hint when a column still needs input', () => { + renderTable([ + col({ + header: 'Final', + action: 'existing', + existingTarget: '', + status: 'incomplete', + }), + ]); + expect(screen.getByText(/finish mapping/i)).toBeInTheDocument(); +}); + +it('shows a quiet footer hint when nothing is set to import', () => { + renderTable([col({ header: 'Project', action: 'ignore' })]); + expect(screen.getByText(/at least one column/i)).toBeInTheDocument(); +}); + +it('renders the helper line explaining the numeric rule without inviting text imports', () => { + renderTable([col({ header: 'Quiz 1', action: 'create' })]); + expect( + screen.getByText(/Grade columns must contain only numbers/i), + ).toBeInTheDocument(); + // Text columns are unimportable (validation blocks them), so the copy must + // not promise the user can "import it anyway". + expect(screen.queryByText(/import it anyway/i)).toBeNull(); +}); + +it('shows a grey non-grade note for an ignore column with no numeric data', () => { + renderTable([ + col({ header: 'Name', action: 'ignore', hasNumericData: false }), + ]); + expect( + screen.getByText(/no numeric values found. Treated as a non-grade column/i), + ).toBeInTheDocument(); +}); + +it('does not show the non-grade note for an ignore column that has numeric data', () => { + renderTable([ + col({ header: 'Score', action: 'ignore', hasNumericData: true }), + ]); + expect(screen.queryByText(/non-grade column/i)).toBeNull(); +}); diff --git a/client/app/bundles/course/gradebook/components/import/__tests__/importValidation.test.ts b/client/app/bundles/course/gradebook/components/import/__tests__/importValidation.test.ts new file mode 100644 index 0000000000..742665f42e --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/__tests__/importValidation.test.ts @@ -0,0 +1,230 @@ +import { + classifyColumns, + type ColumnConfig, + detectUploadBlock, + unknownIdentifiers, +} from '../importValidation'; + +const EXTERNAL_ID = 'External ID'; + +const existing = [ + { name: 'Final', maximumGrade: 80, weight: 20 }, + { name: 'Other', maximumGrade: 50, weight: 5 }, +]; + +const cfg = (over: Partial): ColumnConfig => ({ + header: 'H', + action: 'create', + newTitle: 'H', + newMaxGrade: 100, + newWeight: 0, + existingTarget: '', + ...over, +}); + +describe('classifyColumns', () => { + it('marks an ignore column ok', () => { + const [c] = classifyColumns([cfg({ action: 'ignore' })], [], existing); + expect(c.status).toBe('ok'); + }); + + it('marks a create column with an empty title incomplete, not error', () => { + const [c] = classifyColumns([cfg({ newTitle: ' ' })], [], existing); + expect(c.status).toBe('incomplete'); + expect(c.error).toBeUndefined(); + }); + + it('flags a create title colliding with an existing component', () => { + const [c] = classifyColumns([cfg({ newTitle: 'final' })], [], existing); + expect(c.status).toBe('error'); + expect(c.error).toBe('titleCollision'); + }); + + it('flags two create columns sharing a title (case-insensitive)', () => { + const [a, b] = classifyColumns( + [ + cfg({ header: 'A', newTitle: 'Quiz' }), + cfg({ header: 'B', newTitle: 'quiz' }), + ], + [], + existing, + ); + expect(a.error).toBe('duplicateTitle'); + expect(b.error).toBe('duplicateTitle'); + }); + + it('marks an existing column with no target incomplete', () => { + const [c] = classifyColumns( + [cfg({ action: 'existing', existingTarget: '' })], + [], + existing, + ); + expect(c.status).toBe('incomplete'); + }); + + it('marks a mapped existing column ok', () => { + const [c] = classifyColumns( + [cfg({ action: 'existing', existingTarget: 'Final' })], + [], + existing, + ); + expect(c.status).toBe('ok'); + expect(c.error).toBeUndefined(); + }); + + it('flags non-numeric cells in an imported column and records the first bad cell', () => { + const rows = [{ H: '80' }, { H: 'absnt' }]; + const [c] = classifyColumns([cfg({})], rows, existing); + expect(c.error).toBe('nonNumeric'); + expect(c.badCells).toEqual([{ row: 2, value: 'absnt' }]); + }); + + it('treats blank / dash / N/A as no-grade, not bad', () => { + const rows = [{ H: '-' }, { H: 'N/A' }, { H: '' }, { H: '80' }]; + const [c] = classifyColumns([cfg({})], rows, existing); + expect(c.status).toBe('ok'); + expect(c.badCells).toEqual([]); + }); + + it('sets hasNumericData true when a column has at least one numeric cell', () => { + const rows = [{ H: '-' }, { H: '80' }, { H: '' }]; + const [c] = classifyColumns([cfg({ action: 'ignore' })], rows, existing); + expect(c.hasNumericData).toBe(true); + }); + + it('sets hasNumericData false for an all-blank / no-grade column', () => { + const rows = [{ H: '' }, { H: '-' }, { H: 'N/A' }]; + const [c] = classifyColumns([cfg({ action: 'ignore' })], rows, existing); + expect(c.hasNumericData).toBe(false); + }); + + it('sets hasNumericData false for an all-text column', () => { + const rows = [{ H: 'John' }, { H: 'Mary' }]; + const [c] = classifyColumns([cfg({ action: 'ignore' })], rows, existing); + expect(c.hasNumericData).toBe(false); + }); +}); + +describe('detectUploadBlock', () => { + const rows = [{ [EXTERNAL_ID]: 'S1', Midterm: '80' }]; + const roster = [{ externalId: 'S1' }]; + + it('blocks an unreadable file with no headers', () => { + expect(detectUploadBlock([], [], 'external_id', roster)).toEqual({ + kind: 'unreadable', + }); + }); + + it('blocks duplicate headers (case-insensitive)', () => { + expect( + detectUploadBlock( + [EXTERNAL_ID, 'Quiz', 'quiz'], + rows, + 'external_id', + roster, + ), + ).toMatchObject({ kind: 'duplicateHeaders' }); + }); + + it('blocks when the first column is not the identifier for the chosen mode', () => { + expect( + detectUploadBlock( + ['Name', EXTERNAL_ID, 'Midterm'], + rows, + 'external_id', + roster, + ), + ).toEqual({ + kind: 'identifierNotFirst', + expected: EXTERNAL_ID, + found: 'Name', + }); + }); + + it('accepts a first column matching the mode case-insensitively', () => { + expect( + detectUploadBlock( + ['external id', 'Midterm'], + rows, + 'external_id', + roster, + ), + ).toBeNull(); + expect( + detectUploadBlock(['Email', 'Midterm'], rows, 'email', roster), + ).toBeNull(); + }); + + it('blocks a file with only the identifier column', () => { + expect( + detectUploadBlock([EXTERNAL_ID], rows, 'external_id', roster), + ).toEqual({ + kind: 'noGradeColumns', + }); + }); + + it('blocks a file with headers but zero data rows', () => { + expect( + detectUploadBlock([EXTERNAL_ID, 'Midterm'], [], 'external_id', roster), + ).toEqual({ + kind: 'noDataRows', + }); + // a trailing all-empty parsed row does not count as data + expect( + detectUploadBlock( + [EXTERNAL_ID, 'Midterm'], + [{ [EXTERNAL_ID]: '', Midterm: '' }], + 'external_id', + roster, + ), + ).toEqual({ kind: 'noDataRows' }); + }); + + it('returns null for a well-formed file', () => { + expect( + detectUploadBlock([EXTERNAL_ID, 'Midterm'], rows, 'external_id', roster), + ).toBeNull(); + }); + + it('blocks CSV identifiers with no matching student in the course', () => { + expect( + detectUploadBlock( + [EXTERNAL_ID, 'Midterm'], + [ + { [EXTERNAL_ID]: 'S1', Midterm: '80' }, + { [EXTERNAL_ID]: 'S999', Midterm: '90' }, + ], + 'external_id', + roster, + ), + ).toEqual({ kind: 'unknownIdentifiers', ids: ['S999'] }); + }); +}); + +describe('unknownIdentifiers', () => { + const roster = [ + { externalId: 'S1', email: 'a@x.com' }, + { externalId: 'S2', email: 'b@x.com' }, + ]; + + it('returns identifiers not in the roster, de-duplicated, ignoring blanks', () => { + const rows = [ + { ID: 'S1' }, + { ID: 'S999' }, + { ID: '' }, + { ID: 'S999' }, + { ID: 'S3' }, + ]; + expect(unknownIdentifiers(rows, 'ID', 'external_id', roster)).toEqual([ + 'S999', + 'S3', + ]); + }); + + it('matches emails case-insensitively', () => { + const rows = [{ ID: 'A@X.COM' }, { ID: 'zzz@x.com' }]; + expect(unknownIdentifiers(rows, 'ID', 'email', roster)).toEqual([ + 'zzz@x.com', + ]); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/import/__tests__/useImportMapping.test.ts b/client/app/bundles/course/gradebook/components/import/__tests__/useImportMapping.test.ts new file mode 100644 index 0000000000..27eb8ff30d --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/__tests__/useImportMapping.test.ts @@ -0,0 +1,325 @@ +import { act, renderHook, waitFor } from 'test-utils'; + +import { previewImport } from '../../../operations'; +import useImportMapping from '../useImportMapping'; + +jest.mock('../../../operations', () => ({ + previewImport: jest.fn( + () => async (): Promise> => ({ + ok: true, + unresolved: [], + malformed: [], + outOfRange: [], + sample: [], + conflictRows: [], + reassignments: [], + totalRows: 1, + columnOrder: ['Midterm'], + }), + ), +})); + +const mockPreviewImport = previewImport as jest.Mock; + +const preloadedState = { + gradebook: { + categories: [], + tabs: [], + assessments: [ + { id: 1, title: 'Final', tabId: 1, maxGrade: 100, external: true }, + ], + students: [ + { + id: 1, + name: 'Alice', + email: 'alice@x.com', + externalId: 'S1', + level: 0, + totalXp: 0, + levelContribution: null, + }, + { + id: 2, + name: 'Bob', + email: 'bob@x.com', + externalId: 'S2', + level: 0, + totalXp: 0, + levelContribution: null, + }, + ], + submissions: [], + gamificationEnabled: false, + weightedViewEnabled: false, + canManageWeights: true, + courseMaxLevel: 0, + levelContribution: { + enabled: false, + formula: '', + weight: 0, + show: false, + clamp: true, + }, + capTotal: false, + }, +}; + +const by = ( + result: ReturnType, + header: string, +): ReturnType['columns'][number] | undefined => + result.columns.find((c) => c.header === header); + +beforeEach(() => { + jest.useFakeTimers({ legacyFakeTimers: false }); + mockPreviewImport.mockClear(); +}); + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +it('uses the first column as the identifier and lists the rest as columns', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile( + 'External ID,Name,Midterm,Final\nS1,Alice,80,90\n', + ); + }); + + expect(result.current.identifierColumn).toBe('External ID'); + expect(result.current.columns.map((c) => c.header)).toEqual([ + 'Name', + 'Midterm', + 'Final', + ]); + expect(result.current.uploadBlock).toBeNull(); +}); + +it('auto-detects existing / create / ignore defaults without regrouping', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile( + 'External ID,Name,Midterm,Final\nS1,Alice,80,90\n', + ); + }); + + expect(by(result.current, 'Final')).toMatchObject({ + action: 'existing', + existingTarget: 'Final', + }); + expect(by(result.current, 'Midterm')).toMatchObject({ + action: 'create', + newTitle: 'Midterm', + }); + expect(by(result.current, 'Name')).toMatchObject({ action: 'ignore' }); +}); + +it('reports a structural upload block when the first column is not the identifier', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('Name,Midterm\nAlice,80\n'); + }); + + expect(result.current.uploadBlock).toMatchObject({ + kind: 'identifierNotFirst', + }); +}); + +it('keeps a create column incomplete (not error) until it has a title', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Midterm\nS1,80\n'); + }); + act(() => { + result.current.setCreateTitle('Midterm', ''); + }); + + expect(by(result.current, 'Midterm')?.status).toBe('incomplete'); + expect(result.current.canPreview).toBe(false); +}); + +it('keeps an existing column incomplete until a component is chosen', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Quiz 1\nS1,80\n'); + }); + act(() => { + result.current.setColumnAction('Quiz 1', 'existing'); + }); + + expect(by(result.current, 'Quiz 1')).toMatchObject({ + action: 'existing', + existingTarget: '', + status: 'incomplete', + }); + expect(result.current.canPreview).toBe(false); +}); + +it('persists create-mode edits across a round trip through another mode', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Quiz 1\nS1,80\n'); + }); + act(() => { + result.current.setCreateTitle('Quiz 1', 'Quiz One'); + result.current.setCreateMaxGrade('Quiz 1', 25); + }); + act(() => { + result.current.setColumnAction('Quiz 1', 'ignore'); + }); + act(() => { + result.current.setColumnAction('Quiz 1', 'create'); + }); + + expect(by(result.current, 'Quiz 1')).toMatchObject({ + newTitle: 'Quiz One', + newMaxGrade: 25, + }); +}); + +it('flags a create title that collides with an existing assessment', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Score\nS1,80\n'); + }); + act(() => { + result.current.setCreateTitle('Score', 'Final'); + }); + + expect(by(result.current, 'Score')?.error).toBe('titleCollision'); + expect(result.current.canPreview).toBe(false); +}); + +it('flags a non-numeric cell in an imported column', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Midterm\nS1,80\nS2,absnt\n'); + }); + + expect(by(result.current, 'Midterm')?.error).toBe('nonNumeric'); + expect(by(result.current, 'Midterm')?.badCells).toEqual([ + { row: 2, value: 'absnt' }, + ]); + expect(result.current.canPreview).toBe(false); +}); + +it('is not previewable until at least one column is imported', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Name\nS1,Alice\n'); + }); + + expect(result.current.canPreview).toBe(false); + expect(result.current.preview).toBeNull(); +}); + +it('runs the debounced preview and populates preview once it resolves', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Midterm\nS1,80\n'); + }); + + expect(result.current.canPreview).toBe(true); + expect(mockPreviewImport).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(400); + }); + + await waitFor(() => expect(result.current.preview).not.toBeNull()); + expect(mockPreviewImport).toHaveBeenCalledTimes(1); +}); + +it('cleans up a pending preview timer on unmount', async () => { + const { result, unmount } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Midterm\nS1,80\n'); + }); + act(() => { + jest.advanceTimersByTime(399); + }); + unmount(); + act(() => { + jest.advanceTimersByTime(1); + }); + expect(mockPreviewImport).not.toHaveBeenCalled(); +}); + +it('blocks duplicate CSV headers that PapaParse would silently rename', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Midterm,Midterm\nS1,80,90\n'); + }); + expect(result.current.uploadBlock).toMatchObject({ + kind: 'duplicateHeaders', + headers: ['Midterm'], + }); +}); + +it('blocks the upload when a CSV identifier is not a student in the course', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile('External ID,Midterm\nS1,80\nS999,90\n'); + }); + expect(result.current.uploadBlock).toEqual({ + kind: 'unknownIdentifiers', + ids: ['S999'], + }); + expect(result.current.canPreview).toBe(false); +}); + +it('buildRequest resolves target per action and excludes ignored columns', async () => { + const { result } = await renderHook(() => useImportMapping(), { + preloadedState, + }); + act(() => { + result.current.parseFile( + 'External ID,Name,Midterm,Final\nS1,Alice,80,90\n', + ); + }); + act(() => { + result.current.setCreateMaxGrade('Midterm', 50); + result.current.setCreateWeight('Midterm', 10); + }); + + const request = result.current.buildRequest(); + expect(request.identifierColumn).toBe('External ID'); + expect(request.mappings).toEqual([ + { + header: 'Midterm', + action: 'create', + target: 'Midterm', + maxGrade: 50, + weight: 10, + }, + { header: 'Final', action: 'existing', target: 'Final' }, + ]); +}); diff --git a/client/app/bundles/course/gradebook/components/import/buildTemplate.ts b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts index 4754820aa2..9973e491d0 100644 --- a/client/app/bundles/course/gradebook/components/import/buildTemplate.ts +++ b/client/app/bundles/course/gradebook/components/import/buildTemplate.ts @@ -1,38 +1,32 @@ -import type { IdentifierMode, ImportComponent } from 'types/course/gradebook'; - -const csvCell = (value: string): string => - /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value; +import type { IdentifierMode } from 'types/course/gradebook'; export const identifierHeader = (mode: IdentifierMode): string => mode === 'email' ? 'Email' : 'External ID'; -// Header-only template: per-mode identifier header + one column per component. -export const buildTemplateCsv = ( - components: ImportComponent[], - mode: IdentifierMode, -): string => { - const header = [identifierHeader(mode), ...components.map((c) => c.name)] - .map(csvCell) - .join(','); - return `${header}\n`; +// Sample identifier values per mode for the downloadable/preview template. +// Mirrors the invite-users dialog convention (A0123456, test1@example.com). +const SAMPLE_IDENTIFIERS: Record = { + external_id: ['A0123456', 'A0123457'], + email: ['test1@example.com', 'test2@example.com'], }; -// Triggers a client-side download of the template. -export const downloadTemplate = ( - components: ImportComponent[], - mode: IdentifierMode, -): void => { - const blob = new Blob([buildTemplateCsv(components, mode)], { - type: 'text/csv;charset=utf-8;', - }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - link.download = 'external_assessments_template.csv'; - link.click(); - URL.revokeObjectURL(url); +// The example CSV shown (and downloaded) in the wizard's Upload step: an +// identifier column plus two illustrative assessment columns. +export const exampleCsv = (mode: IdentifierMode): string => { + const [first, second] = SAMPLE_IDENTIFIERS[mode]; + return [ + `${identifierHeader(mode)},Assessment 1,Assessment 2`, + `${first},85,90`, + `${second},78,88`, + ].join('\n'); }; +// A client-side download target for the example CSV. The gradebook import has +// no server template endpoint (unlike the invite-users flow), so the template +// is generated inline as a data URI. +export const templateDataUri = (mode: IdentifierMode): string => + `data:text/csv;charset=utf-8,${encodeURIComponent(`${exampleCsv(mode)}\n`)}`; + // Reads an uploaded File to text (raw CSV; the server parses authoritatively). export const readFileText = (file: File): Promise => new Promise((resolve, reject) => { diff --git a/client/app/bundles/course/gradebook/components/import/importValidation.ts b/client/app/bundles/course/gradebook/components/import/importValidation.ts new file mode 100644 index 0000000000..a7690525ba --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/importValidation.ts @@ -0,0 +1,193 @@ +import type { + ExistingExternalAssessment, + IdentifierMode, +} from 'types/course/gradebook'; + +export type ColumnAction = 'ignore' | 'create' | 'existing'; +export type ColumnStatus = 'ok' | 'incomplete' | 'error'; +export type ImportColumnError = + | 'nonNumeric' + | 'titleCollision' + | 'duplicateTitle' + | 'duplicateExisting'; + +// Mutable per-column configuration the user drives via the table's cells. +export interface ColumnConfig { + header: string; + action: ColumnAction; + newTitle: string; // create-mode title; default = header + newMaxGrade: number; // create-mode; default 100 + newWeight: number; // create-mode; default 0 (used only when weighted) + existingTarget: string; // existing-mode component name; '' = not chosen +} + +export interface ColumnState extends ColumnConfig { + status: ColumnStatus; + error?: ImportColumnError; + badCells: { row: number; value: string }[]; + hasNumericData: boolean; +} +// NOTE: for existing rows the greyed max/weight are looked up by the table from +// its own `existing` prop (which carries the real weight). They are NOT on +// ColumnState because the hook's selector does not expose per-assessment weight. + +export type UploadBlock = + | { kind: 'unreadable' } + | { kind: 'duplicateHeaders'; headers: string[] } + | { kind: 'identifierNotFirst'; expected: string; found: string } + | { kind: 'noGradeColumns' } + | { kind: 'noDataRows' } + | { kind: 'unknownIdentifiers'; ids: string[] }; + +// Cells that mean "no grade entered", not "bad data" — case-insensitive. +const NO_GRADE_VALUES = new Set(['', '-', '–', '—', 'n/a']); +const NUMERIC_PATTERN = /^-?\d+(\.\d+)?$/; + +export const isNoGrade = (raw: string): boolean => + NO_GRADE_VALUES.has(raw.trim().toLowerCase()); +export const isNumeric = (raw: string): boolean => + NUMERIC_PATTERN.test(raw.trim()); + +const canonicalIdentifierHeader = (mode: IdentifierMode): string => + mode === 'email' ? 'Email' : 'External ID'; + +const key = (s: string): string => s.trim().toLowerCase(); + +// Minimal roster shape for identifier matching (StudentData is assignable). +export interface RosterMember { + externalId?: string | null; + email?: string | null; +} + +// Normalize an identifier for matching: trim always; lowercase for email +// (emails are case-insensitive). Deliberately lenient so a valid row is never +// wrongly blocked — the server preview stays the authoritative check. +const normIdentifier = (raw: string, mode: IdentifierMode): string => + mode === 'email' ? raw.trim().toLowerCase() : raw.trim(); + +// First-column identifiers with no matching student in the course roster, +// de-duplicated in first-seen order. Blank identifier cells are ignored +// (trailing/empty rows), not reported as unknown. +export const unknownIdentifiers = ( + rows: Record[], + identifierColumn: string, + identifierMode: IdentifierMode, + roster: RosterMember[], +): string[] => { + const known = new Set( + roster + .map((m) => (identifierMode === 'email' ? m.email : m.externalId) ?? '') + .filter((v) => v.trim() !== '') + .map((v) => normIdentifier(v, identifierMode)), + ); + const seen = new Set(); + const unknown: string[] = []; + rows.forEach((row) => { + const raw = (row[identifierColumn] ?? '').trim(); + if (raw === '' || seen.has(raw)) return; + seen.add(raw); + if (!known.has(normIdentifier(raw, identifierMode))) unknown.push(raw); + }); + return unknown; +}; + +export const classifyColumns = ( + configs: ColumnConfig[], + rows: Record[], + existing: ExistingExternalAssessment[], +): ColumnState[] => { + const existingTitles = new Set(existing.map((a) => key(a.name))); + + // Count colliding targets so every member of a pair/group is flagged. + const createTitleCounts = new Map(); + const existingTargetCounts = new Map(); + configs.forEach((c) => { + if (c.action === 'create' && c.newTitle.trim() !== '') { + const k = key(c.newTitle); + createTitleCounts.set(k, (createTitleCounts.get(k) ?? 0) + 1); + } else if (c.action === 'existing' && c.existingTarget !== '') { + const k = key(c.existingTarget); + existingTargetCounts.set(k, (existingTargetCounts.get(k) ?? 0) + 1); + } + }); + + return configs.map((c) => { + const active = c.action !== 'ignore'; + const badCells: { row: number; value: string }[] = []; + if (active) { + rows.forEach((row, index) => { + const raw = row[c.header] ?? ''; + if (isNoGrade(raw) || isNumeric(raw)) return; + badCells.push({ row: index + 1, value: raw }); + }); + } + + let status: ColumnStatus = 'ok'; + let error: ImportColumnError | undefined; + + if (active && badCells.length > 0) { + status = 'error'; + error = 'nonNumeric'; + } else if (c.action === 'create') { + const title = c.newTitle.trim(); + if (title === '') { + status = 'incomplete'; + } else if (existingTitles.has(key(title))) { + status = 'error'; + error = 'titleCollision'; + } else if ((createTitleCounts.get(key(title)) ?? 0) > 1) { + status = 'error'; + error = 'duplicateTitle'; + } + } else if (c.action === 'existing') { + if (c.existingTarget === '') { + status = 'incomplete'; + } else if ((existingTargetCounts.get(key(c.existingTarget)) ?? 0) > 1) { + status = 'error'; + error = 'duplicateExisting'; + } + } + + const hasNumericData = rows.some((row) => { + const raw = row[c.header] ?? ''; + return !isNoGrade(raw) && isNumeric(raw); + }); + + return { ...c, status, error, badCells, hasNumericData }; + }); +}; + +const hasData = (rows: Record[]): boolean => + rows.some((row) => Object.values(row).some((v) => (v ?? '').trim() !== '')); + +export const detectUploadBlock = ( + headers: string[], + rows: Record[], + identifierMode: IdentifierMode, + roster: RosterMember[], +): UploadBlock | null => { + if (headers.length === 0) return { kind: 'unreadable' }; + + const seen = new Map(); + headers.forEach((h) => seen.set(key(h), (seen.get(key(h)) ?? 0) + 1)); + const duplicateHeaders = headers.filter((h) => (seen.get(key(h)) ?? 0) > 1); + if (duplicateHeaders.length > 0) { + return { + kind: 'duplicateHeaders', + headers: [...new Set(duplicateHeaders)], + }; + } + + const expected = canonicalIdentifierHeader(identifierMode); + if (key(headers[0]) !== key(expected)) { + return { kind: 'identifierNotFirst', expected, found: headers[0] }; + } + + if (headers.length < 2) return { kind: 'noGradeColumns' }; + if (!hasData(rows)) return { kind: 'noDataRows' }; + + const unknown = unknownIdentifiers(rows, headers[0], identifierMode, roster); + if (unknown.length > 0) return { kind: 'unknownIdentifiers', ids: unknown }; + + return null; +}; diff --git a/client/app/bundles/course/gradebook/components/import/useImportMapping.ts b/client/app/bundles/course/gradebook/components/import/useImportMapping.ts new file mode 100644 index 0000000000..e5c34449ae --- /dev/null +++ b/client/app/bundles/course/gradebook/components/import/useImportMapping.ts @@ -0,0 +1,311 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import Papa from 'papaparse'; +import type { + IdentifierMode, + ImportColumnAction, + ImportColumnMapping, + ImportPreviewRequest, + ImportPreviewResult, +} from 'types/course/gradebook'; + +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { previewImport } from '../../operations'; +import { getExternalAssessments, getStudents } from '../../selectors'; + +import { + classifyColumns, + type ColumnAction, + type ColumnConfig, + type ColumnState, + detectUploadBlock, + isNoGrade, + isNumeric, + type UploadBlock, +} from './importValidation'; + +const PREVIEW_DEBOUNCE_MS = 400; + +const translations = defineMessages({ + previewError: { + id: 'course.gradebook.ImportWizard.previewError', + defaultMessage: 'Could not verify the file. Please try again.', + }, + emptyCsv: { + id: 'course.gradebook.ImportWizard.emptyCsv', + defaultMessage: + 'The uploaded file has no data rows. Add at least one student row and try again.', + }, + duplicateIdentifier: { + id: 'course.gradebook.ImportWizard.duplicateIdentifier', + defaultMessage: + 'The file lists {count, plural, one {an identifier} other {identifiers}} more than once: {ids}. Each student should appear on a single row.', + }, +}); + +const importErrorCode = ( + error: unknown, +): { message: string; identifiers?: string[] } | null => { + const msg = ( + error as { + response?: { + data?: { errors?: { message?: string; identifiers?: string[] } }; + }; + } + )?.response?.data?.errors?.message; + if (!msg) return null; + return ( + error as { + response: { + data: { errors: { message: string; identifiers?: string[] } }; + }; + } + ).response.data.errors; +}; + +export type { ColumnState } from './importValidation'; + +export interface UseImportMapping { + headers: string[]; + identifierMode: IdentifierMode; + setIdentifierMode: (m: IdentifierMode) => void; + identifierColumn: string; // always headers[0] (or '' before upload) + columns: ColumnState[]; // all non-identifier columns, CSV order + uploadBlock: UploadBlock | null; + setColumnAction: (header: string, action: ColumnAction) => void; + setCreateTitle: (header: string, title: string) => void; + setExistingTarget: (header: string, name: string) => void; + setCreateMaxGrade: (header: string, max: number) => void; + setCreateWeight: (header: string, weight: number) => void; + parseFile: (csvData: string) => void; + canPreview: boolean; + preview: ImportPreviewResult | null; + previewing: boolean; + previewError: string | null; + buildRequest: () => ImportPreviewRequest; +} + +const useImportMapping = (): UseImportMapping => { + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + const existingAssessments = useAppSelector(getExternalAssessments); + const students = useAppSelector(getStudents); + + const [csvData, setCsvData] = useState(''); + const [headers, setHeaders] = useState([]); + // PapaParse (header:true) silently renames duplicate columns (Midterm -> + // Midterm_1), so `meta.fields` never reveals duplicates. Keep the raw header + // row separately so detectUploadBlock can reject true duplicates. + const [rawHeaders, setRawHeaders] = useState([]); + const [rows, setRows] = useState[]>([]); + const [identifierMode, setIdentifierMode] = + useState('external_id'); + const [configs, setConfigs] = useState([]); + const [preview, setPreview] = useState(null); + const [previewing, setPreviewing] = useState(false); + const [previewError, setPreviewError] = useState(null); + + // The identifier is always the first CSV column; detectUploadBlock enforces + // that its header actually matches the chosen mode. + const identifierColumn = headers[0] ?? ''; + + const existing = useMemo( + () => + existingAssessments.map((a) => ({ + name: a.title, + maximumGrade: a.maxGrade, + weight: 0, // weight is looked up for display only; import ignores it + })), + [existingAssessments], + ); + + const parseFile = useCallback( + (data: string): void => { + setCsvData(data); + const result = Papa.parse>(data, { + header: true, + }); + const fields = result.meta.fields ?? []; + const parsedRows = result.data; + + // Re-read the un-renamed header row (no header mode, first line only) so + // duplicate columns are visible to detectUploadBlock. + const rawHeaderRow = + Papa.parse(data, { preview: 1 }).data[0] ?? []; + + const idColumn = fields[0] ?? ''; + const existingTitleMatch = (header: string): string | undefined => + existingAssessments.find( + (a) => a.title.toLowerCase() === header.toLowerCase(), + )?.title; + + const newConfigs: ColumnConfig[] = fields + .filter((h) => h !== idColumn) + .map((header) => { + const matchedTitle = existingTitleMatch(header); + if (matchedTitle) { + return { + header, + action: 'existing' as const, + newTitle: header, + newMaxGrade: 100, + newWeight: 0, + existingTarget: matchedTitle, + }; + } + + const hasNumericCell = parsedRows.some((row) => { + const raw = row[header] ?? ''; + return !isNoGrade(raw) && isNumeric(raw); + }); + + return { + header, + action: hasNumericCell ? ('create' as const) : ('ignore' as const), + newTitle: header, + newMaxGrade: 100, + newWeight: 0, + existingTarget: '', + }; + }); + + setHeaders(fields); + setRawHeaders(rawHeaderRow); + setRows(parsedRows); + setConfigs(newConfigs); + setPreview(null); + }, + [existingAssessments], + ); + + const patch = useCallback( + (header: string, next: Partial): void => { + setConfigs((cs) => + cs.map((c) => (c.header === header ? { ...c, ...next } : c)), + ); + }, + [], + ); + + const setColumnAction = useCallback( + (header: string, action: ColumnAction): void => patch(header, { action }), + [patch], + ); + const setCreateTitle = useCallback( + (header: string, title: string): void => patch(header, { newTitle: title }), + [patch], + ); + const setExistingTarget = useCallback( + (header: string, name: string): void => + patch(header, { action: 'existing', existingTarget: name }), + [patch], + ); + const setCreateMaxGrade = useCallback( + (header: string, max: number): void => patch(header, { newMaxGrade: max }), + [patch], + ); + const setCreateWeight = useCallback( + (header: string, weight: number): void => + patch(header, { newWeight: weight }), + [patch], + ); + + const columns = useMemo( + () => classifyColumns(configs, rows, existing), + [configs, rows, existing], + ); + + const uploadBlock = useMemo( + () => detectUploadBlock(rawHeaders, rows, identifierMode, students), + [rawHeaders, rows, identifierMode, students], + ); + + const canPreview = useMemo( + () => + uploadBlock === null && + columns.some((c) => c.action !== 'ignore') && + columns.every((c) => c.status === 'ok'), + [uploadBlock, columns], + ); + + const buildRequest = useCallback((): ImportPreviewRequest => { + const mappings: ImportColumnMapping[] = columns + .filter((c) => c.action !== 'ignore') + .map((c) => { + const target = c.action === 'create' ? c.newTitle : c.existingTarget; + const mapping: ImportColumnMapping = { + header: c.header, + action: c.action as ImportColumnAction, + target, + }; + if (c.action === 'create') { + mapping.maxGrade = c.newMaxGrade; + mapping.weight = c.newWeight; + } + return mapping; + }); + + return { identifierMode, identifierColumn, csvData, mappings }; + }, [columns, identifierMode, identifierColumn, csvData]); + + const extractMessage = useCallback( + (error: unknown): string => { + const known = importErrorCode(error); + if (known?.message === 'empty_csv') { + return t(translations.emptyCsv); + } + if (known?.message === 'duplicate_identifier') { + const ids = known.identifiers ?? []; + return t(translations.duplicateIdentifier, { + count: ids.length, + ids: ids.join(', '), + }); + } + return t(translations.previewError); + }, + [t], + ); + + useEffect(() => { + if (!canPreview) { + setPreview(null); + setPreviewing(false); + return undefined; + } + + setPreviewError(null); + setPreviewing(true); + const timer = setTimeout(() => { + dispatch(previewImport(buildRequest())) + .then((result) => setPreview(result)) + .catch((err: unknown) => setPreviewError(extractMessage(err))) + .finally(() => setPreviewing(false)); + }, PREVIEW_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }, [canPreview, buildRequest, dispatch, extractMessage]); + + return { + headers, + identifierMode, + setIdentifierMode, + identifierColumn, + columns, + uploadBlock, + setColumnAction, + setCreateTitle, + setExistingTarget, + setCreateMaxGrade, + setCreateWeight, + parseFile, + canPreview, + preview, + previewing, + previewError, + buildRequest, + }; +}; + +export default useImportMapping; diff --git a/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx b/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx index d727f8b7c8..1542fb5b92 100644 --- a/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx +++ b/client/app/bundles/course/gradebook/components/manage/EditExternalAssessmentPrompt.tsx @@ -1,7 +1,11 @@ import { FC, useEffect, useState } from 'react'; import { defineMessages } from 'react-intl'; import InfoOutlined from '@mui/icons-material/InfoOutlined'; +import KeyboardArrowRight from '@mui/icons-material/KeyboardArrowRight'; import { + Accordion, + AccordionDetails, + AccordionSummary, Button, Dialog, DialogActions, @@ -11,6 +15,7 @@ import { Switch, TextField, Tooltip, + Typography, } from '@mui/material'; import type { AssessmentData } from 'types/course/gradebook'; @@ -33,9 +38,9 @@ const translations = defineMessages({ id: 'course.gradebook.EditExternalAssessmentPrompt.maxLabel', defaultMessage: 'Max marks', }, - weightLabel: { - id: 'course.gradebook.EditExternalAssessmentPrompt.weightLabel', - defaultMessage: 'Weightage', + advancedLabel: { + id: 'course.gradebook.EditExternalAssessmentPrompt.advancedLabel', + defaultMessage: 'Advanced settings', }, floorLabel: { id: 'course.gradebook.EditExternalAssessmentPrompt.floorLabel', @@ -48,12 +53,12 @@ const translations = defineMessages({ floorHint: { id: 'course.gradebook.EditExternalAssessmentPrompt.floorHint', defaultMessage: - 'Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged.', + 'Counts negative grades as 0 when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged.', }, capHint: { id: 'course.gradebook.EditExternalAssessmentPrompt.capHint', defaultMessage: - 'Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged.', + 'Counts grades above the maximum as the maximum when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged.', }, cancel: { id: 'course.gradebook.EditExternalAssessmentPrompt.cancel', @@ -72,23 +77,18 @@ const translations = defineMessages({ interface Props { open: boolean; assessment: AssessmentData; - weightedViewEnabled?: boolean; - currentWeight?: number; onClose: () => void; } const EditExternalAssessmentPrompt: FC = ({ open, assessment, - weightedViewEnabled = false, - currentWeight = 0, onClose, }) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const [name, setName] = useState(assessment.title); const [max, setMax] = useState(String(assessment.maxGrade)); - const [weight, setWeight] = useState(String(currentWeight)); const [floorAtZero, setFloorAtZero] = useState( assessment.floorAtZero ?? true, ); @@ -96,6 +96,7 @@ const EditExternalAssessmentPrompt: FC = ({ assessment.capAtMaximum ?? true, ); const [saving, setSaving] = useState(false); + const [advancedOpen, setAdvancedOpen] = useState(false); useEffect(() => { if (open) { @@ -103,7 +104,6 @@ const EditExternalAssessmentPrompt: FC = ({ setMax(String(assessment.maxGrade)); setFloorAtZero(assessment.floorAtZero ?? true); setCapAtMaximum(assessment.capAtMaximum ?? true); - setWeight(String(currentWeight)); } }, [open, assessment]); @@ -119,7 +119,6 @@ const EditExternalAssessmentPrompt: FC = ({ maximumGrade: Number(max), floorAtZero, capAtMaximum, - ...(weightedViewEnabled ? { weight: Number(weight) } : {}), }), ); onClose(); @@ -150,52 +149,92 @@ const EditExternalAssessmentPrompt: FC = ({ type="number" value={max} /> - {weightedViewEnabled && ( - setWeight(e.target.value)} - type="number" - value={weight} - /> - )} -
      - setFloorAtZero(e.target.checked)} - /> - } - label={t(translations.floorLabel)} - /> - - setAdvancedOpen(expanded)} + square + sx={{ '&:before': { display: 'none' } }} + > + + - -
      -
      - setCapAtMaximum(e.target.checked)} + + {t(translations.advancedLabel)} + + + +
      + setFloorAtZero(e.target.checked)} + size="small" + /> + } + label={ + + {t(translations.floorLabel)} + + } /> - } - label={t(translations.capLabel)} - /> - - - -
      + + + +
      +
      + setCapAtMaximum(e.target.checked)} + size="small" + /> + } + label={ + + {t(translations.capLabel)} + + } + /> + + + +
      + + + setOpen(false)} + open={open} + /> + + ); +}; + +export default GradebookSettingsButton; diff --git a/client/app/bundles/course/gradebook/components/manage/GradebookSettingsDialog.tsx b/client/app/bundles/course/gradebook/components/manage/GradebookSettingsDialog.tsx new file mode 100644 index 0000000000..c3a1ccebb8 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/manage/GradebookSettingsDialog.tsx @@ -0,0 +1,134 @@ +import { FC, useRef, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Tab, + Tabs, +} from '@mui/material'; +import type { + AssessmentData, + CategoryData, + LevelContributionData, + StudentData, + TabData, +} from 'types/course/gradebook'; + +import useTranslation from 'lib/hooks/useTranslation'; + +import ConfigureWeightsContent from '../ConfigureWeightsContent'; + +import ManageExternalAssessmentsContent from './ManageExternalAssessmentsContent'; + +const translations = defineMessages({ + title: { + id: 'course.gradebook.GradebookSettingsDialog.title', + defaultMessage: 'Gradebook settings', + }, + weightsTab: { + id: 'course.gradebook.GradebookSettingsDialog.weightsTab', + defaultMessage: 'Weights', + }, + externalTab: { + id: 'course.gradebook.GradebookSettingsDialog.externalTab', + defaultMessage: 'External assessments', + }, + save: { + id: 'course.gradebook.GradebookSettingsDialog.save', + defaultMessage: 'Save', + }, + close: { + id: 'course.gradebook.GradebookSettingsDialog.close', + defaultMessage: 'Close', + }, +}); + +interface Props { + open: boolean; + onClose: () => void; + weightedViewEnabled: boolean; + categories: CategoryData[]; + tabs: TabData[]; + assessments: AssessmentData[]; + gamificationEnabled: boolean; + courseMaxLevel: number; + levelContribution: LevelContributionData; + capTotal: boolean; + students: StudentData[]; +} + +const GradebookSettingsDialog: FC = ({ + open, + onClose, + weightedViewEnabled, + ...weights +}) => { + const { t } = useTranslation(); + const [tab, setTab] = useState<'weights' | 'external'>('weights'); + // The weights body's save handler lives in a ref (read on click); its validity + // is held in state so the footer's disabled affordance stays in sync. + const saveFnRef = useRef<() => Promise>(async () => {}); + const [canSave, setCanSave] = useState(false); + + return ( + // One stable width for both tabs: a settings dialog that resized when you + // switched tabs would read as broken. 660px is trimmed from the default md + // so the Weights controls sit nearer their labels and the narrow External + // list roughly fills the dialog instead of stranding half of it empty. + + {t(translations.title)} + {weightedViewEnabled && ( + setTab(v)} value={tab}> + + + + )} + + {weightedViewEnabled ? ( + <> + + + + ) : ( + + )} + + + + {weightedViewEnabled && tab === 'weights' && ( + + )} + + + ); +}; + +export default GradebookSettingsDialog; diff --git a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx deleted file mode 100644 index 01870a3034..0000000000 --- a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsButton.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { FC, useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { Button } from '@mui/material'; - -import useTranslation from 'lib/hooks/useTranslation'; - -import ManageExternalAssessmentsPanel from './ManageExternalAssessmentsPanel'; - -const translations = defineMessages({ - manage: { - id: 'course.gradebook.ManageExternalAssessmentsButton.label', - defaultMessage: 'Manage external assessments', - }, -}); - -interface Props { - /** Match the host toolbar's button size. Defaults to MUI's `medium`. */ - size?: 'small' | 'medium'; -} - -const ManageExternalAssessmentsButton: FC = ({ size }) => { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - - return ( - <> - - setOpen(false)} - open={open} - /> - - ); -}; - -export default ManageExternalAssessmentsButton; diff --git a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsContent.tsx b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsContent.tsx new file mode 100644 index 0000000000..89d0cc9fa6 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsContent.tsx @@ -0,0 +1,324 @@ +import { FC, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + DragDropContext, + Draggable, + Droppable, + DropResult, +} from '@hello-pangea/dnd'; +import { Add, Delete, DragIndicator, Edit } from '@mui/icons-material'; +import { + Button, + Chip, + IconButton, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import type { AppDispatch } from 'store'; +import type { AssessmentData } from 'types/course/gradebook'; + +import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { reorderExternalAssessments } from '../../operations'; +import { getExternalAssessments } from '../../selectors'; +import AddExternalColumnPrompt from '../AddExternalColumnPrompt'; +import DeleteExternalColumnPrompt from '../DeleteExternalColumnPrompt'; + +import EditExternalAssessmentPrompt from './EditExternalAssessmentPrompt'; + +const translations = defineMessages({ + add: { + id: 'course.gradebook.ManageExternalAssessmentContent.add', + defaultMessage: 'Add', + }, + name: { + id: 'course.gradebook.ManageExternalAssessmentContent.name', + defaultMessage: 'Name', + }, + max: { + id: 'course.gradebook.ManageExternalAssessmentContent.max', + defaultMessage: 'Max', + }, + remarks: { + id: 'course.gradebook.ManageExternalAssessmentContent.remarks', + defaultMessage: 'Remarks', + }, + noFloor: { + id: 'course.gradebook.ManageExternalAssessmentContent.noFloor', + defaultMessage: 'No floor', + }, + noCap: { + id: 'course.gradebook.ManageExternalAssessmentContent.noCap', + defaultMessage: 'No cap', + }, + noFloorHint: { + id: 'course.gradebook.ManageExternalAssessmentContent.noFloorHint', + defaultMessage: + 'Negative grades are kept as-is (not floored to 0) when computing the weighted total. Gradebook warnings for negative grades are hidden.', + }, + noCapHint: { + id: 'course.gradebook.ManageExternalAssessmentContent.noCapHint', + defaultMessage: + 'Grades above the maximum are kept as-is (not capped) when computing the weighted total. Gradebook warnings for grades above the maximum are hidden.', + }, + actions: { + id: 'course.gradebook.ManageExternalAssessmentContent.actions', + defaultMessage: 'Actions', + }, + empty: { + id: 'course.gradebook.ManageExternalAssessmentContent.empty', + defaultMessage: 'No external assessments yet', + }, + emptyHint: { + id: 'course.gradebook.ManageExternalAssessmentContent.emptyHint', + defaultMessage: + 'Add one manually, or import a CSV of grades earned outside Coursemology.', + }, + reorderError: { + id: 'course.gradebook.ManageExternalAssessmentContent.reorderError', + defaultMessage: 'Could not save the new order. Please try again.', + }, +}); + +// Returns a new array with the item at `from` moved to `to`. +export const moveItem = (ids: number[], from: number, to: number): number[] => { + const next = [...ids]; + const [moved] = next.splice(from, 1); + next.splice(to, 0, moved); + return next; +}; + +// Builds the new external order from a drag result and persists it. +// Exported so the no-op / dispatch / failure branches stay unit-testable +// without driving the drag-and-drop library in jsdom. +export const handleDragEnd = ( + externalIds: number[], + result: DropResult, + dispatch: AppDispatch, + onError: () => void, +): void => { + if (!result.destination || result.destination.index === result.source.index) { + return; + } + const order = moveItem( + externalIds, + result.source.index, + result.destination.index, + ); + dispatch(reorderExternalAssessments(order)).catch(onError); +}; + +interface Props { + onRequestImport?: () => void; +} + +const ManageExternalAssessmentsContent: FC = () => { + const { t } = useTranslation(); + const externals = useAppSelector(getExternalAssessments); + const dispatch = useAppDispatch(); + const [addOpen, setAddOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const onDragEnd = (result: DropResult): void => + handleDragEnd( + externals.map((a) => a.id), + result, + dispatch, + () => toast.error(t(translations.reorderError)), + ); + + // The Remarks column only appears when some row carries a chip ("No floor" / + // "No cap"), and is widened when a row shows both so they stay on one line. + const maxRemarksPerRow = externals.reduce((max, a) => { + const count = + (a.floorAtZero === false ? 1 : 0) + (a.capAtMaximum === false ? 1 : 0); + return Math.max(max, count); + }, 0); + const showRemarks = maxRemarksPerRow > 0; + const remarksColWidth = maxRemarksPerRow >= 2 ? '12rem' : '6rem'; + + // Name/Max/Remarks keep natural widths and cluster at the left; a flexible + // spacer then pushes Actions to the right edge — the standard "list row with + // trailing actions" pattern, so the row spreads across the available width. + // Symmetric horizontal padding (px-8) insets the whole table from the dialog + // edges. Fixed widths keep the header and each row (separate grid containers) + // aligned. + const gridCols = [ + '2.5rem', + '14rem', + '4rem', + ...(showRemarks ? [remarksColWidth] : []), + '1fr', + '5rem', + ].join(' '); + + // A lone assessment has nothing to reorder against, so dragging is disabled + // and the handle is hidden. Its grid column is kept (empty) so the rest of + // the row stays in identical placement. + const reorderable = externals.length > 1; + + return ( + <> + + + + + {externals.length === 0 ? ( +
      + + {t(translations.empty)} + + + {t(translations.emptyHint)} + +
      + ) : ( + <> +
      + + {t(translations.name)} + {t(translations.max)} + {showRemarks && {t(translations.remarks)}} + + {t(translations.actions)} +
      + + + + {(dropProvided) => ( +
      + {externals.map((a, index) => ( + + {(dragProvided, { isDragging }) => ( +
      + {reorderable ? ( + + + + ) : ( + + )} + + {a.title} + + {a.maxGrade} + {showRemarks && ( + + + {a.floorAtZero === false && ( + + + + )} + {a.capAtMaximum === false && ( + + + + )} + + + )} + + + setEditing(a)} + size="small" + > + + + setDeleting(a)} + size="small" + > + + + +
      + )} +
      + ))} + {dropProvided.placeholder} +
      + )} +
      +
      + + )} + + setAddOpen(false)} + open={addOpen} + /> + {editing && ( + setEditing(null)} + open={Boolean(editing)} + /> + )} + {deleting && ( + setDeleting(null)} + open={Boolean(deleting)} + title={deleting.title} + /> + )} + + ); +}; + +export default ManageExternalAssessmentsContent; diff --git a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx b/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx deleted file mode 100644 index 60a564da42..0000000000 --- a/client/app/bundles/course/gradebook/components/manage/ManageExternalAssessmentsPanel.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import { FC, useState } from 'react'; -import { defineMessages } from 'react-intl'; -import { - DragDropContext, - Draggable, - Droppable, - DropResult, -} from '@hello-pangea/dnd'; -import { Add, Delete, DragIndicator, Edit, Upload } from '@mui/icons-material'; -import { - Button, - Chip, - Dialog, - DialogActions, - DialogContent, - DialogTitle, - IconButton, - Stack, - Typography, -} from '@mui/material'; -import type { AppDispatch } from 'store'; -import type { - AssessmentData, - ExistingExternalAssessment, -} from 'types/course/gradebook'; - -import { useAppDispatch, useAppSelector } from 'lib/hooks/store'; -import toast from 'lib/hooks/toast'; -import useTranslation from 'lib/hooks/useTranslation'; - -import { reorderExternalAssessments } from '../../operations'; -import { - getExternalAssessments, - getTabs, - getWeightedViewEnabled, -} from '../../selectors'; -import AddExternalColumnPrompt from '../AddExternalColumnPrompt'; -import DeleteExternalColumnPrompt from '../DeleteExternalColumnPrompt'; -import ImportExternalAssessmentsWizard from '../import/ImportExternalAssessmentsWizard'; - -import EditExternalAssessmentPrompt from './EditExternalAssessmentPrompt'; - -const translations = defineMessages({ - title: { - id: 'course.gradebook.ManageExternalPanel.title', - defaultMessage: 'External assessments', - }, - add: { - id: 'course.gradebook.ManageExternalPanel.add', - defaultMessage: 'Add', - }, - import: { - id: 'course.gradebook.ManageExternalPanel.import', - defaultMessage: 'Import CSV', - }, - name: { - id: 'course.gradebook.ManageExternalPanel.name', - defaultMessage: 'Name', - }, - max: { - id: 'course.gradebook.ManageExternalPanel.max', - defaultMessage: 'Max', - }, - weight: { - id: 'course.gradebook.ManageExternalPanel.weight', - defaultMessage: 'Weight', - }, - bounds: { - id: 'course.gradebook.ManageExternalPanel.bounds', - defaultMessage: 'Bounds', - }, - floored: { - id: 'course.gradebook.ManageExternalPanel.floored', - defaultMessage: '≥ 0', - }, - capped: { - id: 'course.gradebook.ManageExternalPanel.capped', - defaultMessage: '≤ max', - }, - actions: { - id: 'course.gradebook.ManageExternalPanel.actions', - defaultMessage: 'Actions', - }, - empty: { - id: 'course.gradebook.ManageExternalPanel.empty', - defaultMessage: 'No external assessments yet', - }, - emptyHint: { - id: 'course.gradebook.ManageExternalPanel.emptyHint', - defaultMessage: - 'Add one manually, or import a CSV of grades earned outside Coursemology.', - }, - close: { - id: 'course.gradebook.ManageExternalPanel.close', - defaultMessage: 'Close', - }, - reorderError: { - id: 'course.gradebook.ManageExternalPanel.reorderError', - defaultMessage: 'Could not save the new order. Please try again.', - }, -}); - -interface Props { - open: boolean; - onClose: () => void; -} - -// Returns a new array with the item at `from` moved to `to`. -export const moveItem = (ids: number[], from: number, to: number): number[] => { - const next = [...ids]; - const [moved] = next.splice(from, 1); - next.splice(to, 0, moved); - return next; -}; - -// Builds the new external order from a drag result and persists it. -// Exported so the no-op / dispatch / failure branches stay unit-testable -// without driving the drag-and-drop library in jsdom. -export const handleDragEnd = ( - externalIds: number[], - result: DropResult, - dispatch: AppDispatch, - onError: () => void, -): void => { - if (!result.destination || result.destination.index === result.source.index) { - return; - } - const order = moveItem( - externalIds, - result.source.index, - result.destination.index, - ); - dispatch(reorderExternalAssessments(order)).catch(onError); -}; - -const ManageExternalAssessmentsPanel: FC = ({ open, onClose }) => { - const { t } = useTranslation(); - const externals = useAppSelector(getExternalAssessments); - const tabs = useAppSelector(getTabs); - const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); - const dispatch = useAppDispatch(); - const [addOpen, setAddOpen] = useState(false); - const [importOpen, setImportOpen] = useState(false); - const [editing, setEditing] = useState(null); - const [deleting, setDeleting] = useState(null); - - const tabWeights = Object.fromEntries( - tabs.map((tab) => [tab.id, tab.gradebookWeight ?? 0]), - ); - const existingAssessments: ExistingExternalAssessment[] = externals.map( - (a) => ({ - name: a.title, - maximumGrade: a.maxGrade, - weightage: tabWeights[a.tabId] ?? 0, - }), - ); - - const onDragEnd = (result: DropResult): void => - handleDragEnd( - externals.map((a) => a.id), - result, - dispatch, - () => toast.error(t(translations.reorderError)), - ); - - const gridCols = weightedViewEnabled - ? '2.5rem minmax(10rem,1fr) 5rem 5rem 9.5rem 6rem' - : '2.5rem minmax(10rem,1fr) 5rem 9.5rem 6rem'; - - // A lone assessment has nothing to reorder against, so dragging is disabled - // and the handle is hidden. Its grid column is kept (empty) so the rest of - // the row stays in identical placement. - const reorderable = externals.length > 1; - - return ( - <> - - {t(translations.title)} - - - - - - - {externals.length === 0 ? ( -
      - - {t(translations.empty)} - - - {t(translations.emptyHint)} - -
      - ) : ( - <> -
      - - {t(translations.name)} - {t(translations.max)} - {weightedViewEnabled && {t(translations.weight)}} - {t(translations.bounds)} - {t(translations.actions)} -
      - - - - {(dropProvided) => ( -
      - {externals.map((a, index) => ( - - {(dragProvided, { isDragging }) => ( -
      - {reorderable ? ( - - - - ) : ( - - )} - - {a.title} - - {a.maxGrade} - {weightedViewEnabled && ( - {tabWeights[a.tabId] ?? 0} - )} - - - {(a.floorAtZero ?? true) && ( - - )} - {(a.capAtMaximum ?? true) && ( - - )} - - - - setEditing(a)} - size="small" - > - - - setDeleting(a)} - size="small" - > - - - -
      - )} -
      - ))} - {dropProvided.placeholder} -
      - )} -
      -
      - - )} -
      - - - - - setAddOpen(false)} - open={addOpen} - weightedViewEnabled={weightedViewEnabled} - /> - {editing && ( - setEditing(null)} - open={Boolean(editing)} - weightedViewEnabled={weightedViewEnabled} - /> - )} - {deleting && ( - setDeleting(null)} - open={Boolean(deleting)} - title={deleting.title} - /> - )} -
      - setImportOpen(false)} - open={open && importOpen} - weightedViewEnabled={weightedViewEnabled} - /> - - ); -}; - -export default ManageExternalAssessmentsPanel; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx index 154fb13f8a..fc9b2e47b5 100644 --- a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -13,7 +13,8 @@ import useTranslation from 'lib/hooks/useTranslation'; import { useCourseContext } from '../../../container/CourseLoader'; import GradebookTable from '../../components/GradebookTable'; import GradeLinkHint from '../../components/GradeLinkHint'; -import ManageExternalAssessmentsButton from '../../components/manage/ManageExternalAssessmentsButton'; +import ImportCsvButton from '../../components/import/ImportCsvButton'; +import GradebookSettingsButton from '../../components/manage/GradebookSettingsButton'; import OutOfRangeAlert from '../../components/OutOfRangeAlert'; import WeightedGradebookTable from '../../components/WeightedGradebookTable'; import WeightedViewHint from '../../components/WeightedViewHint'; @@ -90,6 +91,33 @@ const GradebookIndex: FC = () => { [assessments, submissions], ); + // Top-level toolbar actions: import CSV + the ⚙ Gradebook settings gear. The + // gear hosts weights (when weighted) and external-assessment management. Gated + // on manage-weights permission and sized to match the host toolbar. + const renderToolbarActions = ( + size?: 'small' | 'medium', + ): JSX.Element | undefined => + canManageWeights ? ( + <> + + + + ) : undefined; + useEffect(() => { dispatch(fetchGradebook()) .finally(() => setIsLoading(false)) @@ -126,11 +154,7 @@ const GradebookIndex: FC = () => { students={students} submissions={submissions} tabs={tabs} - toolbarAction={ - canManageWeights ? ( - - ) : undefined - } + toolbarAction={renderToolbarActions('small')} /> ); } else { @@ -144,9 +168,7 @@ const GradebookIndex: FC = () => { students={students} submissions={submissions} tabs={tabs} - toolbarAction={ - canManageWeights ? : undefined - } + toolbarAction={renderToolbarActions()} weightedViewEnabled={weightedViewEnabled} /> ); diff --git a/client/app/lib/components/core/buttons/SegmentedSwitch.tsx b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx index 88956fae16..700fd4aa3b 100644 --- a/client/app/lib/components/core/buttons/SegmentedSwitch.tsx +++ b/client/app/lib/components/core/buttons/SegmentedSwitch.tsx @@ -1,11 +1,6 @@ -import { - KeyboardEvent, - ReactNode, - useLayoutEffect, - useRef, - useState, -} from 'react'; +import { KeyboardEvent, ReactNode, useRef } from 'react'; import { Box, ButtonBase, Tooltip } from '@mui/material'; +import { alpha } from '@mui/material/styles'; export interface SegmentedSwitchOption { value: T; @@ -47,47 +42,28 @@ 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. + * A compact, peer-state mode switcher: a connected row of segments, the active + * one filled in the primary color and the rest outlined. It mirrors the + * `ButtonGroup` toggle used elsewhere in the app (e.g. the submission + * Timeline/Sequence switch) so the two read as the same control. * * 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). + * and it stays content-width, so it fits a packed toolbar or a dense prompt + * row. Use it when a binary choice has no default "on" state (e.g. Points vs. + * Percentage, Equal vs. Custom). */ const SegmentedSwitch = ( props: SegmentedSwitchProps, ): JSX.Element => { const { value, options, onChange, ariaLabel, disabled, className } = props; - const containerRef = useRef(null); const optionRefs = useRef<(HTMLButtonElement | null)[]>([]); - const [thumb, setThumb] = useState<{ left: number; width: number }>({ - left: 0, - width: 0, - }); const activeIndex = Math.max( 0, options.findIndex((o) => o.value === value), ); - useLayoutEffect(() => { - const measure = (): void => { - const el = optionRefs.current[activeIndex]; - const container = containerRef.current; - if (!el || !container) return; - setThumb({ - left: el.offsetLeft - container.clientLeft, - width: el.offsetWidth, - }); - }; - measure(); - const observer = new ResizeObserver(measure); - if (containerRef.current) observer.observe(containerRef.current); - return () => observer.disconnect(); - }, [activeIndex, options.length]); - const select = (index: number): void => { const next = options[index]; if (next && next.value !== value) onChange(next.value); @@ -111,47 +87,23 @@ const SegmentedSwitch = ( return ( ({ - position: 'relative', + sx={{ display: 'inline-flex', alignItems: 'stretch', minHeight: MIN_HEIGHT, boxSizing: 'border-box', - p: '3px', - borderRadius: 999, - bgcolor: theme.palette.action.hover, - border: `1px solid ${theme.palette.divider}`, opacity: disabled ? 0.5 : 1, pointerEvents: disabled ? 'none' : 'auto', - })} + }} > - ({ - position: 'absolute', - top: '3px', - bottom: '3px', - left: 0, - width: thumb.width, - borderRadius: 999, - bgcolor: theme.palette.background.paper, - boxShadow: theme.shadows[1], - opacity: thumb.width === 0 ? 0 : 1, - transform: `translateX(${thumb.left}px)`, - transition: theme.transitions.create(['transform', 'width'], { - duration: 260, - easing: 'cubic-bezier(0.34, 1.36, 0.64, 1)', - }), - zIndex: 0, - })} - /> {options.map((option, index) => { const selected = index === activeIndex; + const isFirst = index === 0; + const isLast = index === options.length - 1; const label = option.ariaLabel ?? (typeof option.label === 'string' ? option.label : undefined); @@ -166,31 +118,73 @@ const SegmentedSwitch = ( disableRipple onClick={() => select(index)} role="radio" - sx={(theme) => ({ - position: 'relative', - zIndex: 1, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - fontFamily: 'inherit', - fontSize: theme.typography.pxToRem(FONT_PX), - height: '100%', - fontWeight: selected ? 650 : 550, - letterSpacing: '0.01em', - color: selected - ? theme.palette.text.primary - : theme.palette.text.secondary, - px: PADDING_X, - py: 0, - borderRadius: 999, - whiteSpace: 'nowrap', - transition: theme.transitions.create('color', { duration: 180 }), - '&:hover': { color: theme.palette.text.primary }, - '&:focus-visible': { - outline: `2px solid ${theme.palette.primary.main}`, - outlineOffset: 2, - }, - })} + sx={(theme) => { + // Use the `borderRadius` shorthand, never per-corner longhands: + // MUI's `ButtonBase` base style sets `border-radius: 0` as a + // shorthand, and under `injectFirst` that shorthand wins over + // longhand corner overrides — collapsing the corners to 0. A + // shorthand from `sx` overrides it reliably. Build it as a string + // so `sx` doesn't re-scale it by `theme.shape.borderRadius`. + const r = `${theme.shape.borderRadius}px`; + const borderRadius = + // eslint-disable-next-line no-nested-ternary + isFirst && isLast + ? r + : // eslint-disable-next-line no-nested-ternary + isFirst + ? `${r} 0 0 ${r}` + : isLast + ? `0 ${r} ${r} 0` + : 0; + return { + position: 'relative', + // Keep the active (filled) segment's border above its + // neighbours' overlapping borders; a focus ring wins over both. + zIndex: selected ? 1 : 0, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + fontFamily: 'inherit', + fontSize: theme.typography.pxToRem(FONT_PX), + // No explicit `height`: a percentage height would resolve + // against the radiogroup's `minHeight` (not a definite height) + // and collapse to the text's line height, *and* setting any + // height cancels the parent's `alignItems: 'stretch'`. Letting + // the segment stretch fills it to `MIN_HEIGHT` and lets it grow + // with a taller `self-stretch` neighbour. + fontWeight: 600, + letterSpacing: '0.01em', + px: PADDING_X, + py: 0, + whiteSpace: 'nowrap', + border: `1px solid ${theme.palette.primary.main}`, + borderRadius, + // Collapse the shared edge between adjacent segments, the way + // MUI's `ButtonGroup` does, so the divider stays 1px wide. + ml: isFirst ? 0 : '-1px', + color: selected + ? theme.palette.primary.contrastText + : theme.palette.primary.main, + bgcolor: selected ? theme.palette.primary.main : 'transparent', + transition: theme.transitions.create( + ['background-color', 'color'], + { duration: 180 }, + ), + '&:hover': { + bgcolor: selected + ? theme.palette.primary.dark + : alpha( + theme.palette.primary.main, + theme.palette.action.hoverOpacity, + ), + }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.primary.main}`, + outlineOffset: 2, + zIndex: 2, + }, + }; + }} tabIndex={selected ? 0 : -1} > {option.label} diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx index 1e352fd26e..c44c37eaf5 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTableToolbar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; import { defineMessages } from 'react-intl'; -import { Download } from '@mui/icons-material'; +import { Download, FilterList } from '@mui/icons-material'; import { Button, IconButton, Tooltip } from '@mui/material'; import SearchField from 'lib/components/core/fields/SearchField'; @@ -62,19 +62,36 @@ const MuiTableToolbar = (props: ToolbarProps): JSX.Element | null => { )} {renderAlternative && props.alternative?.render()} - {renderNative && !renderAlternative && props.buttons} - - {renderNative && props.columnPicker && ( - + {renderNative && !renderAlternative && props.buttons && ( +
      + {props.buttons} +
      )} + {renderNative && + props.columnPicker && + (props.columnPicker.triggerIconOnly ? ( + + setPickerOpen(true)} + > + + + + ) : ( + + ))} + {renderNative && props.columnPicker && props.onDirectExport && ( diff --git a/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx index e8ef504f02..476ce4762c 100644 --- a/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx +++ b/client/app/lib/components/table/__tests__/MuiTableToolbar.test.tsx @@ -41,6 +41,22 @@ describe('MuiTableToolbar columnPicker trigger', () => { }); }); +describe('MuiTableToolbar native buttons', () => { + it('wraps native buttons in a non-shrinking container so they are not crushed into their min-width when the toolbar narrows', () => { + const props: ToolbarProps = { + ...baseToolbar, + buttons: [ + , + ], + }; + render(wrap()); + const button = screen.getByRole('button', { name: /import csv/i }); + expect(button.parentElement).toHaveClass('shrink-0'); + }); +}); + describe('MuiTableToolbar direct export button', () => { const directExportProps: ToolbarProps = { ...baseToolbar, diff --git a/client/app/lib/components/table/builder/ColumnPickerTemplate.ts b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts index 9fc78e7692..f75b849cc2 100644 --- a/client/app/lib/components/table/builder/ColumnPickerTemplate.ts +++ b/client/app/lib/components/table/builder/ColumnPickerTemplate.ts @@ -16,6 +16,9 @@ interface ColumnPickerTemplate { /** Toolbar trigger button text, default "Export…". Opens the picker dialog. */ triggerLabel?: string; + /** Render the trigger as an icon-only filter button (triggerLabel becomes its tooltip/aria-label). */ + triggerIconOnly?: boolean; + /** Label for the direct-export button rendered next to the trigger in the toolbar. */ directExportLabel?: string; diff --git a/client/app/types/course/gradebook.ts b/client/app/types/course/gradebook.ts index c5c7d2fc8e..07257914e0 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -114,22 +114,27 @@ export interface ExternalGradePayload { export type IdentifierMode = 'email' | 'external_id'; -export interface ImportComponent { - name: string; - weightage: number; - maximumGrade: number; +export type ImportColumnAction = 'existing' | 'create'; + +export interface ImportColumnMapping { + header: string; + action: ImportColumnAction; + target: string; // existing assessment title, or the new component's title (create) + maxGrade?: number; // create only; prefilled 100 + weight?: number; // create only, weighted view; prefilled 0 } export interface ExistingExternalAssessment { name: string; maximumGrade: number; - weightage: number; + weight: number; } export interface ImportPreviewRequest { - components: ImportComponent[]; identifierMode: IdentifierMode; + identifierColumn: string; csvData: string; + mappings: ImportColumnMapping[]; // excludes "Don't import" columns } export interface ConflictCell { diff --git a/client/app/utilities/test-utils.tsx b/client/app/utilities/test-utils.tsx index b1b749405f..69bec275ca 100644 --- a/client/app/utilities/test-utils.tsx +++ b/client/app/utilities/test-utils.tsx @@ -1,5 +1,12 @@ // eslint-disable-next-line import/no-extraneous-dependencies -import { render, RenderResult } from '@testing-library/react'; +import { + render, + renderHook, + RenderHookOptions, + RenderHookResult, + RenderResult, + waitFor, +} from '@testing-library/react'; import TestApp, { CustomRenderOptions } from './TestApp'; @@ -8,6 +15,40 @@ const customRender = ( options?: CustomRenderOptions, ): RenderResult => render({ui}); +// Hooks that read from the Redux store (useAppSelector) need the same +// Providers/store wiring as customRender. `preloadedState` mirrors `render`'s +// `state` option so hook tests can seed the store the same way component +// tests do. +interface CustomRenderHookOptions + extends RenderHookOptions, + Pick { + preloadedState?: CustomRenderOptions['state']; +} + +// `TestApp` renders `I18nProvider`, which shows a `LoadingIndicator` until it +// asynchronously loads the compiled locale messages (dynamic `import()`), so +// the wrapped hook does not mount synchronously. Component tests already +// account for this by `await`-ing `screen.findBy...`/`waitFor` past the +// loading state; there's no DOM text to await for a bare hook, so this +// wrapper waits for the initial render (`result.current` populated) itself +// before handing control back to the caller. +const customRenderHook = async ( + callback: (props: Props) => Result, + options?: CustomRenderHookOptions, +): Promise> => { + const { preloadedState, at, ...rest } = options ?? {}; + const utils = renderHook(callback, { + ...rest, + wrapper: ({ children }) => ( + + {children} + + ), + }); + await waitFor(() => expect(utils.result.current).not.toBeNull()); + return utils; +}; + // eslint-disable-next-line import/no-extraneous-dependencies export * from '@testing-library/react'; -export { customRender as render }; +export { customRender as render, customRenderHook as renderHook }; diff --git a/client/locales/en.json b/client/locales/en.json index b707d141ab..520654372e 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -5549,6 +5549,24 @@ "course.gradebook.GradebookIndex.noStudentsHint": { "defaultMessage": "Grades will appear here once students join the course." }, + "course.gradebook.GradebookSettingsButton.label": { + "defaultMessage": "Gradebook settings" + }, + "course.gradebook.GradebookSettingsDialog.title": { + "defaultMessage": "Gradebook settings" + }, + "course.gradebook.GradebookSettingsDialog.weightsTab": { + "defaultMessage": "Weights" + }, + "course.gradebook.GradebookSettingsDialog.externalTab": { + "defaultMessage": "External assessments" + }, + "course.gradebook.GradebookSettingsDialog.save": { + "defaultMessage": "Save" + }, + "course.gradebook.GradebookSettingsDialog.close": { + "defaultMessage": "Close" + }, "course.gradebook.GradebookTable.maxMarks": { "defaultMessage": "Max Marks" }, @@ -9677,9 +9695,6 @@ "course.gradebook.WeightedGradebookTable.collapseRow": { "defaultMessage": "Collapse {name}" }, - "course.gradebook.WeightedGradebookTable.configureWeights": { - "defaultMessage": "Configure Weights" - }, "course.gradebook.WeightedGradebookTable.defaultWeights": { "defaultMessage": "Showing default weights - every tab counts equally. Click \"Configure Weights\" to set your own." }, @@ -9776,11 +9791,14 @@ "course.gradebook.GradebookTable.gradeSaved": { "defaultMessage": "Grade saved. {title} · {name}: {oldGrade} → {newGrade}" }, + "course.gradebook.AddExternalColumnPrompt.advancedLabel": { + "defaultMessage": "Advanced settings" + }, "course.gradebook.AddExternalColumnPrompt.cancel": { "defaultMessage": "Cancel" }, "course.gradebook.AddExternalColumnPrompt.capHint": { - "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged." + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged." }, "course.gradebook.AddExternalColumnPrompt.capLabel": { "defaultMessage": "Cap grades at max" @@ -9792,7 +9810,7 @@ "defaultMessage": "Could not create the external assessment." }, "course.gradebook.AddExternalColumnPrompt.floorHint": { - "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged." + "defaultMessage": "Counts negative grades as 0 when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged." }, "course.gradebook.AddExternalColumnPrompt.floorLabel": { "defaultMessage": "Floor grades at 0" @@ -9809,9 +9827,6 @@ "course.gradebook.AddExternalColumnPrompt.title": { "defaultMessage": "Add external assessment" }, - "course.gradebook.AddExternalColumnPrompt.weightLabel": { - "defaultMessage": "Weightage" - }, "course.gradebook.DeleteExternalColumnPrompt.body": { "defaultMessage": "Delete \"{title}\"? This permanently removes the column and every student grade in it. This cannot be undone." }, @@ -9827,11 +9842,80 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "Delete external assessment" }, + "course.gradebook.ColumnMappingTable.createNew": { + "defaultMessage": "Create new" + }, + "course.gradebook.ColumnMappingTable.dontImport": { + "defaultMessage": "Don't import" + }, + "course.gradebook.ColumnMappingTable.duplicateTitleError": { + "defaultMessage": "Another column also creates a component with this name." + }, + "course.gradebook.ColumnMappingTable.duplicateExistingError": { + "defaultMessage": "Another column is also mapped to this existing external assessment." + }, + "course.gradebook.ColumnMappingTable.helperLine": { + "defaultMessage": "Grade columns must contain only numbers (blank, \"-\" or \"N/A\" mean no grade). Columns with non-numeric values can't be imported as grades and are set to Don't import." + }, + "course.gradebook.ColumnMappingTable.mapToExisting": { + "defaultMessage": "Map to existing" + }, + "course.gradebook.ColumnMappingTable.matchedHeading": { + "defaultMessage": "{count, plural, one {# column matched} other {# columns matched}} to existing" + }, + "course.gradebook.ColumnMappingTable.maxLabel": { + "defaultMessage": "Max marks" + }, + "course.gradebook.ColumnMappingTable.needsInputHeading": { + "defaultMessage": "Needs your input" + }, + "course.gradebook.ColumnMappingTable.nonGradeColumnNote": { + "defaultMessage": "No numeric values found. Treated as a non-grade column." + }, + "course.gradebook.ColumnMappingTable.nonNumericError": { + "defaultMessage": "Row {row} ‘{value}’ isn't a number. To import, fix the CSV and re-upload." + }, + "course.gradebook.ColumnMappingTable.notUsed": { + "defaultMessage": "Not used" + }, + "course.gradebook.ColumnMappingTable.skippedHeading": { + "defaultMessage": "{count, plural, one {# column skipped} other {# columns skipped}} — not grade columns" + }, + "course.gradebook.ColumnMappingTable.skippedTooltip": { + "defaultMessage": "Looks like labels, not grades — not imported. Change the dropdown to import it as grades." + }, + "course.gradebook.ColumnMappingTable.titleCollisionError": { + "defaultMessage": "This name is already used by an existing external assessment." + }, + "course.gradebook.ColumnMappingTable.titleLabel": { + "defaultMessage": "Title" + }, + "course.gradebook.ColumnMappingTable.weightLabel": { + "defaultMessage": "Weight" + }, + "course.gradebook.ColumnMappingTable.csvHeader": { + "defaultMessage": "CSV Header" + }, + "course.gradebook.ColumnMappingTable.actionLabel": { + "defaultMessage": "Action" + }, + "course.gradebook.ColumnMappingTable.selectComponent": { + "defaultMessage": "Select component…" + }, + "course.gradebook.ColumnMappingTable.incompleteHint": { + "defaultMessage": "Finish mapping the highlighted columns to continue." + }, + "course.gradebook.ColumnMappingTable.nothingImportedHint": { + "defaultMessage": "Set at least one column to Create new or Map to existing to continue." + }, + "course.gradebook.EditExternalAssessmentPrompt.advancedLabel": { + "defaultMessage": "Advanced settings" + }, "course.gradebook.EditExternalAssessmentPrompt.cancel": { "defaultMessage": "Cancel" }, "course.gradebook.EditExternalAssessmentPrompt.capHint": { - "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total. The actual grade is unchanged." + "defaultMessage": "Counts grades above the maximum as the maximum when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged." }, "course.gradebook.EditExternalAssessmentPrompt.capLabel": { "defaultMessage": "Cap grades at max" @@ -9840,7 +9924,7 @@ "defaultMessage": "Could not save the external assessment." }, "course.gradebook.EditExternalAssessmentPrompt.floorHint": { - "defaultMessage": "Counts negative grades as 0 when computing the weighted total. The actual grade is unchanged." + "defaultMessage": "Counts negative grades as 0 when computing the weighted total, and marks them with a warning in the gradebook. The actual grade is unchanged." }, "course.gradebook.EditExternalAssessmentPrompt.floorLabel": { "defaultMessage": "Floor grades at 0" @@ -9857,53 +9941,44 @@ "course.gradebook.EditExternalAssessmentPrompt.title": { "defaultMessage": "Edit external assessment" }, - "course.gradebook.EditExternalAssessmentPrompt.weightLabel": { - "defaultMessage": "Weightage" - }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "Display mode" }, - "course.gradebook.ManageExternalAssessmentsButton.label": { - "defaultMessage": "Manage external assessments" - }, - "course.gradebook.ManageExternalPanel.actions": { + "course.gradebook.ManageExternalAssessmentContent.actions": { "defaultMessage": "Actions" }, - "course.gradebook.ManageExternalPanel.add": { + "course.gradebook.ManageExternalAssessmentContent.add": { "defaultMessage": "Add" }, - "course.gradebook.ManageExternalPanel.bounds": { - "defaultMessage": "Bounds" - }, - "course.gradebook.ManageExternalPanel.capped": { - "defaultMessage": "≤ max" - }, - "course.gradebook.ManageExternalPanel.close": { - "defaultMessage": "Close" - }, - "course.gradebook.ManageExternalPanel.empty": { + "course.gradebook.ManageExternalAssessmentContent.empty": { "defaultMessage": "No external assessments yet" }, - "course.gradebook.ManageExternalPanel.emptyHint": { + "course.gradebook.ManageExternalAssessmentContent.emptyHint": { "defaultMessage": "Add one manually, or import a CSV of grades earned outside Coursemology." }, - "course.gradebook.ManageExternalPanel.floored": { - "defaultMessage": "≥ 0" - }, - "course.gradebook.ManageExternalPanel.max": { + "course.gradebook.ManageExternalAssessmentContent.max": { "defaultMessage": "Max" }, - "course.gradebook.ManageExternalPanel.name": { + "course.gradebook.ManageExternalAssessmentContent.name": { "defaultMessage": "Name" }, - "course.gradebook.ManageExternalPanel.reorderError": { - "defaultMessage": "Could not save the new order. Please try again." + "course.gradebook.ManageExternalAssessmentContent.noCap": { + "defaultMessage": "No cap" }, - "course.gradebook.ManageExternalPanel.title": { - "defaultMessage": "External assessments" + "course.gradebook.ManageExternalAssessmentContent.noFloor": { + "defaultMessage": "No floor" }, - "course.gradebook.ManageExternalPanel.weight": { - "defaultMessage": "Weight" + "course.gradebook.ManageExternalAssessmentContent.noCapHint": { + "defaultMessage": "Grades above the maximum are kept as-is (not capped) when computing the weighted total. Gradebook warnings for grades above the maximum are hidden." + }, + "course.gradebook.ManageExternalAssessmentContent.noFloorHint": { + "defaultMessage": "Negative grades are kept as-is (not floored to 0) when computing the weighted total. Gradebook warnings for negative grades are hidden." + }, + "course.gradebook.ManageExternalAssessmentContent.remarks": { + "defaultMessage": "Remarks" + }, + "course.gradebook.ManageExternalAssessmentContent.reorderError": { + "defaultMessage": "Could not save the new order. Please try again." }, "course.gradebook.ProjectedTotalHint.policy": { "defaultMessage": "Totals count ungraded assessments as 0." @@ -9962,8 +10037,8 @@ "course.gradebook.ExternalGradeConflictTable.name": { "defaultMessage": "Name" }, - "course.gradebook.ImportWizard.addComponent": { - "defaultMessage": "Add component" + "course.gradebook.ImportCsvButton.label": { + "defaultMessage": "Import CSV" }, "course.gradebook.ImportWizard.back": { "defaultMessage": "Back" @@ -9974,30 +10049,15 @@ "course.gradebook.ImportWizard.commitError": { "defaultMessage": "Import failed. Nothing was saved." }, - "course.gradebook.ImportWizard.committed": { - "defaultMessage": "Import complete." - }, - "course.gradebook.ImportWizard.committedKept": { - "defaultMessage": "Import complete. {count, plural, one {# existing grade kept} other {# existing grades kept}}." - }, - "course.gradebook.ImportWizard.committedReplaced": { - "defaultMessage": "Import complete. {count, plural, one {# existing grade replaced} other {# existing grades replaced}}." - }, - "course.gradebook.ImportWizard.componentName": { - "defaultMessage": "Component name" - }, "course.gradebook.ImportWizard.continue": { "defaultMessage": "Confirm import" }, - "course.gradebook.ImportWizard.downloadTemplate": { - "defaultMessage": "Download template" + "course.gradebook.ImportWizard.resolveConflicts": { + "defaultMessage": "Resolve Conflicts" }, "course.gradebook.ImportWizard.dropzone": { "defaultMessage": "Drag a CSV here, or click to choose a file" }, - "course.gradebook.ImportWizard.duplicateHeaders": { - "defaultMessage": "{count, plural, one {This column appears more than once: {dupes}.} other {These columns appear more than once: {dupes}.}}" - }, "course.gradebook.ImportWizard.duplicateIdentifier": { "defaultMessage": "The file lists {count, plural, one {an identifier} other {identifiers}} more than once: {ids}. Each student should appear on a single row." }, @@ -10016,23 +10076,14 @@ "course.gradebook.ImportWizard.externalIdHint": { "defaultMessage": "Matching uses each student's External ID. Keep External IDs up to date in Manage Users." }, - "course.gradebook.ImportWizard.fromExisting": { - "defaultMessage": "From existing" - }, - "course.gradebook.ImportWizard.headerErrorsClosing": { - "defaultMessage": "Correct these in your CSV, then re-upload." - }, - "course.gradebook.ImportWizard.headerErrorsHeading": { - "defaultMessage": "These headers need fixing:" - }, - "course.gradebook.ImportWizard.headerSuggestion": { - "defaultMessage": "No column named ‘{suggestion}’ — did you mean ‘{expected}’?" + "course.gradebook.ImportWizard.identifierColumnLabel": { + "defaultMessage": "Identifier column" }, "course.gradebook.ImportWizard.identifierMode": { "defaultMessage": "Match students by" }, - "course.gradebook.ImportWizard.identifierNotFirst": { - "defaultMessage": "‘{identifier}’ must be the first column." + "course.gradebook.ImportWizard.importSuccess": { + "defaultMessage": "Imported grades. Created {count, plural, one {# external assessment} other {# external assessments}}." }, "course.gradebook.ImportWizard.malformed": { "defaultMessage": "These cells do not contain valid numbers:" @@ -10040,12 +10091,6 @@ "course.gradebook.ImportWizard.malformedMore": { "defaultMessage": "and {count} more" }, - "course.gradebook.ImportWizard.maxMarks": { - "defaultMessage": "Max marks" - }, - "course.gradebook.ImportWizard.missingHeaders": { - "defaultMessage": "{count, plural, one {Your CSV is missing this column: {missing}.} other {Your CSV is missing these columns: {missing}.}}" - }, "course.gradebook.ImportWizard.next": { "defaultMessage": "Next" }, @@ -10062,10 +10107,10 @@ "defaultMessage": "Could not verify the file. Please try again." }, "course.gradebook.ImportWizard.previewFewRows": { - "defaultMessage": "Previewing all {totalRows} rows. Check that this preview matches your CSV before continuing." + "defaultMessage": "Previewing all {totalRows} rows. Check that these grades match your CSV before continuing." }, "course.gradebook.ImportWizard.previewRows": { - "defaultMessage": "Previewing the first 5 of {totalRows} rows. Check that this preview matches your CSV before continuing." + "defaultMessage": "Previewing the first 5 of {totalRows} rows. Check that these grades match your CSV before continuing." }, "course.gradebook.ImportWizard.reassignmentSubtitle": { "defaultMessage": "These identifiers were previously imported for another student. Grades are matched by the current student, not the identifier — confirm these are the people you intend before importing." @@ -10074,45 +10119,30 @@ "defaultMessage": "Some identifiers now match a different student" }, "course.gradebook.ImportWizard.requiredHeaders": { - "defaultMessage": "Your CSV needs these column headers: {headers}. ‘{identifier}’ must be the first column." + "defaultMessage": "Your CSV's first column must be {headers} to identify students." }, - "course.gradebook.ImportWizard.stepDefine": { - "defaultMessage": "Define components" + "course.gradebook.ImportWizard.stepMap": { + "defaultMessage": "Map columns" }, - "course.gradebook.ImportWizard.stepUpload": { - "defaultMessage": "Template & upload" + "course.gradebook.ImportWizard.stepPreview": { + "defaultMessage": "Preview" }, - "course.gradebook.ImportWizard.stepVerify": { - "defaultMessage": "Verify" + "course.gradebook.ImportWizard.stepUpload": { + "defaultMessage": "Upload" }, "course.gradebook.ImportWizard.title": { "defaultMessage": "Import external assessments" }, - "course.gradebook.ImportWizard.unrecognizedHeaders": { - "defaultMessage": "{count, plural, one {This column isn’t recognized: {unrecognized}.} other {These columns aren’t recognized: {unrecognized}.}}" - }, "course.gradebook.ImportWizard.unresolvedEmail": { "defaultMessage": "{count, plural, one {This email address was not found in the course: {ids}} other {These email addresses were not found in the course: {ids}}}" }, "course.gradebook.ImportWizard.unresolvedExternalId": { "defaultMessage": "{count, plural, one {This external ID was not found in the course: {ids}} other {These external IDs were not found in the course: {ids}}}" }, - "course.gradebook.ImportWizard.updatesExisting": { - "defaultMessage": "Existing assessment - settings managed in Manage External Assessments" - }, "course.gradebook.ImportWizard.upload": { "defaultMessage": "Upload filled CSV" }, - "course.gradebook.ImportWizard.verify": { - "defaultMessage": "Verify" - }, - "course.gradebook.ImportWizard.weightage": { - "defaultMessage": "Weightage" - }, "course.gradebook.ImportWizard.willChangeExisting": { - "defaultMessage": "{count, plural, one {# row contains} other {# rows contain}} changes to existing grades. After checking this preview, click Confirm import to review these conflicts before anything is imported." - }, - "course.gradebook.ManageExternalPanel.import": { - "defaultMessage": "Import CSV" + "defaultMessage": "{count, plural, one {# row contains} other {# rows contain}} changes to existing grades. After checking this preview, click Resolve Conflicts to review these conflicts before anything is imported." } } diff --git a/client/locales/ko.json b/client/locales/ko.json index d497167813..47b705e976 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9803,6 +9803,72 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "외부 평가 삭제" }, + "course.gradebook.ColumnMappingTable.createNew": { + "defaultMessage": "새로 만들기" + }, + "course.gradebook.ColumnMappingTable.dontImport": { + "defaultMessage": "가져오지 않음" + }, + "course.gradebook.ColumnMappingTable.duplicateTitleError": { + "defaultMessage": "다른 열도 이 이름으로 구성 요소를 생성합니다." + }, + "course.gradebook.ColumnMappingTable.duplicateExistingError": { + "defaultMessage": "다른 열도 이 기존 외부 평가에 매핑되어 있습니다." + }, + "course.gradebook.ColumnMappingTable.helperLine": { + "defaultMessage": "성적 열에는 숫자만 포함되어야 합니다(빈칸, \"-\" 또는 \"N/A\"는 성적 없음 의미). 숫자가 아닌 값이 있는 열은 성적으로 가져올 수 없으며 가져오지 않음으로 설정됩니다." + }, + "course.gradebook.ColumnMappingTable.mapToExisting": { + "defaultMessage": "기존 항목에 매핑" + }, + "course.gradebook.ColumnMappingTable.matchedHeading": { + "defaultMessage": "{count, plural, one {#개 열이 기존 항목에 매칭됨} other {#개 열이 기존 항목에 매칭됨}}" + }, + "course.gradebook.ColumnMappingTable.maxLabel": { + "defaultMessage": "최대 점수" + }, + "course.gradebook.ColumnMappingTable.needsInputHeading": { + "defaultMessage": "입력이 필요함" + }, + "course.gradebook.ColumnMappingTable.nonGradeColumnNote": { + "defaultMessage": "숫자 값이 없습니다. 성적이 아닌 열로 처리됩니다." + }, + "course.gradebook.ColumnMappingTable.nonNumericError": { + "defaultMessage": "{row}행의 ‘{value}’은(는) 숫자가 아닙니다. 가져오려면 CSV를 수정한 후 다시 업로드하세요." + }, + "course.gradebook.ColumnMappingTable.notUsed": { + "defaultMessage": "사용되지 않음" + }, + "course.gradebook.ColumnMappingTable.skippedHeading": { + "defaultMessage": "{count, plural, one {#개 열 건너뜀} other {#개 열 건너뜀}} — 성적 열이 아님" + }, + "course.gradebook.ColumnMappingTable.skippedTooltip": { + "defaultMessage": "성적이 아니라 라벨로 보입니다 — 가져오지 않았습니다. 성적으로 가져오려면 드롭다운을 변경하세요." + }, + "course.gradebook.ColumnMappingTable.titleCollisionError": { + "defaultMessage": "이 이름은 이미 기존 외부 평가에서 사용 중입니다." + }, + "course.gradebook.ColumnMappingTable.titleLabel": { + "defaultMessage": "제목" + }, + "course.gradebook.ColumnMappingTable.weightLabel": { + "defaultMessage": "가중치" + }, + "course.gradebook.ColumnMappingTable.csvHeader": { + "defaultMessage": "CSV 헤더" + }, + "course.gradebook.ColumnMappingTable.actionLabel": { + "defaultMessage": "작업" + }, + "course.gradebook.ColumnMappingTable.selectComponent": { + "defaultMessage": "구성 요소 선택…" + }, + "course.gradebook.ColumnMappingTable.incompleteHint": { + "defaultMessage": "계속하려면 강조 표시된 열의 매핑을 완료하세요." + }, + "course.gradebook.ColumnMappingTable.nothingImportedHint": { + "defaultMessage": "계속하려면 하나 이상의 열을 새로 만들기 또는 기존 항목에 매핑으로 설정하세요." + }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "표시 모드" }, @@ -9821,9 +9887,6 @@ "course.gradebook.AddExternalColumnPrompt.floorLabel": { "defaultMessage": "0으로 하한 적용" }, - "course.gradebook.AddExternalColumnPrompt.weightLabel": { - "defaultMessage": "가중치" - }, "course.gradebook.EditExternalAssessmentPrompt.cancel": { "defaultMessage": "취소" }, @@ -9854,51 +9917,45 @@ "course.gradebook.EditExternalAssessmentPrompt.title": { "defaultMessage": "외부 평가 편집" }, - "course.gradebook.EditExternalAssessmentPrompt.weightLabel": { - "defaultMessage": "가중치" - }, "course.gradebook.ManageExternalAssessmentsButton.label": { "defaultMessage": "외부 평가 관리" }, - "course.gradebook.ManageExternalPanel.actions": { + "course.gradebook.ManageExternalAssessmentContent.actions": { "defaultMessage": "작업" }, - "course.gradebook.ManageExternalPanel.add": { + "course.gradebook.ManageExternalAssessmentContent.add": { "defaultMessage": "추가" }, - "course.gradebook.ManageExternalPanel.bounds": { + "course.gradebook.ManageExternalAssessmentContent.bounds": { "defaultMessage": "범위" }, - "course.gradebook.ManageExternalPanel.capped": { + "course.gradebook.ManageExternalAssessmentContent.capped": { "defaultMessage": "≤ 최대" }, - "course.gradebook.ManageExternalPanel.close": { + "course.gradebook.ManageExternalAssessmentContent.close": { "defaultMessage": "닫기" }, - "course.gradebook.ManageExternalPanel.empty": { + "course.gradebook.ManageExternalAssessmentContent.empty": { "defaultMessage": "아직 외부 평가가 없습니다" }, - "course.gradebook.ManageExternalPanel.emptyHint": { + "course.gradebook.ManageExternalAssessmentContent.emptyHint": { "defaultMessage": "직접 추가하거나, Coursemology 외부에서 받은 성적을 CSV로 가져오세요." }, - "course.gradebook.ManageExternalPanel.floored": { + "course.gradebook.ManageExternalAssessmentContent.floored": { "defaultMessage": "≥ 0" }, - "course.gradebook.ManageExternalPanel.max": { + "course.gradebook.ManageExternalAssessmentContent.max": { "defaultMessage": "최대 점수" }, - "course.gradebook.ManageExternalPanel.name": { + "course.gradebook.ManageExternalAssessmentContent.name": { "defaultMessage": "이름" }, - "course.gradebook.ManageExternalPanel.reorderError": { + "course.gradebook.ManageExternalAssessmentContent.reorderError": { "defaultMessage": "새 순서를 저장할 수 없습니다. 다시 시도해 주세요." }, - "course.gradebook.ManageExternalPanel.title": { + "course.gradebook.ManageExternalAssessmentContent.title": { "defaultMessage": "외부 평가" }, - "course.gradebook.ManageExternalPanel.weight": { - "defaultMessage": "가중치" - }, "course.gradebook.GradebookTable.acceptEdit": { "defaultMessage": "적용" }, @@ -9980,6 +10037,9 @@ "course.gradebook.ImportWizard.continue": { "defaultMessage": "가져오기 확인" }, + "course.gradebook.ImportWizard.resolveConflicts": { + "defaultMessage": "충돌 해결" + }, "course.gradebook.ImportWizard.downloadTemplate": { "defaultMessage": "템플릿 다운로드" }, @@ -10065,7 +10125,7 @@ "defaultMessage": "일부 식별자가 이제 다른 학생과 일치합니다" }, "course.gradebook.ImportWizard.requiredHeaders": { - "defaultMessage": "CSV에는 다음 열 헤더가 필요합니다: {headers}. ‘{identifier}’은(는) 첫 번째 열이어야 합니다." + "defaultMessage": "학생을 식별하려면 CSV의 첫 번째 열이 {headers}이어야 합니다." }, "course.gradebook.ImportWizard.stepDefine": { "defaultMessage": "구성요소 정의" @@ -10101,9 +10161,9 @@ "defaultMessage": "가중치" }, "course.gradebook.ImportWizard.willChangeExisting": { - "defaultMessage": "{count, plural, one {#개 행에} other {#개 행에}} 기존 성적에 대한 변경 사항이 포함되어 있습니다. 이 미리보기를 확인한 후, 가져오기 확인을 클릭하여 가져오기 전에 이러한 충돌을 검토하세요." + "defaultMessage": "{count, plural, one {#개 행에 기존 성적 변경 사항이 포함되어 있습니다} other {#개 행에 기존 성적 변경 사항이 포함되어 있습니다}}. 이 미리보기를 확인한 후, 항목을 가져오기 전에 충돌 해결을 클릭하여 이러한 충돌을 검토하세요." }, - "course.gradebook.ManageExternalPanel.import": { + "course.gradebook.ManageExternalAssessmentContent.import": { "defaultMessage": "CSV 가져오기" } } diff --git a/client/locales/zh.json b/client/locales/zh.json index 6fadf25c24..369502d8d3 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9797,6 +9797,72 @@ "course.gradebook.DeleteExternalColumnPrompt.title": { "defaultMessage": "删除外部评估" }, + "course.gradebook.ColumnMappingTable.createNew": { + "defaultMessage": "新建" + }, + "course.gradebook.ColumnMappingTable.dontImport": { + "defaultMessage": "不导入" + }, + "course.gradebook.ColumnMappingTable.duplicateTitleError": { + "defaultMessage": "另一列也会创建同名组件。" + }, + "course.gradebook.ColumnMappingTable.duplicateExistingError": { + "defaultMessage": "另一列也映射到了这个现有外部评估。" + }, + "course.gradebook.ColumnMappingTable.helperLine": { + "defaultMessage": "成绩列只能包含数字(空白、\"-\" 或 \"N/A\" 表示无成绩)。包含非数值的列不能作为成绩导入,并会被设为不导入。" + }, + "course.gradebook.ColumnMappingTable.mapToExisting": { + "defaultMessage": "映射到现有项" + }, + "course.gradebook.ColumnMappingTable.matchedHeading": { + "defaultMessage": "{count, plural, one {# 列已匹配到现有项} other {# 列已匹配到现有项}}" + }, + "course.gradebook.ColumnMappingTable.maxLabel": { + "defaultMessage": "最高分" + }, + "course.gradebook.ColumnMappingTable.needsInputHeading": { + "defaultMessage": "需要你的输入" + }, + "course.gradebook.ColumnMappingTable.nonGradeColumnNote": { + "defaultMessage": "未找到数值。已作为非成绩列处理。" + }, + "course.gradebook.ColumnMappingTable.nonNumericError": { + "defaultMessage": "第 {row} 行的“{value}”不是数字。要导入,请修正 CSV 后重新上传。" + }, + "course.gradebook.ColumnMappingTable.notUsed": { + "defaultMessage": "未使用" + }, + "course.gradebook.ColumnMappingTable.skippedHeading": { + "defaultMessage": "{count, plural, one {# 列已跳过} other {# 列已跳过}} — 不是成绩列" + }, + "course.gradebook.ColumnMappingTable.skippedTooltip": { + "defaultMessage": "看起来像标签,而不是成绩 — 未导入。可更改下拉选项,将其作为成绩导入。" + }, + "course.gradebook.ColumnMappingTable.titleCollisionError": { + "defaultMessage": "此名称已被现有外部评估使用。" + }, + "course.gradebook.ColumnMappingTable.titleLabel": { + "defaultMessage": "标题" + }, + "course.gradebook.ColumnMappingTable.weightLabel": { + "defaultMessage": "权重" + }, + "course.gradebook.ColumnMappingTable.csvHeader": { + "defaultMessage": "CSV 表头" + }, + "course.gradebook.ColumnMappingTable.actionLabel": { + "defaultMessage": "操作" + }, + "course.gradebook.ColumnMappingTable.selectComponent": { + "defaultMessage": "选择组件…" + }, + "course.gradebook.ColumnMappingTable.incompleteHint": { + "defaultMessage": "请完成高亮列的映射后继续。" + }, + "course.gradebook.ColumnMappingTable.nothingImportedHint": { + "defaultMessage": "请将至少一列设为新建或映射到现有项后继续。" + }, "course.gradebook.GradebookWeightedTable.displayMode": { "defaultMessage": "显示模式" }, @@ -9815,9 +9881,6 @@ "course.gradebook.AddExternalColumnPrompt.floorLabel": { "defaultMessage": "成绩下限设为 0" }, - "course.gradebook.AddExternalColumnPrompt.weightLabel": { - "defaultMessage": "权重" - }, "course.gradebook.EditExternalAssessmentPrompt.cancel": { "defaultMessage": "取消" }, @@ -9848,51 +9911,45 @@ "course.gradebook.EditExternalAssessmentPrompt.title": { "defaultMessage": "编辑外部评估" }, - "course.gradebook.EditExternalAssessmentPrompt.weightLabel": { - "defaultMessage": "权重" - }, "course.gradebook.ManageExternalAssessmentsButton.label": { "defaultMessage": "管理外部评估" }, - "course.gradebook.ManageExternalPanel.actions": { + "course.gradebook.ManageExternalAssessmentContent.actions": { "defaultMessage": "操作" }, - "course.gradebook.ManageExternalPanel.add": { + "course.gradebook.ManageExternalAssessmentContent.add": { "defaultMessage": "添加" }, - "course.gradebook.ManageExternalPanel.bounds": { + "course.gradebook.ManageExternalAssessmentContent.bounds": { "defaultMessage": "范围" }, - "course.gradebook.ManageExternalPanel.capped": { + "course.gradebook.ManageExternalAssessmentContent.capped": { "defaultMessage": "≤ 最高分" }, - "course.gradebook.ManageExternalPanel.close": { + "course.gradebook.ManageExternalAssessmentContent.close": { "defaultMessage": "关闭" }, - "course.gradebook.ManageExternalPanel.empty": { + "course.gradebook.ManageExternalAssessmentContent.empty": { "defaultMessage": "暂无外部评估" }, - "course.gradebook.ManageExternalPanel.emptyHint": { + "course.gradebook.ManageExternalAssessmentContent.emptyHint": { "defaultMessage": "手动添加,或导入在 Coursemology 之外获得的成绩的 CSV 文件。" }, - "course.gradebook.ManageExternalPanel.floored": { + "course.gradebook.ManageExternalAssessmentContent.floored": { "defaultMessage": "≥ 0" }, - "course.gradebook.ManageExternalPanel.max": { + "course.gradebook.ManageExternalAssessmentContent.max": { "defaultMessage": "最高分" }, - "course.gradebook.ManageExternalPanel.name": { + "course.gradebook.ManageExternalAssessmentContent.name": { "defaultMessage": "名称" }, - "course.gradebook.ManageExternalPanel.reorderError": { + "course.gradebook.ManageExternalAssessmentContent.reorderError": { "defaultMessage": "无法保存新的顺序。请重试。" }, - "course.gradebook.ManageExternalPanel.title": { + "course.gradebook.ManageExternalAssessmentContent.title": { "defaultMessage": "外部评估" }, - "course.gradebook.ManageExternalPanel.weight": { - "defaultMessage": "权重" - }, "course.gradebook.GradebookTable.acceptEdit": { "defaultMessage": "接受" }, @@ -9974,6 +10031,9 @@ "course.gradebook.ImportWizard.continue": { "defaultMessage": "确认导入" }, + "course.gradebook.ImportWizard.resolveConflicts": { + "defaultMessage": "解决冲突" + }, "course.gradebook.ImportWizard.downloadTemplate": { "defaultMessage": "下载模板" }, @@ -10059,7 +10119,7 @@ "defaultMessage": "部分标识符现在匹配到了另一名学生" }, "course.gradebook.ImportWizard.requiredHeaders": { - "defaultMessage": "您的 CSV 需要以下列表头:{headers}。‘{identifier}’ 必须是第一列。" + "defaultMessage": "您的 CSV 的第一列必须是 {headers} 以识别学生。" }, "course.gradebook.ImportWizard.stepDefine": { "defaultMessage": "定义组件" @@ -10095,9 +10155,9 @@ "defaultMessage": "权重" }, "course.gradebook.ImportWizard.willChangeExisting": { - "defaultMessage": "{count, plural, one {# 行包含} other {# 行包含}}对现有成绩的变更。检查此预览后,点击确认导入以在任何内容导入前查看这些冲突。" + "defaultMessage": "{count, plural, one {# 行包含对现有成绩的更改} other {# 行包含对现有成绩的更改}}。检查此预览后,请在导入任何内容之前点击解决冲突以查看这些冲突。" }, - "course.gradebook.ManageExternalPanel.import": { + "course.gradebook.ManageExternalAssessmentContent.import": { "defaultMessage": "导入 CSV" } } diff --git a/spec/controllers/course/external_assessment_imports_controller_spec.rb b/spec/controllers/course/external_assessment_imports_controller_spec.rb index fb2bec27b6..eb1a781c51 100644 --- a/spec/controllers/course/external_assessment_imports_controller_spec.rb +++ b/spec/controllers/course/external_assessment_imports_controller_spec.rb @@ -11,11 +11,14 @@ let!(:alice) { create(:course_student, course: course, external_id: 'A001') } let!(:bob) { create(:course_student, course: course, external_id: 'A002') } - let(:components) { [name: 'Midterm', weightage: 30, maximumGrade: 50] } + let(:mappings) do + [header: 'Midterm', action: 'create', target: 'Midterm', maxGrade: 50, weight: 30] + end let(:csv_data) { "External ID,Midterm\nA001,41\n" } let(:base_params) do { course_id: course.id, format: :json, - components: components, identifierMode: 'student_id', csvData: csv_data } + mappings: mappings, identifierMode: 'student_id', identifierColumn: 'External ID', + csvData: csv_data } end describe '#preview' do @@ -23,6 +26,15 @@ context 'as a manager' do before { controller_sign_in(controller, manager.user) } + it 'previews via mappings' do + post :preview, as: :json, params: { + course_id: course.id, identifierMode: 'external_id', identifierColumn: 'External ID', + csvData: "External ID,Midterm\nS1,80\n", + mappings: [header: 'Midterm', action: 'create', target: 'Midterm'] + } + expect(response).to have_http_status(:ok) + end + it 'returns ok with a sample and writes nothing' do expect { post :preview, params: base_params }. not_to(change { Course::ExternalAssessmentGrade.count }) @@ -48,12 +60,15 @@ # Seed an existing grade for alice service = Course::Gradebook::ExternalAssessmentImportService.new( course: course, actor: manager.user, - components: [name: 'Midterm', weightage: 30, maximum_grade: 50], - identifier_mode: 'student_id', csv_data: "External ID,Midterm\nA001,10\n" + identifier_mode: 'student_id', identifier_column: 'External ID', + csv_data: "External ID,Midterm\nA001,10\n", + mappings: [header: 'Midterm', action: 'create', target: 'Midterm'] ) service.commit(on_conflict: 'replace') - post :preview, params: base_params.merge(csvData: "External ID,Midterm\nA001,20\n") + existing_mappings = [header: 'Midterm', action: 'existing', target: 'Midterm'] + post :preview, params: base_params.merge(mappings: existing_mappings, + csvData: "External ID,Midterm\nA001,20\n") data = JSON.parse(response.body) expect(data['conflictRows'].size).to eq(1) expect(data['conflictRows'].first['studentName']).to eq(alice.name) @@ -66,20 +81,20 @@ expect(data['malformed']).to be_present end - it 'returns 422 on duplicate component names' do - dup_components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }, - { name: 'Midterm', weightage: 20, maximumGrade: 40 }] + it 'returns 422 on duplicate create targets' do + dup_mappings = [{ header: 'Midterm', action: 'create', target: 'Midterm' }, + { header: 'Midterm 2', action: 'create', target: 'Midterm' }] post :preview, params: base_params.merge( - components: dup_components, - csvData: "External ID,Midterm,Midterm\nA001,1,2\n" + mappings: dup_mappings, + csvData: "External ID,Midterm,Midterm 2\nA001,1,2\n" ) - expect(JSON.parse(response.body)['errors']['message']).to eq('duplicate_component_name') + expect(JSON.parse(response.body)['errors']['message']).to eq('duplicate_target') expect(response).to have_http_status(:unprocessable_entity) end it 'returns out-of-range cells in the preview payload' do out_of_range_params = base_params.merge( - components: [name: 'Midterm', weightage: 30, maximumGrade: 50], + mappings: [header: 'Midterm', action: 'create', target: 'Midterm', maxGrade: 50], csvData: "External ID,Midterm\nA001,105\n" ) post :preview, params: out_of_range_params, format: :json @@ -89,7 +104,7 @@ it 'resolves by email when identifierMode is email' do post :preview, params: base_params.merge( - identifierMode: 'email', + identifierMode: 'email', identifierColumn: 'Email', csvData: "Email,Midterm\n#{alice.user.email},41\n" ) data = JSON.parse(response.body) @@ -132,9 +147,10 @@ it 'commits with onConflict keep and returns updatedComponents' do # Seed first post :create, params: base_params.merge(onConflict: 'replace') - # Re-import with keep + # Re-import with keep, referencing the now-existing assessment + existing_mappings = [header: 'Midterm', action: 'existing', target: 'Midterm'] expect do - post :create, params: base_params.merge(onConflict: 'keep', + post :create, params: base_params.merge(mappings: existing_mappings, onConflict: 'keep', csvData: "External ID,Midterm\nA001,99\n") end.not_to(change { Course::ExternalAssessmentGrade.count }) data = JSON.parse(response.body) @@ -147,7 +163,8 @@ it 'overwrites an existing grade when onConflict is replace' do post :create, params: base_params.merge(onConflict: 'replace') - post :create, params: base_params.merge(onConflict: 'replace', + existing_mappings = [header: 'Midterm', action: 'existing', target: 'Midterm'] + post :create, params: base_params.merge(mappings: existing_mappings, onConflict: 'replace', csvData: "External ID,Midterm\nA001,99\n") data = JSON.parse(response.body) expect(data['updatedComponents']).to eq(1) @@ -159,13 +176,13 @@ expect(grade.grade).to eq(99) end - it 'returns 422 on duplicate component names' do - dup_components = [{ name: 'Midterm', weightage: 30, maximumGrade: 50 }, - { name: 'Midterm', weightage: 20, maximumGrade: 40 }] + it 'returns 422 on duplicate create targets' do + dup_mappings = [{ header: 'Midterm', action: 'create', target: 'Midterm' }, + { header: 'Midterm 2', action: 'create', target: 'Midterm' }] expect do post :create, params: base_params.merge( - components: dup_components, - csvData: "External ID,Midterm,Midterm\nA001,1,2\n", + mappings: dup_mappings, + csvData: "External ID,Midterm,Midterm 2\nA001,1,2\n", onConflict: 'replace' ) end.not_to(change { Course::ExternalAssessmentGrade.count }) diff --git a/spec/services/course/gradebook/external_assessment_import_service_spec.rb b/spec/services/course/gradebook/external_assessment_import_service_spec.rb index 8152661594..ddbb2d9ce3 100644 --- a/spec/services/course/gradebook/external_assessment_import_service_spec.rb +++ b/spec/services/course/gradebook/external_assessment_import_service_spec.rb @@ -6,348 +6,219 @@ with_tenant(:instance) do let(:course) { create(:course) } - let(:actor) { create(:course_manager, course: course).user } + let(:teacher) { create(:course_manager, course: course).user } let!(:alice) { create(:course_student, course: course, external_id: 'A001') } let!(:bob) { create(:course_student, course: course, external_id: 'A002') } + let!(:existing_final) do + User.with_stamper(teacher) do + Course::ExternalAssessment.create_for_course!(course: course, title: 'Final', maximum_grade: 100, weight: 50) + end + end - def service(csv_data:, components:, identifier_mode: 'student_id') + def service(mappings, data: nil, identifier_mode: 'external_id', identifier_column: 'External ID') described_class.new( - course: course, actor: actor, components: components, - identifier_mode: identifier_mode, csv_data: csv_data + course: course, actor: teacher, identifier_mode: identifier_mode, + identifier_column: identifier_column, csv_data: data, mappings: mappings ) end - let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } - - describe '#preview' do - it 'writes nothing (dry-run)' do - csv = "External ID,Midterm\nA001,41\nA002,37\n" - expect { service(csv_data: csv, components: components).preview }. - to not_change { Course::ExternalAssessmentGrade.count }. - and(not_change { Course::ExternalAssessment.count }) - end - - it 'returns ok with the first 5 resolved rows (External IDs)' do - csv = "External ID,Midterm\nA001,41\nA002,37\n" - result = service(csv_data: csv, components: components).preview - expect(result[:ok]).to be(true) - expect(result[:unresolved]).to be_empty - expect(result[:sample].size).to eq(2) - expect(result[:sample].map { |r| r[:identifier] }).to include(alice.external_id, bob.external_id) - expect(result[:sample].first[:grades]['Midterm']).to eq(41.0) - end + describe 'mappings-based import' do + let(:csv) { "External ID,Midterm,Final\nA001,80,90\n" } - it 'caps the sample at 5 rows but reports the true total in total_rows' do - extra = (1..5).map { |i| create(:course_student, course: course, external_id: "X00#{i}") } - ids = ['A001', 'A002'] + extra.map(&:external_id) - csv = "External ID,Midterm\n#{ids.map { |id| "#{id},10" }.join("\n")}\n" - result = service(csv_data: csv, components: components).preview - expect(result[:sample].size).to eq(5) - expect(result[:total_rows]).to eq(7) + it 'creates a component using the mapping max_grade / weight' do + result = service( + [header: 'Midterm', action: 'create', target: 'Midterm', max_grade: 50, weight: 20], + data: csv + ).commit(on_conflict: 'replace') + expect(result[:createdComponents]).to eq(1) + ext = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + expect(ext.maximum_grade).to eq(50) + expect(ext.gradebook_contribution.weight).to eq(20) end - it 'normalizes preview identifiers to the roster email when resolving by email' do - csv = "Email,Midterm\n#{alice.user.email.upcase},41\n" - result = service(csv_data: csv, components: components, identifier_mode: 'email').preview - expect(result[:ok]).to be(true) - expect(result[:sample].first[:identifier]).to eq(alice.user.email) - end - - it 'fails the whole batch on any unresolved identifier' do - csv = "External ID,Midterm\nA001,41\nZZZZ,37\n" - result = service(csv_data: csv, components: components).preview - expect(result[:ok]).to be(false) - expect(result[:unresolved]).to include('ZZZZ') + it 'defaults max_grade/weight to 100/0 when omitted' do + service([header: 'Midterm', action: 'create', target: 'Midterm'], data: csv). + commit(on_conflict: 'replace') + ext = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') + expect(ext.maximum_grade).to eq(100) + expect(ext.gradebook_contribution.weight).to eq(0) end - it 'flags a malformed (non-numeric) cell' do - csv = "External ID,Midterm\nA001,oops\n" - result = service(csv_data: csv, components: components).preview - expect(result[:ok]).to be(false) - expect(result[:malformed]).to be_present + it 'writes grades to an existing target by title' do + service([header: 'Final', action: 'existing', target: 'Final'], data: csv). + commit(on_conflict: 'replace') + expect(existing_final.reload.external_assessment_grades.first.grade).to eq(90) end - it 'rejects an in-file duplicate component name' do - dup = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Midterm', weightage: 20, maximum_grade: 40 }] - csv = "External ID,Midterm,Midterm\nA001,1,2\n" - expect { service(csv_data: csv, components: dup).preview }. - to raise_error(described_class::ImportError) + it 'reads the identifier from the chosen column and ignores unmapped columns' do + res = service([header: 'Midterm', action: 'create', target: 'Midterm'], data: csv).preview + expect(res[:column_order]).to eq(['Midterm']) # Final unmapped -> ignored + expect(res[:unresolved]).to be_empty end - it 'raises ImportError on wrong CSV header' do - csv = "Wrong,Midterm\nA001,41\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) + it 'labels preview columns by their mapped target, not the CSV header' do + # CSV header 'Midterm' is renamed to a new component 'Quiz 3'. The preview + # must key the column by the target so it lines up with the grades hash + # (which parse_grades keys by target), not the header — otherwise the + # renamed column renders blank. + res = service([header: 'Midterm', action: 'create', target: 'Quiz 3'], data: csv).preview + expect(res[:column_order]).to eq(['Quiz 3']) + expect(res[:sample].first[:grades]).to include('Quiz 3' => 80.0) end - it 'raises duplicate_component_name (not bad_header) for an in-file duplicate component' do - dup = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Midterm', weightage: 20, maximum_grade: 40 }] - csv = "External ID,Midterm,Midterm\nA001,1,2\n" - expect { service(csv_data: csv, components: dup).preview }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:message]).to eq('duplicate_component_name') - end + it 'treats blank, dash and N/A cells as no-grade (nil), not malformed' do + data = "External ID,Midterm\nS1,-\nS2,N/A\nS3,\n" + expect do + service([header: 'Midterm', action: 'create', target: 'Midterm'], data: data).preview + end.not_to raise_error end - it 'rejects an otherwise-valid CSV with no data rows as empty_csv' do - csv = "External ID,Midterm\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:message]).to eq('empty_csv') - end + it 'raises on a create target colliding with an existing assessment or another create' do + expect { service([header: 'Final', action: 'create', target: 'Final'], data: csv).preview }. + to raise_error(described_class::ImportError) # 'Final' already exists end - it 'writes nothing and raises empty_csv on commit of a header-only CSV' do - csv = "External ID,Midterm\n" - expect do - expect { service(csv_data: csv, components: components).commit(on_conflict: 'replace') }. - to raise_error(described_class::ImportError) - end.not_to(change { Course::ExternalAssessmentGrade.count }) + it 'raises on two create mappings sharing a target (case-insensitive)' do + mappings = [ + { header: 'Midterm', action: 'create', target: 'Quiz' }, + { header: 'Final', action: 'create', target: 'QUIZ' } + ] + expect { service(mappings, data: csv).preview }.to raise_error(described_class::ImportError) do |error| + expect(error.payload[:message]).to eq('duplicate_target') + end end - it 'treats a whitespace-only cell as ungraded, not malformed' do - csv = "External ID,Midterm\nA001, \n" - result = service(csv_data: csv, components: components).preview - expect(result[:ok]).to be(true) - expect(result[:malformed]).to be_empty - expect(result[:sample].first[:grades]['Midterm']).to be_nil - end + it 'still raises on empty csv and duplicate identifiers' do + empty = described_class.new( + course: course, actor: teacher, identifier_mode: 'external_id', + identifier_column: 'External ID', csv_data: "External ID,Midterm\n", + mappings: [header: 'Midterm', action: 'create', target: 'Midterm'] + ) + expect { empty.preview }.to raise_error(described_class::ImportError) - it 'rejects duplicate identifiers even if unresolvable' do - csv = "External ID,Midterm\nZZZZ,1\nZZZZ,2\n" - expect { service(csv_data: csv, components: components).preview }. + dup_csv = "External ID,Midterm\nA001,1\nA001,2\n" + expect { service([header: 'Midterm', action: 'create', target: 'Midterm'], data: dup_csv).preview }. to raise_error(described_class::ImportError) do |error| expect(error.payload[:message]).to eq('duplicate_identifier') - expect(error.payload[:identifiers]).to include('ZZZZ') end end - it 'reports the malformed cell with its 1-based data-row number and component' do - csv = "External ID,Midterm\nA001,41\nA002,oops\n" - result = service(csv_data: csv, components: components).preview - expect(result[:malformed]).to include('row 3, Midterm: oops') - end - - it 'accumulates every malformed cell across rows and components' do - comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Final', weightage: 50, maximum_grade: 100 }] - csv = "External ID,Midterm,Final\nA001,bad,worse\n" - result = service(csv_data: csv, components: comps).preview - expect(result[:malformed].size).to eq(2) - end - - it 'treats a blank cell as ungraded in the sample' do - csv = "External ID,Midterm\nA001,\n" - result = service(csv_data: csv, components: components).preview - expect(result[:sample].first[:grades]['Midterm']).to be_nil - end - - it 'accepts the External ID header in student_id mode' do - csv = "External ID,Midterm\nA001,41\n" - result = service(csv_data: csv, components: components).preview - expect(result[:ok]).to be(true) - expect(result[:sample].first[:identifier]).to eq(alice.external_id) - end - - it 'accepts the Email header in email mode' do - csv = "Email,Midterm\n#{alice.user.email},41\n" - result = service(csv_data: csv, components: components, identifier_mode: 'email').preview - expect(result[:ok]).to be(true) - expect(result[:sample].first[:identifier]).to eq(alice.user.email) - end - - it 'rejects the External ID header when resolving by email' do - csv = "External ID,Midterm\n#{alice.user.email},41\n" - expect { service(csv_data: csv, components: components, identifier_mode: 'email').preview }. + it 'raises bad_header when the identifier column is missing' do + data = "Midterm\n41\n" + expect { service([header: 'Midterm', action: 'create', target: 'Midterm'], data: data).preview }. to raise_error(described_class::ImportError) do |error| expect(error.payload[:message]).to eq('bad_header') + expect(error.payload[:missing]).to include('External ID') end end - it 'reports duplicate headers in the bad_header payload' do - csv = "External ID,Midterm,Midterm\nA001,1,2\n" - expect { service(csv_data: csv, components: components).preview }. + it 'raises bad_header when a mapped header is missing from the file' do + data = "External ID\nA001\n" + expect { service([header: 'Midterm', action: 'create', target: 'Midterm'], data: data).preview }. to raise_error(described_class::ImportError) do |error| expect(error.payload[:message]).to eq('bad_header') - expect(error.payload[:duplicates]).to include(name: 'Midterm', count: 2) + expect(error.payload[:missing]).to include('Midterm') end end - it 'leaves duplicates empty for a non-duplicate header mismatch' do - csv = "Wrong,Midterm\nA001,41\n" - expect { service(csv_data: csv, components: components).preview }. + it 'raises bad_header with duplicates when a mapped header repeats in the file' do + data = "External ID,Midterm,Midterm\nA001,1,2\n" + expect { service([header: 'Midterm', action: 'create', target: 'Midterm'], data: data).preview }. to raise_error(described_class::ImportError) do |error| - expect(error.payload[:duplicates]).to eq([]) + expect(error.payload[:message]).to eq('bad_header') + expect(error.payload[:duplicates]).to include(name: 'Midterm', count: 2) end end - it 'reports only duplicates when every expected column is present but repeated' do - csv = "External ID,Midterm,Midterm\nA001,1,2\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:duplicates]).to include(name: 'Midterm', count: 2) - expect(error.payload[:missing]).to eq([]) - expect(error.payload[:unrecognized]).to eq([]) - end + it 'flags a malformed (non-numeric) cell' do + data = "External ID,Midterm\nA001,oops\n" + result = service([header: 'Midterm', action: 'create', target: 'Midterm'], data: data).preview + expect(result[:ok]).to be(false) + expect(result[:malformed]).to include('row 2, Midterm: oops') end - it 'reports missing and unrecognized columns for a header mismatch' do - csv = "External ID,Wrong\nA001,41\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:missing]).to eq(['Midterm']) - expect(error.payload[:unrecognized]).to eq(['Wrong']) - end + it 'fails the whole batch on any unresolved identifier' do + data = "External ID,Midterm\nA001,41\nZZZZ,37\n" + result = service([header: 'Midterm', action: 'create', target: 'Midterm'], data: data).preview + expect(result[:ok]).to be(false) + expect(result[:unresolved]).to include('ZZZZ') end - it 'returns the component columns in the CSV header order, not the defined order' do - two_components = [ - { name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Final', weightage: 70, maximum_grade: 100 } - ] - # CSV lists Final before Midterm;. - csv = "External ID,Final,Midterm\n80,A001,41\n" - result = service(csv_data: csv, components: two_components).preview - expect(result[:column_order]).to eq(['Final', 'Midterm']) + it 'normalizes preview identifiers to the roster email when resolving by email' do + data = "Email,Midterm\n#{alice.user.email.upcase},41\n" + result = service( + [header: 'Midterm', action: 'create', target: 'Midterm'], + data: data, identifier_mode: 'email', identifier_column: 'Email' + ).preview + expect(result[:ok]).to be(true) + expect(result[:sample].first[:identifier]).to eq(alice.user.email) end end - describe '#preview out-of-range detection' do - let(:oor_components) { [name: 'Midterms', maximum_grade: 100, weightage: 0] } + describe 'out-of-range detection' do let!(:charlie) { create(:course_student, course: course, external_id: 'S123') } - it 'lists grades below 0 or above the component max without failing the preview' do - csv = "External ID,Midterms\nS123,105\n" - result = service(csv_data: csv, components: oor_components).preview - expect(result[:ok]).to be(true) # out-of-range is advisory, not a block + it 'lists grades below 0 or above the mapping max without failing the preview' do + data = "External ID,Midterms\nS123,105\n" + result = service( + [header: 'Midterms', action: 'create', target: 'Midterms', max_grade: 100, weight: 0], data: data + ).preview + expect(result[:ok]).to be(true) expect(result[:out_of_range]).to include( - a_hash_including( - component: 'Midterms', - identifier: 'S123', - grade: 105.0, - kind: 'above', - max: 100 - ) + a_hash_including(component: 'Midterms', identifier: 'S123', grade: 105.0, kind: 'above', max: 100) ) end - it 'flags grades below 0' do - csv = "External ID,Midterms\nS123,-2\n" - result = service(csv_data: csv, components: oor_components).preview - expect(result[:ok]).to be(true) + it 'uses the existing assessment maximum for an "existing" mapping' do + data = "External ID,Final\nS123,150\n" + result = service([header: 'Final', action: 'existing', target: 'Final'], data: data).preview expect(result[:out_of_range]).to include( - a_hash_including( - component: 'Midterms', - identifier: 'S123', - grade: -2.0, - kind: 'below', - max: 100 - ) + a_hash_including(component: 'Final', identifier: 'S123', grade: 150.0, kind: 'above', max: 100) ) end - - it 'does not flag a grade exactly at the maximum or at zero' do - csv = "External ID,Midterms\nS123,100\nA001,0\n" - result = service(csv_data: csv, components: oor_components).preview - expect(result[:ok]).to be(true) - expect(result[:out_of_range]).to be_empty - end - - it 'ignores blank cells for out-of-range detection' do - csv = "External ID,Midterms\nS123,\n" - result = service(csv_data: csv, components: oor_components).preview - expect(result[:out_of_range]).to be_empty - end end - describe '#commit (fresh import)' do - let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } - - it 'creates the external in the External Assessments category with the typed weight' do - csv = "External ID,Midterm\nA001,41\nA002,37\n" - summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') - external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') - expect(external).to be_present - expect(external.maximum_grade).to eq(50) - expect(external.gradebook_contribution.weight).to eq(30) - expect(summary[:createdComponents]).to eq(1) - expect(summary[:gradesWritten]).to eq(2) - end - - it 'writes one grade row per resolved student bound to course_user' do - csv = "External ID,Midterm\nA001,41\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') - grade = external.external_assessment_grades.find_by!(course_user: alice) - expect(grade.course_user_id).to eq(alice.id) - expect(grade.grade).to eq(41) - expect(grade.imported_identifier).to eq('A001') - end - - it 'skips a blank cell on a fresh import (no grade row created)' do - csv = "External ID,Midterm\nA001,\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - # After fix: blank cell on fresh import does NOT create a grade row (filter_map skips nil) - external = Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') - expect(external.external_assessment_grades.count).to eq(0) - end - - it 'accepts a grade greater than the max (no ceiling)' do - csv = "External ID,Midterm\nA001,60\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') - expect(external.external_assessment_grades.find_by!(course_user: alice).grade).to eq(60) - end - - it 'creates multiple components as separate externals' do - comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Final', weightage: 50, maximum_grade: 100 }] - csv = "External ID,Midterm,Final\nA001,40,80\n" - service(csv_data: csv, components: comps).commit(on_conflict: 'replace') - expect(Course::ExternalAssessment.for_course(course).pluck(:title)).to contain_exactly('Midterm', 'Final') - expect(Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm'). - external_assessment_grades.count).to eq(1) - expect(Course::ExternalAssessment.for_course(course).find_by!(title: 'Final'). - external_assessment_grades.count).to eq(1) + describe '#commit upsert into existing component' do + def seed_initial! + data = "External ID,Midterm\nA001,10\n" + service([header: 'Midterm', action: 'create', target: 'Midterm', max_grade: 50, weight: 30], + data: data).commit(on_conflict: 'replace') + Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') end - it 'writes nothing when an identifier does not resolve' do - csv = "External ID,Midterm\nA001,41\nZZZ,9\n" - expect do - expect do - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - end.to raise_error(described_class::ImportError) - end.not_to(change { Course::ExternalAssessmentGrade.count }) + it 'updates grades into the same component on a second import (no second tab)' do + external = seed_initial! + data = "External ID,Midterm\nA001,20\n" + service([header: 'Midterm', action: 'existing', target: 'Midterm'], data: data). + commit(on_conflict: 'replace') + expect(Course::ExternalAssessment.for_course(course).where(title: 'Midterm').count).to eq(1) + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(20) end - it 'raises validation_failed with the unresolved identifiers in the payload' do - csv = "External ID,Midterm\nA001,41\nZZZ,9\n" - expect { service(csv_data: csv, components: components).commit(on_conflict: 'replace') }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:message]).to eq('validation_failed') - expect(error.payload[:unresolved]).to include('ZZZ') - end + it "keeps existing grades when on_conflict is 'keep'" do + external = seed_initial! + data = "External ID,Midterm\nA001,99\n" + service([header: 'Midterm', action: 'existing', target: 'Midterm'], data: data). + commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) end - it 'aborts the commit and writes nothing when a cell is malformed' do - csv = "External ID,Midterm\nA001,41\nA002,oops\n" - expect do - expect do - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - end.to raise_error(described_class::ImportError) { |e| expect(e.payload[:malformed]).to be_present } - end.not_to(change { Course::ExternalAssessmentGrade.count }) + it 'inserts a grade for a brand-new student regardless of on_conflict' do + external = seed_initial! + data = "External ID,Midterm\nA002,55\n" + service([header: 'Midterm', action: 'existing', target: 'Midterm'], data: data). + commit(on_conflict: 'keep') + expect(external.external_assessment_grades.find_by(course_user: bob).grade).to eq(55) end + end + describe 'commit rollback' do it 'rolls back all components when a later component fails mid-write' do - # Pre-create "Final" outside the service WITHOUT a gradebook_contribution so - # the service treats it as new (existing_external matches by title) — instead, - # create a title collision the service cannot reconcile. - comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Quiz', weightage: 20, maximum_grade: 20 }] - csv = "External ID,Midterm,Quiz\nA001,40,10\n" - # Sabotage: make the second create! blow up by stubbing it to raise after the first wrote. + mappings = [ + { header: 'Midterm', action: 'create', target: 'Midterm', max_grade: 50, weight: 30 }, + { header: 'Quiz', action: 'create', target: 'Quiz', max_grade: 20, weight: 20 } + ] + data = "External ID,Midterm,Quiz\nA001,40,10\n" call = 0 allow(Course::ExternalAssessment).to receive(:create_for_course!).and_wrap_original do |orig, **kwargs| call += 1 @@ -356,65 +227,24 @@ def service(csv_data:, components:, identifier_mode: 'student_id') orig.call(**kwargs) end expect do - expect do - service(csv_data: csv, components: comps).commit(on_conflict: 'replace') - end.to raise_error(ActiveRecord::RecordInvalid) + expect { service(mappings, data: data).commit(on_conflict: 'replace') }. + to raise_error(ActiveRecord::RecordInvalid) end.to change { Course::ExternalAssessment.for_course(course).count }.by(0). and(change { Course::ExternalAssessmentGrade.count }.by(0)) end end - describe '#commit (upsert into existing component)' do - let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } - + describe 'conflict rows and reassignments' do def seed_initial! - csv = "External ID,Midterm\nA001,10\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - Course::ExternalAssessment.for_course(course).find_by(title: 'Midterm') - end - - it 'updates grades into the same component (no second tab)' do - external = seed_initial! - csv = "External ID,Midterm\nA001,20\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - expect(Course::ExternalAssessment.for_course(course).where(title: 'Midterm').count).to eq(1) - expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(20) - end - - it "keeps existing grades when on_conflict is 'keep'" do - external = seed_initial! - csv = "External ID,Midterm\nA001,99\n" - service(csv_data: csv, components: components).commit(on_conflict: 'keep') - expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) - end - - it 'inserts a grade for a brand-new student regardless of on_conflict' do - external = seed_initial! - csv = "External ID,Midterm\nA002,55\n" - service(csv_data: csv, components: components).commit(on_conflict: 'keep') - expect(external.external_assessment_grades.find_by(course_user: bob).grade).to eq(55) - end - - it 'skips a blank cell on upsert (existing grade unchanged)' do - external = seed_initial! - csv = "External ID,Midterm\nA001,\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(10) - end - - it 'never changes the external max or contribution weight on upsert' do - external = seed_initial! - csv = "External ID,Midterm\nA001,20\n" - comps = [name: 'Midterm', weightage: 99, maximum_grade: 999] - service(csv_data: csv, components: comps).commit(on_conflict: 'replace') - expect(external.reload.maximum_grade).to eq(50) - expect(external.gradebook_contribution.reload.weight).to eq(30) + data = "External ID,Midterm\nA001,10\n" + service([header: 'Midterm', action: 'create', target: 'Midterm', max_grade: 50, weight: 30], + data: data).commit(on_conflict: 'replace') end it 'groups changed cells by student and drops unchanged / new-fill students' do seed_initial! # alice (A001) Midterm=10; bob (A002) has no grade yet - csv = "External ID,Midterm\nA001,20\nA002,33\n" - result = service(csv_data: csv, components: components).preview + data = "External ID,Midterm\nA001,20\nA002,33\n" + result = service([header: 'Midterm', action: 'existing', target: 'Midterm'], data: data).preview rows = result[:conflict_rows] expect(rows.map { |r| r[:studentName] }).to contain_exactly(alice.name) @@ -424,238 +254,16 @@ def seed_initial! expect(cell[:changed]).to be(true) end - it 'drops a student whose grade is unchanged (equal at 2 dp)' do - seed_initial! # alice Midterm=10 - csv = "External ID,Midterm\nA001,10.00\n" - result = service(csv_data: csv, components: components).preview - expect(result[:conflict_rows]).to be_empty - end - - it 'flags a reassignment when an identifier now resolves to a different student than it was imported under' do + it 'flags a reassignment when an identifier now resolves to a different student' do seed_initial! # alice imported under 'A001' (snapshot 'A001' on alice's grade) alice.update!(external_id: 'AOLD') # free up A001 carol = create(:course_student, course: course, external_id: 'A001') # A001 recycled to carol - csv = "External ID,Midterm\nA001,77\n" - result = service(csv_data: csv, components: components).preview + data = "External ID,Midterm\nA001,77\n" + result = service([header: 'Midterm', action: 'existing', target: 'Midterm'], data: data).preview entry = result[:reassignments].find { |r| r[:identifier] == 'A001' } expect(entry[:currentStudent]).to eq(carol.name) expect(entry[:previousStudents]).to include(alice.name) - # carol is a brand-new insert, so she is NOT in the conflict table - expect(result[:conflict_rows].map { |r| r[:studentName] }).not_to include(carol.name) - end - - it 'does not flag a reassignment when only the same student\'s own identifier changed' do - seed_initial! # alice imported under 'A001' - bob.update!(external_id: 'A777') # free A002 - alice.update!(external_id: 'A002') # alice's OWN id drifted A001 -> A002 - csv = "External ID,Midterm\nA002,20\n" - result = service(csv_data: csv, components: components).preview - expect(result[:reassignments]).to be_empty - end - - it 'does not flag a reassignment when switching identifier mode between imports' do - seed_initial! # alice imported under External ID 'A001' - csv = "Email,Midterm\n#{alice.user.email},20\n" - result = service(csv_data: csv, components: components, identifier_mode: 'email').preview - expect(result[:reassignments]).to be_empty - end - - it 'reports changed cells across multiple components on one student row' do - comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Final', weightage: 50, maximum_grade: 100 }] - service(csv_data: "External ID,Midterm,Final\nA001,10,80\n", components: comps). - commit(on_conflict: 'replace') - csv = "External ID,Midterm,Final\nA001,20,90\n" - result = service(csv_data: csv, components: comps).preview - - expect(result[:conflict_rows].length).to eq(1) - cells = result[:conflict_rows].first[:cells] - expect(cells['Midterm'][:changed]).to be(true) - expect(cells['Final'][:changed]).to be(true) - end - - it 'returns updatedComponents: 1 after an upsert' do - seed_initial! - csv = "External ID,Midterm\nA001,20\n" - summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') - expect(summary[:updatedComponents]).to eq(1) - expect(summary[:createdComponents]).to eq(0) - end - - it 'updates a nil existing grade even when on_conflict is keep' do - external = seed_initial! - # Manually clear the grade to nil (simulates a partial import that wrote the row but not the value) - external.external_assessment_grades.find_by(course_user: alice).update_column(:grade, nil) - csv = "External ID,Midterm\nA001,50\n" - service(csv_data: csv, components: components).commit(on_conflict: 'keep') - expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(50) - end - - it 'refreshes imported_identifier on upsert while preserving the original creator' do - external = seed_initial! # alice imported under 'A001', value 10 - grade = external.external_assessment_grades.find_by(course_user: alice) - original_creator_id = grade.creator_id - alice.update!(external_id: 'A001-NEW') - csv = "External ID,Midterm\nA001-NEW,22\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - grade.reload - expect(grade.grade).to eq(22) - expect(grade.imported_identifier).to eq('A001-NEW') - expect(grade.creator_id).to eq(original_creator_id) - end - - it 'inserts new students and upserts existing ones in a single replace commit' do - external = seed_initial! # alice has Midterm=10, bob has none - csv = "External ID,Midterm\nA001,15\nA002,20\n" - summary = service(csv_data: csv, components: components).commit(on_conflict: 'replace') - expect(external.external_assessment_grades.find_by(course_user: alice).grade).to eq(15) - expect(external.external_assessment_grades.find_by(course_user: bob).grade).to eq(20) - expect(summary[:gradesWritten]).to eq(2) - end - - it 'includes the unchanged cell with its real existing value when another cell changed' do - comps = [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Final', weightage: 50, maximum_grade: 100 }] - service(csv_data: "External ID,Midterm,Final\nA001,10,80\n", components: comps). - commit(on_conflict: 'replace') - csv = "External ID,Midterm,Final\nA001,20,80\n" # only Midterm changes - result = service(csv_data: csv, components: comps).preview - cells = result[:conflict_rows].first[:cells] - expect(cells['Midterm']).to include(existing: 10.0, inFile: 20.0, changed: true) - expect(cells['Final']).to include(existing: 80.0, inFile: 80.0, changed: false) - end - end - - describe 'determinacy' do - let(:components) { [name: 'Midterm', weightage: 30, maximum_grade: 50] } - - it 'does not move a grade when the student external_id changes after import' do - csv = "External ID,Midterm\nA001,41\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - grade = Course::ExternalAssessmentGrade.last - alice.update!(external_id: 'CHANGED') - expect(grade.reload.course_user_id).to eq(alice.id) - expect(grade.grade).to eq(41) - end - end - - describe 'order-free assessment columns' do - let(:components) do - [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Finals', weightage: 70, maximum_grade: 100 }] - end - - it 'accepts assessment columns in any order, with the identifier first' do - csv = "External ID,Finals,Midterm\nA001,1,2\n" - result = service(csv_data: csv, components: components).preview - expect(result[:ok]).to be(true) - expect(result[:sample].first[:grades]['Midterm']).to eq(2.0) - expect(result[:sample].first[:grades]['Finals']).to eq(1.0) - - csv = "External ID,Midterm,Finals\nA001,1,2\n" - result = service(csv_data: csv, components: components).preview - expect(result[:ok]).to be(true) - expect(result[:sample].first[:grades]['Midterm']).to eq(1.0) - expect(result[:sample].first[:grades]['Finals']).to eq(2.0) - end - - it 'rejects an unexpected extra column' do - csv = "External ID,Midterm,Finals,Notes\nA001,41,61,x\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) { |e| - expect(e.payload[:message]).to eq('bad_header') - } - end - - it 'rejects an unexpected extra column even when the number of headers are correct' do - csv = "External ID,Finals,Notes\nA001,1,x\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) { |e| - expect(e.payload[:message]).to eq('bad_header') - } - end - - it 'rejects a duplicated column' do - csv = "External ID,Midterm,Midterm,Finals\nA001,41,41,61\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) - end - - it 'rejects a duplicated column even when the number of headers are correct' do - csv = "External ID,Midterm,Midterm\nA001,41,41\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) - end - - it 'rejects a file missing the identifier column' do - csv = "Midterm\n41\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:message]).to eq('bad_header') - expect(error.payload[:missing]).to include('External ID') - end - end - end - - describe 'duplicate identifiers' do - it 'rejects a CSV with the same identifier in two rows' do - csv = "External ID,Midterm\nA001,40\nA001,50\n" - expect { service(csv_data: csv, components: components).preview }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:message]).to eq('duplicate_identifier') - expect(error.payload[:identifiers]).to include('A001') - end - end - - it 'treats case-different emails as the same duplicated identifier' do - csv = "Email,Midterm\n#{alice.user.email},40\n#{alice.user.email.upcase},50\n" - expect { service(csv_data: csv, components: components, identifier_mode: 'email').preview }. - to raise_error(described_class::ImportError) do |error| - expect(error.payload[:message]).to eq('duplicate_identifier') - end - end - - it 'writes nothing when an identifier is duplicated' do - csv = "External ID,Midterm\nA001,40\nA001,50\n" - expect do - expect { service(csv_data: csv, components: components).commit(on_conflict: 'replace') }. - to raise_error(described_class::ImportError) - end.not_to(change { Course::ExternalAssessmentGrade.count }) - end - end - - describe 'bad-header suggestions' do - let(:components) { [name: 'Midterms', weightage: 30, maximum_grade: 50] } - - it 'suggests the near-miss uploaded header for a missing expected header' do - csv = "External ID,Midterm\nA001,41\n" # header "Midterm" vs component "Midterms" - error = nil - begin - service(csv_data: csv, components: components).preview - rescue described_class::ImportError => e - error = e - end - expect(error).to be_present - expect(error.payload[:message]).to eq('bad_header') - expect(error.payload[:suggestions]).to include( - a_hash_including(expected: 'Midterms', didYouMean: 'Midterm') - ) - # The typo pair is surfaced as a suggestion, not double-reported as a - # plain missing/unrecognized column. - expect(error.payload[:missing]).to eq([]) - expect(error.payload[:unrecognized]).to eq([]) - end - - it 'omits a suggestion when no uploaded header is within the edit-distance threshold' do - csv = "External ID,Homework\nA001,41\n" # "Homework" is far from "Midterms" - error = nil - begin - service(csv_data: csv, components: components).preview - rescue described_class::ImportError => e - error = e - end - expect(error.payload[:suggestions]).to be_empty end end @@ -683,13 +291,15 @@ def csv_for(students, value) "External ID,Midterm\n#{rows}\n" end + let(:mappings) { [header: 'Midterm', action: 'create', target: 'Midterm', max_grade: 50, weight: 30] } + it 'inserts many brand-new grades with a single INSERT statement' do students = roster students.each { |s| s.update!(external_id: "E#{s.id}") } - csv = csv_for(students, 41) + data = csv_for(students, 41) writes = write_query_count do - service(csv_data: csv, components: components).commit(on_conflict: 'replace') + service(mappings, data: data).commit(on_conflict: 'replace') end expect(Course::ExternalAssessmentGrade.where(course_user: students).count).to eq(students.size) @@ -699,78 +309,28 @@ def csv_for(students, value) it 'replaces many existing grades without one UPDATE per row' do students = roster students.each { |s| s.update!(external_id: "E#{s.id}") } - # First import seeds existing grades. - service(csv_data: csv_for(students, 41), components: components).commit(on_conflict: 'replace') + service(mappings, data: csv_for(students, 41)).commit(on_conflict: 'replace') + existing_mapping = [header: 'Midterm', action: 'existing', target: 'Midterm'] writes = write_query_count do - service(csv_data: csv_for(students, 99), components: components).commit(on_conflict: 'replace') + service(existing_mapping, data: csv_for(students, 99)).commit(on_conflict: 'replace') end grades = Course::ExternalAssessmentGrade.where(course_user: students).where.not(grade: nil).pluck(:grade) expect(grades).to all(eq(99)) expect(writes).to be <= 1 # single upsert statement for all rows end - - it 'keeps existing non-null grades but fills nil ones under keep' do - students = roster - students.each { |s| s.update!(external_id: "E#{s.id}") } - service(csv_data: csv_for(students, 41), components: components).commit(on_conflict: 'replace') - external = Course::ExternalAssessment.for_course(course).find_by!(title: 'Midterm') - blanked = students.first - external.external_assessment_grades.find_by(course_user: blanked).update_column(:grade, nil) - - service(csv_data: csv_for(students, 99), components: components).commit(on_conflict: 'keep') - grades = external.external_assessment_grades.where.not(course_user: blanked).pluck(:grade) - expect(grades).to all(eq(41)) - expect(external.external_assessment_grades.find_by(course_user: blanked).grade).to eq(99) - end end describe 'header ordering' do - let(:components) do - [{ name: 'Midterm', weightage: 30, maximum_grade: 50 }, - { name: 'Final', weightage: 70, maximum_grade: 100 }] - end - - it 'returns column_order following the uploaded CSV header order (faithful preview)' do - ordered = "External ID,Midterm,Final\nA001,41,80\n" - shuffled = "External ID,Final,Midterm\nA001,80,41\n" - a = service(csv_data: ordered, components: components).preview[:column_order] - b = service(csv_data: shuffled, components: components).preview[:column_order] - expect(a).to eq(%w[Midterm Final]) - expect(b).to eq(%w[Final Midterm]) - end - - it 'keeps canonical (position) order in define-step order, not CSV header order' do - # CSV columns are in the opposite order to the defined components; the - # gradebook's canonical order must follow the defined (append) order. - csv = "External ID,Final,Midterm\nA001,80,41\n" - service(csv_data: csv, components: components).commit(on_conflict: 'replace') - titles = Course::ExternalAssessment.for_course(course).order(:position).pluck(:title) - expect(titles).to eq(%w[Midterm Final]) - end - - it 'blocks when the identifier is present but not the first column' do - csv = "Midterm,External ID,Final\n41,A001,80\n" - error = service(csv_data: csv, components: components).preview rescue $ERROR_INFO # rubocop:disable Style/RescueModifier - expect(error).to be_a(described_class::ImportError) - expect(error.payload[:message]).to eq('bad_header') - expect(error.payload[:identifierNotFirst]).to be(true) - end - - it 'does not flag identifierNotFirst when the identifier is first' do - csv = "External ID,Final,Midterm\nA001,80,41\n" - expect { service(csv_data: csv, components: components).preview }.not_to raise_error - end - - it 'treats a missing identifier as missing, not identifierNotFirst' do - csv = "Midterm,Final\n41,80\n" - begin - service(csv_data: csv, components: components).preview - rescue described_class::ImportError => e - expect(e.payload[:identifierNotFirst]).to be(false) - expect(e.payload[:missing]).to include('External ID') - end + it 'follows the mapping order for column_order, independent of CSV column order' do + mappings = [ + { header: 'Midterm', action: 'create', target: 'Midterm' }, + { header: 'Final', action: 'existing', target: 'Final' } + ] + data = "External ID,Final,Midterm\nA001,80,41\n" + result = service(mappings, data: data).preview + expect(result[:column_order]).to eq(%w[Midterm Final]) end end end