diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index 3c7c83bd487..bc3d9207819 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -30,6 +30,8 @@ def index end @conditional_service = Course::Assessment::AchievementPreloadService.new(@assessments) + @marketplace_container = current_course.preview? && can?(:manage, :all) + @marketplace_versions = marketplace_version_labels if @marketplace_container end def show @@ -39,6 +41,10 @@ def show @question_assessments = @assessment.question_assessments.with_question_actables @assessment_conditions = @assessment.assessment_conditions.includes({ conditional: :actable }) @questions = @assessment.questions.includes({ actable: :test_cases }) + @marketplace_update = Course::Assessment::Marketplace::Adoption.update_notice_for(@assessment.id) + # Same gate and same labels as the index: opening a container row must not lose the identity the + # row carried, since every snapshot and working copy there shares one title and one tab. + @marketplace_version = marketplace_version_label if current_course.preview? && can?(:manage, :all) @requirements = @assessment.specific_conditions.map do |condition| { @@ -257,6 +263,51 @@ def load_assessment_options private + # Drives the view-only version badge on the container course's assessment index. Every published + # snapshot keeps its original title and shares one tab, so without it an admin sees an + # undifferentiated pile of identically-named assessments. Skipped everywhere else. + # + # @return [Hash{Integer => Hash}] + def marketplace_version_labels + Course::Assessment::Marketplace::ListingVersion.labels_for_assessments(@assessments.pluck(:id)) + end + + # The single-assessment reading of the same labels, for `show`. Nil for a container assessment that + # is neither a snapshot nor a listing's working copy — one authored in the container directly. + # + # A snapshot additionally carries where to edit the content it froze. Merged here rather than in + # `labels_for_assessments`, which the index shares and has no use for the field. + # + # @return [Hash, nil] + def marketplace_version_label + label = Course::Assessment::Marketplace::ListingVersion. + labels_for_assessments([@assessment.id])[@assessment.id] + return nil if label.nil? + # Skipped for the working copy: the source assessment is this page. + return label if label[:published_at].nil? + + label.merge(source_assessment_url: source_assessment_url(label[:listing_id])) + end + + # Absolute, and carrying the source assessment's own host: a course id only resolves on its + # instance's host, and a listing's source lives on whichever instance published it. Nil for an + # orphaned listing, whose source was deleted and whose rebuild has not landed. + # + # @param [Integer] listing_id + # @return [String, nil] + def source_assessment_url(listing_id) + ActsAsTenant.without_tenant do + listing = Course::Assessment::Marketplace::Listing. + includes(authoring_assessment: { lesson_plan_item: { course: :instance } }). + find_by(id: listing_id) + assessment = listing&.authoring_assessment + next nil if assessment.nil? + + course_assessment_url(assessment.course_id, assessment, + **assessment.course.instance.host_options) + end + end + def load_assessment_submission_counts @all_students = current_course.course_users.students.without_phantom_users @assessment_counts = num_submitted_students_hash diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 3ef8152e888..85bea0e9102 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -4,13 +4,15 @@ class Course::Assessment::Marketplace::ListingsController < Course::Assessment:: def index ActsAsTenant.without_tenant do - # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on - # the acting-as record. + # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on the + # acting-as record. Reads go through the current version snapshot, never the authoring copy: the + # marketplace serves what a duplicate would give you. `where.not(current_version_id: + # nil)` guards a published listing with no snapshot, whose nil `current_version` would 500 browse. @listings = Course::Assessment::Marketplace::Listing.published. - where.not(authoring_assessment_id: nil). - includes(authoring_assessment: :lesson_plan_item).to_a + where.not(current_version_id: nil). + includes(current_version: { assessment: :lesson_plan_item }).to_a @adoption_counts = adoption_counts(@listings.map(&:id)) - @question_counts = question_counts(@listings.map(&:authoring_assessment_id)) + @question_counts = question_counts(@listings.map { |listing| listing.current_version.assessment_id }) @destination_tabs = destination_tabs end end @@ -29,11 +31,11 @@ def duplicate def show ActsAsTenant.without_tenant do @listing = Course::Assessment::Marketplace::Listing.published. - includes(:authoring_assessment).find_by(id: params[:id]) + includes(current_version: :assessment).find_by(id: params[:id]) raise CanCan::AccessDenied unless @listing - @assessment = @listing.authoring_assessment - # This page renders the authoring copy, which an orphaned listing no longer has — see `index`. + # The SNAPSHOT, never the authoring copy (design §4.2). + @assessment = @listing.current_version&.assessment raise CanCan::AccessDenied unless @assessment authorize!(:preview_in_marketplace, @listing) @@ -73,10 +75,8 @@ def destination_tabs def authorized_listings listings = ActsAsTenant.without_tenant do - # Orphaned listings excluded for the reason `index` gives — the duplicate copies the - # authoring assessment, so there is nothing for it to read. Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]). - where.not(authoring_assessment_id: nil).includes(:authoring_assessment) + includes(current_version: :assessment) end raise CanCan::AccessDenied if listings.empty? diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb index ccbba11729b..22a6b21ffe3 100644 --- a/app/controllers/course/assessment/marketplace/questions_controller.rb +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -4,12 +4,12 @@ class Course::Assessment::Marketplace::QuestionsController < Course::Assessment: def show ActsAsTenant.without_tenant do - listing = Course::Assessment::Marketplace::Listing.published.includes(:authoring_assessment). - find_by(id: params[:listing_id]) + listing = Course::Assessment::Marketplace::Listing.published. + includes(current_version: :assessment).find_by(id: params[:listing_id]) raise CanCan::AccessDenied unless listing - @assessment = listing.authoring_assessment - # An orphaned listing has nothing left to preview — see ListingsController#index. + # The SNAPSHOT, never the authoring copy. + @assessment = listing.current_version&.assessment raise CanCan::AccessDenied unless @assessment authorize!(:preview_in_marketplace, listing) diff --git a/app/controllers/course/assessment/marketplace_adoptions_controller.rb b/app/controllers/course/assessment/marketplace_adoptions_controller.rb new file mode 100644 index 00000000000..a5910af70af --- /dev/null +++ b/app/controllers/course/assessment/marketplace_adoptions_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true +# Adopter-side actions on a duplicated marketplace assessment. +class Course::Assessment::MarketplaceAdoptionsController < Course::Assessment::Controller + before_action :authorize_manage_assessment! + + def apply_latest_version + adoption = Course::Assessment::Marketplace::Adoption.find_by(duplicated_assessment_id: @assessment.id) + return head :not_found if adoption.nil? + + if @assessment.submission_counts_by_author[:student] > 0 + return render json: { errors: [t('.student_submissions_exist')] }, + status: :unprocessable_content + end + + job = Course::Assessment::Marketplace::ApplyVersionJob. + perform_later(@assessment, current_user: current_user).job + render partial: 'jobs/submitted', locals: { job: job }, status: :ok + end + + private + + def authorize_manage_assessment! + authorize!(:manage, @assessment) + end + + def component + current_component_host[:course_assessments_component] + end +end diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb index de9462916f7..e7e9c7c0cd3 100644 --- a/app/controllers/course/assessment/marketplace_listings_controller.rb +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -2,20 +2,31 @@ class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller before_action :authorize_publish_to_marketplace! + # A published version of an existing listing is not a source assessment. Refused server-side and + # not only by withholding the button: the listing this would create has its source assessment + # frozen inside the container, so it could never be edited nor cut a further version. + SNAPSHOT_REJECTION = 'This is a published version of an existing listing, not a source assessment.' + def create - listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(authoring_assessment: @assessment) - now = Time.zone.now - listing.published = true - listing.first_published_at ||= now - listing.last_published_at = now - # `publisher` is an audit userstamp for the *latest* publish (design D29), so it moves with - # `last_published_at`. `creator` already retains whoever first created the row. - listing.publisher = current_user - if listing.save - render json: { published: true }, status: :ok - else - render json: { errors: listing.errors.full_messages }, status: :unprocessable_content - end + return render json: { errors: [SNAPSHOT_REJECTION] }, status: :unprocessable_content if + @assessment.marketplace_snapshot? + + listing = Course::Assessment::Marketplace::PublishService.publish(@assessment, current_user) + render json: { published: listing.published }, status: :ok + rescue ActiveRecord::RecordInvalid => e + render json: { errors: e.record.errors.full_messages }, status: :unprocessable_content + end + + # Cuts a new version from the authoring copy. Deliberately separate from `create`: re-listing an unlisted + # assessment reactivates the row but must NOT silently republish changed content. + def publish_version + listing = @assessment.marketplace_listing + return render json: { errors: ['Not listed on the marketplace.'] }, status: :unprocessable_content if listing.nil? + + version = Course::Assessment::Marketplace::PublishService.publish_new_version(listing, current_user) + render json: { published_at: version.published_at }, status: :ok + rescue ArgumentError => e + render json: { errors: [e.message] }, status: :unprocessable_content end def destroy diff --git a/app/jobs/course/assessment/marketplace/apply_version_job.rb b/app/jobs/course/assessment/marketplace/apply_version_job.rb new file mode 100644 index 00000000000..fcd83f41caa --- /dev/null +++ b/app/jobs/course/assessment/marketplace/apply_version_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +# Runs the in-place version update in the background, matching marketplace import's polling flow. +class Course::Assessment::Marketplace::ApplyVersionJob < ApplicationJob + include TrackableJob + include Rails.application.routes.url_helpers + + queue_as :duplication + + protected + + def perform_tracked(assessment, options = {}) + current_user = options[:current_user] + Course::Assessment::Marketplace::ApplyVersionService.apply(assessment, current_user) + + course = assessment.course + redirect_to course_assessment_url(course, assessment, host: course.instance.host) + end +end diff --git a/app/jobs/course/assessment/marketplace/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb index 14bfdbc0ac2..997863c563a 100644 --- a/app/jobs/course/assessment/marketplace/duplication_job.rb +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -5,6 +5,10 @@ class Course::Assessment::Marketplace::DuplicationJob < ApplicationJob queue_as :duplication + # Mirrors `validates :title, length: { maximum: 255 }` on Course::LessonPlan::Item, which is where + # an assessment's title actually lives. + TITLE_LIMIT = 255 + protected def perform_tracked(listing_ids, destination_course, destination_tab_id, options = {}) @@ -12,15 +16,15 @@ def perform_tracked(listing_ids, destination_course, destination_tab_id, options ActsAsTenant.without_tenant do listings = Course::Assessment::Marketplace::Listing.published.where(id: listing_ids) target_tab = find_tab(destination_course, destination_tab_id) - last_copy = nil - listings.each do |listing| - # The adoption row is written by the duplication service itself, which tracks every copy of a - # listed assessment regardless of the path that produced it. See - # `Course::Duplication::BaseService#record_marketplace_adoptions`. - last_copy = duplicate_listing(listing, destination_course, current_user) - reparent_into_tab(last_copy, target_tab) + copies = listings.map do |listing| + copy = duplicate_listing(listing, destination_course, current_user) + reparent_into_tab(copy, target_tab) + resolve_title_collision(copy, listing, destination_course) + record_adoption(listing, destination_course, copy, current_user) + copy end - redirect_to assessments_url(destination_course, target_tab || last_copy&.tab) + landing_url = landing_url_for(copies, destination_course) + redirect_to landing_url if landing_url end end @@ -36,7 +40,7 @@ def find_tab(destination_course, destination_tab_id) end def duplicate_listing(listing, destination_course, current_user) - source = listing.authoring_assessment + source = listing.current_version.assessment Course::Duplication::ObjectDuplicationService.duplicate_objects( source.course, destination_course, source, current_user: current_user ) @@ -50,15 +54,78 @@ def reparent_into_tab(copy, target_tab) copy.save! end - # Points at the tab the copies actually landed in. No tab is requested from the sidebar entry - # point, and a requested tab may not belong to the destination course -- in both cases the - # duplication picks the destination's default tab, and the redirect has to follow it there - # instead of naming a tab (and its category) that the user cannot open. - def assessments_url(destination_course, tab) - redirect_category_id = tab&.category_id || destination_course.assessment_categories.first.id - course_assessments_url(destination_course, - category: redirect_category_id, - tab: tab&.id, - host: destination_course.instance.host) + # Renames an imported copy whose title is already taken in the destination course. + # + # Fires on every import, not only on re-import of the same listing: a copy landing on top of an + # unrelated assessment of the same name collides just as badly, and previously landed silently. + # + # Escalates only as far as it has to: + # "Lab 3" -> "Lab 3 [12 Jun 2026]" -> "Lab 3 [12 Jun 2026] (2)" -> (3) ... + # + # @param [Course::Assessment] copy + # @param [Course::Assessment::Marketplace::Listing] listing + # @param [Course] destination_course + # @return [void] + def resolve_title_collision(copy, listing, destination_course) + taken = Course::Assessment.titles_in_course(destination_course, except_id: copy.id) + base = copy.title + return if taken.exclude?(base.downcase) + + published_at = ActsAsTenant.without_tenant { listing.current_version&.published_at } + # A listing with no recorded vintage has nothing to name, so it goes straight to the counter — + # stamping an empty "[]" would be worse than the collision it is trying to resolve. + dated = published_at ? "#{base} [#{published_at.strftime('%d %b %Y')}]" : base + candidate = truncate_to_limit(dated, base) + + suffix_number = 2 + while taken.include?(candidate.downcase) + candidate = truncate_to_limit("#{dated} (#{suffix_number})", base) + suffix_number += 1 + end + + copy.title = candidate + copy.save! + end + + # Truncate the base for an over-long title. + # + # @param [String] candidate + # @param [String] base + # @return [String] + def truncate_to_limit(candidate, base) + return candidate if candidate.length <= TITLE_LIMIT + + suffix = candidate.delete_prefix(base) + base.truncate(TITLE_LIMIT - suffix.length) + suffix + end + + # Where the completion toast's link sends the manager. + # + # @param [Array] copies + # @param [Course] destination_course + # @return [String, nil] nil when every listing was filtered out by `.published`, in which case + # nothing landed and there is nowhere to link to. + def landing_url_for(copies, destination_course) + return nil if copies.empty? + + host = destination_course.instance.host + return course_assessment_url(destination_course, copies.first, host: host) if copies.one? + + tab = copies.first.tab + course_assessments_url(destination_course, category: tab.category_id, tab: tab.id, host: host) + end + + # Written here rather than left to `Course::Duplication::BaseService#record_marketplace_adoptions`: + # that sweep keys off the SOURCE's own `marketplace_listing`, and the source here is the container + # snapshot, which authors no listing. This path is the only one that knows which listing it served. + def record_adoption(listing, destination_course, copy, current_user) + Course::Assessment::Marketplace::Adoption.create!( + listing: listing, + destination_course: destination_course, + duplicated_assessment: copy, + adopted_version_at: listing.current_version.published_at, + creator: current_user, + updater: current_user + ) end end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index d5d66f8892c..4d3d1c4233f 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -307,11 +307,17 @@ def csv_downloadable? # @param [User] current_user The user who triggered the duplication. def record_marketplace_adoption(duplicate, destination_course, current_user) return unless marketplace_listing&.published? + # Publishing duplicates the source INTO the container to cut a snapshot. That is the listing + # growing a version, not a course adopting it, so the container is never an adopter. + return if destination_course.preview? Course::Assessment::Marketplace::Adoption.create!( listing: marketplace_listing, destination_course: destination_course, duplicated_assessment: duplicate, + # Stamped here rather than at the call site: this is the single writer of adoption rows, and the + # adopter's "your copy is behind" banner has nothing to compare against without it. + adopted_version_at: marketplace_listing.current_version&.published_at, creator: current_user, updater: current_user ) diff --git a/app/models/course/assessment/marketplace/adoption.rb b/app/models/course/assessment/marketplace/adoption.rb index dac5a616290..dbcde23872c 100644 --- a/app/models/course/assessment/marketplace/adoption.rb +++ b/app/models/course/assessment/marketplace/adoption.rb @@ -7,4 +7,43 @@ class Course::Assessment::Marketplace::Adoption < ApplicationRecord validates :duplicated_assessment_id, uniqueness: true validates :creator, presence: true validates :updater, presence: true + + # Resolves the "a newer version is available" notice for an adopted assessment. + # + # Deliberately a pure timestamp comparison over adoptions / listings / listing_versions — the + # container snapshot is never loaded, so this stays cheap enough to run on every assessment show. + # `duplicated_assessment_id` carries a unique index, so the lookup is a single indexed hit. + # + # @param [Integer] assessment_id the adopter's own copy + # @return [Hash, nil] + def self.update_notice_for(assessment_id) + adoption = includes(listing: :current_version).find_by(duplicated_assessment_id: assessment_id) + return nil unless adoption&.update_pending? + + counts = adoption.duplicated_assessment.submission_counts_by_author + + { adopted_version_at: adoption.adopted_version_at, + latest_version_at: adoption.latest_version_at, + # Advisory only: the endpoint re-checks this before it destroys anything. When false the + # banner has no action to offer — it explains why instead. + can_update_in_place: counts[:student] == 0, + # Staff and phantom test runs do not block the update, but they are deleted by it. + test_submission_count: counts[:other] } + end + + # @return [ActiveSupport::TimeWithZone, nil] when the content the listing currently serves was + # published + def latest_version_at + listing.current_version&.published_at + end + + # Whether this adopter should be told about a newer version. Fails toward SILENCE: + # an unknown `adopted_version_at` or a version-less listing yields false. + # + # @return [Boolean] + def update_pending? + return false if adopted_version_at.nil? || latest_version_at.nil? + + latest_version_at > adopted_version_at + end end diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb index 85d181a1f26..21faa61fa97 100644 --- a/app/models/course/assessment/marketplace/listing.rb +++ b/app/models/course/assessment/marketplace/listing.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true class Course::Assessment::Marketplace::Listing < ApplicationRecord # The mutable authoring copy — the origin-course assessment. Nullable: the listing outlives - # deletion of its origin. Browse, preview and duplicate all still read this; the snapshots in - # `versions` are recorded here but not yet served. + # deletion of its origin. What the marketplace serves is `current_version.assessment`. belongs_to :authoring_assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing, optional: true belongs_to :publisher, class_name: 'User', inverse_of: false @@ -43,9 +42,8 @@ def self.for_admin_index end end - # An orphaned listing lost its authoring copy (the origin assessment was deleted). Its snapshots - # survive, but every course-facing path reads the authoring copy, so the listing leaves the - # marketplace until the rebuild lands. Deliberately separate from `admin_state`, a display concern. + # An orphaned listing lost its authoring copy (the origin assessment was deleted) but still + # serves its last snapshot. Deliberately separate from `admin_state`, which is a display concern. # @return [Boolean] def orphaned? authoring_assessment_id.nil? diff --git a/app/models/instance.rb b/app/models/instance.rb index 38eb55e8654..31761d10685 100644 --- a/app/models/instance.rb +++ b/app/models/instance.rb @@ -139,6 +139,21 @@ def host read_attribute(:host).gsub('coursemology.org', default_host) end + # `#host` carries the port the app is publicly served on, and a url built from it must name that + # port separately: a controller's `url_options` always supplies `port: request.optional_port`, and + # Rails reads a port out of `host:` only when no `:port` key is present — so passing the host + # alone silently swaps in the port the request reached Rails on. + # + # The two differ whenever a proxy sits in front, i.e. every development setup, and the url then + # names a port the browser cannot reach. A host with no port yields `port: nil`, which is what + # production wants. Jobs and mailers escape this: no request, hence no `:port` key. + # + # @return [Hash] the `host:`/`port:` options for a url on this instance + def host_options + name, port = host.split(':', 2) + { host: name, port: port } + end + def redirect_uri protocol = if Rails.env.development? && ENV['RAILS_USE_HTTP'] 'http' diff --git a/app/services/course/assessment/marketplace/apply_version_service.rb b/app/services/course/assessment/marketplace/apply_version_service.rb new file mode 100644 index 00000000000..e88b4df7caa --- /dev/null +++ b/app/services/course/assessment/marketplace/apply_version_service.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true +# Replaces an adopted copy's CONTENT with the version the marketplace currently serves, without +# replacing the assessment itself. +# +# The copy keeps its id and therefore its URL, its tab position, its published state, its unlock +# conditions and its adoption row. Only what the marketplace authored is overwritten. That is the +# difference between this and importing a fresh copy alongside: an instructor who has already put +# this assessment into their lesson plan keeps every local decision they made about it. +# +# DESTRUCTIVE and irreversible. Only ever reached through a gate that refuses when any non-phantom +# student of the course has a submission (`Course::Assessment#submission_counts_by_author`), and the +# controller re-checks that gate rather than trusting the client. +class Course::Assessment::Marketplace::ApplyVersionService + # @param [Course::Assessment] assessment the adopter's own copy + # @param [User] current_user + # @return [Course::Assessment] + def self.apply(assessment, current_user) + new(assessment, current_user).apply + end + + def initialize(assessment, current_user) + @assessment = assessment + @current_user = current_user + end + + # @raise [ArgumentError] when the assessment is not a marketplace adoption, or its listing serves + # nothing to apply. + # @return [Course::Assessment] + def apply + adoption = Course::Assessment::Marketplace::Adoption. + find_by(duplicated_assessment_id: @assessment.id) + raise ArgumentError, 'assessment was not adopted from the marketplace' if adoption.nil? + + # Read at EXECUTION time, never from the request: a version published between page load and + # click must be the one applied, not the stale one the banner named. + version = ActsAsTenant.without_tenant { adoption.listing.current_version } + raise ArgumentError, 'listing has no current version' if version.nil? + + User.with_stamper(@current_user) do + Course::Assessment.transaction do + # Serialises two managers clicking at once; the loser applies to already-replaced content, + # which is idempotent, rather than interleaving with the winner's destroys. + @assessment.with_lock do + ensure_no_student_submissions! + transplant!(version) + end + end + end + + @assessment + end + + private + + def ensure_no_student_submissions! + return if @assessment.submission_counts_by_author[:student] == 0 + + raise ArgumentError, 'students have already submitted work for this assessment' + end + + def transplant!(version) + temp = duplicate_snapshot(version) + clear_existing_content! + adopt_content!(temp) + copy_attributes!(temp, version) + temp.destroy! + advance_adoption!(version) + end + + # The snapshot lives in the container course, which sits in the preview instance — never the + # caller's — so the read and the duplication both run without a tenant. + # @return [Course::Assessment] a throwaway copy in the destination course + def duplicate_snapshot(version) + ActsAsTenant.without_tenant do + snapshot = version.assessment + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( + snapshot.course, @assessment.course, snapshot, current_user: @current_user + ) + # No detach needed: this crosses out of the preview instance, and `#initialize_duplicate` drops + # links that would span the boundary. The duplication root is kept on purpose. + copy + end + end + + # ORDER IS LOAD-BEARING. + # + # Submissions first: answers carry a `question_id` FK, so questions cannot be deleted while any + # answer references them. This is the same reason `Course::Assessment` declares `has_many + # :submissions` above `:questions`. + # + # Then the join rows, then the questions themselves — `questions` is a `has_many through`, so + # destroying the joins alone would leave orphaned Question rows behind forever. + # + # Personal times last: they were computed against the schedule this update is about to overwrite. + # `Course::LessonPlan::Item#find_or_create_personal_time_for` rebuilds them on demand from the new + # reference times, so removing them is a reset, not data loss. + def clear_existing_content! + @assessment.submissions.destroy_all + + questions = @assessment.questions.to_a + @assessment.question_assessments.destroy_all + questions.each(&:destroy!) + + @assessment.folder.materials.destroy_all + @assessment.lesson_plan_item.personal_times.destroy_all + end + + # Reparents the throwaway's content onto the surviving row rather than re-duplicating it, so the + # questions the duplicator just built are used exactly once. + def adopt_content!(temp) + Course::QuestionAssessment.where(assessment_id: temp.id). + update_all(assessment_id: @assessment.id) + Course::Material.where(folder_id: temp.folder.id). + update_all(folder_id: @assessment.folder.id) + temp.question_assessments.reset + temp.folder.materials.reset + end + + # Everything the marketplace authored, and nothing the adopting course owns. + # + # Times arrive already shifted by `ObjectDuplicationService`'s `time_shift`, so the result matches + # what a fresh import into this same course would have produced. + # + # `published` and `tab_id` are deliberately absent: replacing content must not silently expose or + # hide an assessment, nor move it out from under the manager who filed it. Unlock conditions and + # link-tree membership are untouched for a stronger reason — they reference this course's objects, + # so the snapshot's would be meaningless. + # rubocop:disable Metrics/AbcSize + def copy_attributes!(temp, version) + @assessment.title = resolved_title(temp.title, version) + @assessment.description = temp.description + @assessment.start_at = temp.start_at + @assessment.end_at = temp.end_at + @assessment.bonus_end_at = temp.bonus_end_at + @assessment.base_exp = temp.base_exp + @assessment.time_bonus_exp = temp.time_bonus_exp + @assessment.autograded = temp.autograded + @assessment.tabbed_view = temp.tabbed_view + @assessment.delayed_grade_publication = temp.delayed_grade_publication + @assessment.view_password = temp.view_password + @assessment.session_password = temp.session_password + @assessment.has_personal_times = temp.has_personal_times + @assessment.affects_personal_times = temp.affects_personal_times + @assessment.save! + end + # rubocop:enable Metrics/AbcSize + + # Collision rule excluding this assessment itself. Its own old title is exactly what it + # is replacing, so it must not count as a collision. + def resolved_title(new_title, version) + taken = Course::Assessment.titles_in_course(@assessment.course, except_id: @assessment.id) + temporary_title_index = taken.index(new_title.downcase) + taken.delete_at(temporary_title_index) if temporary_title_index + return new_title if taken.exclude?(new_title.downcase) + + dated = "#{new_title} [#{version.published_at.strftime('%d %b %Y')}]" + candidate = dated + + suffix_number = 2 + while taken.include?(candidate.downcase) + candidate = "#{dated} (#{suffix_number})" + suffix_number += 1 + end + + candidate + end + + def advance_adoption!(version) + adoption = Course::Assessment::Marketplace::Adoption. + find_by(duplicated_assessment_id: @assessment.id) + # Restamping the vintage is the ONLY thing that retires the update banner: it is a fact about + # the copy, not a notification, so there is nothing else to clear. + adoption.update!(adopted_version_at: version.published_at) + end +end diff --git a/app/views/course/assessment/assessments/index.json.jbuilder b/app/views/course/assessment/assessments/index.json.jbuilder index a2fbca99c0a..14a964d7045 100644 --- a/app/views/course/assessment/assessments/index.json.jbuilder +++ b/app/views/course/assessment/assessments/index.json.jbuilder @@ -1,6 +1,8 @@ # frozen_string_literal: true achievements_enabled = !current_component_host[:course_achievements_component].nil? submissions_hash = @assessments.to_h { |assessment| [assessment.id, assessment.submissions] } +# Empty for every course except the marketplace's snapshot container viewed by a system admin. +marketplace_versions = defined?(@marketplace_versions) ? @marketplace_versions : {} json.display do json.isStudent current_course_user&.student? || false @@ -14,6 +16,11 @@ json.display do json.canCreateAssessments can?(:create, Course::Assessment.new(tab: @tab)) json.canManageMonitor @can_manage_monitor && @monitoring_component_enabled + # True only in the marketplace's snapshot container, viewed by a system admin. Switches on the + # container-only Listing/Version/Source columns and the search toolbar — every other course's + # assessments index must stay exactly as it was. + json.isMarketplaceContainer @marketplace_container || false + json.category do json.id @category.id json.title @category.title @@ -51,6 +58,17 @@ json.assessments @assessments do |assessment| json.isKoditsuAssessmentEnabled assessment.is_koditsu_enabled end + marketplace_version = marketplace_versions[assessment.id] + if marketplace_version + json.marketplaceVersion do + json.listingId marketplace_version[:listing_id] + json.publishedAt marketplace_version[:published_at] + json.source marketplace_version[:source] + json.latest marketplace_version[:latest] + json.listed marketplace_version[:listed] + end + end + assessment_with_loaded_timeline = @items_hash[assessment.id].actable # assessment_with_loaded_timeline is passed below since the timeline is already preloaded and will be checked can_attempt_assessment = can?(:attempt, assessment_with_loaded_timeline) diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index b801cc43559..e7352701563 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -77,12 +77,38 @@ json.permissions do json.canManage can_manage json.canObserve can_observe json.canInviteToKoditsu can?(:invite_to_koditsu, assessment) - json.canPublishToMarketplace((can?(:publish_to_marketplace, @assessment) && current_user&.administrator?) || false) + json.canPublishToMarketplace((can?(:publish_to_marketplace, @assessment) && + current_user&.administrator? && + !@assessment.marketplace_snapshot?) || false) end json.isPublishedToMarketplace @assessment.marketplace_listing&.published? || false json.marketplaceListingUrl course_assessment_marketplace_listing_path(current_course, @assessment) +if @marketplace_version + json.marketplaceVersion do + json.listingId @marketplace_version[:listing_id] + json.publishedAt @marketplace_version[:published_at] + json.source @marketplace_version[:source] + json.latest @marketplace_version[:latest] + json.listed @marketplace_version[:listed] + if @marketplace_version.key?(:source_assessment_url) + json.sourceAssessmentUrl @marketplace_version[:source_assessment_url] + end + end +end + +if @marketplace_update + json.marketplaceUpdate do + json.adoptedVersionAt @marketplace_update[:adopted_version_at] + json.latestVersionAt @marketplace_update[:latest_version_at] + json.canUpdateInPlace @marketplace_update[:can_update_in_place] + json.testSubmissionCount @marketplace_update[:test_submission_count] + end +else + json.marketplaceUpdate nil +end + unless can_attempt not_started_for_user = assessment_not_started(assessment.time_for(current_course_user)) json.willStartAt assessment.time_for(current_course_user).start_at if not_started_for_user diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder index 5b67c876813..39c64621555 100644 --- a/app/views/course/assessment/marketplace/listings/index.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder @@ -1,7 +1,7 @@ # frozen_string_literal: true json.canAccess true json.listings @listings do |listing| - assessment = listing.authoring_assessment + assessment = listing.current_version.assessment json.id listing.id json.assessmentId assessment.id json.title assessment.title diff --git a/app/views/course/assessment/marketplace/listings/show.json.jbuilder b/app/views/course/assessment/marketplace/listings/show.json.jbuilder index 92d6b4f7305..06cc7bddcca 100644 --- a/app/views/course/assessment/marketplace/listings/show.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/show.json.jbuilder @@ -1,5 +1,8 @@ # frozen_string_literal: true -json.id @assessment.id +# The LISTING's id, matching `index.json.jbuilder` — everything below is the snapshot assessment's +# content, but the resource this payload identifies is the listing. The duplicate dialog posts this +# id back as a `listing_ids` entry, so the snapshot assessment's id here 403s the duplicate. +json.id @listing.id json.title @assessment.title json.description format_ckeditor_rich_text(@assessment.description) diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 344178c65d5..7a7b89f55f6 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -22,6 +22,20 @@ export default class MarketplaceAPI extends BaseCourseAPI { ); } + publishNewVersion( + assessmentId: number, + ): Promise> { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing/versions`, + ); + } + + applyLatestVersion(assessmentId: number): Promise { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_adoption/apply_latest_version`, + ); + } + index(): Promise< AxiosResponse<{ listings: MarketplaceListing[]; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx index 0837787ab82..c81dcb970dc 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx @@ -18,6 +18,7 @@ import marketplaceTranslations from 'course/marketplace/translations'; import DeleteButton from 'lib/components/core/buttons/DeleteButton'; import { PromptText } from 'lib/components/core/dialogs/Prompt'; import Link from 'lib/components/core/Link'; +import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; @@ -68,7 +69,7 @@ const AssessmentShowHeader = ( }; return ( - <> +
{assessment.deleteUrl && ( {t(translations.deletingThisAssessment)} {assessment.title} - {t(translations.deleteAssessmentWarning)} {publishedToMarketplace && ( - {t(marketplaceTranslations.deleteWarning)} + + {t(marketplaceTranslations.deleteWarning, { + mailto: (chunk: string): JSX.Element => ( + + {chunk} + + ), + })} + )} + {t(translations.deleteAssessmentWarning)} )} @@ -178,7 +187,7 @@ const AssessmentShowHeader = ( )} - +
); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx index b46eee73f3e..452456a512a 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx @@ -22,10 +22,13 @@ import { SYNC_STATUS } from 'lib/constants/sharedConstants'; import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../translations'; +import MarketplaceVersionChip from '../AssessmentsIndex/MarketplaceVersionChip'; import AssessmentDetails from './AssessmentDetails'; import AssessmentShowHeader from './AssessmentShowHeader'; import GenerateQuestionMenu from './GenerateQuestionMenu'; +import MarketplaceSnapshotBanner from './MarketplaceSnapshotBanner'; +import MarketplaceUpdateBanner from './MarketplaceUpdateBanner'; import NewQuestionMenu from './NewQuestionMenu'; import QuestionsManager from './QuestionsManager'; import UnavailableAlert from './UnavailableAlert'; @@ -61,6 +64,11 @@ const AssessmentShowPage = (props: AssessmentShowPageProps): JSX.Element => { title={
{assessment.title} + + {assessment.marketplaceVersion && ( + + )} + {isKoditsuIndicatorShown && ( { )} + + + + {assessment.description && ( )} diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceSnapshotBanner.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceSnapshotBanner.tsx new file mode 100644 index 00000000000..382f66fc567 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceSnapshotBanner.tsx @@ -0,0 +1,56 @@ +import { Alert, Typography } from '@mui/material'; +import { MarketplaceVersionData } from 'types/course/assessment/assessments'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; + +interface Props { + version?: MarketplaceVersionData; +} + +/** + * Warns a system admin that this assessment is a published version rather than a source assessment. + * Every management affordance stays live - the surface is admin-only and the escape hatch for + * fixing served content is deliberate - so this banner is the only thing on the page saying that + * editing here changes what future adopters copy without publishing a version. + * + * Not dismissible, for the reason MarketplaceUpdateBanner gives: it states a fact about the object, + * so it stands for exactly as long as it is true. + */ +const MarketplaceSnapshotBanner = ({ version }: Props): JSX.Element | null => { + const { t } = useTranslation(); + + // An assessment the marketplace does not own carries no version at all. + if (!version) return null; + // A null vintage is the listing's working copy, which is exactly what an admin is meant to edit. + // Kept as its own guard rather than an optional chain: `version?.publishedAt === null` is false + // for an absent version, so the two conditions do not collapse into one. + if (version.publishedAt === null) return null; + + return ( + + + {t(translations.marketplaceSnapshotWarning)} + + + {version.sourceAssessmentUrl ? ( + + {t(translations.marketplaceSnapshotSourceLink)} + + ) : ( + + {t(translations.marketplaceSnapshotSourceMissing)} + + )} + + ); +}; + +export default MarketplaceSnapshotBanner; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceUpdateBanner.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceUpdateBanner.tsx new file mode 100644 index 00000000000..de5e0f6349d --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceUpdateBanner.tsx @@ -0,0 +1,131 @@ +import { useState } from 'react'; +import { Alert, Button, Typography } from '@mui/material'; +import { MarketplaceUpdateData } from 'types/course/assessment/assessments'; + +import CourseAPI from 'api/course'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import pollJob from 'lib/helpers/jobHelpers'; +import { loadingToast } from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; + +import { formatVintagePair } from './versionVintage'; + +interface Props { + assessmentId: number; + update: MarketplaceUpdateData | null; +} + +/** + * Tells a course that already copied a marketplace assessment that a newer version exists. + * The copy deliberately avoids the words "sync" and "behind": the action replaces + * untouched local content in place. + * + * The notice cannot be dismissed or muted. It is a statement of fact about the copy rather than a + * notification, so it stands for exactly as long as it is true — until the copy is updated, or + * deleted. + */ +const MarketplaceUpdateBanner = ({ + assessmentId, + update, +}: Props): JSX.Element | null => { + const { t } = useTranslation(); + // Not a dismissal: the update has actually landed, so the banner's claim has stopped being true. + // The page still holds the pre-update payload (the toast asks for a refresh), so nothing else + // here can notice. + const [updated, setUpdated] = useState(false); + const [confirming, setConfirming] = useState(false); + const [submitting, setSubmitting] = useState(false); + + if (!update || updated) return null; + + const { adopted: adoptedDate, latest: latestDate } = formatVintagePair( + update.adoptedVersionAt, + update.latestVersionAt, + ); + + const updateInPlace = async (): Promise => { + setSubmitting(true); + const updateToast = loadingToast(t(translations.marketplaceUpdateStarted)); + + try { + const response = + await CourseAPI.marketplace.applyLatestVersion(assessmentId); + + pollJob( + response.data.jobUrl, + () => { + updateToast.success(t(translations.marketplaceUpdateCompleted)); + setSubmitting(false); + setConfirming(false); + setUpdated(true); + }, + () => { + updateToast.error(t(translations.marketplaceUpdateFailed)); + setSubmitting(false); + }, + 2000, + ); + } catch { + updateToast.error(t(translations.marketplaceUpdateFailed)); + setSubmitting(false); + } + }; + + return ( + <> + + + {t(translations.marketplaceUpdateAvailable, { + adopted: adoptedDate, + latest: latestDate, + })} + + + {/* Student work makes an in-place replacement destructive, so there is no action to offer + — only the reason, so the manager is not left hunting for a button that cannot exist. */} + {update.canUpdateInPlace ? ( + + ) : ( + + {t(translations.marketplaceUpdateBlocked)} + + )} + + + setConfirming(false)} + open={confirming} + primaryColor="primary" + primaryLabel={t(translations.marketplaceUpdateInPlace)} + title={t(translations.marketplaceUpdateConfirmTitle)} + > + + {t(translations.marketplaceUpdateConfirmBody, { + latest: latestDate, + })} + + + {update.testSubmissionCount > 0 && ( + + {t(translations.marketplaceUpdateConfirmDeletion, { + count: update.testSubmissionCount, + })} + + )} + + + ); +}; + +export default MarketplaceUpdateBanner; diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx index 8b8f3c42174..e5ead14331b 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx @@ -2,6 +2,7 @@ import { createMockAdapter } from 'mocks/axiosMock'; import { fireEvent, render, waitFor, within } from 'test-utils'; import CourseAPI from 'api/course'; +import { SUPPORT_EMAIL } from 'lib/constants/sharedConstants'; import AssessmentShowHeader from '../AssessmentShowHeader'; @@ -29,10 +30,11 @@ const baseAssessment = { // Test the conditional in the delete Prompt whose // message contains this phrase, rendered only when `isPublishedToMarketplace`. -const MARKETPLACE_WARNING = /removes it from the marketplace/i; +const MARKETPLACE_WARNING = /keeps serving its last published version/i; +const DELETE_ASSESSMENT_LABEL = 'Delete Assessment'; describe('', () => { - it('warns that deletion removes the marketplace listing when the assessment is listed', async () => { + it('explains that the marketplace listing survives deletion when the assessment is listed', async () => { const page = render( ', () => { ); // First query awaits the i18n LoadingIndicator; subsequent getBy* are sync. - fireEvent.click(await page.findByLabelText('Delete Assessment')); // opens the delete Prompt + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); // opens the delete Prompt expect(page.getByText(MARKETPLACE_WARNING)).toBeVisible(); }); + it('names the assessment right after the intro line, before the marketplace explanation', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); + + const content = page.getByRole('dialog').textContent ?? ''; + const positions = [ + 'You are about to delete the following assessment:', + baseAssessment.title, + 'keeps serving its last published version', + 'This action cannot be undone!', + ].map((phrase) => content.indexOf(phrase)); + + // -1 would make the ascending check vacuously true, so require every phrase. + expect(positions).not.toContain(-1); + expect(positions).toEqual([...positions].sort((a, b) => a - b)); + }); + + it('links to support so the listing can be unlisted', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); + + expect(page.getByRole('link', { name: /contact us/i })).toHaveAttribute( + 'href', + `mailto:${SUPPORT_EMAIL}`, + ); + }); + + // Internal vocabulary must not leak into the instructor-facing warning. + it('calls the lost object the source assessment', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); + + const dialog = await page.findByRole('dialog'); + expect(within(dialog).getByText(/source assessment/)).toBeVisible(); + expect( + within(dialog).queryByText(/authoring\s+copy/), + ).not.toBeInTheDocument(); + }); + it('shows no marketplace warning when the assessment is not listed', async () => { const page = render( ', () => { />, ); - fireEvent.click(await page.findByLabelText('Delete Assessment')); // delete Prompt still opens + fireEvent.click(await page.findByLabelText(DELETE_ASSESSMENT_LABEL)); // delete Prompt still opens expect(page.queryByText(MARKETPLACE_WARNING)).not.toBeInTheDocument(); }); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx new file mode 100644 index 00000000000..dce462b8c97 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx @@ -0,0 +1,111 @@ +import { render, RenderResult } from 'test-utils'; +import { AssessmentData } from 'types/course/assessment/assessments'; + +import AssessmentShowPage from '../AssessmentShowPage'; + +// Minimal AssessmentData: enough for the page to mount. Everything optional is left out so the +// assertions below can only be about the marketplace chip. +const baseAssessment = { + id: 1, + title: 'Sample Assessment', + tabTitle: 'Assessments: Default', + tabUrl: '/courses/1/assessments', + description: '', + autograded: false, + startAt: { isFixed: false, effectiveTime: null, referenceTime: null }, + hasAttempts: false, + status: 'open', + actionButtonUrl: null, + permissions: { + canAttempt: true, + canManage: true, + canObserve: false, + canInviteToKoditsu: false, + canPublishToMarketplace: false, + }, + isPublishedToMarketplace: false, + marketplaceListingUrl: '/courses/1/assessments/1/marketplace_listing', + marketplaceUpdate: null, + requirements: [], + indexUrl: '/courses/1/assessments', + isStudent: false, +} as unknown as AssessmentData; + +const renderWith = ( + marketplaceVersion?: AssessmentData['marketplaceVersion'], +): RenderResult => + render( + , + ); + +// '2026-07-24T07:04:00Z' rendered in Asia/Singapore (UTC+8), as in MarketplaceVersionChip's own test. +const PUBLISHED_AT_LABEL = '24 Jul 2026, 3:04pm'; + +describe('', () => { + // Every snapshot in the container carries the origin's title verbatim and shares one tab, so the + // page has to say which one this is — otherwise opening a container row loses the identity the + // index row showed. + it('dates a container snapshot and marks it live', async () => { + const page = renderWith({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: true, + listed: true, + }); + + expect(await page.findByText(PUBLISHED_AT_LABEL)).toBeVisible(); + expect(page.getByText('Live')).toBeVisible(); + }); + + // The working copy is not a version at all — mistaking it for one would read as though the + // marketplace serves whatever an admin is midway through editing. + it("labels the listing's working copy as the source assessment", async () => { + const page = renderWith({ + listingId: 7, + publishedAt: null, + source: 'MP Allowlist Source Course', + latest: false, + listed: true, + }); + + expect(await page.findByText('Source Assessment')).toBeVisible(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + // Editing the working copy is the point, so it must not be warned against. The chip assertion + // above is the async gate: once it is up, the banner has had its chance to render. + expect( + page.queryByText(/frozen at its publication date/), + ).not.toBeInTheDocument(); + }); + + it('shows no marketplace chip outside the container', async () => { + const page = renderWith(undefined); + + expect(await page.findByText(baseAssessment.title)).toBeVisible(); + expect(page.queryByText(/2026/)).not.toBeInTheDocument(); + expect(page.queryByText('Source Assessment')).not.toBeInTheDocument(); + expect( + page.queryByText(/frozen at its publication date/), + ).not.toBeInTheDocument(); + }); + + // The show page is the only route to a snapshot, so the warning has to reach it through the page, + // not merely render in isolation. + it('warns on the page when the assessment is a published snapshot', async () => { + const page = renderWith({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: true, + listed: true, + sourceAssessmentUrl: 'http://origin.lvh.me/courses/3/assessments/9', + }); + + expect( + await page.findByText(/frozen at its publication date/), + ).toBeInTheDocument(); + expect( + page.getByRole('link', { name: 'Open source assessment' }), + ).toHaveAttribute('href', 'http://origin.lvh.me/courses/3/assessments/9'); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx new file mode 100644 index 00000000000..7f261798028 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx @@ -0,0 +1,81 @@ +import { render, RenderResult } from 'test-utils'; +import { MarketplaceVersionData } from 'types/course/assessment/assessments'; + +import MarketplaceSnapshotBanner from '../MarketplaceSnapshotBanner'; + +/** + * `NotificationPopup` mounts inside `I18nProvider` (see `Providers`), so its presence is the signal + * that the provider resolved its messages and the banner has had its chance to render. Without this + * gate an absence assertion passes vacuously, by running before anything has mounted at all. + */ +const settle = (page: RenderResult): Promise => + page.findByLabelText(/Notifications/); + +const SOURCE_URL = 'http://origin.lvh.me/courses/3/assessments/9'; + +const snapshot = ( + overrides: Partial = {}, +): MarketplaceVersionData => ({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: true, + listed: true, + sourceAssessmentUrl: SOURCE_URL, + ...overrides, +}); + +describe('', () => { + it('warns that a snapshot is frozen and sends the admin to the source assessment', async () => { + const page = render(); + + expect( + await page.findByText(/frozen at its publication date/), + ).toBeInTheDocument(); + // `role="alert"` is what the two absence assertions below query on, so pin it here. + expect(page.getByRole('alert')).toBeInTheDocument(); + + // `href`, not `to`: a cross-instance absolute url must not be routed as an in-app path. + const link = page.getByRole('link', { name: 'Open source assessment' }); + expect(link).toHaveAttribute('href', SOURCE_URL); + }); + + // An orphaned listing has no source to open yet. Saying so beats a dead or absent link. + it('explains the missing source instead of linking when the listing is orphaned', async () => { + const page = render( + , + ); + + expect( + await page.findByText(/one is being rebuilt from this version/), + ).toBeInTheDocument(); + expect(page.queryByRole('link')).not.toBeInTheDocument(); + }); + + // `publishedAt === null` is the working copy, which is exactly what an admin is meant to edit — + // the same discriminator MarketplaceVersionChip uses to label it "Source Assessment". + it('renders nothing for the listing working copy', async () => { + const page = render( + , + ); + + await settle(page); + + expect(page.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('renders nothing for an assessment the marketplace does not own', async () => { + const page = render(); + + await settle(page); + + expect(page.queryByRole('alert')).not.toBeInTheDocument(); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceUpdateBanner.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceUpdateBanner.test.tsx new file mode 100644 index 00000000000..936545a3549 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceUpdateBanner.test.tsx @@ -0,0 +1,321 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import GlobalAPI from 'api'; +import CourseAPI from 'api/course'; + +import MarketplaceUpdateBanner from '../MarketplaceUpdateBanner'; + +const mockUpdateToast = { + success: jest.fn(), + error: jest.fn(), +}; + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { success: jest.fn(), error: jest.fn() }, + loadingToast: jest.fn(() => mockUpdateToast), +})); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +// pollJob polls the *jobs* endpoint, which lives on a different axios client to the marketplace API. +const jobsMock = createMockAdapter(GlobalAPI.jobs.client); + +beforeEach(() => { + mock.reset(); + jobsMock.reset(); + jest.clearAllMocks(); +}); + +// Students have submitted work, so this copy can never be replaced in place. +const update = { + adoptedVersionAt: '2026-06-12T00:00:00Z', + latestVersionAt: '2026-07-24T00:00:00Z', + canUpdateInPlace: false, + testSubmissionCount: 0, +}; + +// No student has touched this copy, so the marketplace's newer content can replace it where it sits. +const updatableInPlace = { + ...update, + canUpdateInPlace: true, +}; + +// Two cuts on the same calendar day: the pair must escalate to include the time, or the banner +// would tell the manager their copy is from the same day it was superseded. +const sameDayUpdate = { + ...update, + adoptedVersionAt: '2026-07-24T01:00:00Z', + latestVersionAt: '2026-07-24T07:04:00Z', +}; + +const APPLY_URL = `/courses/${global.courseId}/assessments/5/marketplace_adoption/apply_latest_version`; +const JOB_URL = '/jobs/9'; +const REDIRECT_URL = `/courses/${global.courseId}/assessments/53`; +// What the apply endpoint answers with: the job is merely enqueued, and `jobUrl` is where its +// progress is reported. +const enqueued = { status: 'submitted', jobUrl: JOB_URL }; +const UPDATE = 'Update this assessment'; + +// Version numbers are not a user-facing concept: the manager who copied this assessment never saw +// "v1". The banner therefore dates both content vintages instead of numbering them. +// formatLongDate('2026-07-24T00:00:00Z') under TZ=Asia/Singapore → '24 Jul 2026'. +it('dates both content vintages without version numbers, sync or behind', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(alert.textContent).toContain( + 'This assessment was updated in the marketplace on 24 Jul 2026. Your copy is from 12 Jun 2026.', + ); + expect(alert.textContent).not.toMatch(/\bv\d/); + expect(alert.textContent).not.toMatch(/sync/i); + expect(alert.textContent).not.toMatch(/behind/i); +}); + +it('escalates to the time when both vintages fall on one day', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(alert.textContent).toContain( + 'This assessment was updated in the marketplace on 24 Jul 2026, 3:04pm. Your copy is from 24 Jul 2026, 9:00am.', + ); +}); + +// The notice is a statement of fact about the copy, not a notification, so nothing may silence it. +// MUI renders Alert's close × whenever `onClose` is passed, so counting the buttons is what keeps +// the banner un-closeable — the update is the only thing it may ever offer. +it('renders the update as its only button, with nothing to close it', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(within(alert).getAllByRole('button')).toHaveLength(1); + expect( + within(alert).getByRole('button', { name: UPDATE }), + ).toBeInTheDocument(); +}); + +// Replacing the content would destroy the students' work, so there is nothing safe to offer. An +// action-less banner is only honest if it says why — otherwise the manager hunts for a button. +it('explains why it cannot update when students have submitted work', async () => { + const page = render( + , + ); + + const alert = await page.findByRole('alert'); + expect(within(alert).queryAllByRole('button')).toHaveLength(0); + expect(alert.textContent).toContain('can no longer be updated automatically'); + expect(alert.textContent).toContain('students have already submitted work'); + expect(alert.textContent).toMatch(/edits of your own/i); + expect(alert.textContent).toMatch(/import this assessment .* again/i); +}); + +it('offers to update in place when no student has submitted work', async () => { + const page = render( + , + ); + + expect(await page.findByRole('button', { name: UPDATE })).toBeInTheDocument(); + expect( + page.queryByText(/can no longer be updated automatically/), + ).not.toBeInTheDocument(); +}); + +it('names the test submissions the update will delete', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + + const dialog = await page.findByRole('dialog'); + expect(dialog.textContent).toContain('2 test submissions'); + expect(dialog.textContent).toContain('replaces'); +}); + +it('omits the deletion warning when there is nothing to delete', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + + const dialog = await page.findByRole('dialog'); + expect(dialog.textContent).not.toMatch(/test submission/i); +}); + +// The manager is about to overwrite their content, so the prompt has to name WHICH version it is +// about to bring in — the same vintage the banner is reporting. +it('names the incoming version in the confirmation', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + + const dialog = await page.findByRole('dialog'); + expect(dialog.textContent).toContain('published on 24 Jul 2026'); +}); + +it('posts the in-place update on confirm', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock.onGet(JOB_URL).reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: new RegExp(UPDATE) }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(mock.history.post[0].url).toBe(APPLY_URL); + await waitFor(() => expect(mockUpdateToast.error).toHaveBeenCalled(), { + timeout: 6000, + }); +}); + +// `canUpdateInPlace` is advisory: the endpoint re-checks for student work and answers 422 if a +// student has submitted since the page loaded. The request never reaches pollJob, so nothing else +// can unlock the prompt or retract the loading toast. +it('reports a refused update and unlocks the prompt', async () => { + mock + .onPost(APPLY_URL) + .reply(422, { errors: ['Students have submitted work.'] }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + const confirm = within(dialog).getByRole('button', { + name: new RegExp(UPDATE), + }); + fireEvent.click(confirm); + + await waitFor(() => + expect(mockUpdateToast.error).toHaveBeenCalledWith( + 'Could not update this assessment.', + ), + ); + expect(page.getByRole('dialog')).toBeInTheDocument(); + await waitFor(() => expect(confirm).toBeEnabled()); +}); + +// The one thing that retires the banner: the copy has stopped being behind. The page still holds +// the pre-update payload, so the banner is the only thing that can notice. +it('reports completion once the in-place update job finishes', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock + .onGet(JOB_URL) + .reply(200, { status: 'completed', redirectUrl: REDIRECT_URL }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: new RegExp(UPDATE) }), + ); + + await waitFor(() => expect(mockUpdateToast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + await waitFor(() => + expect(page.queryByRole('alert')).not.toBeInTheDocument(), + ); +}, 10000); + +it('keeps the update locked while the job is still running', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock + .onGet(JOB_URL) + .replyOnce(200, { status: 'submitted' }) + .onGet(JOB_URL) + .reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + const confirm = within(dialog).getByRole('button', { + name: new RegExp(UPDATE), + }); + fireEvent.click(confirm); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + // The job has not reported back, so the dialog must stay open and un-resubmittable. + expect(confirm).toBeDisabled(); + expect(within(dialog).getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + fireEvent.click(confirm); + expect(mock.history.post).toHaveLength(1); + + await waitFor(() => expect(mockUpdateToast.error).toHaveBeenCalled(), { + timeout: 6000, + }); +}, 10000); + +it('reports a failed job and unlocks the dialog for a retry', async () => { + mock.onPost(APPLY_URL).reply(200, enqueued); + jobsMock.onGet(JOB_URL).reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText(UPDATE)); + const dialog = await page.findByRole('dialog'); + const confirm = within(dialog).getByRole('button', { + name: new RegExp(UPDATE), + }); + fireEvent.click(confirm); + + await waitFor(() => expect(mockUpdateToast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(mockUpdateToast.error).toHaveBeenCalledWith( + 'Could not update this assessment.', + ); + expect(page.getByRole('dialog')).toBeInTheDocument(); + // The banner is still there too: nothing was updated, so it is still telling the truth. Queried + // by text rather than by role — the open dialog `aria-hidden`s the rest of the body, so its + // `alert` role is unreachable while the retry prompt is up. + expect( + page.getByText(/This assessment was updated in the marketplace/), + ).toBeInTheDocument(); + await waitFor(() => expect(confirm).toBeEnabled()); +}, 10000); + +it('renders nothing when there is no update', async () => { + // The sentinel is what makes this assertion mean anything: `test-utils` mounts a translations + // Suspense, so the alert is absent on the first tick regardless. Awaiting a sibling proves the + // tree finished mounting; only then is the alert's absence evidence the component returned null. + const page = render( + <> + sentinel + + , + ); + + expect(await page.findByText('sentinel')).toBeInTheDocument(); + expect(page.queryByRole('alert')).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/versionVintage.test.ts b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/versionVintage.test.ts new file mode 100644 index 00000000000..db97056d49a --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/versionVintage.test.ts @@ -0,0 +1,41 @@ +import { formatVintagePair } from '../versionVintage'; + +// Tests run under TZ=Asia/Singapore, so a UTC instant renders +8h. +describe('formatVintagePair', () => { + it('renders dates only when the two vintages fall on different days', () => { + expect( + formatVintagePair('2026-06-12T00:00:00Z', '2026-07-24T00:00:00Z'), + ).toEqual({ adopted: '12 Jun 2026', latest: '24 Jul 2026' }); + }); + + // A listing republished twice in one day would otherwise render "updated on 24 Jul 2026, your + // copy is from 24 Jul 2026" — self-contradicting, and the adopter cannot resolve it. + it('escalates BOTH vintages to include the time when they share a calendar day', () => { + expect( + formatVintagePair('2026-07-24T01:00:00Z', '2026-07-24T07:04:00Z'), + ).toEqual({ + adopted: '24 Jul 2026, 9:00am', + latest: '24 Jul 2026, 3:04pm', + }); + }); + + // Same calendar day is judged in the VIEWER's zone, which is what they read on screen. These two + // instants are different UTC days but the same Singapore day. + it('judges the shared day in the viewer timezone, not UTC', () => { + expect( + formatVintagePair('2026-07-23T17:00:00Z', '2026-07-24T02:00:00Z'), + ).toEqual({ + adopted: '24 Jul 2026, 1:00am', + latest: '24 Jul 2026, 10:00am', + }); + }); + + it('escalates when the two vintages are the identical instant', () => { + expect( + formatVintagePair('2026-07-24T07:04:00Z', '2026-07-24T07:04:00Z'), + ).toEqual({ + adopted: '24 Jul 2026, 3:04pm', + latest: '24 Jul 2026, 3:04pm', + }); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/versionVintage.ts b/client/app/bundles/course/assessment/pages/AssessmentShow/versionVintage.ts new file mode 100644 index 00000000000..c24a25c2a6e --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/versionVintage.ts @@ -0,0 +1,24 @@ +import moment, { formatLongDate, formatLongDateTime } from 'lib/moment'; + +/** + * Formats an adopted vintage and the served vintage as a pair. + * + * A version is identified by when it was published, and adopters read that as a date — a time is + * false precision here. But a listing republished twice in one day would render "updated on 24 Jul + * 2026. Your copy is from 24 Jul 2026.", which the adopter cannot resolve. So precision escalates to + * include the time exactly when the two vintages would otherwise be indistinguishable. + * + * Both sides escalate together — one dated and one timestamped would read as a different kind of + * thing rather than as two points on one scale. + * + * The shared-day test is made in the viewer's timezone, because that is the rendering they compare. + */ +export const formatVintagePair = ( + adopted: string, + latest: string, +): { adopted: string; latest: string } => { + const sameDay = moment(adopted).isSame(moment(latest), 'day'); + const format = sameDay ? formatLongDateTime : formatLongDate; + + return { adopted: format(adopted), latest: format(latest) }; +}; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx index 85602e2722c..cb6ab8cbdad 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import { AssessmentListData, AssessmentsListData, @@ -13,6 +14,7 @@ import useTranslation from 'lib/hooks/useTranslation'; import translations from '../../translations'; import ActionButtons from './ActionButtons'; +import MarketplaceVersionChip from './MarketplaceVersionChip'; import StatusBadges from './StatusBadges'; interface AssessmentsTableProps { @@ -23,10 +25,59 @@ const AssessmentsTable = (props: AssessmentsTableProps): JSX.Element => { const { display, assessments, totalStudentCount } = props.assessments; const { t } = useTranslation(); + const isContainer = display.isMarketplaceContainer; + + const listingLabels = useMemo((): Record => { + const newest: Record = {}; + + assessments.forEach((assessment) => { + const version = assessment.marketplaceVersion; + if (!version) return; + + const held = newest[version.listingId]; + const publishedAt = version.publishedAt ?? ''; + if (!held || publishedAt > held.publishedAt) + newest[version.listingId] = { title: assessment.title, publishedAt }; + }); + + return Object.fromEntries( + Object.entries(newest).map(([listingId, held]) => [ + listingId, + t(translations.marketplaceListingLabel, { + title: held.title, + listingId, + }), + ]), + ); + }, [assessments, t]); + + const listingLabelFor = (assessment: AssessmentListData): string => + assessment.marketplaceVersion + ? listingLabels[assessment.marketplaceVersion.listingId] + : ''; + + /** + * Live / Latest / Older version / Source Assessment — null for an assessment that belongs to no + * listing. Live and Latest are mutually exclusive: both mean "newest cut", and Live additionally + * means the listing is on the marketplace, so it stands in for the weaker label. + */ + const versionKindFor = (assessment: AssessmentListData): string | null => { + const version = assessment.marketplaceVersion; + if (!version) return null; + if (version.publishedAt === null) + return t(translations.marketplaceAuthoring); + if (!version.latest) return t(translations.marketplaceOlderVersion); + + return version.listed + ? t(translations.marketplaceLive) + : t(translations.marketplaceLatest); + }; + const columns: ColumnTemplate[] = [ { of: 'title', title: t(translations.title), + searchable: isContainer, cell: (assessment) => (
), }, + { + id: 'marketplaceListing', + title: t(translations.marketplaceListingColumn), + unless: !isContainer, + filterable: true, + filterProps: { + getValue: (assessment) => + assessment.marketplaceVersion ? [listingLabelFor(assessment)] : [], + shouldInclude: (assessment, filterValue?: string[]) => + !filterValue?.length || + filterValue.includes(listingLabelFor(assessment)), + }, + cell: (assessment) => + assessment.marketplaceVersion ? ( + + {listingLabelFor(assessment)} + + ) : ( + t(translations.marketplaceNotAVersion) + ), + }, + { + id: 'marketplaceVersion', + title: t(translations.marketplaceVersionColumn), + unless: !isContainer, + sortable: true, + filterable: true, + // Sorts on the publication instant, not the rendered label. The server orders by + // `ordered_by_date_and_title`, and every snapshot of a listing inherits the origin's identical + // start_at AND title — so siblings have no tiebreak and their order can differ between loads. + // This column is how an admin pins them down. + accessorFn: (assessment) => + assessment.marketplaceVersion?.publishedAt ?? '', + filterProps: { + getValue: (assessment): string[] => { + const kind = versionKindFor(assessment); + return kind ? [kind] : []; + }, + shouldInclude: (assessment, filterValue?: string[]) => + !filterValue?.length || + filterValue.includes(versionKindFor(assessment) ?? ''), + }, + cell: (assessment) => + assessment.marketplaceVersion ? ( + + ) : ( + t(translations.marketplaceNotAVersion) + ), + }, + { + id: 'marketplaceSource', + title: t(translations.marketplaceSourceColumn), + unless: !isContainer, + sortable: true, + searchable: true, + // Deliberately NOT filterable, mirroring MarketplaceListingsTable: source courses number in the + // hundreds, most contributing one or two listings, and the filter is client-side over loaded + // rows. + accessorFn: (assessment) => assessment.marketplaceVersion?.source ?? '', + cell: (assessment) => + assessment.marketplaceVersion?.source ?? + t(translations.marketplaceNotAVersion), + }, { of: 'baseExp', title: t(translations.exp), @@ -185,6 +302,17 @@ const AssessmentsTable = (props: AssessmentsTableProps): JSX.Element => { }` } getRowId={(assessment): string => assessment.id.toString()} + renderEmpty={ + isContainer ? ( + + ) : undefined + } + search={ + isContainer + ? { searchPlaceholder: t(translations.marketplaceSearchText) } + : undefined + } + toolbar={isContainer ? { show: true } : undefined} /> ); }; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/MarketplaceVersionChip.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/MarketplaceVersionChip.tsx new file mode 100644 index 00000000000..878aa37c1f3 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/MarketplaceVersionChip.tsx @@ -0,0 +1,102 @@ +import { FC } from 'react'; +import { Chip, Tooltip } from '@mui/material'; +import { MarketplaceVersionData } from 'types/course/assessment/assessments'; + +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +import translations from '../../translations'; + +interface MarketplaceVersionChipProps { + for: MarketplaceVersionData; +} + +/** + * Tells apart the assessments in the marketplace container course, which all sit in one tab under + * their original titles: immutable published snapshots, chipped with their publication date, and the + * listing's editable working copy, chipped "Source Assessment". View-only — nothing here is ever + * retitled, because an adopter's duplicated copy reads that title. + */ +const MarketplaceVersionChip: FC = (props) => { + const { for: marketplaceVersion } = props; + const { t } = useTranslation(); + const publishedAt = marketplaceVersion.publishedAt; + + // A null vintage means the working copy, which is not a version at all — hence a different label + // and a different colour, so an admin never mistakes it for something the marketplace serves. + const isAuthoring = publishedAt === null; + + // Two different facts. `latest` is the newest cut; `listed` is whether the listing is on the + // marketplace. Only their conjunction means "this is what an adopter gets", and only that earns + // the strong label — so Live stands in for Latest rather than sitting beside it. + const isLive = marketplaceVersion.latest && marketplaceVersion.listed; + + const hint = ((): string => { + if (isAuthoring) { + return marketplaceVersion.source + ? t(translations.marketplaceAuthoringHintWithSource, { + listingId: marketplaceVersion.listingId, + source: marketplaceVersion.source, + }) + : t(translations.marketplaceAuthoringHint, { + listingId: marketplaceVersion.listingId, + }); + } + + return marketplaceVersion.source + ? t(translations.marketplaceVersionHintWithSource, { + listingId: marketplaceVersion.listingId, + source: marketplaceVersion.source, + }) + : t(translations.marketplaceVersionHint, { + listingId: marketplaceVersion.listingId, + }); + })(); + + // Date AND time: one container tab holds every snapshot of every listing, so same-day siblings + // sit next to each other and the time is the only thing separating them. + const label = isAuthoring + ? t(translations.marketplaceAuthoring) + : t(translations.marketplaceVersion, { + version: formatLongDateTime(publishedAt), + }); + + return ( +
+ + + + + {!isAuthoring && marketplaceVersion.latest && ( + + + + )} +
+ ); +}; + +export default MarketplaceVersionChip; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx new file mode 100644 index 00000000000..73ef86ed8b4 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx @@ -0,0 +1,318 @@ +import userEvent from '@testing-library/user-event'; +import { render, waitFor, within } from 'test-utils'; +import { + AssessmentListData, + AssessmentsListData, +} from 'types/course/assessment/assessments'; + +import AssessmentsTable from '../AssessmentsTable'; + +const SEARCH_PLACEHOLDER = 'Search by assessment title or source course'; +const NO_RESULTS_MESSAGE = "Whoops, there's nothing to see here, yet!"; + +const assessment = ( + overrides: Partial = {}, +): AssessmentListData => ({ + id: 1, + title: 'Recursion', + status: 'open', + actionButtonUrl: null, + passwordProtected: false, + published: true, + autograded: false, + hasPersonalTimes: false, + affectsPersonalTimes: false, + url: '/courses/1/assessments/1', + conditionSatisfied: true, + startAt: { isFixed: false, effectiveTime: null, referenceTime: null }, + isStartTimeBegin: true, + ...overrides, +}); + +const listData = ( + assessments: AssessmentListData[], + isMarketplaceContainer: boolean, +): AssessmentsListData => ({ + display: { + isStudent: false, + isGamified: false, + isKoditsuExamEnabled: false, + timelineAlgorithm: 'fixed', + allowRandomization: false, + isAchievementsEnabled: false, + isMonitoringEnabled: false, + bonusAttributes: false, + endTimes: false, + canCreateAssessments: true, + tabId: 1, + tabTitle: 'Assessments: Default', + tabUrl: '/courses/1/assessments', + canManageMonitor: false, + isMarketplaceContainer, + category: { + id: 1, + title: 'Assessments', + tabs: [{ id: 1, title: 'Default' }], + }, + }, + assessments, +}); + +/** + * Four rows across three listings, covering every version kind: two cuts of a published listing + * (one served, one superseded), the single served cut of another, and the newest cut of a listing + * that has been taken off the marketplace. + */ +const containerRows = (): AssessmentListData[] => [ + assessment({ + id: 1, + title: 'Publish me 2', + marketplaceVersion: { + listingId: 4, + publishedAt: '2026-07-29T01:01:00Z', + source: 'Marketplace Preview Fixtures', + latest: false, + listed: true, + }, + }), + assessment({ + id: 2, + title: 'Publish me 2', + marketplaceVersion: { + listingId: 4, + publishedAt: '2026-07-29T01:04:00Z', + source: 'Marketplace Preview Fixtures', + latest: true, + listed: true, + }, + }), + assessment({ + id: 3, + title: 'Listed MCQ', + marketplaceVersion: { + listingId: 2, + publishedAt: '2026-07-29T00:59:00Z', + source: 'Other Source Course', + latest: true, + listed: true, + }, + }), + assessment({ + id: 4, + title: 'Taken down', + marketplaceVersion: { + listingId: 5, + publishedAt: '2026-07-29T02:07:00Z', + source: 'Retired Source Course', + latest: true, + listed: false, + }, + }), +]; + +// Column headers are matched by REGEX, never by an exact string: a filterable column's header cell +// also contains the filter IconButton, whose tooltip contributes "Filter" to the cell's accessible +// name (MUI applies the tooltip title as `aria-label` on a child with no text of its own). +describe(' in the marketplace container', () => { + it('adds the Listing, Version and Source columns', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('columnheader', { name: /Listing/ }), + ).toBeInTheDocument(); + expect( + page.getByRole('columnheader', { name: /Version/ }), + ).toBeInTheDocument(); + expect( + page.getByRole('columnheader', { name: /Source/ }), + ).toBeInTheDocument(); + }); + + // The container tab is the ONLY place these belong. Leaking them would rewrite the assessments + // index for every course in the deployment. + it('shows none of them, and no search box, in an ordinary course', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('link', { name: 'Recursion' }), + ).toBeInTheDocument(); + expect( + page.queryByRole('columnheader', { name: /Listing/ }), + ).not.toBeInTheDocument(); + expect( + page.queryByRole('columnheader', { name: /Version/ }), + ).not.toBeInTheDocument(); + expect( + page.queryByRole('columnheader', { name: /Source/ }), + ).not.toBeInTheDocument(); + expect( + page.queryByPlaceholderText(SEARCH_PLACEHOLDER), + ).not.toBeInTheDocument(); + // Generic, rather than keyed off our placeholder text: `MuiTableToolbar`'s `SearchField` falls + // back to a generic "Search" placeholder whenever the toolbar renders but `search` is unset, so a + // toolbar leaking in unconditionally would still pass the placeholder-only check above. + expect(page.queryByRole('textbox')).not.toBeInTheDocument(); + }); + + it('offers a search box in the container', async () => { + const page = render( + , + ); + + expect( + await page.findByPlaceholderText(SEARCH_PLACEHOLDER), + ).toBeInTheDocument(); + }); + + // Source course is searchable rather than filterable, matching the decision already recorded on + // MarketplaceListingsTable: courses number in the hundreds and a menu would grow without bound. + it('narrows to one listing by searching its source course', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.type( + await page.findByPlaceholderText(SEARCH_PLACEHOLDER), + 'Other Source', + ); + + expect( + await page.findByRole('link', { name: 'Listed MCQ' }), + ).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: 'Publish me 2' }), + ).not.toBeInTheDocument(); + expect( + page.queryByRole('link', { name: 'Taken down' }), + ).not.toBeInTheDocument(); + }); + + // The reason the Listing axis is a filter and not a search: these two rows are textually + // identical, so no search string can separate them from the third. + it('labels every row of one listing identically, using its newest title', async () => { + const page = render( + , + ); + + expect( + await page.findAllByRole('link', { name: 'Publish me 2 · ID 4' }), + ).toHaveLength(2); + expect( + page.getAllByRole('link', { name: 'Listed MCQ · ID 2' }), + ).toHaveLength(1); + }); + + it('links a listing to its admin history page', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('link', { name: 'Listed MCQ · ID 2' }), + ).toHaveAttribute('href', '/admin/marketplace_listings/2'); + }); + + it('shows the Live chip only on the served snapshot of each published listing', async () => { + const page = render( + , + ); + + // Two published listings, one served snapshot each. The superseded cut and the unlisted + // listing's newest cut are both excluded. + expect(await page.findAllByText('Live')).toHaveLength(2); + }); + + // An unlisted listing still has a newest version — the one an admin re-publishing acts on — but + // nothing is being served, so it must read Latest and never Live. + it('marks an unlisted listing’s newest version Latest rather than Live', async () => { + const page = render( + , + ); + + expect(await page.findByText('Latest')).toBeInTheDocument(); + + const takenDownRow = page + .getByRole('link', { name: 'Taken down' }) + .closest('tr') as HTMLElement; + expect(within(takenDownRow).getByText('Latest')).toBeInTheDocument(); + expect(within(takenDownRow).queryByText('Live')).not.toBeInTheDocument(); + }); + + // Selecting Live is "what is the marketplace serving right now" in one click. The filter button is + // addressed by its 'Filter' name because the header also holds a sort button. + it('isolates what the marketplace is serving through the Version filter', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + const versionHeader = await page.findByRole('columnheader', { + name: /Version/, + }); + await user.click( + within(versionHeader).getByRole('button', { name: 'Filter' }), + ); + await user.click(await page.findByRole('menuitem', { name: 'Live' })); + // An open MUI menu marks the rest of the page `aria-hidden`, so the table rows are unqueryable + // until it is closed — matching the established pattern in MarketplaceListingsIndex.test.tsx. + await user.keyboard('{Escape}'); + await waitFor(() => + expect(page.queryByRole('menu')).not.toBeInTheDocument(), + ); + + // Listing 4's 9:04 cut survives and its 9:01 sibling does not; listing 2's only cut survives; + // the unlisted listing's newest cut is excluded because nothing of it is being served. + expect(page.getAllByRole('link', { name: 'Publish me 2' })).toHaveLength(1); + expect(page.getByRole('link', { name: 'Listed MCQ' })).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: 'Taken down' }), + ).not.toBeInTheDocument(); + }); + + // Unlike the all-or-nothing `assessments.length === 0` case (covered elsewhere), a search or + // filter that matches nothing is reached with rows still in the payload, so the empty note has to + // come from the table itself rather than a check before it. + it('shows an empty state when the search matches nothing, but not while rows still match', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + const search = await page.findByPlaceholderText(SEARCH_PLACEHOLDER); + + expect(page.queryByText(NO_RESULTS_MESSAGE)).not.toBeInTheDocument(); + + await user.type(search, 'No source course matches this string'); + + expect(await page.findByText(NO_RESULTS_MESSAGE)).toBeInTheDocument(); + }); + + it('leaves the Listing, Version and Source cells empty for an assessment authored in the container', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('link', { name: 'Hand-made in the container' }), + ).toBeInTheDocument(); + + // Indexed off the row rather than counting em dashes across the whole table, so an unrelated + // column rendering one cannot silently satisfy this. + const cells = within(page.getAllByRole('row')[1]).getAllByRole('cell'); + expect(cells[1]).toHaveTextContent('—'); + expect(cells[2]).toHaveTextContent('—'); + expect(cells[3]).toHaveTextContent('—'); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/MarketplaceVersionChip.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/MarketplaceVersionChip.test.tsx new file mode 100644 index 00000000000..b0e9c2722cf --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/MarketplaceVersionChip.test.tsx @@ -0,0 +1,140 @@ +import userEvent from '@testing-library/user-event'; +import { render } from 'test-utils'; +import { MarketplaceVersionData } from 'types/course/assessment/assessments'; + +import MarketplaceVersionChip from '../MarketplaceVersionChip'; + +// '2026-07-24T07:04:00Z' rendered in Asia/Singapore (UTC+8). +const PUBLISHED_AT_LABEL = '24 Jul 2026, 3:04pm'; + +const snapshot = ( + overrides: Partial = {}, +): MarketplaceVersionData => ({ + listingId: 7, + publishedAt: '2026-07-24T07:04:00Z', + source: 'MP Allowlist Source Course', + latest: false, + listed: true, + ...overrides, +}); + +describe('', () => { + // One container tab holds every snapshot of every listing under identical titles, so siblings ARE + // side by side here — the time is what tells two same-day cuts apart. + it('labels a snapshot with its publish date and time', async () => { + const page = render(); + + expect(await page.findByText(PUBLISHED_AT_LABEL)).toBeInTheDocument(); + }); + + it('labels the working copy as the source assessment rather than a date', async () => { + const page = render( + , + ); + + expect(await page.findByText('Source Assessment')).toBeInTheDocument(); + expect(page.queryByText(/2026/)).not.toBeInTheDocument(); + }); + + it('marks the newest version of a published listing as Live, alongside its date', async () => { + const page = render( + , + ); + + expect(await page.findByText('Live')).toBeInTheDocument(); + // The date is not replaced by the status — an admin needs both. + expect(page.getByText(PUBLISHED_AT_LABEL)).toBeInTheDocument(); + // Live and Latest are mutually exclusive: Live is the stronger of the two and stands in for it. + expect(page.queryByText('Latest')).not.toBeInTheDocument(); + }); + + // An unlisted listing still HAS a newest version — it is what an admin re-publishing acts on — but + // nothing is being served, so it must not read Live. + it('marks the newest version of an unlisted listing as Latest, not Live', async () => { + const page = render( + , + ); + + expect(await page.findByText('Latest')).toBeInTheDocument(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + }); + + it('marks a superseded snapshot neither Live nor Latest', async () => { + const page = render( + , + ); + + expect(await page.findByText(PUBLISHED_AT_LABEL)).toBeInTheDocument(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + expect(page.queryByText('Latest')).not.toBeInTheDocument(); + }); + + // Unreachable today — the backend hardcodes `latest: false` for the working copy — but + // constructible here, and the two classifiers must not be able to disagree: the Version filter in + // AssessmentsTable already treats a null `publishedAt` as "Source Assessment" regardless of + // `latest`, so this chip must never render Live or Latest alongside it. + it('never marks the working copy Live or Latest, even if `latest` were true', async () => { + const page = render( + , + ); + + expect(await page.findByText('Source Assessment')).toBeInTheDocument(); + expect(page.queryByText('Live')).not.toBeInTheDocument(); + expect(page.queryByText('Latest')).not.toBeInTheDocument(); + }); + + it('identifies the listing by a stable id rather than an ordinal', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.hover(await page.findByText(PUBLISHED_AT_LABEL)); + + const tooltip = await page.findByRole('tooltip'); + expect(tooltip).toHaveTextContent( + 'Listing ID 12 · from MP Allowlist Source Course', + ); + // "#12" reads as a position in a list, which is what made an admin expect it to renumber when a + // neighbouring listing was deleted. It is a primary key and never moves. + expect(tooltip).not.toHaveTextContent('#12'); + }); + + it('names the listing alone when the source course was never recorded', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.hover(await page.findByText(PUBLISHED_AT_LABEL)); + + const tooltip = await page.findByRole('tooltip'); + expect(tooltip).toHaveTextContent('Listing ID 12'); + expect(tooltip).not.toHaveTextContent('from'); + }); + + it('says the working copy is not a published version', async () => { + const user = userEvent.setup(); + const page = render( + , + ); + + await user.hover(await page.findByText('Source Assessment')); + + expect(await page.findByRole('tooltip')).toHaveTextContent( + 'Listing ID 12 · editable working copy, not a published version', + ); + }); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx new file mode 100644 index 00000000000..1f24bb7f0e6 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx @@ -0,0 +1,41 @@ +import { render, screen } from 'test-utils'; +import { AssessmentListData } from 'types/course/assessment/assessments'; + +import StatusBadges from '../StatusBadges'; + +const assessment = ( + overrides: Partial = {}, +): AssessmentListData => ({ + id: 1, + title: 'Recursion', + status: 'open', + actionButtonUrl: null, + passwordProtected: false, + published: true, + autograded: false, + hasPersonalTimes: false, + affectsPersonalTimes: false, + url: '/courses/1/assessments/1', + conditionSatisfied: true, + startAt: { isFixed: false, effectiveTime: null, referenceTime: null }, + isStartTimeBegin: true, + ...overrides, +}); + +const renderBadges = (data: AssessmentListData): void => { + render( + , + ); +}; + +// The marketplace cases that used to live here moved with the chip: two to +// MarketplaceVersionChip.test.tsx in Task 3, and the rest to AssessmentsTable.test.tsx, which is +// where the chip now renders. They are deleted rather than inverted into absence assertions — a +// removed behaviour gets its tests removed, not rewritten to assert it is gone. +describe('', () => { + it('marks an unpublished assessment as a draft', async () => { + renderBadges(assessment({ published: false })); + + expect(await screen.findByText('Draft')).toBeVisible(); + }); +}); diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index eddd80ac53b..19ffbe54d0e 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -1,6 +1,47 @@ import { defineMessages } from 'react-intl'; const translations = defineMessages({ + marketplaceUpdateAvailable: { + id: 'course.assessment.marketplaceUpdateAvailable', + defaultMessage: + 'This assessment was updated in the marketplace on {latest}. Your copy is from {adopted}.', + }, + marketplaceUpdateInPlace: { + id: 'course.assessment.marketplaceUpdateInPlace', + defaultMessage: 'Update this assessment', + }, + marketplaceUpdateBlocked: { + id: 'course.assessment.marketplaceUpdateBlocked', + defaultMessage: + 'This assessment can no longer be updated automatically: students have already submitted work for it, and it may carry edits of your own. Replacing its content would discard both. To use the new version, import this assessment from the marketplace again.', + }, + marketplaceUpdateConfirmTitle: { + id: 'course.assessment.marketplaceUpdateConfirmTitle', + defaultMessage: 'Update this assessment?', + }, + marketplaceUpdateConfirmBody: { + id: 'course.assessment.marketplaceUpdateConfirmBody', + defaultMessage: + "This replaces this assessment's questions and materials with the version published on {latest}. It keeps its place in your course, its deadlines, and whether it is published.", + }, + marketplaceUpdateConfirmDeletion: { + id: 'course.assessment.marketplaceUpdateConfirmDeletion', + defaultMessage: + '{count, plural, one {# test submission} other {# test submissions}} on this assessment will be deleted. No student has submitted work for it.', + }, + marketplaceUpdateStarted: { + id: 'course.assessment.marketplaceUpdateStarted', + defaultMessage: 'Updating this assessment…', + }, + marketplaceUpdateCompleted: { + id: 'course.assessment.marketplaceUpdateCompleted', + defaultMessage: + 'Assessment updated to the latest version. Refresh to see the latest version.', + }, + marketplaceUpdateFailed: { + id: 'course.assessment.marketplaceUpdateFailed', + defaultMessage: 'Could not update this assessment.', + }, updateAssessment: { id: 'course.assessment.edit.update', defaultMessage: 'Save', @@ -160,6 +201,96 @@ const translations = defineMessages({ id: 'course.assessments.index.seeAllRequirements', defaultMessage: 'See all requirements', }, + marketplaceVersion: { + id: 'course.assessments.index.marketplaceVersion', + defaultMessage: '{version}', + }, + // The label is renamed; the message id is not. "Source assessment" is already this codebase's + // user-facing name for the authoring copy (MarketplaceListingsTable's "Open source assessment", + // MarketplaceRestoreAuthoringButton's "Rebuild source assessment"). Renaming the id would orphan + // the key in all three locale files for a copy change; `authoring_assessment` is unaffected. + marketplaceAuthoring: { + id: 'course.assessments.index.marketplaceAuthoring', + defaultMessage: 'Source Assessment', + }, + marketplaceAuthoringHint: { + id: 'course.assessments.index.marketplaceAuthoringHint', + defaultMessage: + 'Listing ID {listingId} · editable working copy, not a published version', + }, + marketplaceAuthoringHintWithSource: { + id: 'course.assessments.index.marketplaceAuthoringHintWithSource', + defaultMessage: + 'Listing ID {listingId} · editable working copy, not a published version · from {source}', + }, + marketplaceVersionHint: { + id: 'course.assessments.index.marketplaceVersionHint', + defaultMessage: 'Listing ID {listingId}', + }, + marketplaceVersionHintWithSource: { + id: 'course.assessments.index.marketplaceVersionHintWithSource', + defaultMessage: 'Listing ID {listingId} · from {source}', + }, + marketplaceLive: { + id: 'course.assessments.index.marketplaceLive', + defaultMessage: 'Live', + }, + marketplaceLiveHint: { + id: 'course.assessments.index.marketplaceLiveHint', + defaultMessage: + 'The version the marketplace is serving right now. Adopting this listing copies this content.', + }, + marketplaceLatest: { + id: 'course.assessments.index.marketplaceLatest', + defaultMessage: 'Latest', + }, + marketplaceLatestHint: { + id: 'course.assessments.index.marketplaceLatestHint', + defaultMessage: + 'The newest version of this listing. Nothing is being served — the listing is off the marketplace.', + }, + marketplaceListingColumn: { + id: 'course.assessments.index.marketplaceListingColumn', + defaultMessage: 'Listing', + }, + marketplaceVersionColumn: { + id: 'course.assessments.index.marketplaceVersionColumn', + defaultMessage: 'Version', + }, + marketplaceSourceColumn: { + id: 'course.assessments.index.marketplaceSourceColumn', + defaultMessage: 'Source', + }, + marketplaceListingLabel: { + id: 'course.assessments.index.marketplaceListingLabel', + defaultMessage: '{title} · ID {listingId}', + }, + marketplaceOlderVersion: { + id: 'course.assessments.index.marketplaceOlderVersion', + defaultMessage: 'Older version', + }, + marketplaceNotAVersion: { + id: 'course.assessments.index.marketplaceNotAVersion', + defaultMessage: '—', + }, + marketplaceSearchText: { + id: 'course.assessments.index.marketplaceSearchText', + defaultMessage: 'Search by assessment title or source course', + }, + marketplaceSnapshotWarning: { + id: 'course.assessments.show.marketplaceSnapshotWarning', + defaultMessage: + 'This is a published version, frozen at its publication date. Editing it silently changes what courses copy from the marketplace without publishing a new version. Make changes on the source assessment instead, then publish a new version.', + }, + marketplaceSnapshotSourceLink: { + id: 'course.assessments.show.marketplaceSnapshotSourceLink', + defaultMessage: 'Open source assessment', + }, + marketplaceSnapshotSourceMissing: { + id: 'course.assessments.show.marketplaceSnapshotSourceMissing', + defaultMessage: + 'This listing has no source assessment right now — one is being rebuilt from this version.', + }, requirements: { id: 'course.assessment.show.requirements', defaultMessage: 'Requirements', diff --git a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx index 5b6518dfdce..d04a0f1a2cd 100644 --- a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -103,12 +103,11 @@ const DuplicateConfirmation = ({ <> {t(translations.duplicateCompleted, { n })} {redirectUrl && ( - <> - {' '} - - {t(translations.viewDuplicatedAssessment)} - - + + {t(translations.viewDuplicatedAssessment, { + n: listings.length, + })} + )} , ); diff --git a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx index 56aaaf19fe5..dec05373d7b 100644 --- a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -23,6 +23,7 @@ const PublishToMarketplaceButton = ({ }: Props): JSX.Element | null => { const { t } = useTranslation(); const [open, setOpen] = useState(false); + const [versionOpen, setVersionOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const listed = assessment.isPublishedToMarketplace; @@ -53,8 +54,31 @@ const PublishToMarketplaceButton = ({ } }; + const confirmNewVersion = async (): Promise => { + setSubmitting(true); + try { + await CourseAPI.marketplace.publishNewVersion(assessment.id); + toast.success(t(translations.newVersionPublished)); + setVersionOpen(false); + } catch { + toast.error(t(translations.newVersionFailed)); + } finally { + setSubmitting(false); + } + }; + return ( <> + {listed && ( + + )} +