From 245d005f699d41a0054c4cde844823de65ab3e93 Mon Sep 17 00:00:00 2001 From: lws49 Date: Sun, 28 Jun 2026 12:40:14 +0800 Subject: [PATCH] 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. Consolidates the previously planned pr4a (correctness) and pr4b (ux) into one PR: the import wizard and service are edited by both themes at the hunk level, so they do not separate into independently-reviewable slices. Fixes a pre-existing broken assertion in the import controller spec (course_externalassessments -> course_external_assessments) that prevented the replace-on-conflict test from running. --- .../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 | 1679 +++++++++++++++++ .../ManageExternalAssessmentsPanel.test.tsx | 117 +- .../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 | 342 ++-- .../bundles/course/gradebook/operations.ts | 25 +- client/app/types/course/gradebook.ts | 65 + client/locales/en.json | 172 +- 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, 5671 insertions(+), 158 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..262c08dc77 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/ImportExternalAssessmentsWizard.test.tsx @@ -0,0 +1,1679 @@ +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, + ...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 eebd65e2be..57a72ca6bf 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'), @@ -127,7 +156,9 @@ it('shows an empty state when there are no externals', async () => { }); expect(await screen.findByText('No external assessments yet')).toBeVisible(); expect( - screen.getByText('Add one to track grades earned outside Coursemology.'), + screen.getByText( + 'Add one manually, or import a CSV of grades earned outside Coursemology.', + ), ).toBeVisible(); expect(screen.queryByRole('table')).not.toBeInTheDocument(); }); @@ -292,6 +323,88 @@ 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, + }, + }; + 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 281df18212..e1a47382f6 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()); @@ -349,4 +381,16 @@ describe('simple pass-through thunks', () => { payload: { weights: [] }, }); }); + + it('previewImport returns the API data without dispatching', async () => { + const { dispatched, dispatch, getState } = passHarness(); + jest + .spyOn(CourseAPI.gradebook, 'importPreview') + .mockResolvedValue({ data: { conflicts: [] } } as never); + + const result = await previewImport({} as never)(dispatch, getState, {}); + + expect(result).toEqual({ conflicts: [] }); + expect(dispatched).toHaveLength(0); + }); }); 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 37aa848756..9ab509f817 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,162 +173,185 @@ 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 d1c0c9537f..1002791143 100644 --- a/client/app/bundles/course/gradebook/operations.ts +++ b/client/app/bundles/course/gradebook/operations.ts @@ -1,5 +1,10 @@ import type { Operation } from 'store'; -import type { UpdateWeightsPayload } from 'types/course/gradebook'; +import type { + ImportCommitSummary, + ImportPreviewRequest, + ImportPreviewResult, + UpdateWeightsPayload, +} from 'types/course/gradebook'; import CourseAPI from 'api/course'; @@ -110,4 +115,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 e3d2765d19..27bf9646bf 100644 --- a/client/app/types/course/gradebook.ts +++ b/client/app/types/course/gradebook.ts @@ -76,3 +76,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 68eef63b34..ee364d3fcf 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9549,7 +9549,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" @@ -9604,5 +9604,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" } -} \ No newline at end of file +} diff --git a/client/locales/ko.json b/client/locales/ko.json index 7f76deac15..561f507a5d 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9543,7 +9543,7 @@ "defaultMessage": "아직 외부 평가가 없습니다" }, "course.gradebook.ManageExternalPanel.emptyHint": { - "defaultMessage": "Coursemology 외부에서 받은 성적을 추적하려면 하나를 추가하세요." + "defaultMessage": "직접 추가하거나, Coursemology 외부에서 받은 성적을 CSV로 가져오세요." }, "course.gradebook.ManageExternalPanel.floored": { "defaultMessage": "≥ 0" @@ -9595,5 +9595,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 a1f98b9b5a..12aaff7efd 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9537,7 +9537,7 @@ "defaultMessage": "暂无外部评估" }, "course.gradebook.ManageExternalPanel.emptyHint": { - "defaultMessage": "添加一个以跟踪在 Coursemology 之外获得的成绩。" + "defaultMessage": "手动添加,或导入在 Coursemology 之外获得的成绩的 CSV 文件。" }, "course.gradebook.ManageExternalPanel.floored": { "defaultMessage": "≥ 0" @@ -9589,5 +9589,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 479ddabd06..a088e71faa 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