From f60570c063f4c4f0591d166422d2f271cf975d4b Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 7 Jul 2026 17:11:11 +0800 Subject: [PATCH 01/30] feat(marketplace): add data model, migrations, and permissions - add Listing and Adoption models under Course::Assessment::Marketplace namespace, with course_assessment_marketplace_ prefix on both tables - Listing tracks published state and publisher, with a uniqueness constraint per assessment and an adoption_count helper - Adoption links a listing to a destination course and duplicated assessment, one adoption per duplicated assessment - wire has_one :marketplace_listing onto Course::Assessment - add AssessmentMarketplaceAbilityComponent: admins can publish listings, course managers/owners can access, duplicate, and preview published listings --- ...ssessment_marketplace_ability_component.rb | 26 ++++++++++ app/models/course/assessment.rb | 2 + app/models/course/assessment/marketplace.rb | 6 +++ .../course/assessment/marketplace/adoption.rb | 10 ++++ .../course/assessment/marketplace/listing.rb | 18 +++++++ ..._course_assessment_marketplace_listings.rb | 30 ++++++++++++ ...course_assessment_marketplace_adoptions.rb | 33 +++++++++++++ db/schema.rb | 44 ++++++++++++++++- ...course_assessment_marketplace_adoptions.rb | 9 ++++ .../course_assessment_marketplace_listings.rb | 14 ++++++ .../assessment/marketplace/adoption_spec.rb | 24 ++++++++++ .../assessment/marketplace/listing_spec.rb | 47 +++++++++++++++++++ .../assessment_marketplace_ability_spec.rb | 41 ++++++++++++++++ 13 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 app/models/components/course/assessment_marketplace_ability_component.rb create mode 100644 app/models/course/assessment/marketplace.rb create mode 100644 app/models/course/assessment/marketplace/adoption.rb create mode 100644 app/models/course/assessment/marketplace/listing.rb create mode 100644 db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb create mode 100644 db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb create mode 100644 spec/factories/course_assessment_marketplace_adoptions.rb create mode 100644 spec/factories/course_assessment_marketplace_listings.rb create mode 100644 spec/models/course/assessment/marketplace/adoption_spec.rb create mode 100644 spec/models/course/assessment/marketplace/listing_spec.rb create mode 100644 spec/models/course/assessment_marketplace_ability_spec.rb diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb new file mode 100644 index 0000000000..e7a7972085 --- /dev/null +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true +module Course::AssessmentMarketplaceAbilityComponent + include AbilityHost::Component + + def define_permissions + allow_admins_publish_to_marketplace if user&.administrator? + allow_managers_access_marketplace if course_user&.manager_or_owner? + super + end + + private + + def allow_admins_publish_to_marketplace + can :publish_to_marketplace, Course::Assessment + end + + def allow_managers_access_marketplace + can :access_marketplace, Course, id: course.id + can :duplicate_from_marketplace, Course::Assessment do |assessment| + assessment.marketplace_listing&.published? || false + end + can :preview_in_marketplace, Course::Assessment do |assessment| + assessment.marketplace_listing&.published? || false + end + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 7bde6a0d4b..e489dce65d 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -82,6 +82,8 @@ class Course::Assessment < ApplicationRecord has_one :gradebook_assessment_contribution, class_name: 'Course::Gradebook::AssessmentContribution', dependent: :destroy, inverse_of: :assessment + has_one :marketplace_listing, class_name: 'Course::Assessment::Marketplace::Listing', + inverse_of: :assessment, dependent: :destroy has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', inverse_of: :assessment, dependent: :destroy has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy diff --git a/app/models/course/assessment/marketplace.rb b/app/models/course/assessment/marketplace.rb new file mode 100644 index 0000000000..235cbc69e9 --- /dev/null +++ b/app/models/course/assessment/marketplace.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true +module Course::Assessment::Marketplace + def self.table_name_prefix + 'course_assessment_marketplace_' + end +end diff --git a/app/models/course/assessment/marketplace/adoption.rb b/app/models/course/assessment/marketplace/adoption.rb new file mode 100644 index 0000000000..dac5a61629 --- /dev/null +++ b/app/models/course/assessment/marketplace/adoption.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::Adoption < ApplicationRecord + belongs_to :listing, class_name: 'Course::Assessment::Marketplace::Listing', inverse_of: :adoptions + belongs_to :destination_course, class_name: 'Course', inverse_of: false + belongs_to :duplicated_assessment, class_name: 'Course::Assessment', inverse_of: false + + validates :duplicated_assessment_id, uniqueness: true + validates :creator, presence: true + validates :updater, presence: true +end diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb new file mode 100644 index 0000000000..0722ac6492 --- /dev/null +++ b/app/models/course/assessment/marketplace/listing.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::Listing < ApplicationRecord + belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing + belongs_to :publisher, class_name: 'User', inverse_of: false + has_many :adoptions, class_name: 'Course::Assessment::Marketplace::Adoption', + inverse_of: :listing, dependent: :destroy + + validates :assessment_id, uniqueness: true + validates :publisher, presence: true + validates :creator, presence: true + validates :updater, presence: true + + scope :published, -> { where(published: true) } + + def adoption_count + adoptions.distinct.count(:destination_course_id) + end +end diff --git a/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb new file mode 100644 index 0000000000..e1b094eb9a --- /dev/null +++ b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb @@ -0,0 +1,30 @@ +class CreateCourseAssessmentMarketplaceListings < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_listings do |t| + t.references :assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_course_assessment_marketplace_listings_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_listings_assessment_id', + unique: true } + t.boolean :published, null: false, default: false + t.datetime :first_published_at + t.datetime :last_published_at + t.references :publisher, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_publisher_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_publisher_id' } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_creator_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_listings_updater_id' }, + index: { name: 'fk__course_assessment_marketplace_listings_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_listings, :published, + name: 'index_course_assessment_marketplace_listings_on_published' + end +end diff --git a/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb new file mode 100644 index 0000000000..1f7eee7c3d --- /dev/null +++ b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb @@ -0,0 +1,33 @@ +class CreateCourseAssessmentMarketplaceAdoptions < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_adoptions do |t| + t.references :listing, null: false, + foreign_key: { to_table: :course_assessment_marketplace_listings, + name: 'fk_course_assessment_marketplace_adoptions_listing_id', + on_delete: :cascade }, + index: { name: 'fk__course_assessment_marketplace_adoptions_listing_id' } + t.references :destination_course, null: false, + foreign_key: { to_table: :courses, + name: 'fk_cama_destination_course_id', + on_delete: :cascade }, + index: { name: 'fk__cama_destination_course_id' } + t.references :duplicated_assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_cama_duplicated_assessment_id', + on_delete: :cascade }, + index: { name: 'fk__cama_duplicated_assessment_id', + unique: true } + t.references :creator, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_creator_id' }, + index: { name: 'fk__cama_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, + name: 'fk_course_assessment_marketplace_adoptions_updater_id' }, + index: { name: 'fk__cama_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_adoptions, [:listing_id, :destination_course_id], + name: 'index_cama_on_listing_id_and_destination_course_id' + end +end diff --git a/db/schema.rb b/db/schema.rb index 9af6d94e06..00735f0677 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_01_100000) do +ActiveRecord::Schema[7.2].define(version: 2026_07_07_000002) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -270,6 +270,39 @@ t.index ["question_id"], name: "index_course_assessment_live_feedbacks_on_question_id" end + create_table "course_assessment_marketplace_adoptions", force: :cascade do |t| + t.bigint "listing_id", null: false + t.bigint "destination_course_id", null: false + t.bigint "duplicated_assessment_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "fk__cama_creator_id" + t.index ["destination_course_id"], name: "fk__cama_destination_course_id" + t.index ["duplicated_assessment_id"], name: "fk__cama_duplicated_assessment_id", unique: true + t.index ["listing_id", "destination_course_id"], name: "index_cama_on_listing_id_and_destination_course_id" + t.index ["listing_id"], name: "fk__course_assessment_marketplace_adoptions_listing_id" + t.index ["updater_id"], name: "fk__cama_updater_id" + end + + create_table "course_assessment_marketplace_listings", force: :cascade do |t| + t.bigint "assessment_id", null: false + t.boolean "published", default: false, null: false + t.datetime "first_published_at" + t.datetime "last_published_at" + t.bigint "publisher_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assessment_id"], name: "fk__course_assessment_marketplace_listings_assessment_id", unique: true + t.index ["creator_id"], name: "fk__course_assessment_marketplace_listings_creator_id" + t.index ["published"], name: "index_course_assessment_marketplace_listings_on_published" + t.index ["publisher_id"], name: "fk__course_assessment_marketplace_listings_publisher_id" + t.index ["updater_id"], name: "fk__course_assessment_marketplace_listings_updater_id" + end + create_table "course_assessment_plagiarism_checks", force: :cascade do |t| t.datetime "created_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false @@ -1979,6 +2012,15 @@ add_foreign_key "course_assessment_live_feedbacks", "course_assessment_questions", column: "question_id" add_foreign_key "course_assessment_live_feedbacks", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_live_feedbacks", "users", column: "creator_id" + add_foreign_key "course_assessment_marketplace_adoptions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_course_assessment_marketplace_adoptions_listing_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "course_assessments", column: "duplicated_assessment_id", name: "fk_cama_duplicated_assessment_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "courses", column: "destination_course_id", name: "fk_cama_destination_course_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "creator_id", name: "fk_course_assessment_marketplace_adoptions_creator_id" + add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" + add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" + add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" + add_foreign_key "course_assessment_marketplace_listings", "users", column: "updater_id", name: "fk_course_assessment_marketplace_listings_updater_id" add_foreign_key "course_assessment_plagiarism_checks", "course_assessments", column: "assessment_id", name: "fk_course_assessment_plagiarism_checks_assessment_id" add_foreign_key "course_assessment_plagiarism_checks", "jobs", name: "fk_course_assessment_plagiarism_checks_job_id", on_delete: :nullify add_foreign_key "course_assessment_question_bundle_assignments", "course_assessment_question_bundles", column: "bundle_id" diff --git a/spec/factories/course_assessment_marketplace_adoptions.rb b/spec/factories/course_assessment_marketplace_adoptions.rb new file mode 100644 index 0000000000..912723e5b6 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_adoptions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_adoption, + class: Course::Assessment::Marketplace::Adoption do + listing { association :course_assessment_marketplace_listing } + destination_course { association :course } + duplicated_assessment { association :assessment, course: destination_course } + end +end diff --git a/spec/factories/course_assessment_marketplace_listings.rb b/spec/factories/course_assessment_marketplace_listings.rb new file mode 100644 index 0000000000..6ea0a819ff --- /dev/null +++ b/spec/factories/course_assessment_marketplace_listings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_listing, + class: Course::Assessment::Marketplace::Listing do + transient do + course { nil } + end + assessment { association :assessment, course: course || create(:course) } + publisher { assessment.course.creator } + published { true } + first_published_at { Time.zone.now } + last_published_at { Time.zone.now } + end +end diff --git a/spec/models/course/assessment/marketplace/adoption_spec.rb b/spec/models/course/assessment/marketplace/adoption_spec.rb new file mode 100644 index 0000000000..7b0e9c3848 --- /dev/null +++ b/spec/models/course/assessment/marketplace/adoption_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Adoption, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:listing).class_name('Course::Assessment::Marketplace::Listing') } + it { is_expected.to belong_to(:destination_course).class_name('Course') } + it { is_expected.to belong_to(:duplicated_assessment).class_name('Course::Assessment') } + + it 'validates uniqueness of duplicated_assessment_id' do + existing = create(:course_assessment_marketplace_adoption) + dup = build(:course_assessment_marketplace_adoption, + duplicated_assessment: existing.duplicated_assessment) + expect(dup).not_to be_valid + end + + it 'is destroyed when its duplicated assessment is destroyed (DB cascade)' do + adoption = create(:course_assessment_marketplace_adoption) + adoption.duplicated_assessment.destroy + expect(described_class.exists?(adoption.id)).to be(false) + end + end +end diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb new file mode 100644 index 0000000000..7eeb9a26f3 --- /dev/null +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::Listing, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + it { is_expected.to belong_to(:assessment).class_name('Course::Assessment') } + it { is_expected.to belong_to(:publisher).class_name('User') } + it do + is_expected.to have_many(:adoptions). + class_name('Course::Assessment::Marketplace::Adoption').dependent(:destroy) + end + + describe 'validations' do + subject { build(:course_assessment_marketplace_listing) } + + it { is_expected.to validate_presence_of(:publisher) } + + it 'validates uniqueness of assessment_id' do + existing = create(:course_assessment_marketplace_listing) + dup = build(:course_assessment_marketplace_listing, assessment: existing.assessment) + expect(dup).not_to be_valid + end + end + + describe '.published' do + it 'includes published listings and excludes unpublished ones' do + published = create(:course_assessment_marketplace_listing, published: true) + unpublished = create(:course_assessment_marketplace_listing, published: false) + expect(described_class.published).to include(published) + expect(described_class.published).not_to include(unpublished) + end + end + + describe '#adoption_count' do + subject { create(:course_assessment_marketplace_listing) } + + it 'counts distinct destination courses' do + course_a = create(:course) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: course_a) + create(:course_assessment_marketplace_adoption, listing: subject, destination_course: create(:course)) + expect(subject.adoption_count).to eq(2) + end + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb new file mode 100644 index 0000000000..1dabaf126c --- /dev/null +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace, type: :model do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:listing) { create(:course_assessment_marketplace_listing, published: true) } + let(:published_assessment) { listing.assessment } + + subject { Ability.new(user, course, course_user) } + + context 'when the user is a system administrator' do + let(:user) { create(:administrator) } + let(:course_user) { nil } + it { is_expected.to be_able_to(:publish_to_marketplace, build(:assessment)) } + end + + context 'when the user is a course manager' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + it { is_expected.to be_able_to(:access_marketplace, course) } + it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } + it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + + it 'cannot duplicate/preview an unpublished listing' do + unpublished = create(:course_assessment_marketplace_listing, published: false).assessment + expect(subject).not_to be_able_to(:duplicate_from_marketplace, unpublished) + expect(subject).not_to be_able_to(:preview_in_marketplace, unpublished) + end + end + + context 'when the user is a course student' do + let(:course_user) { create(:course_student, course: course) } + let(:user) { course_user.user } + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + end +end From a805dc13a2cec81942b1db4a988ef5d1936bdbdc Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 8 Jul 2026 09:36:20 +0800 Subject: [PATCH 02/30] feat(marketplace): admin publish control for assessments - add publish/remove listing endpoints (admin-gated create/destroy) - expose canPublishToMarketplace + listing state on assessment show DTO - add Publish/Remove to Marketplace button on the assessment header - warn in the delete Prompt when a listed assessment is removed - add MarketplaceAPI client, translations, and controller/FE specs --- .../marketplace_listings_controller.rb | 44 +++++++++ .../assessment/assessments/show.json.jbuilder | 4 + client/app/api/course/Marketplace.ts | 21 +++++ client/app/api/course/index.js | 2 + .../AssessmentShow/AssessmentShowHeader.tsx | 18 ++++ .../__test__/AssessmentShowHeader.test.tsx | 90 ++++++++++++++++++ .../components/PublishToMarketplaceButton.tsx | 90 ++++++++++++++++++ .../PublishToMarketplaceButton.test.tsx | 93 +++++++++++++++++++ .../course/marketplace/translations.ts | 51 ++++++++++ .../types/course/assessment/assessments.ts | 3 + client/locales/en.json | 33 +++++++ client/locales/ko.json | 33 +++++++ client/locales/zh.json | 33 +++++++ config/routes.rb | 2 + .../assessments_marketplace_spec.rb | 43 +++++++++ .../marketplace_listings_controller_spec.rb | 84 +++++++++++++++++ 16 files changed, 644 insertions(+) create mode 100644 app/controllers/course/assessment/marketplace_listings_controller.rb create mode 100644 client/app/api/course/Marketplace.ts create mode 100644 client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx create mode 100644 client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx create mode 100644 client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx create mode 100644 client/app/bundles/course/marketplace/translations.ts create mode 100644 spec/controllers/course/assessment/assessments_marketplace_spec.rb create mode 100644 spec/controllers/course/assessment/marketplace_listings_controller_spec.rb diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb new file mode 100644 index 0000000000..a05ec766cb --- /dev/null +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true +class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller + before_action :authorize_publish_to_marketplace! + + def create + listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(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 + end + + def destroy + listing = @assessment.marketplace_listing + if listing&.update(published: false) + head :ok + else + head :unprocessable_content + end + end + + private + + # Publishing is admin-only. `authorize!(:publish_to_marketplace, @assessment)` alone is + # insufficient: teaching staff hold `can :manage, Course::Assessment` over their own course's + # assessments (assessment_ability.rb:189), and CanCan's `:manage` wildcard subsumes every + # custom action — including `:publish_to_marketplace`. Gate explicitly on administrator status. + def authorize_publish_to_marketplace! + authorize!(:publish_to_marketplace, @assessment) + raise CanCan::AccessDenied unless current_user&.administrator? + end + + def component + current_component_host[:course_assessments_component] + end +end diff --git a/app/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index d22f4b56bf..b801cc4355 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -77,8 +77,12 @@ 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) end +json.isPublishedToMarketplace @assessment.marketplace_listing&.published? || false +json.marketplaceListingUrl course_assessment_marketplace_listing_path(current_course, @assessment) + 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/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts new file mode 100644 index 0000000000..d06a224111 --- /dev/null +++ b/client/app/api/course/Marketplace.ts @@ -0,0 +1,21 @@ +import { AxiosResponse } from 'axios'; + +import BaseCourseAPI from './Base'; + +export default class MarketplaceAPI extends BaseCourseAPI { + get #urlPrefix(): string { + return `/courses/${this.courseId}/marketplace`; + } + + publishListing(assessmentId: number): Promise { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } + + removeListing(assessmentId: number): Promise { + return this.client.delete( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, + ); + } +} diff --git a/client/app/api/course/index.js b/client/app/api/course/index.js index 355a5878c5..8087e014bc 100644 --- a/client/app/api/course/index.js +++ b/client/app/api/course/index.js @@ -18,6 +18,7 @@ import LeaderboardAPI from './Leaderboard'; import LearningMapAPI from './LearningMap'; import LessonPlanAPI from './LessonPlan'; import LevelAPI from './Level'; +import MarketplaceAPI from './Marketplace'; import MaterialFoldersAPI from './MaterialFolders'; import MaterialsAPI from './Materials'; import PersonalTimesAPI from './PersonalTimes'; @@ -55,6 +56,7 @@ const CourseAPI = { learningMap: new LearningMapAPI(), lessonPlan: new LessonPlanAPI(), level: new LevelAPI(), + marketplace: new MarketplaceAPI(), materials: new MaterialsAPI(), materialFolders: new MaterialFoldersAPI(), personalTimes: new PersonalTimesAPI(), diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx index 3d9b1be88a..0837787ab8 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowHeader.tsx @@ -13,6 +13,8 @@ import { AssessmentDeleteResult, } from 'types/course/assessment/assessments'; +import PublishToMarketplaceButton from 'course/marketplace/components/PublishToMarketplaceButton'; +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'; @@ -37,6 +39,9 @@ const AssessmentShowHeader = ( const { t } = useTranslation(); const [deleting, setDeleting] = useState(false); const [inviting, setInviting] = useState(false); + const [publishedToMarketplace, setPublishedToMarketplace] = useState( + assessment.isPublishedToMarketplace, + ); const navigate = useNavigate(); const handleDelete = (): Promise => { @@ -75,6 +80,9 @@ const AssessmentShowHeader = ( {t(translations.deletingThisAssessment)} {assessment.title} {t(translations.deleteAssessmentWarning)} + {publishedToMarketplace && ( + {t(marketplaceTranslations.deleteWarning)} + )} )} @@ -146,6 +154,16 @@ const AssessmentShowHeader = ( )} + {assessment.permissions.canPublishToMarketplace && ( + + )} + {assessment.actionButtonUrl && ( mock.reset()); + +// Minimal AssessmentData: only `deleteUrl` + `title` are needed for the delete +// Prompt to render (see AssessmentShowHeader.tsx:71 / DeleteButton.tsx). All other +// action buttons stay hidden by leaving their URLs undefined, and the publish +// button stays hidden via `canPublishToMarketplace: false`. +const baseAssessment = { + id: 1, + title: 'Sample Assessment', + deleteUrl: '/courses/1/assessments/1', + status: 'open', + permissions: { + canAttempt: false, + canManage: true, + canObserve: true, + canInviteToKoditsu: false, + canPublishToMarketplace: false, + }, + isPublishedToMarketplace: false, +}; + +// 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; + +describe('', () => { + it('warns that deletion removes the marketplace listing 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 + expect(page.getByText(MARKETPLACE_WARNING)).toBeVisible(); + }); + + 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 + expect(page.queryByText(MARKETPLACE_WARNING)).not.toBeInTheDocument(); + }); + + it('warns after the assessment is published in the same session', async () => { + mock + .onPost(`/courses/${global.courseId}/assessments/1/marketplace_listing`) + .reply(200, { published: true }); + + const page = render( + , + ); + + fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button + const publishPrompt = await page.findByRole('dialog'); + fireEvent.click( + within(publishPrompt).getByRole('button', { + name: /Publish to Marketplace/, + }), + ); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + // The warning reads the live state, not the initial `isPublishedToMarketplace` prop. + fireEvent.click(page.getByLabelText('Delete Assessment')); + expect(await page.findByText(MARKETPLACE_WARNING)).toBeVisible(); + }); +}); diff --git a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx new file mode 100644 index 0000000000..05a2c069bf --- /dev/null +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; +import { Button } from '@mui/material'; +import { AssessmentData } from 'types/course/assessment/assessments'; + +import CourseAPI from 'api/course'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import toast from 'lib/hooks/toast'; + +import translations from '../translations'; + +interface Props { + assessment: Pick< + AssessmentData, + 'id' | 'isPublishedToMarketplace' | 'permissions' + >; + onChange: (published: boolean) => void; +} + +const PublishToMarketplaceButton = ({ + assessment, + onChange, +}: Props): JSX.Element | null => { + const { formatMessage: t } = useIntl(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const listed = assessment.isPublishedToMarketplace; + + if (!assessment.permissions.canPublishToMarketplace) return null; + + // `Prompt`'s primary button does not await this handler, so every rejection must be caught + // here: an uncaught one surfaces nothing to the user and becomes an unhandled rejection. + const confirm = async (): Promise => { + setSubmitting(true); + try { + if (listed) { + await CourseAPI.marketplace.removeListing(assessment.id); + toast.success(t(translations.removed)); + onChange(false); + } else { + await CourseAPI.marketplace.publishListing(assessment.id); + toast.success(t(translations.published)); + onChange(true); + } + setOpen(false); + } catch { + // Dialog stays open so the user can retry. + toast.error( + t(listed ? translations.removeFailed : translations.publishFailed), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + setOpen(false)} + open={open} + primaryColor={listed ? 'error' : 'primary'} + primaryLabel={t(listed ? translations.remove : translations.publish)} + title={t( + listed + ? translations.removeConfirmTitle + : translations.publishConfirmTitle, + )} + > + + {t( + listed + ? translations.removeConfirmBody + : translations.publishConfirmBody, + )} + + + + ); +}; + +export default PublishToMarketplaceButton; diff --git a/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx new file mode 100644 index 0000000000..3639f497ae --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx @@ -0,0 +1,93 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import PublishToMarketplaceButton from '../PublishToMarketplaceButton'; + +const confirmInDialog = async ( + page: ReturnType, + name: RegExp, +): Promise => { + const dialog = await page.findByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name })); +}; + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +const assessmentAt = ( + isPublishedToMarketplace: boolean, + canPublishToMarketplace = true, +): never => + ({ + id: 5, + isPublishedToMarketplace, + permissions: { canPublishToMarketplace }, + }) as never; + +const url = `/courses/${global.courseId}/assessments/5/marketplace_listing`; + +it('renders nothing when the user cannot publish', () => { + const page = render( + , + ); + expect(page.queryByText('Publish to Marketplace')).not.toBeInTheDocument(); + expect(page.queryByText('Remove from Marketplace')).not.toBeInTheDocument(); +}); + +it('publishes after confirming and reports published=true', async () => { + mock.onPost(url).reply(200, { published: true }); + const onChange = jest.fn(); + const page = render( + , + ); + + // findByText: test-utils wraps the tree in a translations Suspense whose fallback is a + // LoadingIndicator; the trigger button only exists after messages resolve. + fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button + await confirmInDialog(page, /Publish to Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(true); +}); + +it('removes after confirming when already listed, reports published=false', async () => { + mock.onDelete(url).reply(200); + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByText('Remove from Marketplace')); // trigger button + await confirmInDialog(page, /Remove from Marketplace/); // primary button inside the Prompt + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(onChange).toHaveBeenCalledWith(false); +}); + +it('surfaces an error and keeps the dialog open when publishing fails', async () => { + mock.onPost(url).reply(422, { errors: ['nope'] }); + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByText('Publish to Marketplace')); + await confirmInDialog(page, /Publish to Marketplace/); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + + expect(await page.findByText(/Failed to publish/i)).toBeVisible(); // error toast + expect(page.getByRole('dialog')).toBeVisible(); // still open, so the user can retry + expect(onChange).not.toHaveBeenCalled(); +}); diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts new file mode 100644 index 0000000000..3f3125004f --- /dev/null +++ b/client/app/bundles/course/marketplace/translations.ts @@ -0,0 +1,51 @@ +import { defineMessages } from 'react-intl'; + +export default defineMessages({ + publish: { + id: 'course.marketplace.publish', + defaultMessage: 'Publish to Marketplace', + }, + remove: { + id: 'course.marketplace.remove', + defaultMessage: 'Remove from Marketplace', + }, + publishConfirmTitle: { + id: 'course.marketplace.publishConfirmTitle', + defaultMessage: 'Publish to Marketplace?', + }, + publishConfirmBody: { + id: 'course.marketplace.publishConfirmBody', + defaultMessage: + 'This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description.', + }, + removeConfirmTitle: { + id: 'course.marketplace.removeConfirmTitle', + defaultMessage: 'Remove from Marketplace?', + }, + removeConfirmBody: { + id: 'course.marketplace.removeConfirmBody', + defaultMessage: + 'It will no longer appear in the marketplace. Existing copies are unaffected.', + }, + published: { + id: 'course.marketplace.publishedToast', + defaultMessage: 'Published to the marketplace.', + }, + removed: { + id: 'course.marketplace.removedToast', + defaultMessage: 'Removed from the marketplace.', + }, + publishFailed: { + id: 'course.marketplace.publishFailedToast', + defaultMessage: 'Failed to publish to the marketplace. Please try again.', + }, + removeFailed: { + id: 'course.marketplace.removeFailedToast', + defaultMessage: 'Failed to remove from the marketplace. Please try again.', + }, + deleteWarning: { + id: 'course.marketplace.deleteWarning', + defaultMessage: + 'This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected.', + }, +}); diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 1fcdcb6631..f23d634b07 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -106,7 +106,10 @@ export interface AssessmentData extends AssessmentActionsData { canManage: boolean; canObserve: boolean; canInviteToKoditsu: boolean; + canPublishToMarketplace: boolean; }; + isPublishedToMarketplace: boolean; + marketplaceListingUrl: string; requirements: { title: string; satisfied?: boolean; diff --git a/client/locales/en.json b/client/locales/en.json index a655ccd405..014a14d4f6 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6062,6 +6062,39 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "Experience points threshold cannot be 0" }, + "course.marketplace.publish": { + "defaultMessage": "Publish to Marketplace" + }, + "course.marketplace.remove": { + "defaultMessage": "Remove from Marketplace" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "Publish to Marketplace?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "Remove from Marketplace?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "It will no longer appear in the marketplace. Existing copies are unaffected." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "Published to the marketplace." + }, + "course.marketplace.removedToast": { + "defaultMessage": "Removed from the marketplace." + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "Failed to publish to the marketplace. Please try again." + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "Failed to remove from the marketplace. Please try again." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 34fa37efc3..e44cce3520 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6026,6 +6026,39 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "경험치 기준은 0이 될 수 없습니다" }, + "course.marketplace.publish": { + "defaultMessage": "마켓플레이스에 게시" + }, + "course.marketplace.remove": { + "defaultMessage": "마켓플레이스에서 제거" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "마켓플레이스에 게시하시겠습니까?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "이 평가는 강좌 관리자가 찾아볼 수 있으며, 미리보기 및 복제할 수 있습니다. 이 평가의 자체 제목과 설명이 사용됩니다." + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "마켓플레이스에서 제거하시겠습니까?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "더 이상 마켓플레이스에 표시되지 않습니다. 기존 복사본은 영향을 받지 않습니다." + }, + "course.marketplace.publishedToast": { + "defaultMessage": "마켓플레이스에 게시되었습니다." + }, + "course.marketplace.removedToast": { + "defaultMessage": "마켓플레이스에서 제거되었습니다." + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "마켓플레이스에 게시하지 못했습니다. 다시 시도해 주세요." + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "마켓플레이스에서 제거하지 못했습니다. 다시 시도해 주세요." + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "이 평가는 평가 마켓플레이스에 있습니다. 삭제하면 마켓플레이스에서 제거되고 채택 기록도 삭제됩니다. 다른 강좌의 기존 복사본은 영향을 받지 않습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 42e4ee8703..e8020d3d11 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6020,6 +6020,39 @@ "course.level.LevelRow.zeroThresholdError": { "defaultMessage": "经验值阈值不能为0" }, + "course.marketplace.publish": { + "defaultMessage": "发布到市场" + }, + "course.marketplace.remove": { + "defaultMessage": "从市场移除" + }, + "course.marketplace.publishConfirmTitle": { + "defaultMessage": "发布到市场?" + }, + "course.marketplace.publishConfirmBody": { + "defaultMessage": "课程管理员可以浏览此评估,并可预览和复制它。它会使用此评估自身的标题和描述。" + }, + "course.marketplace.removeConfirmTitle": { + "defaultMessage": "从市场移除?" + }, + "course.marketplace.removeConfirmBody": { + "defaultMessage": "它将不再显示在市场中。现有副本不受影响。" + }, + "course.marketplace.publishedToast": { + "defaultMessage": "已发布到市场。" + }, + "course.marketplace.removedToast": { + "defaultMessage": "已从市场移除。" + }, + "course.marketplace.publishFailedToast": { + "defaultMessage": "发布到市场失败,请重试。" + }, + "course.marketplace.removeFailedToast": { + "defaultMessage": "从市场移除失败,请重试。" + }, + "course.marketplace.deleteWarning": { + "defaultMessage": "此评估位于评估市场中。删除它会将其从市场移除,并删除其采用历史记录。其他课程中的现有副本不受影响。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, diff --git a/config/routes.rb b/config/routes.rb index a65da4a24d..bf673706ae 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -287,6 +287,8 @@ resources :mock_answers, on: :member, only: [:index, :create, :update, :destroy] end + resource :marketplace_listing, only: [:create, :destroy] + namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do post :generate, on: :collection diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb new file mode 100644 index 0000000000..8c0b966a9a --- /dev/null +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::AssessmentsController, type: :controller do + render_views + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + describe 'GET #show — marketplace fields' do + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'grants the publish permission and reports not-yet-published' do + get :show, as: :json, params: { course_id: course, id: assessment } + body = JSON.parse(response.body) + expect(body['permissions']).to include('canPublishToMarketplace' => true) + expect(body).to include('isPublishedToMarketplace' => false) + expect(body['marketplaceListingUrl']).to be_present + end + + it 'reports isPublishedToMarketplace true once a published listing exists' do + create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)).to include('isPublishedToMarketplace' => true) + end + end + + context 'as a course manager (non-admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + + it 'withholds the publish permission' do + get :show, as: :json, params: { course_id: course, id: assessment } + expect(JSON.parse(response.body)['permissions']).to include('canPublishToMarketplace' => false) + end + end + end + end +end diff --git a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb new file mode 100644 index 0000000000..2f261b11b0 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::MarketplaceListingsController, type: :controller do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + subject { post :create, params: { course_id: course, assessment_id: assessment, format: :json } } + + it 'creates a published listing' do + expect { subject }.to change { Course::Assessment::Marketplace::Listing.count }.by(1) + listing = assessment.reload.marketplace_listing + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_present + expect(listing.last_published_at).to be_present + expect(listing.publisher).to eq(admin) + end + + context 'when the assessment was previously published then removed (re-publish)' do + let!(:listing) do + create(:course_assessment_marketplace_listing, assessment: assessment, published: false, + first_published_at: 3.days.ago, last_published_at: 3.days.ago) + end + + it 'reuses the existing row, preserves first_published_at, bumps last_published_at' do + original_first = listing.first_published_at + expect { subject }.not_to(change { Course::Assessment::Marketplace::Listing.count }) + listing.reload + expect(listing.published).to be(true) + expect(listing.first_published_at).to be_within(1.second).of(original_first) # NOT overwritten + expect(listing.last_published_at).to be > original_first # bumped to now + end + + it 'stamps the re-publishing admin as the publisher' do + expect(listing.publisher).not_to eq(admin) # factory publisher: the course creator + subject + expect(listing.reload.publisher).to eq(admin) # moves with last_published_at + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it { expect { subject }.to raise_exception(CanCan::AccessDenied) } + end + end + + describe 'DELETE #destroy' do + let!(:listing) { create(:course_assessment_marketplace_listing, assessment: assessment, published: true) } + + it 'soft-removes: keeps the row, sets published false' do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + expect(listing.reload.published).to be(false) + expect(Course::Assessment::Marketplace::Listing.exists?(listing.id)).to be(true) + end + + context 'when the assessment has no marketplace listing' do + let(:unlisted_assessment) { create(:assessment, course: course) } + + it 'responds unprocessable' do + delete :destroy, params: { course_id: course, assessment_id: unlisted_assessment, format: :json } + expect(response).to have_http_status(:unprocessable_content) + end + end + + context 'when the user is a course manager (can read but not an admin)' do + let(:manager) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, manager) } + it 'is forbidden and leaves the listing published' do + expect do + delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } + end.to raise_exception(CanCan::AccessDenied) + expect(listing.reload.published).to be(true) + end + end + end + end +end From 6c42381af4e684937885161a5535650cecf15740 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 8 Jul 2026 11:58:29 +0800 Subject: [PATCH 03/30] feat(marketplace): cross-instance browse page + entry points - add cross-instance listings index (published only, live counts) - add browse page with title search, adoptions/newest sort, row select - add sidebar admin entry + /courses/:id/marketplace route - add "Import Assessments" button on assessments index (from_tab) - add FE api/operations/types, controller + component specs --- .../assessment_marketplace_component.rb | 18 ++++ .../components/course/gradebook_component.rb | 4 - .../assessment/marketplace/controller.rb | 8 ++ .../marketplace/listings_controller.rb | 27 +++++ .../marketplace/listings/index.json.jbuilder | 13 +++ client/app/api/course/Marketplace.ts | 8 ++ .../ImportAssessmentsButton.tsx | 27 +++++ .../__test__/ImportAssessmentsButton.test.tsx | 19 ++++ .../pages/AssessmentsIndex/index.tsx | 29 +++-- .../bundles/course/assessment/translations.ts | 4 + .../bundles/course/marketplace/operations.ts | 8 ++ .../MarketplaceIndex/MarketplaceTable.tsx | 102 ++++++++++++++++++ .../MarketplaceIndex/__test__/index.test.tsx | 83 ++++++++++++++ .../pages/MarketplaceIndex/index.tsx | 28 +++++ .../course/marketplace/translations.ts | 36 +++++++ .../app/bundles/course/marketplace/types.ts | 10 ++ client/app/bundles/course/translations.ts | 8 ++ client/app/lib/constants/icons.ts | 3 + client/app/routers/course/index.tsx | 2 + client/app/routers/course/marketplace.tsx | 18 ++++ client/locales/en.json | 42 ++++++++ client/locales/ko.json | 42 ++++++++ client/locales/zh.json | 42 ++++++++ config/routes.rb | 7 ++ .../marketplace/listings_controller_spec.rb | 74 +++++++++++++ .../assessment_marketplace_component_spec.rb | 37 +++++++ 26 files changed, 684 insertions(+), 15 deletions(-) create mode 100644 app/controllers/components/course/assessment_marketplace_component.rb create mode 100644 app/controllers/course/assessment/marketplace/controller.rb create mode 100644 app/controllers/course/assessment/marketplace/listings_controller.rb create mode 100644 app/views/course/assessment/marketplace/listings/index.json.jbuilder create mode 100644 client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx create mode 100644 client/app/bundles/course/marketplace/operations.ts create mode 100644 client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx create mode 100644 client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx create mode 100644 client/app/bundles/course/marketplace/types.ts create mode 100644 client/app/routers/course/marketplace.tsx create mode 100644 spec/controllers/course/assessment/marketplace/listings_controller_spec.rb create mode 100644 spec/controllers/course/assessment_marketplace_component_spec.rb diff --git a/app/controllers/components/course/assessment_marketplace_component.rb b/app/controllers/components/course/assessment_marketplace_component.rb new file mode 100644 index 0000000000..a956490f46 --- /dev/null +++ b/app/controllers/components/course/assessment_marketplace_component.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true +class Course::AssessmentMarketplaceComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def sidebar_items + return [] unless can?(:access_marketplace, current_course) + + [ + { + key: :admin_marketplace, + icon: :marketplace, + type: :admin, + weight: 6, + path: course_marketplace_path(current_course) + } + ] + end +end diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb index a54d4dae4f..3df1e5254a 100644 --- a/app/controllers/components/course/gradebook_component.rb +++ b/app/controllers/components/course/gradebook_component.rb @@ -2,10 +2,6 @@ class Course::GradebookComponent < SimpleDelegator include Course::ControllerComponentHost::Component - def self.display_name - 'Gradebook' - end - def sidebar_items main_sidebar_items + settings_sidebar_items end diff --git a/app/controllers/course/assessment/marketplace/controller.rb b/app/controllers/course/assessment/marketplace/controller.rb new file mode 100644 index 0000000000..b617d93833 --- /dev/null +++ b/app/controllers/course/assessment/marketplace/controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::Controller < Course::ComponentController + private + + def component + current_component_host[:course_assessment_marketplace_component] + end +end diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb new file mode 100644 index 0000000000..6f144d7b99 --- /dev/null +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::ListingsController < Course::Assessment::Marketplace::Controller + before_action :authorize_access! + + def index + ActsAsTenant.without_tenant do + @listings = Course::Assessment::Marketplace::Listing.published.includes(:assessment).to_a + listing_ids = @listings.map(&:id) + assessment_ids = @listings.map(&:assessment_id) + @adoption_counts = Course::Assessment::Marketplace::Adoption. + where(listing_id: listing_ids).group(:listing_id). + distinct.count(:destination_course_id) + # reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it + # the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is + # neither grouped nor aggregated). + @question_counts = Course::QuestionAssessment. + where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id). + distinct.count(:question_id) + end + end + + private + + def authorize_access! + authorize!(:access_marketplace, current_course) + end +end diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder new file mode 100644 index 0000000000..33ea65ec1e --- /dev/null +++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder @@ -0,0 +1,13 @@ +# frozen_string_literal: true +json.canAccess true +json.listings @listings do |listing| + assessment = listing.assessment + json.id listing.id + json.assessmentId assessment.id + json.title assessment.title + json.questionCount(@question_counts[assessment.id] || 0) + json.adoptions(@adoption_counts[listing.id] || 0) + json.firstPublishedAt listing.first_published_at + json.previewUrl course_listing_path(current_course, listing) + json.duplicateUrl duplicate_course_listings_path(current_course) +end diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index d06a224111..4feade4a09 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -1,5 +1,7 @@ import { AxiosResponse } from 'axios'; +import { MarketplaceListing } from 'course/marketplace/types'; + import BaseCourseAPI from './Base'; export default class MarketplaceAPI extends BaseCourseAPI { @@ -18,4 +20,10 @@ export default class MarketplaceAPI extends BaseCourseAPI { `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`, ); } + + index(): Promise< + AxiosResponse<{ listings: MarketplaceListing[]; canAccess: boolean }> + > { + return this.client.get(this.#urlPrefix); + } } diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx new file mode 100644 index 0000000000..a0a78741b2 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx @@ -0,0 +1,27 @@ +import { Button } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import useTranslation from 'lib/hooks/useTranslation'; + +import translations from '../../translations'; + +interface Props { + canImport: boolean; + tabId: number; +} + +const ImportAssessmentsButton = ({ + canImport, + tabId, +}: Props): JSX.Element | null => { + const { t } = useTranslation(); + if (!canImport) return null; + + return ( + + + + ); +}; + +export default ImportAssessmentsButton; diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx new file mode 100644 index 0000000000..413b2b7527 --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx @@ -0,0 +1,19 @@ +import { render } from 'test-utils'; + +import ImportAssessmentsButton from '../ImportAssessmentsButton'; + +it('links to the marketplace with the given tab as from_tab when the user can import', async () => { + const page = render(); + const link = await page.findByRole('link', { name: 'Import Assessments' }); + expect(link).toHaveAttribute( + 'href', + expect.stringContaining('/marketplace?from_tab=42'), + ); +}); + +it('renders nothing when the user cannot import', () => { + const page = render(); + expect( + page.queryByRole('link', { name: 'Import Assessments' }), + ).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx index fae862370a..82a28038ba 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx @@ -9,6 +9,7 @@ import Preload from 'lib/components/wrappers/Preload'; import { fetchAssessments } from '../../operations/assessments'; import AssessmentsTable from './AssessmentsTable'; +import ImportAssessmentsButton from './ImportAssessmentsButton'; import NewAssessmentFormButton from './NewAssessmentFormButton'; const AssessmentsIndex = (): JSX.Element => { @@ -30,17 +31,23 @@ const AssessmentsIndex = (): JSX.Element => { + <> + + + ) } title={data.display.category.title} diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts index 4a6e92ebf8..eddd80ac53 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -2052,6 +2052,10 @@ const translations = defineMessages({ id: 'course.assessment.question.programming.liveFeedbackNotSupported', defaultMessage: 'Get Help is not supported for {languageName}.', }, + importAssessments: { + id: 'course.assessment.AssessmentsIndex.importAssessments', + defaultMessage: 'Import Assessments', + }, }); export default translations; diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts new file mode 100644 index 0000000000..8023024d8c --- /dev/null +++ b/client/app/bundles/course/marketplace/operations.ts @@ -0,0 +1,8 @@ +import CourseAPI from 'api/course'; + +import { MarketplaceListing } from './types'; + +export const fetchListings = async (): Promise => { + const response = await CourseAPI.marketplace.index(); + return response.data.listings as MarketplaceListing[]; +}; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx new file mode 100644 index 0000000000..5cabe2e1d9 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx @@ -0,0 +1,102 @@ +import { useMemo, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { MenuItem, TextField } from '@mui/material'; + +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; + +import translations from '../../translations'; +import { MarketplaceListing } from '../../types'; + +type SortMode = 'adoptions' | 'newest'; + +interface Props { + listings: MarketplaceListing[]; + onDuplicate: (rows: MarketplaceListing[]) => void; +} + +const MarketplaceTable = ({ listings, onDuplicate }: Props): JSX.Element => { + const { formatMessage: t } = useIntl(); + const [sortMode, setSortMode] = useState('adoptions'); + + const sorted = useMemo(() => { + const copy = [...listings]; + if (sortMode === 'newest') { + copy.sort((a, b) => + (b.firstPublishedAt ?? '').localeCompare(a.firstPublishedAt ?? ''), + ); + } else { + copy.sort((a, b) => b.adoptions - a.adoptions); + } + return copy; + }, [listings, sortMode]); + + const columns: ColumnTemplate[] = [ + { + of: 'title', + title: t(translations.colTitle), + searchable: true, + cell: (l) => l.title, + }, + { + of: 'questionCount', + title: t(translations.colQuestions), + cell: (l) => l.questionCount, + }, + { + of: 'adoptions', + title: t(translations.colAdoptions), + cell: (l) => l.adoptions, + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (l) => ( + <> + + {t(translations.preview)} + + {/* Row-level Duplicate button wired in Task 18 */} + + ), + }, + ]; + + return ( + <> + setSortMode(e.target.value as SortMode)} + select + size="small" + value={sortMode} + > + {t(translations.sortMostAdopted)} + {t(translations.sortNewest)} + + l.id.toString()} + indexing={{ rowSelectable: true }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (l, filter): boolean => + !filter || l.title.toLowerCase().includes(filter.toLowerCase()), + }, + }} + toolbar={{ + show: true, + activeToolbar: (rows) => ( + + ), + }} + /> + + ); +}; + +export default MarketplaceTable; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx new file mode 100644 index 0000000000..2d1940b9d9 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx @@ -0,0 +1,83 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import MarketplaceIndex from '../index'; + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +// Fixture chosen so the two sort keys DISAGREE: Graph Theory is most-adopted but oldest; +// Recursion Drills is fewer adoptions but newest. This lets the sort tests prove the mode +// actually changes order rather than passing on a coincidental tie. +const LISTINGS = [ + { + id: 1, + assessmentId: 10, + title: 'Recursion Drills', + questionCount: 8, + adoptions: 5, + firstPublishedAt: '2026-06-01T00:00:00Z', + previewUrl: '/p/1', + duplicateUrl: '/d', + }, + { + id: 2, + assessmentId: 11, + title: 'Graph Theory', + questionCount: 3, + adoptions: 12, + firstPublishedAt: '2026-01-01T00:00:00Z', + previewUrl: '/p/2', + duplicateUrl: '/d', + }, +]; + +const url = `/courses/${global.courseId}/marketplace`; +const renderPage = async (page): Promise => { + await waitFor(() => expect(page.getByText('Graph Theory')).toBeVisible()); +}; + +it('renders published listings sorted by most adopted by default', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + const rows = page.getAllByRole('row'); + // Graph Theory (12 adoptions) precedes Recursion Drills (5) by default. + expect(rows[1]).toHaveTextContent('Graph Theory'); +}); + +it('re-sorts by newest when the sort mode changes', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + // Open the MUI "Sort by" select and choose Newest. + // NOTE (executor): confirm the exact idiom for driving a MUI `select`-mode TextField + // against an existing table test (e.g. mouseDown the combobox, then click the option). + fireEvent.mouseDown(page.getByLabelText('Sort by')); + fireEvent.click(page.getByRole('option', { name: 'Newest' })); + + await waitFor(() => { + const rows = page.getAllByRole('row'); + // Recursion Drills (2026-06) is newest and must now lead. + expect(rows[1]).toHaveTextContent('Recursion Drills'); + }); +}); + +it('filters rows by the title search', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [url] }); + await renderPage(page); + + // Search field must be driven with userEvent (React 18 startTransition) — see client/CLAUDE-testing.md. + await userEvent.type(page.getByPlaceholderText('Search by title'), 'Graph'); + + await waitFor(() => + expect(page.queryByText('Recursion Drills')).not.toBeInTheDocument(), + ); + expect(page.getByText('Graph Theory')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx new file mode 100644 index 0000000000..8078f4f03f --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -0,0 +1,28 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; + +import Page from 'lib/components/core/layouts/Page'; +import Preload from 'lib/components/wrappers/Preload'; + +import { fetchListings } from '../../operations'; +import translations from '../../translations'; +import { MarketplaceListing } from '../../types'; + +import MarketplaceTable from './MarketplaceTable'; + +const MarketplaceIndex = (): JSX.Element => { + const { formatMessage: t } = useIntl(); + const [pending, setPending] = useState([]); + + return ( + } while={fetchListings}> + {(listings): JSX.Element => ( + + + + )} + + ); +}; + +export default MarketplaceIndex; diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index 3f3125004f..a613747fbb 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -48,4 +48,40 @@ export default defineMessages({ defaultMessage: 'This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected.', }, + pageTitle: { + id: 'course.marketplace.pageTitle', + defaultMessage: 'Assessment Marketplace', + }, + colTitle: { id: 'course.marketplace.colTitle', defaultMessage: 'Title' }, + colQuestions: { + id: 'course.marketplace.colQuestions', + defaultMessage: 'Questions', + }, + colAdoptions: { + id: 'course.marketplace.colAdoptions', + defaultMessage: 'Adoptions', + }, + colActions: { + id: 'course.marketplace.colActions', + defaultMessage: 'Actions', + }, + preview: { + id: 'course.marketplace.previewAction', + defaultMessage: 'Preview', + }, + searchPlaceholder: { + id: 'course.marketplace.searchPlaceholder', + defaultMessage: 'Search by title', + }, + sortLabel: { id: 'course.marketplace.sortLabel', defaultMessage: 'Sort by' }, + sortMostAdopted: { + id: 'course.marketplace.sortMostAdopted', + defaultMessage: 'Most adopted', + }, + sortNewest: { id: 'course.marketplace.sortNewest', defaultMessage: 'Newest' }, + duplicateN: { + id: 'course.marketplace.duplicateN', + defaultMessage: + '{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}', + }, }); diff --git a/client/app/bundles/course/marketplace/types.ts b/client/app/bundles/course/marketplace/types.ts new file mode 100644 index 0000000000..58053dc1d8 --- /dev/null +++ b/client/app/bundles/course/marketplace/types.ts @@ -0,0 +1,10 @@ +export interface MarketplaceListing { + id: number; + assessmentId: number; + title: string; + questionCount: number; + adoptions: number; + firstPublishedAt: string | null; + previewUrl: string; + duplicateUrl: string; +} diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts index c52ce35907..f5af35441c 100644 --- a/client/app/bundles/course/translations.ts +++ b/client/app/bundles/course/translations.ts @@ -51,6 +51,14 @@ const translations = defineMessages({ id: 'course.componentTitles.course_announcements_component', defaultMessage: 'Announcements', }, + course_assessment_marketplace_component: { + id: 'course.componentTitles.course_assessment_marketplace_component', + defaultMessage: 'Assessment Marketplace', + }, + admin_marketplace: { + id: 'course.courses.SidebarItem.admin.marketplace', + defaultMessage: 'Assessment Marketplace', + }, course_assessments_component: { id: 'course.componentTitles.course_assessments_component', defaultMessage: 'Assessments', diff --git a/client/app/lib/constants/icons.ts b/client/app/lib/constants/icons.ts index 9c1d328e12..b29ab3d3a9 100644 --- a/client/app/lib/constants/icons.ts +++ b/client/app/lib/constants/icons.ts @@ -49,6 +49,8 @@ import { StairsOutlined, Star, StarOutline, + Storefront, + StorefrontOutlined, SvgIconComponent, TableChart, TableChartOutlined, @@ -84,6 +86,7 @@ export const COURSE_COMPONENT_ICONS = { statistics: { outlined: InsertChartOutlined, filled: InsertChart }, experience: { outlined: StarOutline, filled: Star }, duplication: { outlined: FileCopyOutlined, filled: FileCopy }, + marketplace: { outlined: StorefrontOutlined, filled: Storefront }, levels: { outlined: StairsOutlined, filled: Stairs }, groups: { outlined: GroupsOutlined, filled: Groups }, skills: { outlined: OfflineBoltOutlined, filled: OfflineBolt }, diff --git a/client/app/routers/course/index.tsx b/client/app/routers/course/index.tsx index 13bc90b1ac..0abdd08ba4 100644 --- a/client/app/routers/course/index.tsx +++ b/client/app/routers/course/index.tsx @@ -10,6 +10,7 @@ import forumsRouter from './forums'; import gradebookRouter from './gradebook'; import groupsRouter from './groups'; import lessonPlanRouter from './lessonPlan'; +import marketplaceRouter from './marketplace'; import materialsRouter from './materials'; import plagiarismRouter from './plagiarism'; import scholaisticRouter from './scholaistic'; @@ -45,6 +46,7 @@ const courseRouter: Translated = (t) => ({ gradebookRouter(t), groupsRouter(t), lessonPlanRouter(t), + marketplaceRouter(t), materialsRouter(t), plagiarismRouter(t), statisticsRouter(t), diff --git a/client/app/routers/course/marketplace.tsx b/client/app/routers/course/marketplace.tsx new file mode 100644 index 0000000000..e8a42b7db0 --- /dev/null +++ b/client/app/routers/course/marketplace.tsx @@ -0,0 +1,18 @@ +import { RouteObject } from 'react-router-dom'; + +import { Translated } from 'lib/hooks/useTranslation'; + +const marketplaceRouter: Translated = () => ({ + path: 'marketplace', + children: [ + { + index: true, + lazy: async () => ({ + Component: (await import('course/marketplace/pages/MarketplaceIndex')) + .default, + }), + }, + ], +}); + +export default marketplaceRouter; diff --git a/client/locales/en.json b/client/locales/en.json index 014a14d4f6..1913cef7b0 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1493,6 +1493,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "Closing assessment reminder emails have been successfully dispatched." }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "Import Assessments" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "Create As Draft" }, @@ -4325,6 +4328,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "Announcements" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "Assessment Marketplace" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "Assessments" }, @@ -4583,6 +4589,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "Duplicate Data" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "Assessment Marketplace" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "Timeline Designer" }, @@ -6095,6 +6104,39 @@ "course.marketplace.deleteWarning": { "defaultMessage": "This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected." }, + "course.marketplace.pageTitle": { + "defaultMessage": "Assessment Marketplace" + }, + "course.marketplace.colTitle": { + "defaultMessage": "Title" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "Questions" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "Adoptions" + }, + "course.marketplace.colActions": { + "defaultMessage": "Actions" + }, + "course.marketplace.previewAction": { + "defaultMessage": "Preview" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "Search by title" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "Sort by" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "Most adopted" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "Newest" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index e44cce3520..a12807e1e3 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -1493,6 +1493,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "평가 마감 알림 이메일이 성공적으로 발송되었습니다." }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "평가 가져오기" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "드래프트로 생성" }, @@ -4307,6 +4310,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "공지 사항" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "평가 마켓플레이스" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "평가" }, @@ -4565,6 +4571,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "데이터 복제" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "평가 마켓플레이스" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "타임라인 디자이너" }, @@ -6059,6 +6068,39 @@ "course.marketplace.deleteWarning": { "defaultMessage": "이 평가는 평가 마켓플레이스에 있습니다. 삭제하면 마켓플레이스에서 제거되고 채택 기록도 삭제됩니다. 다른 강좌의 기존 복사본은 영향을 받지 않습니다." }, + "course.marketplace.pageTitle": { + "defaultMessage": "평가 마켓플레이스" + }, + "course.marketplace.colTitle": { + "defaultMessage": "제목" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "문제" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "채택" + }, + "course.marketplace.colActions": { + "defaultMessage": "작업" + }, + "course.marketplace.previewAction": { + "defaultMessage": "미리보기" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "제목으로 검색" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "정렬 기준" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "가장 많이 채택됨" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "최신순" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "{n}개 평가 복제" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index e8020d3d11..88bc1ae173 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -1484,6 +1484,9 @@ "course.assessment.assessments.sendReminderEmailSuccess": { "defaultMessage": "已成功发送结束测验的提醒邮件。" }, + "course.assessment.AssessmentsIndex.importAssessments": { + "defaultMessage": "导入评估" + }, "course.assessment.create.createAsDraft": { "defaultMessage": "创建为草稿" }, @@ -4301,6 +4304,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "公告" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "评估市场" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "测验" }, @@ -4559,6 +4565,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "复制数据" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "评估市场" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "时间线设计工具" }, @@ -6053,6 +6062,39 @@ "course.marketplace.deleteWarning": { "defaultMessage": "此评估位于评估市场中。删除它会将其从市场移除,并删除其采用历史记录。其他课程中的现有副本不受影响。" }, + "course.marketplace.pageTitle": { + "defaultMessage": "评估市场" + }, + "course.marketplace.colTitle": { + "defaultMessage": "标题" + }, + "course.marketplace.colQuestions": { + "defaultMessage": "问题" + }, + "course.marketplace.colAdoptions": { + "defaultMessage": "采用次数" + }, + "course.marketplace.colActions": { + "defaultMessage": "操作" + }, + "course.marketplace.previewAction": { + "defaultMessage": "预览" + }, + "course.marketplace.searchPlaceholder": { + "defaultMessage": "按标题搜索" + }, + "course.marketplace.sortLabel": { + "defaultMessage": "排序方式" + }, + "course.marketplace.sortMostAdopted": { + "defaultMessage": "采用最多" + }, + "course.marketplace.sortNewest": { + "defaultMessage": "最新" + }, + "course.marketplace.duplicateN": { + "defaultMessage": "复制 {n} 个评估" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, diff --git a/config/routes.rb b/config/routes.rb index bf673706ae..e22f59bff0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -617,6 +617,13 @@ get 'learn_settings', to: 'stories#learn_settings' get 'mission_control', to: 'stories#mission_control' end + + scope module: 'assessment/marketplace' do + get 'marketplace' => 'listings#index', as: :marketplace + resources :listings, only: [:show], path: 'marketplace/listings' do + post 'duplicate', on: :collection + end + end end end diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb new file mode 100644 index 0000000000..4caf08b581 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ListingsController, type: :controller do + render_views # index.json.jbuilder output is asserted below — controller specs don't render views otherwise + + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:manager) { create(:course_manager, course: course) } + + before { controller_sign_in(controller, manager.user) } + + describe 'GET #index' do + let!(:published) { create(:course_assessment_marketplace_listing, published: true) } + let!(:unpublished) { create(:course_assessment_marketplace_listing, published: false) } + + it 'returns only published listings' do + get :index, params: { course_id: course, format: :json } + ids = response.parsed_body['listings'].map { |l| l['id'] } + expect(ids).to include(published.id) + expect(ids).not_to include(unpublished.id) + end + + it 'includes title, question count and adoptions, and canAccess' do + get :index, params: { course_id: course, format: :json } + expect(response.parsed_body['canAccess']).to be(true) + row = response.parsed_body['listings'].find { |l| l['id'] == published.id } + expect(row).to include('title', 'questionCount', 'adoptions', 'previewUrl', 'duplicateUrl') + end + + it 'reports the actual question count for a listing (not the 0 fallback)' do + assessment_with_questions = create(:assessment, :with_mcq_question, question_count: 3, course: course) + listing = create(:course_assessment_marketplace_listing, published: true, assessment: assessment_with_questions) + get :index, params: { course_id: course, format: :json } + row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } + expect(row['questionCount']).to eq(3) + end + + context 'as a student' do + let(:student) { create(:course_student, course: course).user } + before { controller_sign_in(controller, student) } + it 'is forbidden' do + expect do + get :index, params: { course_id: course, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + end + end + end + + # Cross-instance: a listing published in another instance is visible. + describe 'cross-instance visibility' do + let(:other_instance) { create(:instance) } + let(:home_instance) { create(:instance) } + + it 'lists listings from other instances' do + foreign = ActsAsTenant.with_tenant(other_instance) do + create(:course_assessment_marketplace_listing, published: true) + end + ActsAsTenant.with_tenant(home_instance) do + course = create(:course) + manager = create(:course_manager, course: course) + controller_sign_in(controller, manager.user) + # Point the request at the home instance's host so `deduce_tenant` resolves it (this + # describe is outside `with_tenant`, which would otherwise set the host header for us). + @request.headers['host'] = home_instance.host + get :index, params: { course_id: course, format: :json } + ids = response.parsed_body['listings'].map { |l| l['id'] } + expect(ids).to include(foreign.id) + end + end + end +end diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb new file mode 100644 index 0000000000..0ecb37ec07 --- /dev/null +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::AssessmentMarketplaceComponent do + controller(Course::Controller) {} # rubocop:disable Lint/EmptyBlock + + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + + subject do + controller.instance_variable_set(:@course, course) + described_class.new(controller) + end + + context 'when the user can access the marketplace (course manager)' do + let(:user) { create(:course_manager, course: course).user } + before { controller_sign_in(controller, user) } + + it 'exposes an admin sidebar item pointing at the marketplace' do + item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace } + expect(item).to be_present + expect(item[:type]).to eq(:admin) + expect(item[:path]).to eq(course_marketplace_path(course)) + end + end + + context 'when the user cannot access the marketplace (course student)' do + let(:user) { create(:course_student, course: course).user } + before { controller_sign_in(controller, user) } + + it 'exposes no sidebar item' do + expect(subject.sidebar_items).to be_empty + end + end + end +end From 88940e319f164e9639f50c115ef83029f5c6a77d Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 8 Jul 2026 12:46:26 +0800 Subject: [PATCH 04/30] feat(marketplace): duplicate listings into current course - add DuplicationJob: copies listings into a course tab, writes adoption - add bulk duplicate endpoint enqueuing the job for selected listings - add DuplicateConfirmation modal with row + bulk triggers, job polling - add MarketplaceAPI.duplicate and duplicateListings poll operation - serialize and assert live distinct-course adoption count in index --- .../marketplace/listings_controller.rb | 26 +++ .../assessment/marketplace/duplication_job.rb | 64 ++++++ app/models/course/assessment.rb | 22 ++ .../course/duplication/base_service.rb | 27 +++ .../duplication/course_duplication_service.rb | 1 + .../duplication/object_duplication_service.rb | 3 + client/app/api/course/Marketplace.ts | 11 + .../components/DuplicateConfirmation.tsx | 98 +++++++++ .../__test__/DuplicationConfirmation.test.tsx | 65 ++++++ .../bundles/course/marketplace/operations.ts | 14 ++ .../MarketplaceIndex/MarketplaceTable.tsx | 4 +- .../pages/MarketplaceIndex/index.tsx | 10 + .../course/marketplace/translations.ts | 24 +++ client/locales/en.json | 15 ++ client/locales/ko.json | 15 ++ client/locales/zh.json | 15 ++ lib/autoload/duplicator.rb | 6 + .../marketplace/listings_controller_spec.rb | 57 +++++ .../marketplace/duplication_job_spec.rb | 195 ++++++++++++++++++ 19 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 app/jobs/course/assessment/marketplace/duplication_job.rb create mode 100644 client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx create mode 100644 client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx create mode 100644 spec/jobs/course/assessment/marketplace/duplication_job_spec.rb diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 6f144d7b99..0228b73ba2 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -19,9 +19,35 @@ def index end end + def duplicate + listings = authorized_listings + job = Course::Assessment::Marketplace::DuplicationJob.perform_later( + # `presence` first: an omitted tab (the sidebar entry point) must stay nil so the job lets the + # duplication fall back to the destination course's first tab, rather than looking for tab 0. + listings.map(&:id), current_course, duplicate_params[:destination_tab_id].presence&.to_i, + current_user: current_user + ).job + render partial: 'jobs/submitted', locals: { job: job } + end + private def authorize_access! authorize!(:access_marketplace, current_course) end + + def authorized_listings + listings = ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).includes(:assessment) + end + raise CanCan::AccessDenied if listings.empty? + + listings.each { |listing| authorize!(:duplicate_from_marketplace, listing.assessment) } + authorize!(:duplicate_to, current_course) + listings + end + + def duplicate_params + params.permit(:destination_tab_id, listing_ids: []) + end end diff --git a/app/jobs/course/assessment/marketplace/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb new file mode 100644 index 0000000000..77817351fd --- /dev/null +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::DuplicationJob < ApplicationJob + include TrackableJob + include Rails.application.routes.url_helpers + + queue_as :duplication + + protected + + def perform_tracked(listing_ids, destination_course, destination_tab_id, options = {}) + current_user = options[:current_user] + 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) + end + redirect_to assessments_url(destination_course, target_tab || last_copy&.tab) + end + end + + private + + # @return [Course::Assessment::Tab, nil] The requested tab, or nil when no tab was requested or + # the requested one does not belong to the destination course. + def find_tab(destination_course, destination_tab_id) + return nil unless destination_tab_id + + destination_course.assessment_categories. + flat_map(&:tabs).find { |tab| tab.id == destination_tab_id } + end + + def duplicate_listing(listing, destination_course, current_user) + source = listing.assessment + Course::Duplication::ObjectDuplicationService.duplicate_objects( + source.course, destination_course, source, current_user: current_user + ) + end + + def reparent_into_tab(copy, target_tab) + return unless target_tab && copy.tab_id != target_tab.id + + copy.tab = target_tab + copy.folder.parent = target_tab.category.folder + 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) + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index e489dce65d..b4f5584474 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -250,6 +250,28 @@ def csv_downloadable? questions.any?(&:csv_downloadable?) end + # Records +duplicate+, a copy of this assessment, as an adoption of this assessment's marketplace + # listing. Every copy of a listed assessment is an adoption, whichever duplication path produced + # it, so this is called by the duplication services rather than by the marketplace's own job. + # + # The listing itself is never carried over -- +initialize_duplicate+ below does not duplicate the + # +marketplace_listing+ association -- so a copy always starts out unlisted. + # + # @param [Course::Assessment] duplicate The saved copy of this assessment. + # @param [Course] destination_course The course the copy was duplicated into. + # @param [User] current_user The user who triggered the duplication. + def record_marketplace_adoption(duplicate, destination_course, current_user) + return unless marketplace_listing&.published? + + Course::Assessment::Marketplace::Adoption.create!( + listing: marketplace_listing, + destination_course: destination_course, + duplicated_assessment: duplicate, + creator: current_user, + updater: current_user + ) + end + def initialize_duplicate(duplicator, other) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength copy_attributes(other, duplicator) target_tab = initialize_duplicate_tab(duplicator, other) diff --git a/app/services/course/duplication/base_service.rb b/app/services/course/duplication/base_service.rb index 1b60ff1e53..0e32a99a25 100644 --- a/app/services/course/duplication/base_service.rb +++ b/app/services/course/duplication/base_service.rb @@ -29,4 +29,31 @@ def initialize(options = {}) def initialize_duplicator(*) raise NotImplementedError, 'To be implemented by specific duplication service.' end + + # Hands every duplicated assessment its own copy so it can record a marketplace adoption. Copies + # made outside +Course::Assessment::Marketplace::DuplicationJob+ -- selected object duplications + # and full course duplications that happen to carry a listed assessment along -- are adoptions + # too, and the listing has to know about them to reach every course holding a copy. + # + # This sweep lives in the duplication service rather than in a model's +after_duplicate_save+ + # hook because that hook only runs for the top-level objects of an object duplication, and never + # at all during a course duplication -- both of which are paths this has to cover. The per-copy + # rule itself belongs to the assessment: see +Course::Assessment#record_marketplace_adoption+. + # + # Must be called inside the duplication transaction, after the duplicates have been saved. + def record_marketplace_adoptions + destination_course = @options[:destination_course] || duplicator.options[:destination_course] + return unless destination_course + + duplicated_assessment_pairs.each do |source, duplicate| + source.record_marketplace_adoption(duplicate, destination_course, @options[:current_user]) + end + end + + # @return [Hash] Source-to-duplicate pairs for every assessment this duplication produced. + def duplicated_assessment_pairs + duplicator.duplicated_objects.select do |source, duplicate| + source.is_a?(Course::Assessment) && duplicate&.persisted? + end + end end diff --git a/app/services/course/duplication/course_duplication_service.rb b/app/services/course/duplication/course_duplication_service.rb index 6a709e1e0f..d9d83ab7ee 100644 --- a/app/services/course/duplication/course_duplication_service.rb +++ b/app/services/course/duplication/course_duplication_service.rb @@ -73,6 +73,7 @@ def duplicate_course(source_course, destination_instance_id) update_course_settings(new_course, source_course) update_sidebar_settings(duplicator, new_course, source_course) + record_marketplace_adoptions # As per carrierwave v2.1.0, carrierwave image mounter that retains uploaded file as a cache # is reset upon reload (in our case it is new_course.reload). diff --git a/app/services/course/duplication/object_duplication_service.rb b/app/services/course/duplication/object_duplication_service.rb index 9d44f59233..2008d42b6e 100644 --- a/app/services/course/duplication/object_duplication_service.rb +++ b/app/services/course/duplication/object_duplication_service.rb @@ -45,6 +45,9 @@ def duplicate_objects(objects) duplicated = duplicator.duplicate(objects) before_save(objects, duplicated) save_success = duplicated.respond_to?(:save) ? duplicated.save : duplicated.all?(&:save) + # Recorded before `after_save` so that a failure here rolls the transaction back before the + # models' post-duplication callbacks have run, rather than undoing their work afterwards. + record_marketplace_adoptions if save_success after_save_success = save_success && after_save(objects, duplicated) raise ActiveRecord::Rollback unless after_save_success diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 4feade4a09..38e84d98a8 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -1,4 +1,5 @@ import { AxiosResponse } from 'axios'; +import { JobSubmitted } from 'types/jobs'; import { MarketplaceListing } from 'course/marketplace/types'; @@ -26,4 +27,14 @@ export default class MarketplaceAPI extends BaseCourseAPI { > { return this.client.get(this.#urlPrefix); } + + duplicate( + listingIds: number[], + destinationTabId: number | null, + ): Promise> { + return this.client.post(`${this.#urlPrefix}/listings/duplicate`, { + listing_ids: listingIds, + ...(destinationTabId ? { destination_tab_id: destinationTabId } : {}), + }); + } } diff --git a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx new file mode 100644 index 0000000000..9adea37690 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -0,0 +1,98 @@ +import { useEffect, useRef, useState } from 'react'; +import { useIntl } from 'react-intl'; +import { JobStatus } from 'types/jobs'; + +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import { pollJobRequest } from 'lib/helpers/jobHelpers'; +import toast from 'lib/hooks/toast'; + +import { duplicateListings } from '../operations'; +import translations from '../translations'; +import { MarketplaceListing } from '../types'; + +const JOB_POLL_INTERVAL_MS = 2000; + +interface Props { + listings: Pick[]; + destinationTabId: number | null; + open: boolean; + onClose: () => void; +} + +const DuplicateConfirmation = ({ + listings, + destinationTabId, + open, + onClose, +}: Props): JSX.Element => { + const { formatMessage: t } = useIntl(); + const [submitting, setSubmitting] = useState(false); + const [jobUrl, setJobUrl] = useState(null); + const pollingRef = useRef(false); + + const n = listings.length; + + const confirm = async (): Promise => { + setSubmitting(true); + try { + const url = await duplicateListings( + listings.map((l) => l.id), + destinationTabId, + ); + setJobUrl(url); + } catch { + // The request never reached the queue, so there is no job to poll. Releasing `submitting` + // here is what keeps the prompt usable for a retry instead of disabled for good. + toast.error(t(translations.duplicateFailed, { n })); + setSubmitting(false); + } + }; + + // The poller lives with the component that started the job, so unmounting or navigating away + // tears it down. `pollingRef` stops a slow response from stacking up overlapping requests. + useEffect(() => { + if (!jobUrl) return undefined; + + const finish = (succeeded: boolean): void => { + setJobUrl(null); + setSubmitting(false); + if (succeeded) { + toast.success(t(translations.duplicateStarted, { n })); + onClose(); + } else { + toast.error(t(translations.duplicateFailed, { n })); + } + }; + + const interval = setInterval(() => { + if (pollingRef.current) return; + pollingRef.current = true; + pollJobRequest(jobUrl) + .then((response) => { + if (response.status === JobStatus.completed) finish(true); + else if (response.status === JobStatus.errored) finish(false); + }) + .catch(() => finish(false)) + .finally(() => { + pollingRef.current = false; + }); + }, JOB_POLL_INTERVAL_MS); + + return () => clearInterval(interval); + }, [jobUrl, n]); + + return ( + + {t(translations.duplicateBody, { n })} + + ); +}; + +export default DuplicateConfirmation; diff --git a/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx new file mode 100644 index 0000000000..b0b3d24ddb --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx @@ -0,0 +1,65 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import DuplicateConfirmation from '../DuplicateConfirmation'; + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +const listings = [{ id: 1, title: 'Recursion Drills' }]; +const url = `/courses/${global.courseId}/marketplace/listings/duplicate`; + +it('posts a duplication request with the destination tab on confirm', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + const page = render( + , + ); + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + listing_ids: [1], + destination_tab_id: 42, + }); +}); + +it('omits destination_tab_id when entered without a tab (sidebar entry)', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + const page = render( + , + ); + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + const body = JSON.parse(mock.history.post[0].data); + expect(body).toMatchObject({ listing_ids: [1] }); + expect(body).not.toHaveProperty('destination_tab_id'); // backend then defaults to the first tab +}); + +// A request that never reaches the queue leaves no job to poll, so nothing else can re-enable the +// prompt: the confirm button has to come back by itself for the user to be able to retry. +it('re-enables the prompt when the request itself fails', async () => { + mock.onPost(url).reply(500); + const page = render( + , + ); + const button = await page.findByRole('button', { name: /Duplicate/ }); + fireEvent.click(button); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + await waitFor(() => expect(button).not.toBeDisabled()); +}); diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts index 8023024d8c..0ce1f1d5c1 100644 --- a/client/app/bundles/course/marketplace/operations.ts +++ b/client/app/bundles/course/marketplace/operations.ts @@ -6,3 +6,17 @@ export const fetchListings = async (): Promise => { const response = await CourseAPI.marketplace.index(); return response.data.listings as MarketplaceListing[]; }; + +// Returns the URL of the duplication job to poll. Polling is deliberately left to the caller: it +// has to be started and torn down by the component that owns the flow, so that navigating away +// cannot leave an orphaned poller behind. +export const duplicateListings = async ( + listingIds: number[], + destinationTabId: number | null, +): Promise => { + const response = await CourseAPI.marketplace.duplicate( + listingIds, + destinationTabId, + ); + return response.data.jobUrl; +}; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx index 5cabe2e1d9..5c90dc2e3c 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx @@ -56,7 +56,9 @@ const MarketplaceTable = ({ listings, onDuplicate }: Props): JSX.Element => { {t(translations.preview)} - {/* Row-level Duplicate button wired in Task 18 */} + ), }, diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx index 8078f4f03f..acc11b2222 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import { useIntl } from 'react-intl'; +import { useSearchParams } from 'react-router-dom'; import Page from 'lib/components/core/layouts/Page'; import Preload from 'lib/components/wrappers/Preload'; +import DuplicateConfirmation from '../../components/DuplicateConfirmation'; import { fetchListings } from '../../operations'; import translations from '../../translations'; import { MarketplaceListing } from '../../types'; @@ -12,6 +14,8 @@ import MarketplaceTable from './MarketplaceTable'; const MarketplaceIndex = (): JSX.Element => { const { formatMessage: t } = useIntl(); + const [params] = useSearchParams(); + const destinationTabId = parseInt(params.get('from_tab') ?? '', 10) || null; const [pending, setPending] = useState([]); return ( @@ -19,6 +23,12 @@ const MarketplaceIndex = (): JSX.Element => { {(listings): JSX.Element => ( + setPending([])} + open={pending.length > 0} + /> )} diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index a613747fbb..c73d3012df 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -84,4 +84,28 @@ export default defineMessages({ defaultMessage: '{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}', }, + duplicateTitle: { + id: 'course.marketplace.duplicateTitle', + defaultMessage: + 'Duplicate assessment{n, plural, one {} other {s}} to your course?', + }, + duplicateBody: { + id: 'course.marketplace.duplicateBody', + defaultMessage: + '{n, plural, one {This assessment will be copied to your course.} other {These # assessments will be copied to your course.}}', + }, + duplicateConfirm: { + id: 'course.marketplace.duplicateConfirm', + defaultMessage: 'Duplicate', + }, + duplicateStarted: { + id: 'course.marketplace.duplicateStarted', + defaultMessage: + '{n, plural, one {Duplicating assessment} other {Duplicating assessments}} started.', + }, + duplicateFailed: { + id: 'course.marketplace.duplicateFailed', + defaultMessage: + '{n, plural, one {Duplicating assessment} other {Duplicating assessments}} failed.', + }, }); diff --git a/client/locales/en.json b/client/locales/en.json index 1913cef7b0..bd3e1686dd 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6137,6 +6137,21 @@ "course.marketplace.duplicateN": { "defaultMessage": "{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}" }, + "course.marketplace.duplicateTitle": { + "defaultMessage": "Duplicate assessment{n, plural, one {} other {s}} to your course?" + }, + "course.marketplace.duplicateBody": { + "defaultMessage": "{n, plural, one {This assessment will be copied to your course.} other {These # assessments will be copied to your course.}}" + }, + "course.marketplace.duplicateConfirm": { + "defaultMessage": "Duplicate" + }, + "course.marketplace.duplicateStarted": { + "defaultMessage": "{n, plural, one {Duplicating assessment} other {Duplicating assessments}} started." + }, + "course.marketplace.duplicateFailed": { + "defaultMessage": "{n, plural, one {Duplicating assessment} other {Duplicating assessments}} failed." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index a12807e1e3..6cb43b7d59 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6101,6 +6101,21 @@ "course.marketplace.duplicateN": { "defaultMessage": "{n}개 평가 복제" }, + "course.marketplace.duplicateTitle": { + "defaultMessage": "평가 {n}개를 내 강좌로 복제하시겠습니까?" + }, + "course.marketplace.duplicateBody": { + "defaultMessage": "{n, plural, one {이 평가가 내 강좌로 복사됩니다.} other {이 평가 #개가 내 강좌로 복사됩니다.}}" + }, + "course.marketplace.duplicateConfirm": { + "defaultMessage": "복제" + }, + "course.marketplace.duplicateStarted": { + "defaultMessage": "{n, plural, one {평가 복제가} other {평가 복제가}} 시작되었습니다." + }, + "course.marketplace.duplicateFailed": { + "defaultMessage": "{n, plural, one {평가 복제에} other {평가 복제에}} 실패했습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 88bc1ae173..4d31ca2b70 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6095,6 +6095,21 @@ "course.marketplace.duplicateN": { "defaultMessage": "复制 {n} 个评估" }, + "course.marketplace.duplicateTitle": { + "defaultMessage": "要将 {n} 个评估复制到你的课程吗?" + }, + "course.marketplace.duplicateBody": { + "defaultMessage": "{n, plural, one {此评估将被复制到你的课程。} other {这 # 个评估将被复制到你的课程。}}" + }, + "course.marketplace.duplicateConfirm": { + "defaultMessage": "复制" + }, + "course.marketplace.duplicateStarted": { + "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}已开始。" + }, + "course.marketplace.duplicateFailed": { + "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}失败。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, diff --git a/lib/autoload/duplicator.rb b/lib/autoload/duplicator.rb index 469dce20bd..6ef9bff2a8 100644 --- a/lib/autoload/duplicator.rb +++ b/lib/autoload/duplicator.rb @@ -2,6 +2,12 @@ class Duplicator attr_reader :options, :mode + # @!attribute [r] duplicated_objects + # Maps each duplicated source object to its duplicate, or to +nil+ if the source was excluded. + # Duplication services use this to run bookkeeping over everything a duplication produced. + # @return [Hash] + attr_reader :duplicated_objects + # Create an instance of Duplicator to track duplicated objects. # # Options are used to store information that persists across duplication of objects. diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 4caf08b581..55ceaecda1 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -29,6 +29,14 @@ expect(row).to include('title', 'questionCount', 'adoptions', 'previewUrl', 'duplicateUrl') end + it 'reports the live distinct-course adoption count' do + listing = create(:course_assessment_marketplace_listing, published: true) + create(:course_assessment_marketplace_adoption, listing: listing, destination_course: create(:course)) + get :index, params: { course_id: course, format: :json } + row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } + expect(row['adoptions']).to eq(1) + end + it 'reports the actual question count for a listing (not the 0 fallback)' do assessment_with_questions = create(:assessment, :with_mcq_question, question_count: 3, course: course) listing = create(:course_assessment_marketplace_listing, published: true, assessment: assessment_with_questions) @@ -47,6 +55,55 @@ end end end + describe 'POST #duplicate' do + # `have_enqueued_job` requires the :test adapter; the test env defaults to :background_thread. + # `run_rescue` re-enables handle_access_denied so AccessDenied renders 403 rather than + # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). + run_rescue + + with_active_job_queue_adapter(:test) do + let!(:listing) { create(:course_assessment_marketplace_listing, published: true) } + let!(:tab) { course.assessment_categories.first.tabs.first } + + it 'enqueues a duplication job with the destination course + tab' do + expect do + post :duplicate, params: { + course_id: course, listing_ids: [listing.id], destination_tab_id: tab.id, format: :json + } + end.to have_enqueued_job(Course::Assessment::Marketplace::DuplicationJob). + with([listing.id], course, tab.id, current_user: manager.user) + expect(response.parsed_body['jobUrl']).to be_present + end + + it 'enqueues a nil tab when none is given, so the job falls back to the default tab' do + expect do + post :duplicate, params: { course_id: course, listing_ids: [listing.id], format: :json } + end.to have_enqueued_job(Course::Assessment::Marketplace::DuplicationJob). + with([listing.id], course, nil, current_user: manager.user) + end + + context 'when the listing is unpublished' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: false) } + it 'is forbidden and enqueues nothing' do + expect do + post :duplicate, params: { + course_id: course, listing_ids: [listing.id], destination_tab_id: tab.id, format: :json + } + end.not_to have_enqueued_job(Course::Assessment::Marketplace::DuplicationJob) + expect(response).to have_http_status(:forbidden) + end + end + + context 'when no matching published listing exists (empty/unknown ids)' do + it 'is forbidden (renders 403 on the empty set)' do + post :duplicate, params: { + course_id: course, listing_ids: [-1], destination_tab_id: tab.id, format: :json + } + expect(response).to have_http_status(:forbidden) + end + end + end + end end # Cross-instance: a listing published in another instance is visible. diff --git a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb new file mode 100644 index 0000000000..5faf1b7cf8 --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::DuplicationJob, type: :job do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + let(:listing) { create(:course_assessment_marketplace_listing, assessment: source_assessment, published: true) } + let(:destination_course) { create(:course) } + let(:destination_tab) { destination_course.assessment_categories.first.tabs.first } + let(:user) { create(:administrator) } + + def run + described_class.perform_now([listing.id], destination_course, destination_tab.id, current_user: user) + end + + it 'duplicates the assessment into the destination course' do + expect { run }.to change { destination_course.assessments.count }.by(1) + end + + it 'lands the copy in the chosen tab' do + run + copy = destination_course.assessments.order(:created_at).last + expect(copy.tab_id).to eq(destination_tab.id) + end + + it 'writes an adoption row for the copy' do + expect { run }.to change { Course::Assessment::Marketplace::Adoption.count }.by(1) + adoption = Course::Assessment::Marketplace::Adoption.last + expect(adoption.listing).to eq(listing) + expect(adoption.destination_course).to eq(destination_course) + end + + it 'counts the same destination course only once across two duplications' do + run + run + expect(listing.reload.adoption_count).to eq(1) + end + + it 'skips unpublished listings (job re-filters `.published`)' do + listing.update!(published: false) + expect { run }.not_to change(destination_course.assessments, :count) + # Relative, not `Adoption.count == 0`: the duplication path commits outside the example's + # transaction (rows persist across runs), so only the delta from `run` is meaningful here. + expect { run }.not_to change(Course::Assessment::Marketplace::Adoption, :count) + end + + it 'duplicates every listing when given several ids' do + other = create(:course_assessment_marketplace_listing, + assessment: create(:assessment, :with_mcq_question, course: source_course), published: true) + expect do + described_class.perform_now([listing.id, other.id], destination_course, destination_tab.id, current_user: user) + end.to change { destination_course.assessments.count }.by(2). + and change { Course::Assessment::Marketplace::Adoption.count }.by(2) + end + + # Grandchildren-excluded: an adoption is written for copies of a *listed* assessment, and a copy + # is never itself listed, so duplicating an already-adopted copy writes no second-generation + # adoption. + it 'does not write an adoption for an ordinary ObjectDuplicationService copy' do + run + copy = destination_course.assessments.order(:created_at).last + third_course = create(:course) + expect do + Course::Duplication::ObjectDuplicationService.duplicate_objects( + destination_course, third_course, copy, current_user: user + ) + end.not_to change(Course::Assessment::Marketplace::Adoption, :count) + end + + # The sidebar entry point sends no tab, and a tab from another course can be sent by an + # out-of-date URL. Neither may leave the redirect pointing at a tab the user cannot open. + describe 'when the requested tab is absent or foreign' do + def run_with_tab(tab_id) + job = described_class.new([listing.id], destination_course, tab_id, current_user: user) + job.perform_now + job.job + end + + let(:default_tab) { destination_course.assessment_categories.first.tabs.first } + + it 'lands the copy in the default tab and redirects there when no tab is given' do + job = run_with_tab(nil) + copy = destination_course.assessments.order(:created_at).last + expect(copy.tab_id).to eq(default_tab.id) + expect(job.redirect_to).to include("tab=#{default_tab.id}", "category=#{default_tab.category_id}") + end + + it 'ignores a tab belonging to another course' do + foreign_tab = create(:course).assessment_categories.first.tabs.first + job = run_with_tab(foreign_tab.id) + copy = destination_course.assessments.order(:created_at).last + expect(copy.tab_id).to eq(default_tab.id) + expect(job.redirect_to).not_to include("tab=#{foreign_tab.id}") + end + end + + it 'redirects to the requested tab and its own category' do + job = described_class.new([listing.id], destination_course, destination_tab.id, current_user: user) + job.perform_now + expect(job.job.redirect_to).to include("tab=#{destination_tab.id}", "category=#{destination_tab.category_id}") + end + + describe 'the listing is not itself duplicated' do + # Force the listing before the `expect` blocks below: `run` would otherwise create it lazily + # inside the block and register as a listing-count change of its own. + before { listing } + + it 'does not create a second listing row' do + expect { run }.not_to change(Course::Assessment::Marketplace::Listing, :count) + end + + it 'leaves the duplicated copy unlisted' do + run + copy = destination_course.assessments.order(:created_at).last + expect(copy.marketplace_listing).to be_nil + end + + it 'holds the listing count steady while adoptions accumulate' do + other_course = create(:course) + expect do + run + described_class.perform_now([listing.id], other_course, + other_course.assessment_categories.first.tabs.first.id, + current_user: user) + end.to change { listing.reload.adoption_count }.from(0).to(2). + and not_change(Course::Assessment::Marketplace::Listing, :count) + end + end + + # A listed assessment can leave its course by paths that do not go through this job: an + # instructor duplicating selected objects, or a full course duplication that carries the + # listed assessment along. Those copies must obey the same two rules as the job's copies -- + # the listing stays singular, and the destination course is recorded as an adopter. + describe 'manual duplication of a listed assessment' do + let(:manual_destination) { create(:course) } + + before { listing } + + def duplicate_selected_objects + Course::Duplication::ObjectDuplicationService.duplicate_objects( + source_course, manual_destination, source_assessment, current_user: user + ) + end + + def duplicate_whole_course + Course::Duplication::CourseDuplicationService.duplicate_course( + source_course, current_user: user, new_title: "#{source_course.title} copy" + ) + end + + context 'when duplicating selected objects' do + it 'does not create a second listing row' do + expect { duplicate_selected_objects }.not_to change(Course::Assessment::Marketplace::Listing, :count) + end + + it 'leaves the manual copy unlisted' do + copy = duplicate_selected_objects + expect(copy.marketplace_listing).to be_nil + end + + it 'records the destination course as an adopter' do + copy = nil + expect { copy = duplicate_selected_objects }. + to change(Course::Assessment::Marketplace::Adoption, :count).by(1) + adoption = Course::Assessment::Marketplace::Adoption.order(:id).last + expect(adoption.listing).to eq(listing) + expect(adoption.destination_course).to eq(manual_destination) + expect(adoption.duplicated_assessment).to eq(copy) + end + end + + context 'when duplicating the whole course' do + it 'does not create a second listing row' do + expect { duplicate_whole_course }.not_to change(Course::Assessment::Marketplace::Listing, :count) + end + + it 'leaves the copied assessment unlisted' do + new_course = duplicate_whole_course + expect(new_course.assessments.map(&:marketplace_listing)).to all(be_nil) + end + + it 'records the new course as an adopter' do + new_course = nil + expect { new_course = duplicate_whole_course }. + to change(Course::Assessment::Marketplace::Adoption, :count).by(1) + adoption = Course::Assessment::Marketplace::Adoption.order(:id).last + expect(adoption.listing).to eq(listing) + expect(adoption.destination_course).to eq(new_course) + end + end + end + end +end From 87aafc1842a259024610ad84d2453ddd01d35a4c Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:31 +0800 Subject: [PATCH 05/30] feat(marketplace): preview endpoints and question serializers Add the read-only backend for the marketplace browse flow: - listings#show serializes a curated, read-only view of a published assessment (config + per-question summaries) for the listing preview. - questions#show serializes a single question's detail, dispatching to per-type detail partials (multiple/text/voice/forum/programming/ rubric/scribing) so each renderer gets exactly the data it needs. - The listings index gains destination tabs plus preview/duplicate URLs so the browse table can link into the flow and target a tab. Type labels are serialized human-readable (question_type_readable) to match the real assessment show page, while the demodulized discriminator is kept for frontend renderer dispatch. The base controller pulls in AssessmentsHelper so the preview views can reuse display_graded_test_types, and the sidebar component now uses the :marketplace (storefront) icon. --- .../assessment/marketplace/controller.rb | 4 + .../marketplace/listings_controller.rb | 53 +++-- .../marketplace/questions_controller.rb | 24 +++ .../marketplace/listings/index.json.jbuilder | 6 + .../marketplace/listings/show.json.jbuilder | 39 ++++ .../_forum_post_response.json.jbuilder | 2 + .../details/_multiple_response.json.jbuilder | 8 + .../details/_programming.json.jbuilder | 20 ++ .../_rubric_based_response.json.jbuilder | 8 + .../questions/details/_scribing.json.jbuilder | 3 + .../details/_text_response.json.jbuilder | 11 ++ .../details/_voice_response.json.jbuilder | 4 + .../marketplace/questions/show.json.jbuilder | 29 +++ config/routes.rb | 1 + .../marketplace/listings_controller_spec.rb | 50 +++++ .../marketplace/questions_controller_spec.rb | 182 ++++++++++++++++++ .../assessment_marketplace_component_spec.rb | 1 + 17 files changed, 433 insertions(+), 12 deletions(-) create mode 100644 app/controllers/course/assessment/marketplace/questions_controller.rb create mode 100644 app/views/course/assessment/marketplace/listings/show.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder create mode 100644 app/views/course/assessment/marketplace/questions/show.json.jbuilder create mode 100644 spec/controllers/course/assessment/marketplace/questions_controller_spec.rb diff --git a/app/controllers/course/assessment/marketplace/controller.rb b/app/controllers/course/assessment/marketplace/controller.rb index b617d93833..489a3ec7cd 100644 --- a/app/controllers/course/assessment/marketplace/controller.rb +++ b/app/controllers/course/assessment/marketplace/controller.rb @@ -1,5 +1,9 @@ # frozen_string_literal: true class Course::Assessment::Marketplace::Controller < Course::ComponentController + # display_graded_test_types is defined in Course::Assessment::AssessmentsHelper; the marketplace + # preview views reuse it, but Rails only auto-includes a controller's own matching helper. + helper Course::Assessment::AssessmentsHelper + private def component diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 0228b73ba2..6cb4b5f22c 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -4,18 +4,13 @@ class Course::Assessment::Marketplace::ListingsController < Course::Assessment:: def index ActsAsTenant.without_tenant do - @listings = Course::Assessment::Marketplace::Listing.published.includes(:assessment).to_a - listing_ids = @listings.map(&:id) - assessment_ids = @listings.map(&:assessment_id) - @adoption_counts = Course::Assessment::Marketplace::Adoption. - where(listing_id: listing_ids).group(:listing_id). - distinct.count(:destination_course_id) - # reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it - # the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is - # neither grouped nor aggregated). - @question_counts = Course::QuestionAssessment. - where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id). - distinct.count(:question_id) + # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on + # the acting-as record. + @listings = Course::Assessment::Marketplace::Listing.published. + includes(assessment: :lesson_plan_item).to_a + @adoption_counts = adoption_counts(@listings.map(&:id)) + @question_counts = question_counts(@listings.map(&:assessment_id)) + @destination_tabs = destination_tabs end end @@ -30,12 +25,46 @@ def duplicate render partial: 'jobs/submitted', locals: { job: job } end + def show + ActsAsTenant.without_tenant do + @listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).find_by(id: params[:id]) + raise CanCan::AccessDenied unless @listing + + @assessment = @listing.assessment + authorize!(:preview_in_marketplace, @assessment) + render 'show' + end + end + private def authorize_access! authorize!(:access_marketplace, current_course) end + def adoption_counts(listing_ids) + Course::Assessment::Marketplace::Adoption. + where(listing_id: listing_ids).group(:listing_id). + distinct.count(:destination_course_id) + end + + def question_counts(assessment_ids) + # reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it + # the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is + # neither grouped nor aggregated). + Course::QuestionAssessment. + where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id). + distinct.count(:question_id) + end + + def destination_tabs + current_course.assessment_categories.includes(:tabs).flat_map do |category| + category.tabs.map do |tab| + { id: tab.id, title: tab.title, category_id: category.id, category_title: category.title } + end + end + end + def authorized_listings listings = ActsAsTenant.without_tenant do Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).includes(:assessment) diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb new file mode 100644 index 0000000000..f0e08133e9 --- /dev/null +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::QuestionsController < Course::Assessment::Marketplace::Controller + before_action :authorize_access! + + def show + ActsAsTenant.without_tenant do + listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).find_by(id: params[:listing_id]) + raise CanCan::AccessDenied unless listing + + @assessment = listing.assessment + authorize!(:preview_in_marketplace, @assessment) + + @question = @assessment.questions.includes(:actable).find(params[:id]) + @question_assessment = @question.question_assessments.find_by!(assessment: @assessment) + render 'show' # rendered inside without_tenant so actable associations resolve cross-instance + end + end + + private + + def authorize_access! + authorize!(:access_marketplace, current_course) + end +end \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder index 33ea65ec1e..ead481dbe7 100644 --- a/app/views/course/assessment/marketplace/listings/index.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder @@ -11,3 +11,9 @@ json.listings @listings do |listing| json.previewUrl course_listing_path(current_course, listing) json.duplicateUrl duplicate_course_listings_path(current_course) end +json.destinationTabs @destination_tabs do |tab| + json.id tab[:id] + json.title tab[:title] + json.categoryId tab[:category_id] + json.categoryTitle tab[:category_title] +end diff --git a/app/views/course/assessment/marketplace/listings/show.json.jbuilder b/app/views/course/assessment/marketplace/listings/show.json.jbuilder new file mode 100644 index 0000000000..3cff3f83ac --- /dev/null +++ b/app/views/course/assessment/marketplace/listings/show.json.jbuilder @@ -0,0 +1,39 @@ +json.id @assessment.id +json.title @assessment.title +json.description format_ckeditor_rich_text(@assessment.description) + +json.gradingMode @assessment.autograded? ? 'autograded' : 'manual' +json.baseExp @assessment.base_exp if @assessment.base_exp > 0 +json.bonusExp @assessment.time_bonus_exp if @assessment.time_bonus_exp > 0 +json.showMcqMrqSolution @assessment.show_mcq_mrq_solution +json.showRubricToStudents @assessment.show_rubric_to_students +json.gradedTestCases display_graded_test_types(@assessment) + +questions = @assessment.questions.includes(:actable) + +# Group by the human-readable type (e.g. "Multiple Choice", "Text Response Question") so the +# breakdown matches the per-question chips and the wording of the real assessment show page, +# instead of raw actable class names ("MultipleResponse"). +json.typeCounts questions.group_by(&:question_type_readable).transform_values(&:size) + +json.questions questions do |question| + json.id question.id + json.title question.title + json.description format_ckeditor_rich_text(question.description) + json.staffOnlyComments format_ckeditor_rich_text(question.staff_only_comments) + json.maximumGrade question.maximum_grade + # Human-readable label for the type chip, mirroring _question_assessment.json.jbuilder. The + # renderer dispatch lives on the detail endpoint (which keeps the demodulized discriminator). + json.type question.question_type_readable + json.unautogradable !question.auto_gradable? + + if question.actable_type == 'Course::Assessment::Question::MultipleResponse' + mrq = question.actable + json.mcqMrqType mrq.multiple_choice? ? 'mcq' : 'mrq' # multiple_choice? is aliased to any_correct? + json.options mrq.options do |option| + json.id option.id + json.option format_ckeditor_rich_text(option.option) + json.correct option.correct + end + end +end \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder new file mode 100644 index 0000000000..c883a23416 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder @@ -0,0 +1,2 @@ +json.maxPosts question.max_posts +json.hasTextResponse question.has_text_response \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder new file mode 100644 index 0000000000..f96d1bf8b6 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder @@ -0,0 +1,8 @@ +json.gradingScheme question.grading_scheme +json.options question.options do |option| + json.id option.id + json.option format_ckeditor_rich_text(option.option) + json.correct option.correct + json.explanation format_ckeditor_rich_text(option.explanation) + json.weight option.weight +end \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder new file mode 100644 index 0000000000..6d2fc9e763 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder @@ -0,0 +1,20 @@ +json.languageName question.language&.name +json.memoryLimit question.memory_limit +json.timeLimit question.time_limit + +json.templateFiles question.template_files do |file| + json.filename file.filename + json.content file.content +end + +grouped = question.test_cases.group_by(&:test_case_type) +{ 'publicTestCases' => 'public_test', + 'privateTestCases' => 'private_test', + 'evaluationTestCases' => 'evaluation_test' }.each do |key, type| + json.set! key, (grouped[type] || []) do |tc| + json.identifier tc.identifier + json.expression tc.expression + json.expected tc.expected + json.hint tc.hint + end +end \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder new file mode 100644 index 0000000000..9da40d08b5 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder @@ -0,0 +1,8 @@ +json.categories question.categories do |category| + json.name category.name + json.isBonus category.is_bonus_category + json.criteria category.criterions do |criterion| + json.grade criterion.grade + json.explanation format_ckeditor_rich_text(criterion.explanation) + end +end \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder new file mode 100644 index 0000000000..8f067d91fd --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder @@ -0,0 +1,3 @@ +# Verified against app/views/course/assessment/question/scribing/_scribing_question.json.jbuilder: +# scribing exposes its image via `attachment_reference.generate_public_url`, guarded by presence. +json.imageUrl question.attachment_reference&.generate_public_url \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder new file mode 100644 index 0000000000..da7375e5cf --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder @@ -0,0 +1,11 @@ +json.hideText question.hide_text +json.isAttachmentRequired question.is_attachment_required +json.maxAttachments question.max_attachments +json.maxAttachmentSize question.max_attachment_size +json.isComprehension question.is_comprehension +json.solutions question.solutions do |solution| + json.solutionType solution.solution_type + json.solution format_ckeditor_rich_text(solution.solution) + json.grade solution.grade + json.explanation format_ckeditor_rich_text(solution.explanation) +end \ No newline at end of file diff --git a/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder new file mode 100644 index 0000000000..8d51a9b558 --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder @@ -0,0 +1,4 @@ +# Voice questions have no type-specific setup fields; the base prompt is shown by the shell. +# `json.merge!({})` forces the enclosing `json.detail do … end` block to serialize as an empty +# object `{}`. Without it the block's scope stays blank and jbuilder emits `null` instead. +json.merge!({}) diff --git a/app/views/course/assessment/marketplace/questions/show.json.jbuilder b/app/views/course/assessment/marketplace/questions/show.json.jbuilder new file mode 100644 index 0000000000..8723df023c --- /dev/null +++ b/app/views/course/assessment/marketplace/questions/show.json.jbuilder @@ -0,0 +1,29 @@ +detail_partials = { + 'Course::Assessment::Question::MultipleResponse' => 'multiple_response', + 'Course::Assessment::Question::Programming' => 'programming', + 'Course::Assessment::Question::TextResponse' => 'text_response', + 'Course::Assessment::Question::RubricBasedResponse' => 'rubric_based_response', + 'Course::Assessment::Question::ForumPostResponse' => 'forum_post_response', + 'Course::Assessment::Question::VoiceResponse' => 'voice_response', + 'Course::Assessment::Question::Scribing' => 'scribing' +} + +json.id @question.id +json.title @question.title +json.defaultTitle @question_assessment.default_title(@question_assessment.question_number) +json.description format_ckeditor_rich_text(@question.description) +json.staffOnlyComments format_ckeditor_rich_text(@question.staff_only_comments) +json.maximumGrade @question.maximum_grade +# `type` is the demodulized discriminator that drives the frontend renderer dispatch; keep it stable. +json.type @question.actable_type.demodulize +# `displayType` is the human-readable label shown in the detail header chip (mirrors the card). +json.displayType @question.question_type_readable + +partial = detail_partials[@question.actable_type] +if partial + json.detail do + json.partial! "course/assessment/marketplace/questions/details/#{partial}", question: @question.actable + end +else + json.detail nil +end \ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index e22f59bff0..2b7cc3253a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -622,6 +622,7 @@ get 'marketplace' => 'listings#index', as: :marketplace resources :listings, only: [:show], path: 'marketplace/listings' do post 'duplicate', on: :collection + resources :questions, only: [:show] end end end diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 55ceaecda1..6404b85db7 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -29,6 +29,20 @@ expect(row).to include('title', 'questionCount', 'adoptions', 'previewUrl', 'duplicateUrl') end + it 'includes the current course destination tabs with category names' do + get :index, params: { course_id: course, format: :json } + tabs = response.parsed_body['destinationTabs'] + expect(tabs).to be_present + default_tab = course.assessment_categories.first.tabs.first + row = tabs.find { |tab| tab['id'] == default_tab.id } + expect(row).to include( + 'id' => default_tab.id, + 'title' => default_tab.title, + 'categoryId' => default_tab.category.id, + 'categoryTitle' => default_tab.category.title + ) + end + it 'reports the live distinct-course adoption count' do listing = create(:course_assessment_marketplace_listing, published: true) create(:course_assessment_marketplace_adoption, listing: listing, destination_course: create(:course)) @@ -104,6 +118,42 @@ end end end + describe 'GET #show (preview)' do + # `run_rescue` re-enables handle_access_denied so a denied preview renders 403 rather than + # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). + run_rescue + + let!(:listing) do + assessment = create(:assessment, course: create(:course)) + create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) + create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + end + + it 'renders the assessment config read-only' do + get :show, params: { course_id: course, id: listing.id, format: :json } + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body).to include('title', 'gradingMode', 'showMcqMrqSolution', 'showRubricToStudents', 'gradedTestCases') + # The listing preview reports the human-readable question type, matching the per-question chips. + readable_type = I18n.t('course.assessment.question.multiple_responses.question_type.multiple_choice') + expect(body['typeCounts']).to include(readable_type => 1) + + question = body['questions'].first + expect(question).to have_key('staffOnlyComments') + expect(question['type']).to eq(readable_type) + expect(question['unautogradable']).to be(false) + expect(question['mcqMrqType']).to eq('mcq') + expect(question['options']).to be_present + end + + context 'when the listing is unpublished' do + let!(:listing) { create(:course_assessment_marketplace_listing, published: false) } + it 'is forbidden' do + get :show, params: { course_id: course, id: listing.id, format: :json } + expect(response).to have_http_status(:forbidden) + end + end + end end # Cross-instance: a listing published in another instance is visible. diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb new file mode 100644 index 0000000000..e679abb421 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::QuestionsController, type: :controller do + render_views + + let(:source_instance) { create(:instance) } + let(:destination_instance) { create(:instance) } + + # Source-side data lives in the source instance. The listing is cross-instance, so it is built + # with the tenant switched off — mirroring the controller's own without_tenant reads. These are + # outer-level lets: they run before with_tenant sets the destination tenant, and they don't rely + # on an ambient tenant because each sets its own explicitly. + let!(:source_assessment) do + ActsAsTenant.with_tenant(source_instance) do + course = create(:course, instance: source_instance) + assessment = create(:assessment, course: course) + create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) + assessment + end + end + let!(:listing) do + # NOTE: the factory has no :published trait — `published { true }` is a default attribute + # (spec/factories/course_assessment_marketplace_listings.rb). Do NOT pass `:published`. + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: source_assessment) + end + end + let(:question) { source_assessment.questions.first } + + # Destination-side data + the request run under the destination tenant. with_tenant (controller + # variant) sets ActsAsTenant.current_tenant AND the request host, so every tenant-scoped create + # below (Course, CourseUser) and the controller's own tenant deduction resolve to the destination. + with_tenant(:destination_instance) do + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + + before { controller_sign_in(controller, manager) } + + it 'serializes the question across instances' do + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['id']).to eq(question.id) + expect(body['type']).to eq('MultipleResponse') + expect(body['detail']).to be_present + end + + it 'denies when the listing is unpublished' do + ActsAsTenant.without_tenant { listing.update!(published: false) } + expect do + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + end.to raise_exception(CanCan::AccessDenied) + end + + it 'serializes the MCQ answer key (options with correctness, explanation, weight)' do + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + detail = response.parsed_body['detail'] + expect(detail['gradingScheme']).to be_present + expect(detail['options'].first).to include('option', 'correct', 'explanation', 'weight') + end + + it 'serializes programming template files and test-case buckets' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_programming, assessment: assessment, + test_case_count: 1, private_test_case_count: 1, evaluation_test_case_count: 1) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + detail = response.parsed_body['detail'] + expect(detail['languageName']).to be_present + expect(detail['templateFiles']).to be_present + expect(detail['publicTestCases'].first).to include('expression', 'expected', 'hint') + end + + it 'serializes text-response solutions and attachment settings' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_text_response, :exact_match_solution, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + detail = response.parsed_body['detail'] + expect(detail).to include('hideText', 'isAttachmentRequired', 'maxAttachments', 'isComprehension') + expect(detail['solutions'].first).to include('solution', 'grade') + end + + it 'serializes rubric categories and criteria' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_rubric_based_response, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + category = response.parsed_body['detail']['categories'].first + expect(category).to include('name', 'isBonus') + expect(category['criteria'].first).to include('grade', 'explanation') + end + + it 'serializes forum post requirements' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_forum_post_response, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response.parsed_body['detail']).to include('maxPosts', 'hasTextResponse') + end + + it 'serializes voice response with an empty detail object' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_voice_response, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response.parsed_body['type']).to eq('VoiceResponse') + expect(response.parsed_body['detail']).to eq({}) + end + + it 'serializes scribing with an imageUrl key (null when no attachment)' do + question = nil + listing = ActsAsTenant.with_tenant(source_instance) do + assessment = create(:assessment, course: create(:course, instance: source_instance)) + create(:course_assessment_question_scribing, assessment: assessment) + question = assessment.questions.first + ActsAsTenant.without_tenant do + create(:course_assessment_marketplace_listing, assessment: assessment) + end + end + + get :show, as: :json, params: { + course_id: destination_course.id, listing_id: listing.id, id: question.id + } + expect(response.parsed_body['type']).to eq('Scribing') + expect(response.parsed_body['detail']).to have_key('imageUrl') + expect(response.parsed_body['detail']['imageUrl']).to be_nil + end + end +end diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb index 0ecb37ec07..6fa1c1f6aa 100644 --- a/spec/controllers/course/assessment_marketplace_component_spec.rb +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -21,6 +21,7 @@ item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace } expect(item).to be_present expect(item[:type]).to eq(:admin) + expect(item[:icon]).to eq(:marketplace) expect(item[:path]).to eq(course_marketplace_path(course)) end end From 2dd5c9fbe91b7503a2c60ac733609156af004b12 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:38 +0800 Subject: [PATCH 06/30] refactor(duplication): shared assessment tree + table empty-state support Extract the assessment/tab/question tree from AssessmentsListing into a reusable DuplicationAssessmentTree component so both the duplication page and the marketplace duplicate dialog render an identical tree. The old DuplicateItemsConfirmation listing is rewired onto it. Also add the shared table primitives the marketplace index needs: - renderEmpty flows through TableTemplate -> Body -> MuiTable so a table can render a custom empty state when it has no rows. - hideSelectAll drops the select-all header checkbox while keeping the per-row checkboxes. - Register the storefront icon in COURSE_COMPONENT_ICONS. --- .../components/DuplicationAssessmentTree.tsx | 143 +++++++++++++++++ .../DuplicationAssessmentTree.test.tsx | 47 ++++++ .../AssessmentsListing.tsx | 144 +++++------------- .../DuplicateItemsConfirmation/index.jsx | 27 ---- .../table/MuiTableAdapter/MuiTable.tsx | 2 + .../TanStackTableBuilder/columnsBuilder.ts | 15 +- .../useTanStackTableBuilder.tsx | 2 + .../app/lib/components/table/adapters/Body.ts | 1 + .../components/table/builder/TableTemplate.ts | 3 + .../table/builder/featureTemplates.ts | 3 + 10 files changed, 250 insertions(+), 137 deletions(-) create mode 100644 client/app/bundles/course/duplication/components/DuplicationAssessmentTree.tsx create mode 100644 client/app/bundles/course/duplication/components/__test__/DuplicationAssessmentTree.test.tsx diff --git a/client/app/bundles/course/duplication/components/DuplicationAssessmentTree.tsx b/client/app/bundles/course/duplication/components/DuplicationAssessmentTree.tsx new file mode 100644 index 0000000000..5ee4c24b35 --- /dev/null +++ b/client/app/bundles/course/duplication/components/DuplicationAssessmentTree.tsx @@ -0,0 +1,143 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Tooltip } from 'react-tooltip'; +import { Card, CardContent } from '@mui/material'; + +import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; +import useTranslation from 'lib/hooks/useTranslation'; + +import TypeBadge from './TypeBadge'; +import UnpublishedIcon from './UnpublishedIcon'; + +export interface DuplicationTreeCategory { + id: number; + title: string; +} +export interface DuplicationTreeTab { + id: number; + title: string; +} +export interface DuplicationTreeAssessment { + id: number; + title: string; +} +export interface DuplicationAssessmentTreeNode { + category: DuplicationTreeCategory | null; + tabs: Array<{ + tab: DuplicationTreeTab | null; + assessments: DuplicationTreeAssessment[]; + }>; +} + +interface Props { + nodes: DuplicationAssessmentTreeNode[]; +} + +// IDs kept identical to the strings previously defined in AssessmentsListing / +// DuplicateItemsConfirmation so locales/en.json needs no re-translation. +const translations = defineMessages({ + defaultCategory: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultCategory', + defaultMessage: 'Default Category', + }, + defaultTab: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultTab', + defaultMessage: 'Default Tab', + }, + itemUnpublished: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.itemUnpublished', + defaultMessage: + 'Items are duplicated as unpublished when duplicating to an existing course.', + }, +}); + +const DuplicationAssessmentTree: FC = ({ nodes }) => { + const { t } = useTranslation(); + + const renderAssessmentRow = ( + assessment: DuplicationTreeAssessment, + ): JSX.Element => ( + + + + {assessment.title} + + } + /> + ); + + const renderTabTree = ( + tab: DuplicationTreeTab | null, + assessments: DuplicationTreeAssessment[], + ): JSX.Element => ( +
+ {tab ? ( + + + {tab.title} + + } + /> + ) : ( + + )} + {assessments.map(renderAssessmentRow)} +
+ ); + + const renderNode = ( + node: DuplicationAssessmentTreeNode, + index: number, + ): JSX.Element => ( + + + {node.category ? ( + + + {node.category.title} + + } + /> + ) : ( + + )} + {node.tabs.map(({ tab, assessments }) => + renderTabTree(tab, assessments), + )} + + + ); + + if (nodes.length === 0) return null; + + return ( + <> + {nodes.map(renderNode)} + {t(translations.itemUnpublished)} + + ); +}; + +export default DuplicationAssessmentTree; diff --git a/client/app/bundles/course/duplication/components/__test__/DuplicationAssessmentTree.test.tsx b/client/app/bundles/course/duplication/components/__test__/DuplicationAssessmentTree.test.tsx new file mode 100644 index 0000000000..313ec57701 --- /dev/null +++ b/client/app/bundles/course/duplication/components/__test__/DuplicationAssessmentTree.test.tsx @@ -0,0 +1,47 @@ +import { render } from 'test-utils'; + +import DuplicationAssessmentTree from '../DuplicationAssessmentTree'; + +it('renders category, tab and assessment rows with badges', async () => { + const page = render( + , + ); + + // I18nProvider shows a LoadingIndicator until locale messages async-load; + // await the first query to render past it, then the rest are synchronous. + expect(await page.findByText('Missions')).toBeVisible(); + expect(page.getByText('Assignments')).toBeVisible(); + expect(page.getByText('Mission 1')).toBeVisible(); + expect(page.getByText('Category')).toBeVisible(); + expect(page.getByText('Tab')).toBeVisible(); + expect(page.getByText('Assessment')).toBeVisible(); +}); + +it('renders disabled default placeholders when category/tab are null', async () => { + const page = render( + , + ); + + expect(await page.findByText('Default Category')).toBeVisible(); + expect(page.getByText('Default Tab')).toBeVisible(); + expect(page.getByText('Mission 1')).toBeVisible(); +}); diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx index 4799c28d3b..99242a48d7 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx @@ -2,16 +2,15 @@ import { FC } from 'react'; import { defineMessages } from 'react-intl'; import { Card, CardContent, ListSubheader } from '@mui/material'; -import TypeBadge from 'course/duplication/components/TypeBadge'; -import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; +import DuplicationAssessmentTree, { + DuplicationAssessmentTreeNode, +} from 'course/duplication/components/DuplicationAssessmentTree'; import { selectDuplicationStore } from 'course/duplication/selectors'; import { DuplicationAssessmentData, - DuplicationCategoryData, DuplicationTabData, } from 'course/duplication/types'; import componentTranslations from 'course/translations'; -import IndentedCheckbox from 'lib/components/core/IndentedCheckbox'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; @@ -32,104 +31,7 @@ const AssessmentsListing: FC = () => { ); const { t } = useTranslation(); - const renderAssessmentRow = ( - assessment: DuplicationAssessmentData, - ): JSX.Element => ( - - - - {assessment.title} - - } - /> - ); - - const renderTabRow = (tab: DuplicationTabData): JSX.Element => ( - - - {tab.title} - - } - /> - ); - - const renderCategoryRow = ( - category: DuplicationCategoryData, - ): JSX.Element => ( - - - {category.title} - - } - /> - ); - - const renderTabTree = ( - tab: DuplicationTabData | null, - children: DuplicationAssessmentData[], - ): JSX.Element => ( -
- {tab ? ( - renderTabRow(tab) - ) : ( - - )} - {children.length > 0 && children.map(renderAssessmentRow)} -
- ); - - const renderCategoryCard = ( - category: DuplicationCategoryData | null, - orphanTabs: DuplicationTabData[], - orphanAssessments: DuplicationAssessmentData[], - ): JSX.Element => { - const tabsTrees = (tabs: DuplicationTabData[]): JSX.Element[] => - tabs.map((tab) => renderTabTree(tab, tab.assessments)); - - return ( - - - {category ? ( - renderCategoryRow(category) - ) : ( - - )} - {orphanAssessments.length > 0 && - renderTabTree(null, orphanAssessments)} - {orphanTabs.length > 0 && tabsTrees(orphanTabs)} - {category && tabsTrees(category.tabs)} - - - ); - }; - - // Identifies connected subtrees of selected categories, tabs and assessments. - const categoriesTrees: DuplicationCategoryData[] = []; + const categoriesTrees: DuplicationCategoryLike[] = []; const tabTrees: DuplicationTabData[] = []; const assessmentTrees: DuplicationAssessmentData[] = []; @@ -156,16 +58,48 @@ const AssessmentsListing: FC = () => { const orphanTreesCount = tabTrees.length + assessmentTrees.length; if (orphanTreesCount + categoriesTrees.length < 1) return null; + const nodes: DuplicationAssessmentTreeNode[] = [ + ...categoriesTrees.map((category) => ({ + category: { id: category.id, title: category.title }, + tabs: category.tabs.map((tab) => ({ + tab: { id: tab.id, title: tab.title }, + assessments: tab.assessments, + })), + })), + ...(orphanTreesCount > 0 + ? [ + { + category: null, + tabs: [ + // Orphan assessments render first (matches prior output order), + // then orphan tabs. + ...(assessmentTrees.length > 0 + ? [{ tab: null, assessments: assessmentTrees }] + : []), + ...tabTrees.map((tab) => ({ + tab: { id: tab.id, title: tab.title }, + assessments: tab.assessments, + })), + ], + }, + ] + : []), + ]; + return ( <> {t(componentTranslations.course_assessments_component)} - {categoriesTrees.map((category) => renderCategoryCard(category, [], []))} - {orphanTreesCount > 0 && - renderCategoryCard(null, tabTrees, assessmentTrees)} + ); }; +type DuplicationCategoryLike = { + id: number; + title: string; + tabs: DuplicationTabData[]; +}; + export default AssessmentsListing; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx index 3a94d9d2b9..b3aa575d2c 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx @@ -1,7 +1,6 @@ import { Component } from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; -import { Tooltip } from 'react-tooltip'; import { Card, CardContent, ListSubheader } from '@mui/material'; import PropTypes from 'prop-types'; @@ -15,7 +14,6 @@ import AchievementsListing from './AchievementsListing'; import AssessmentsListing from './AssessmentsListing'; import MaterialsListing from './MaterialsListing'; import SurveyListing from './SurveyListing'; -import VideosListing from './VideosListing'; const translations = defineMessages({ confirmationQuestion: { @@ -42,34 +40,9 @@ const translations = defineMessages({ id: 'course.duplication.Duplication.DuplicateItemsConfirmation.failureMessage', defaultMessage: 'Duplication failed.', }, - itemUnpublished: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.itemUnpublished', - defaultMessage: - 'Items are duplicated as unpublished when duplicating to an existing course.', - }, }); class DuplicateItemsConfirmation extends Component { - renderListing() { - return ( - <> -

- -

- {this.renderdestinationCourseCard()} - - - - - - - - - - - ); - } - renderdestinationCourseCard() { const { destinationCourses, destinationCourseId } = this.props; const destinationCourse = destinationCourses.find( diff --git a/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx index c4e2957ad9..fa8cf81c7e 100644 --- a/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx +++ b/client/app/lib/components/table/MuiTableAdapter/MuiTable.tsx @@ -20,6 +20,8 @@ const MuiTable = (props: TableProps): JSX.Element => {
+ {props.body.rows.length === 0 && props.body.renderEmpty} + {props.pagination && } ); diff --git a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts index 8ebb312766..5465abf8af 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts +++ b/client/app/lib/components/table/TanStackTableBuilder/columnsBuilder.ts @@ -9,6 +9,7 @@ const buildTanStackColumns = ( columns: ColumnTemplate[], hasCheckboxes?: boolean | ((datum: D) => boolean), hasIndices?: boolean, + hideSelectAll?: boolean, ): BuiltColumns> => { const initialColumns: ColumnDef[] = []; @@ -27,11 +28,15 @@ const buildTanStackColumns = ( enableSorting: false, enableColumnFilter: false, enableGlobalFilter: false, - header: ({ table }): RowSelector => ({ - selected: table.getIsAllRowsSelected(), - indeterminate: table.getIsSomeRowsSelected(), - onChange: table.getToggleAllRowsSelectedHandler(), - }), + // A non-RowSelector header (null) renders an empty cell, dropping the + // select-all checkbox while the per-row `cell` checkboxes remain. + header: hideSelectAll + ? (): null => null + : ({ table }): RowSelector => ({ + selected: table.getIsAllRowsSelected(), + indeterminate: table.getIsSomeRowsSelected(), + onChange: table.getToggleAllRowsSelectedHandler(), + }), cell: ({ row }): RowSelector => ({ selected: row.getIsSelected(), disabled: !row.getCanSelect(), diff --git a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx index b6eca581e9..ae83d895f0 100644 --- a/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx +++ b/client/app/lib/components/table/TanStackTableBuilder/useTanStackTableBuilder.tsx @@ -47,6 +47,7 @@ const useTanStackTableBuilder = ( props.columns, props.indexing?.rowSelectable, props.indexing?.indices, + props.indexing?.hideSelectAll, ); const [columnFilters, setColumnFilters] = useState([]); @@ -337,6 +338,7 @@ const useTanStackTableBuilder = ( }, body: { rows: table.getRowModel().rows, + renderEmpty: props.renderEmpty, getCells: (row) => row.getVisibleCells(), // Use getRealColumnById (ID-based) not getRealColumn(index). getVisibleCells() skips hidden // columns, so its positional index diverges from getRealColumn's full-column-list index diff --git a/client/app/lib/components/table/adapters/Body.ts b/client/app/lib/components/table/adapters/Body.ts index 955602d0dd..c507e53638 100644 --- a/client/app/lib/components/table/adapters/Body.ts +++ b/client/app/lib/components/table/adapters/Body.ts @@ -30,6 +30,7 @@ interface BodyProps { allFilteredSelected?: boolean; someFilteredSelected?: boolean; toggleAllFiltered?: () => void; + renderEmpty?: ReactNode; } export default BodyProps; diff --git a/client/app/lib/components/table/builder/TableTemplate.ts b/client/app/lib/components/table/builder/TableTemplate.ts index d6bba03f11..77c8528896 100644 --- a/client/app/lib/components/table/builder/TableTemplate.ts +++ b/client/app/lib/components/table/builder/TableTemplate.ts @@ -1,3 +1,5 @@ +import { ReactNode } from 'react'; + import ColumnPickerTemplate from './ColumnPickerTemplate'; import ColumnTemplate, { Data } from './ColumnTemplate'; import { @@ -17,6 +19,7 @@ interface TableTemplate { getRowClassName?: (datum: D) => string; getRowEqualityData?: (datum: D) => unknown; className?: string; + renderEmpty?: ReactNode; pagination?: PaginationTemplate; csvDownload?: CsvDownloadTemplate; search?: SearchTemplate; diff --git a/client/app/lib/components/table/builder/featureTemplates.ts b/client/app/lib/components/table/builder/featureTemplates.ts index 76aff60222..9eb1abf707 100644 --- a/client/app/lib/components/table/builder/featureTemplates.ts +++ b/client/app/lib/components/table/builder/featureTemplates.ts @@ -33,6 +33,9 @@ export interface SearchTemplate { export interface IndexingTemplate { rowSelectable?: boolean | ((datum: D) => boolean); indices?: boolean; + // Hides the select-all checkbox in the row-selector column header while + // keeping the per-row checkboxes. No effect unless `rowSelectable` is set. + hideSelectAll?: boolean; } export interface FilterTemplate { From d9ae5c5e868d3c9721c3e576e03daffb17dd1c5f Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:38 +0800 Subject: [PATCH 07/30] feat(marketplace): listing + question preview UI and duplicate dialog Build the read-only browse experience on top of the preview endpoints: - Marketplace index: single-toolbar table with pagination, empty states, hidden select-all, and links into the listing preview / duplicate flow. - Listing preview page: read-only assessment config, per-question cards (type chip, staff-only notes, expandable options) and a Duplicate Assessment action. - Question detail preview: header chip plus a renderer dispatcher with a renderer per question type (multiple/text/voice/forum/programming/ rubric/scribing). - Duplicate dialog now shows the destination course and the shared assessment tree. Includes the api client, operations, types, translations and locale strings backing the above. --- client/app/api/course/Marketplace.ts | 18 +- .../marketplace/__test__/fromTab.test.ts | 29 +++ .../components/DuplicateConfirmation.tsx | 40 +++- .../components/PublishToMarketplaceButton.tsx | 4 +- .../__test__/DuplicationConfirmation.test.tsx | 52 +++++ .../app/bundles/course/marketplace/fromTab.ts | 15 ++ .../bundles/course/marketplace/operations.ts | 31 ++- .../PreviewAssessmentDetails.tsx | 51 +++++ .../ListingPreview/PreviewQuestionCard.tsx | 144 +++++++++++++ .../ListingPreview/__test__/index.test.tsx | 201 ++++++++++++++++++ .../pages/ListingPreview/index.tsx | 97 +++++++++ .../MarketplaceIndex/MarketplaceTable.tsx | 163 ++++++++++---- .../__test__/MarketplaceTable.test.tsx | 182 ++++++++++++++++ .../MarketplaceIndex/__test__/index.test.tsx | 36 ++++ .../pages/MarketplaceIndex/index.tsx | 53 +++-- .../QuestionPreview/__test__/index.test.tsx | 57 +++++ .../pages/QuestionPreview/index.tsx | 99 +++++++++ .../renderers/ForumPostResponse.tsx | 38 ++++ .../renderers/MultipleResponse.tsx | 47 ++++ .../QuestionPreview/renderers/Programming.tsx | 127 +++++++++++ .../renderers/RubricBasedResponse.tsx | 74 +++++++ .../QuestionPreview/renderers/Scribing.tsx | 43 ++++ .../renderers/TextResponse.tsx | 69 ++++++ .../renderers/VoiceResponse.tsx | 9 + .../__test__/ForumPostResponse.test.tsx | 27 +++ .../__test__/MultipleResponse.test.tsx | 50 +++++ .../renderers/__test__/Programming.test.tsx | 51 +++++ .../__test__/RubricBasedResponse.test.tsx | 42 ++++ .../renderers/__test__/Scribing.test.tsx | 39 ++++ .../renderers/__test__/TextResponse.test.tsx | 43 ++++ .../renderers/__test__/VoiceResponse.test.tsx | 28 +++ .../pages/QuestionPreview/renderers/types.ts | 5 + .../course/marketplace/translations.ts | 56 ++++- .../app/bundles/course/marketplace/types.ts | 122 +++++++++++ client/locales/en.json | 37 +++- client/locales/ko.json | 35 ++- client/locales/zh.json | 35 ++- 37 files changed, 2160 insertions(+), 89 deletions(-) create mode 100644 client/app/bundles/course/marketplace/__test__/fromTab.test.ts create mode 100644 client/app/bundles/course/marketplace/fromTab.ts create mode 100644 client/app/bundles/course/marketplace/pages/ListingPreview/PreviewAssessmentDetails.tsx create mode 100644 client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx create mode 100644 client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx create mode 100644 client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/__test__/index.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/index.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/ForumPostResponse.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/MultipleResponse.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Programming.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Scribing.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/TextResponse.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/ForumPostResponse.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/MultipleResponse.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Programming.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/RubricBasedResponse.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Scribing.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/VoiceResponse.test.tsx create mode 100644 client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/types.ts diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 38e84d98a8..344178c65d 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -1,7 +1,7 @@ import { AxiosResponse } from 'axios'; import { JobSubmitted } from 'types/jobs'; -import { MarketplaceListing } from 'course/marketplace/types'; +import { DestinationTab, MarketplaceListing } from 'course/marketplace/types'; import BaseCourseAPI from './Base'; @@ -23,7 +23,11 @@ export default class MarketplaceAPI extends BaseCourseAPI { } index(): Promise< - AxiosResponse<{ listings: MarketplaceListing[]; canAccess: boolean }> + AxiosResponse<{ + listings: MarketplaceListing[]; + destinationTabs: DestinationTab[]; + canAccess: boolean; + }> > { return this.client.get(this.#urlPrefix); } @@ -37,4 +41,14 @@ export default class MarketplaceAPI extends BaseCourseAPI { ...(destinationTabId ? { destination_tab_id: destinationTabId } : {}), }); } + + fetchListing(id: number): Promise { + return this.client.get(`${this.#urlPrefix}/listings/${id}`); + } + + fetchQuestion(listingId: number, questionId: number): Promise { + return this.client.get( + `${this.#urlPrefix}/listings/${listingId}/questions/${questionId}`, + ); + } } diff --git a/client/app/bundles/course/marketplace/__test__/fromTab.test.ts b/client/app/bundles/course/marketplace/__test__/fromTab.test.ts new file mode 100644 index 0000000000..d5d5bce8ac --- /dev/null +++ b/client/app/bundles/course/marketplace/__test__/fromTab.test.ts @@ -0,0 +1,29 @@ +import { readFromTab, withFromTab } from '../fromTab'; + +describe('withFromTab', () => { + it('appends from_tab as the first query param when the path has none', () => { + expect(withFromTab('/courses/1/marketplace', '42')).toBe( + '/courses/1/marketplace?from_tab=42', + ); + }); + + it('appends from_tab with & when the path already has a query string', () => { + expect(withFromTab('/p/1?foo=bar', '42')).toBe('/p/1?foo=bar&from_tab=42'); + }); + + it('returns the path unchanged when from_tab is null', () => { + expect(withFromTab('/courses/1/marketplace', null)).toBe( + '/courses/1/marketplace', + ); + }); +}); + +describe('readFromTab', () => { + it('extracts from_tab from a search string', () => { + expect(readFromTab('?from_tab=42&x=1')).toBe('42'); + }); + + it('returns null when from_tab is absent', () => { + expect(readFromTab('?x=1')).toBeNull(); + }); +}); diff --git a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx index 9adea37690..15392be994 100644 --- a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -1,10 +1,13 @@ import { useEffect, useRef, useState } from 'react'; -import { useIntl } from 'react-intl'; +import { Card, CardContent, ListSubheader } from '@mui/material'; import { JobStatus } from 'types/jobs'; -import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import DuplicationAssessmentTree from 'course/duplication/components/DuplicationAssessmentTree'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; import { pollJobRequest } from 'lib/helpers/jobHelpers'; import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; import { duplicateListings } from '../operations'; import translations from '../translations'; @@ -15,6 +18,9 @@ const JOB_POLL_INTERVAL_MS = 2000; interface Props { listings: Pick[]; destinationTabId: number | null; + destinationCourse: { title: string; url: string }; + destinationCategory: { id: number; title: string } | null; + destinationTab: { id: number; title: string } | null; open: boolean; onClose: () => void; } @@ -22,10 +28,13 @@ interface Props { const DuplicateConfirmation = ({ listings, destinationTabId, + destinationCourse, + destinationCategory, + destinationTab, open, onClose, }: Props): JSX.Element => { - const { formatMessage: t } = useIntl(); + const { t } = useTranslation(); const [submitting, setSubmitting] = useState(false); const [jobUrl, setJobUrl] = useState(null); const pollingRef = useRef(false); @@ -88,9 +97,30 @@ const DuplicateConfirmation = ({ onClose={onClose} open={open} primaryLabel={t(translations.duplicateConfirm)} - title={t(translations.duplicateTitle, { n })} + title={t(translations.confirmationQuestion)} > - {t(translations.duplicateBody, { n })} + + {t(translations.destinationCourse)} + + + + + {destinationCourse.title} + + + + + + {t(translations.assessmentsHeading)} + + ); }; diff --git a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx index 05a2c069bf..56aaaf19fe 100644 --- a/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -1,11 +1,11 @@ import { useState } from 'react'; -import { useIntl } from 'react-intl'; import { Button } from '@mui/material'; import { AssessmentData } from 'types/course/assessment/assessments'; import CourseAPI from 'api/course'; import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; import translations from '../translations'; @@ -21,7 +21,7 @@ const PublishToMarketplaceButton = ({ assessment, onChange, }: Props): JSX.Element | null => { - const { formatMessage: t } = useIntl(); + const { t } = useTranslation(); const [open, setOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const listed = assessment.isPublishedToMarketplace; diff --git a/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx index b0b3d24ddb..98a2ef0047 100644 --- a/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx +++ b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx @@ -10,11 +10,57 @@ beforeEach(() => mock.reset()); const listings = [{ id: 1, title: 'Recursion Drills' }]; const url = `/courses/${global.courseId}/marketplace/listings/duplicate`; +const course = { title: 'Enrollable Course', url: '/courses/4' }; + +it('shows the destination course and assessment tree with real names', async () => { + const page = render( + , + ); + + // I18nProvider shows a LoadingIndicator until locale messages async-load; + // await the first query to render past it, then the rest are synchronous. + expect(await page.findByText('Enrollable Course')).toBeVisible(); + expect(page.getByText('Missions')).toBeVisible(); + expect(page.getByText('Assignments')).toBeVisible(); + expect(page.getByText('Recursion Drills')).toBeVisible(); + // The old raw-key bug must not recur. + expect( + page.queryByText('course.marketplace.duplicateTitle'), + ).not.toBeInTheDocument(); +}); + +it('falls back to Default placeholders when entered without a tab', async () => { + const page = render( + , + ); + + expect(await page.findByText('Default Category')).toBeVisible(); + expect(page.getByText('Default Tab')).toBeVisible(); +}); it('posts a duplication request with the destination tab on confirm', async () => { mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); const page = render( { mock.onPost(url).reply(500); const page = render( + new URLSearchParams(search).get(FROM_TAB_PARAM); + +export const withFromTab = (path: string, fromTab: string | null): string => { + if (!fromTab) return path; + const separator = path.includes('?') ? '&' : '?'; + return `${path}${separator}${FROM_TAB_PARAM}=${fromTab}`; +}; diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts index 0ce1f1d5c1..5b1e555600 100644 --- a/client/app/bundles/course/marketplace/operations.ts +++ b/client/app/bundles/course/marketplace/operations.ts @@ -1,10 +1,19 @@ import CourseAPI from 'api/course'; -import { MarketplaceListing } from './types'; +import { + ListingPreviewData, + MarketplaceIndexData, + QuestionPreviewData, +} from './types'; -export const fetchListings = async (): Promise => { +export const fetchListings = async (): Promise => { const response = await CourseAPI.marketplace.index(); - return response.data.listings as MarketplaceListing[]; + return { + listings: (response.data.listings ?? + []) as MarketplaceIndexData['listings'], + destinationTabs: (response.data.destinationTabs ?? + []) as MarketplaceIndexData['destinationTabs'], + }; }; // Returns the URL of the duplication job to poll. Polling is deliberately left to the caller: it @@ -20,3 +29,19 @@ export const duplicateListings = async ( ); return response.data.jobUrl; }; + +export const fetchListing = async (id: number): Promise => { + const response = await CourseAPI.marketplace.fetchListing(id); + return response.data as ListingPreviewData; +}; + +export const fetchQuestion = async ( + listingId: number, + questionId: number, +): Promise => { + const response = await CourseAPI.marketplace.fetchQuestion( + listingId, + questionId, + ); + return response.data as QuestionPreviewData; +}; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewAssessmentDetails.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewAssessmentDetails.tsx new file mode 100644 index 0000000000..e6c8ae0394 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewAssessmentDetails.tsx @@ -0,0 +1,51 @@ +import { TableBody, TableCell, TableRow } from '@mui/material'; + +// Reuse the assessment show-page's own message descriptors so wording (and locale entries) stay +// identical to AssessmentShow/AssessmentDetails.tsx — no duplicate marketplace keys. +import translations from 'course/assessment/translations'; +import TableContainer from 'lib/components/core/layouts/TableContainer'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { ListingPreviewData } from '../../types'; + +interface Props { + for: ListingPreviewData; +} + +const row = (head: string, value: React.ReactNode): JSX.Element => ( + + {head} + {value} + +); + +const PreviewAssessmentDetails = ({ for: a }: Props): JSX.Element => { + const { t } = useTranslation(); + return ( + + + {row( + t(translations.gradingMode), + a.gradingMode === 'autograded' + ? t(translations.autograded) + : t(translations.manuallyGraded), + )} + {a.baseExp != null && + row(t(translations.baseExp), a.baseExp.toString())} + {a.bonusExp != null && + row(t(translations.bonusExp), a.bonusExp.toString())} + {row( + t(translations.showMcqMrqSolution), + a.showMcqMrqSolution ? '✅' : '❌', + )} + {row( + t(translations.showRubricToStudents), + a.showRubricToStudents ? '✅' : '❌', + )} + {row(t(translations.gradedTestCases), a.gradedTestCases)} + + + ); +}; + +export default PreviewAssessmentDetails; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx new file mode 100644 index 0000000000..912198c849 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react'; +import { + EditNote, + ExpandLess, + ExpandMore, + VisibilityOutlined, +} from '@mui/icons-material'; +import { + Alert, + Button, + Chip, + Collapse, + IconButton, + Radio, + Tooltip, + Typography, +} from '@mui/material'; + +// Reuse the assessment show/editor descriptors (type chip, showOptions/hideOptions, staff-only +// comments) so the card is visually identical to AssessmentShow/Question.tsx minus its controls. +import translations from 'course/assessment/translations'; +import Checkbox from 'lib/components/core/buttons/Checkbox'; +import Link from 'lib/components/core/Link'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import { getCourseId } from 'lib/helpers/url-helpers'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { withFromTab } from '../../fromTab'; +import previewTranslations from '../../translations'; +import { PreviewQuestionSummary } from '../../types'; + +interface Props { + of: PreviewQuestionSummary; + index: number; + listingId: string; + fromTab?: string | null; +} + +const PreviewQuestionCard = ({ + of: q, + index, + listingId, + fromTab = null, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + + const detailUrl = withFromTab( + `/courses/${getCourseId()}/marketplace/listings/${listingId}/questions/${q.id}`, + fromTab, + ); + + return ( +
+
+
+ + {index + 1} + +
+ +
+ {q.title} + +
+ + + {q.unautogradable && ( + + )} +
+
+ + + + + + + + +
+ +
+ {q.description && } + + {q.options && q.options.length > 0 && ( +
+ + + + {q.options.map((choice) => ( + + ))} + +
+ )} + + {q.staffOnlyComments && ( + + + + } + severity="info" + > + + + )} +
+
+ ); +}; + +export default PreviewQuestionCard; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx new file mode 100644 index 0000000000..27d03f45d2 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx @@ -0,0 +1,201 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import ListingPreview from '../index'; + +const mockNavigate = jest.fn(); + +// `TestApp` mounts the component directly inside a `MemoryRouter` with no matching +// ``, so `useParams()` would otherwise be empty and the page +// would fetch `.../listings/NaN`. Mock it to supply the route param, mirroring +// survey/pages/ResponseIndex/__test__. `useNavigate` is spied so the back button's +// navigate() target can be asserted (Page renders backTo as a navigate() button, not a link). +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: (): typeof mockNavigate => mockNavigate, + useParams: (): { listingId: string; courseId: string } => ({ + listingId: '7', + courseId: global.courseId.toString(), + }), +})); + +beforeEach(() => mockNavigate.mockClear()); + +// The Duplicate Assessment button needs the destination course, which the page reads from the +// course outlet context. There is no CourseLayout outlet in the test, so mock the hook (mirrors +// MarketplaceIndex/__test__). +jest.mock('../../../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; courseUrl: string } => ({ + courseTitle: 'Test Course', + courseUrl: `/courses/${global.courseId}`, + }), +})); + +// NOTE: do NOT jest.mock('../../../operations') — this bundle mocks the axios adapter and lets the +// real fetchListing run. Auto-mocking operations makes fetchListing return undefined, and Preload's +// `while` callback then does `undefined.then` → "Cannot read properties of undefined (reading 'then')". +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +const LISTING_TITLE = 'Published, All Question Types'; + +it('renders the read-only assessment config', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + description: '

Awesome description 5

', + gradingMode: 'manual', + baseExp: 1000, + bonusExp: 1000, + showMcqMrqSolution: true, + showRubricToStudents: false, + gradedTestCases: 'Public, Private', + // Backend now serializes human-readable type labels (question_type_readable). + typeCounts: { 'Multiple Choice': 1 }, + questions: [ + { + id: 17, + title: 'The awesome question 17', + description: '

Look at this awesome question

', + staffOnlyComments: '

Deep pedagogical insight.

', + maximumGrade: 2, + type: 'Multiple Choice', + unautogradable: false, + mcqMrqType: 'mcq', + options: [ + { id: 1, option: 'true', correct: true }, + { id: 2, option: 'false', correct: false }, + ], + }, + ], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + + // Description renders in the bordered card, not as bare text. + expect(screen.getByText('Awesome description 5')).toBeVisible(); + // Properties table reuses AssessmentShow's labels. + expect(screen.getByText('Grading mode')).toBeVisible(); + expect(screen.getByText('Base EXP')).toBeVisible(); + expect(screen.getByText('Bonus')).toBeVisible(); + // Type chip + summary breakdown both use the readable label. + expect(screen.getAllByText(/Multiple Choice/).length).toBeGreaterThan(0); + // Author's staff-only notes surface for adopters to judge intent. + expect(screen.getByText('Deep pedagogical insight.')).toBeVisible(); + // Top-right action opens the duplicate flow. + expect( + screen.getByRole('button', { name: 'Duplicate Assessment' }), + ).toBeVisible(); + // The card title is plain text now; the eye icon links into the per-question detail route. + expect(screen.getByText('The awesome question 17')).toBeVisible(); + expect( + screen.getByRole('link', { name: 'View question details' }), + ).toHaveAttribute('href', expect.stringContaining('questions/17')); +}); + +// show.json.jbuilder omits baseExp/bonusExp entirely when the assessment awards none, so the rows +// must disappear rather than render a bare "0". +it('hides the EXP rows when the endpoint omits them', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + description: '', + gradingMode: 'manual', + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + expect(screen.getByText('Grading mode')).toBeVisible(); + expect(screen.queryByText('Base EXP')).not.toBeInTheDocument(); + expect(screen.queryByText('Bonus')).not.toBeInTheDocument(); +}); + +it('carries from_tab into the per-question detail links', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + description: '

desc

', + gradingMode: 'manual', + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: { 'Multiple Choice': 1 }, + questions: [ + { + id: 17, + title: 'The awesome question 17', + description: '', + staffOnlyComments: '', + maximumGrade: 2, + type: 'Multiple Choice', + unautogradable: false, + mcqMrqType: 'mcq', + options: [], + }, + ], + }); + + render(, { at: [`${url}?from_tab=42`] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + expect( + screen.getByRole('link', { name: 'View question details' }), + ).toHaveAttribute('href', expect.stringContaining('from_tab=42')); +}); + +it('navigates back to the marketplace carrying from_tab', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + description: '

desc

', + gradingMode: 'manual', + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [`${url}?from_tab=42`] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + fireEvent.click(screen.getByTestId('ArrowBackIconButton')); + expect(mockNavigate).toHaveBeenCalledWith( + `/courses/${global.courseId}/marketplace?from_tab=42`, + ); +}); + +it('renders a back button to the marketplace index', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + description: '

desc

', + gradingMode: 'manual', + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + // Page renders the back affordance as an IconButton with this testid when `backTo` is set. + expect(screen.getByTestId('ArrowBackIconButton')).toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx new file mode 100644 index 0000000000..8907bc2d43 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -0,0 +1,97 @@ +import { useState } from 'react'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { ContentCopy } from '@mui/icons-material'; +import { Button, Chip, Paper } from '@mui/material'; + +// Reuse the assessment show page's "Questions" heading so wording + locales stay identical. +import assessmentTranslations from 'course/assessment/translations'; +import { useCourseContext } from 'course/container/CourseLoader'; +import DescriptionCard from 'lib/components/core/DescriptionCard'; +import Page from 'lib/components/core/layouts/Page'; +import Subsection from 'lib/components/core/layouts/Subsection'; +import Preload from 'lib/components/wrappers/Preload'; +import useTranslation from 'lib/hooks/useTranslation'; + +import DuplicateConfirmation from '../../components/DuplicateConfirmation'; +import { withFromTab } from '../../fromTab'; +import { fetchListing } from '../../operations'; +import translations from '../../translations'; +import { ListingPreviewData } from '../../types'; + +import PreviewAssessmentDetails from './PreviewAssessmentDetails'; +import PreviewQuestionCard from './PreviewQuestionCard'; + +const ListingPreview = (): JSX.Element => { + const { listingId } = useParams(); + const { t } = useTranslation(); + const { courseTitle, courseUrl } = useCourseContext(); + const [params] = useSearchParams(); + // `from_tab` rides in from the marketplace index so the duplicate lands in the tab the user came + // from; null when they reached the preview directly, which DuplicateConfirmation renders fine. + const fromTab = params.get('from_tab'); + const destinationTabId = parseInt(fromTab ?? '', 10) || null; + const [duplicating, setDuplicating] = useState(false); + + return ( + } + while={(): Promise => fetchListing(Number(listingId))} + > + {(listing): JSX.Element => ( + setDuplicating(true)} + startIcon={} + variant="contained" + > + {t(translations.duplicateAssessment)} + + } + backTo={withFromTab(`${courseUrl}/marketplace`, fromTab)} + className="space-y-5" + title={listing.title} + > + {listing.description && ( + + )} + + + + +
+ {Object.entries(listing.typeCounts).map(([type, n]) => ( + + ))} +
+ + + {listing.questions.map((question, index) => ( + + ))} + +
+ + setDuplicating(false)} + open={duplicating} + /> +
+ )} +
+ ); +}; + +export default ListingPreview; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx index 5c90dc2e3c..416bd9b286 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx @@ -1,21 +1,40 @@ import { useMemo, useState } from 'react'; import { useIntl } from 'react-intl'; -import { MenuItem, TextField } from '@mui/material'; +import { + ContentCopy, + StorefrontOutlined, + VisibilityOutlined, +} from '@mui/icons-material'; +import { + Button, + IconButton, + MenuItem, + TextField, + Tooltip, + Typography, +} from '@mui/material'; import Link from 'lib/components/core/Link'; import Table, { ColumnTemplate } from 'lib/components/table'; +import { formatLongDate } from 'lib/moment'; +import { withFromTab } from '../../fromTab'; import translations from '../../translations'; import { MarketplaceListing } from '../../types'; type SortMode = 'adoptions' | 'newest'; interface Props { + fromTab?: string | null; listings: MarketplaceListing[]; onDuplicate: (rows: MarketplaceListing[]) => void; } -const MarketplaceTable = ({ listings, onDuplicate }: Props): JSX.Element => { +const MarketplaceTable = ({ + fromTab = null, + listings, + onDuplicate, +}: Props): JSX.Element => { const { formatMessage: t } = useIntl(); const [sortMode, setSortMode] = useState('adoptions'); @@ -48,56 +67,116 @@ const MarketplaceTable = ({ listings, onDuplicate }: Props): JSX.Element => { title: t(translations.colAdoptions), cell: (l) => l.adoptions, }, + { + of: 'firstPublishedAt', + title: t(translations.colPublished), + cell: (l) => formatLongDate(l.firstPublishedAt), + }, { id: 'actions', title: t(translations.colActions), cell: (l) => ( - <> - - {t(translations.preview)} - - - +
+ + + + + + + onDuplicate([l])} + size="small" + > + + + +
), }, ]; + // Rendered in BOTH toolbar states (idle `buttons` and active `activeToolbar`), + // because `buttons` are hidden once a row is selected. Value is controlled by + // parent state, so remounting across states preserves the chosen sort. + const sortControl = ( + setSortMode(e.target.value as SortMode)} + select + size="small" + value={sortMode} + > + {t(translations.sortMostAdopted)} + {t(translations.sortNewest)} + + ); + + // Idle state: disabled, same position/style as the active button (must not move). + const idleDuplicateButton = ( + + ); + + const emptyState = ( +
+ + + {t( + listings.length === 0 + ? translations.emptyNoListings + : translations.emptyNoMatch, + )} + +
+ ); + return ( - <> - setSortMode(e.target.value as SortMode)} - select - size="small" - value={sortMode} - > - {t(translations.sortMostAdopted)} - {t(translations.sortNewest)} - - l.id.toString()} - indexing={{ rowSelectable: true }} - search={{ - searchPlaceholder: t(translations.searchPlaceholder), - searchProps: { - shouldInclude: (l, filter): boolean => - !filter || l.title.toLowerCase().includes(filter.toLowerCase()), - }, - }} - toolbar={{ - show: true, - activeToolbar: (rows) => ( -
l.id.toString()} + indexing={{ rowSelectable: true, hideSelectAll: true }} + pagination={{ initialPageSize: 20, rowsPerPage: [10, 20, 50] }} + renderEmpty={emptyState} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (l, filter): boolean => + !filter || l.title.toLowerCase().includes(filter.toLowerCase()), + }, + }} + toolbar={{ + show: true, + keepNative: true, + buttons: [sortControl, idleDuplicateButton], + activeToolbar: (rows) => ( +
+ {sortControl} + - ), - }} - /> - + +
+ ), + }} + /> ); }; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx new file mode 100644 index 0000000000..aeb5701ce5 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx @@ -0,0 +1,182 @@ +import userEvent from '@testing-library/user-event'; +import { fireEvent, render, waitFor } from 'test-utils'; + +import { MarketplaceListing } from '../../../types'; + +import MarketplaceTable from '../MarketplaceTable'; + +// Sort keys disagree so order is meaningful: Graph Theory is most-adopted, Recursion newest. +const LISTINGS: MarketplaceListing[] = [ + { + id: 1, + assessmentId: 10, + title: 'Recursion Drills', + questionCount: 8, + adoptions: 5, + firstPublishedAt: '2026-06-01T00:00:00Z', + previewUrl: '/p/1', + duplicateUrl: '/d', + }, + { + id: 2, + assessmentId: 11, + title: 'Graph Theory', + questionCount: 3, + adoptions: 12, + firstPublishedAt: '2026-01-01T00:00:00Z', + previewUrl: '/p/2', + duplicateUrl: '/d', + }, +]; + +it('shows a disabled "Select to duplicate" button when nothing is selected', async () => { + const page = render( + , + ); + // findBy: test-utils wraps the tree in a translations Suspense (LoadingIndicator fallback). + const idle = await page.findByRole('button', { name: 'Select to duplicate' }); + expect(idle).toBeDisabled(); +}); + +it('renders Preview and Duplicate as icon buttons with one-word tooltips/labels', async () => { + const onDuplicate = jest.fn(); + const page = render( + , + ); + + const previews = await page.findAllByLabelText('Preview'); + previews.forEach((el) => expect(el).not.toHaveAttribute('target')); + expect(previews.map((el) => el.getAttribute('href'))).toEqual( + expect.arrayContaining(['/p/1', '/p/2']), + ); + + const duplicates = await page.findAllByLabelText('Duplicate'); + // Default sort = adoptions desc → Graph Theory (12) is the first row. + fireEvent.click(duplicates[0]); + expect(onDuplicate).toHaveBeenCalledWith([ + expect.objectContaining({ title: 'Graph Theory' }), + ]); +}); + +it('carries from_tab into the preview links when set', async () => { + const page = render( + , + ); + const previews = await page.findAllByLabelText('Preview'); + expect(previews.map((el) => el.getAttribute('href'))).toEqual( + expect.arrayContaining(['/p/1?from_tab=42', '/p/2?from_tab=42']), + ); +}); + +it('renders one checkbox per row and no select-all header checkbox', async () => { + const page = render( + , + ); + await page.findByText('Graph Theory'); + + // Only per-row checkboxes — the select-all header checkbox is removed. + expect(page.getAllByRole('checkbox')).toHaveLength(LISTINGS.length); +}); + +it('keeps the search bar visible and shows an enabled count button on selection', async () => { + const onDuplicate = jest.fn(); + const page = render( + , + ); + await page.findByText('Graph Theory'); + + // Data-row checkboxes follow any header checkbox — click the last one to select a row. + const checkboxes = page.getAllByRole('checkbox'); + fireEvent.click(checkboxes[checkboxes.length - 1]); + + // Regression for the vanishing-search bug: search must remain after selection. + expect(page.getByPlaceholderText('Search by title')).toBeVisible(); + + const bulk = page.getByRole('button', { name: 'Duplicate 1 assessment' }); + expect(bulk).toBeEnabled(); + fireEvent.click(bulk); + expect(onDuplicate).toHaveBeenCalledTimes(1); + expect(onDuplicate.mock.calls[0][0]).toHaveLength(1); +}); + +it('paginates to the default page size of 20', async () => { + const many: MarketplaceListing[] = Array.from({ length: 25 }, (_, i) => ({ + id: i + 1, + assessmentId: 100 + i, + title: `Listing ${String(i).padStart(2, '0')}`, + questionCount: 1, + adoptions: 25 - i, // Listing 00 highest → page 1; Listing 24 lowest → page 2 + firstPublishedAt: '2026-01-01T00:00:00Z', + previewUrl: `/p/${i}`, + duplicateUrl: '/d', + })); + + const page = render( + , + ); + await page.findByText('Listing 00'); + expect(page.queryByText('Listing 24')).not.toBeInTheDocument(); +}); + +it('shows a no-match message when the search filters everything, keeping the search bar', async () => { + const page = render( + , + ); + await page.findByText('Graph Theory'); + + // userEvent (not fireEvent) for the search field — React 18 startTransition. + await userEvent.type(page.getByPlaceholderText('Search by title'), 'zzzzz'); + + await waitFor(() => + expect(page.getByText('No assessments match your search.')).toBeVisible(), + ); + // The search bar must remain so the user can clear the query. + expect(page.getByPlaceholderText('Search by title')).toBeVisible(); +}); + +it('shows an empty-marketplace message when there are no listings at all', async () => { + const page = render( + , + ); + expect( + await page.findByText( + 'No assessments have been published to the marketplace yet.', + ), + ).toBeVisible(); +}); + +it('shows the published date, formatted', async () => { + const page = render( + , + ); + await page.findByText('Graph Theory'); + // formatLongDate('2026-06-01T00:00:00Z') under TZ=Asia/Singapore → '01 Jun 2026'. + expect(page.getByText('01 Jun 2026')).toBeVisible(); + expect(page.getByText('01 Jan 2026')).toBeVisible(); +}); + +it('sorts by published date (not adoptions) when Newest is selected', async () => { + const onDuplicate = jest.fn(); + const page = render( + , + ); + await page.findByText('Graph Theory'); + + // Drive the MUI select-mode "Sort by" TextField (idiom mirrored from the sibling + // MarketplaceIndex test): mouseDown the labelled control, then click the option. + fireEvent.mouseDown(page.getByLabelText('Sort by')); + fireEvent.click(page.getByRole('option', { name: 'Newest' })); + + // Recursion Drills has the most recent firstPublishedAt (2026-06) despite fewer adoptions, + // so it must lead. Icon buttons render in row order, so the first Duplicate button belongs + // to the first row. + const duplicates = await page.findAllByLabelText('Duplicate'); + fireEvent.click(duplicates[0]); + expect(onDuplicate).toHaveBeenCalledWith([ + expect.objectContaining({ title: 'Recursion Drills' }), + ]); +}); diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx index 2d1940b9d9..f09510b46e 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx @@ -6,6 +6,13 @@ import CourseAPI from 'api/course'; import MarketplaceIndex from '../index'; +jest.mock('../../../../container/CourseLoader', () => ({ + useCourseContext: (): { courseTitle: string; courseUrl: string } => ({ + courseTitle: 'Test Course', + courseUrl: '/courses/4', + }), +})); + const mock = createMockAdapter(CourseAPI.marketplace.client); beforeEach(() => mock.reset()); @@ -81,3 +88,32 @@ it('filters rows by the title search', async () => { ); expect(page.getByText('Graph Theory')).toBeVisible(); }); + +it('carries from_tab into the preview links', async () => { + mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true }); + const page = render(, { at: [`${url}?from_tab=7`] }); + await renderPage(page); + + const previews = page.getAllByLabelText('Preview'); + expect(previews.map((el) => el.getAttribute('href'))).toEqual( + expect.arrayContaining(['/p/1?from_tab=7', '/p/2?from_tab=7']), + ); +}); + +it('opens the confirmation with the resolved destination tab', async () => { + mock.onGet(url).reply(200, { + listings: LISTINGS, + canAccess: true, + destinationTabs: [ + { id: 7, title: 'Assignments', categoryId: 3, categoryTitle: 'Missions' }, + ], + }); + const page = render(, { at: [`${url}?from_tab=7`] }); + await renderPage(page); + + fireEvent.click(page.getAllByLabelText('Duplicate')[0]); + + expect(await page.findByText('Test Course')).toBeVisible(); + expect(page.getByText('Missions')).toBeVisible(); + expect(page.getByText('Assignments')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx index acc11b2222..4b1f2850fc 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -2,35 +2,62 @@ import { useState } from 'react'; import { useIntl } from 'react-intl'; import { useSearchParams } from 'react-router-dom'; +import { useCourseContext } from 'course/container/CourseLoader'; import Page from 'lib/components/core/layouts/Page'; import Preload from 'lib/components/wrappers/Preload'; import DuplicateConfirmation from '../../components/DuplicateConfirmation'; import { fetchListings } from '../../operations'; import translations from '../../translations'; -import { MarketplaceListing } from '../../types'; +import { DestinationTab, MarketplaceListing } from '../../types'; import MarketplaceTable from './MarketplaceTable'; const MarketplaceIndex = (): JSX.Element => { const { formatMessage: t } = useIntl(); + const { courseTitle, courseUrl } = useCourseContext(); const [params] = useSearchParams(); - const destinationTabId = parseInt(params.get('from_tab') ?? '', 10) || null; + const fromTab = params.get('from_tab'); + const destinationTabId = parseInt(fromTab ?? '', 10) || null; const [pending, setPending] = useState([]); + const resolveDestination = ( + tabs: DestinationTab[], + ): { + category: { id: number; title: string } | null; + tab: { id: number; title: string } | null; + } => { + const match = tabs.find((tab) => tab.id === destinationTabId); + if (!match) return { category: null, tab: null }; + return { + category: { id: match.categoryId, title: match.categoryTitle }, + tab: { id: match.id, title: match.title }, + }; + }; + return ( } while={fetchListings}> - {(listings): JSX.Element => ( - - - setPending([])} - open={pending.length > 0} - /> - - )} + {({ listings, destinationTabs }): JSX.Element => { + const destination = resolveDestination(destinationTabs); + return ( + + + setPending([])} + open={pending.length > 0} + /> + + ); + }} ); }; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/__test__/index.test.tsx new file mode 100644 index 0000000000..82654cf241 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/__test__/index.test.tsx @@ -0,0 +1,57 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { render, screen, waitFor } from 'test-utils'; + +import CourseAPI from 'api/course'; + +import QuestionPreview from '../index'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: (): { + listingId: string; + questionId: string; + courseId: string; + } => ({ + listingId: '7', + questionId: '3', + courseId: global.courseId.toString(), + }), +})); + +const mock = createMockAdapter(CourseAPI.marketplace.client); +beforeEach(() => mock.reset()); + +it('renders the question and dispatches to the type-specific renderer', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7/questions/3`; + mock.onGet(url).reply(200, { + id: 3, + title: 'Sorting in Python', + defaultTitle: 'Question 1', + description: '

Implement sort

', + staffOnlyComments: '', + maximumGrade: 10, + type: 'Programming', + displayType: 'Programming', + detail: { + languageName: 'Python 3.10', + memoryLimit: 32, + timeLimit: 10, + templateFiles: [{ filename: 'main.py', content: 'print(1)' }], + publicTestCases: [], + privateTestCases: [], + evaluationTestCases: [], + }, + }); + + render(, { at: [url] }); + + await waitFor(() => + expect(screen.getByDisplayValue('Sorting in Python')).toBeVisible(), + ); + // The human-readable type chip (displayType) renders beside the Title field. + expect(screen.getByText('Programming')).toBeVisible(); + // Shell renders the reused "Grading" section + "Maximum grade" label around the renderer. + expect(screen.getByText('Grading')).toBeVisible(); + expect(screen.getByText('Maximum grade')).toBeVisible(); + expect(screen.getByTestId('renderer-Programming')).toBeInTheDocument(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/index.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/index.tsx new file mode 100644 index 0000000000..da46296283 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/index.tsx @@ -0,0 +1,99 @@ +import { useParams } from 'react-router-dom'; +import { EditNote } from '@mui/icons-material'; +import { Chip, TextField, Typography } from '@mui/material'; + +import assessmentTranslations from 'course/assessment/translations'; +import Page from 'lib/components/core/layouts/Page'; +import Section from 'lib/components/core/layouts/Section'; +import Subsection from 'lib/components/core/layouts/Subsection'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import Preload from 'lib/components/wrappers/Preload'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { fetchQuestion } from '../../operations'; +import { QuestionPreviewData } from '../../types'; + +import ForumPostResponse from './renderers/ForumPostResponse'; +import MultipleResponse from './renderers/MultipleResponse'; +import Programming from './renderers/Programming'; +import RubricBasedResponse from './renderers/RubricBasedResponse'; +import Scribing from './renderers/Scribing'; +import TextResponse from './renderers/TextResponse'; +import { RendererProps } from './renderers/types'; +import VoiceResponse from './renderers/VoiceResponse'; + +const RENDERERS: Record JSX.Element | null> = { + MultipleResponse, + Programming, + TextResponse, + RubricBasedResponse, + ForumPostResponse, + VoiceResponse, + Scribing, +}; + +const QuestionPreview = (): JSX.Element => { + const { t } = useTranslation(); + const { listingId, questionId } = useParams(); + return ( + } + while={(): Promise => + fetchQuestion(Number(listingId), Number(questionId)) + } + > + {(question): JSX.Element => { + const Renderer = RENDERERS[question.type]; + return ( + +
+ + {question.displayType && ( + + )} + {question.description && ( + + + + )} + {question.staffOnlyComments && ( + } + subtitle={t(assessmentTranslations.staffOnlyCommentsHint)} + title={t(assessmentTranslations.staffOnlyComments)} + > + + + )} +
+ +
+
+ + {t(assessmentTranslations.maximumGrade)} + + {question.maximumGrade} +
+
+ + {Renderer ? : null} +
+ ); + }} +
+ ); +}; + +export default QuestionPreview; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/ForumPostResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/ForumPostResponse.tsx new file mode 100644 index 0000000000..19c44243bd --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/ForumPostResponse.tsx @@ -0,0 +1,38 @@ +import { Typography } from '@mui/material'; + +// Reuse the forum-post editor field labels (max posts, text response) from +// course/assessment/translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type ForumPostDetail = Extract< + QuestionPreviewData['detail'], + { maxPosts: number } +>; + +const ForumPostResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as ForumPostDetail; + return ( +
+
+ + {t(translations.maxPosts)}: {detail.maxPosts} + + + {t(translations.textResponse)}: {detail.hasTextResponse ? '✅' : '❌'} + +
+
+ ); +}; + +export default ForumPostResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/MultipleResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/MultipleResponse.tsx new file mode 100644 index 0000000000..7f284736fe --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/MultipleResponse.tsx @@ -0,0 +1,47 @@ +import { Radio } from '@mui/material'; + +// Reuse the assessment editor's own field labels (same wording + locale entries) instead of +// minting marketplace-local duplicates. `choices` lives in course/assessment/translations. +import translations from 'course/assessment/translations'; +import Checkbox from 'lib/components/core/buttons/Checkbox'; +import Section from 'lib/components/core/layouts/Section'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { RendererProps } from './types'; + +const MultipleResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as Extract< + typeof question.detail, + { gradingScheme: string } + >; + const isMcq = detail.gradingScheme === 'any_correct'; + return ( +
+
+
+ {detail.options.map((choice) => ( +
+ + {choice.explanation && ( + + )} +
+ ))} +
+
+
+ ); +}; + +export default MultipleResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Programming.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Programming.tsx new file mode 100644 index 0000000000..8d77708c05 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Programming.tsx @@ -0,0 +1,127 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +// Reuse the programming-editor field labels (Language/limits, Templates, Test cases, and the +// Expression/Expected/Hint table headers) from course/assessment/translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { ProgrammingTestCase, QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type ProgrammingDetail = Extract< + QuestionPreviewData['detail'], + { templateFiles: unknown } +>; + +interface TestCaseTableProps { + title: string; + rows: ProgrammingTestCase[]; +} + +const TestCaseTable = ({ + title, + rows, +}: TestCaseTableProps): JSX.Element | null => { + const { t } = useTranslation(); + if (!rows.length) return null; + return ( +
+ {title} +
+
+ + + {t(translations.expression)} + {t(translations.expected)} + {t(translations.hint)} + + + + {rows.map((tc) => ( + + {tc.expression} + {tc.expected} + {tc.hint} + + ))} + +
+ + + ); +}; + +const LabeledRow = ({ + label, + value, +}: { + label: string; + value: string | number; +}): JSX.Element => ( +
+ + {label} + + {value} +
+); + +const Programming = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as ProgrammingDetail; + return ( +
+
+ + + +
+ +
+ {detail.templateFiles.map((file) => ( +
+ {file.filename} +
+              {file.content}
+            
+
+ ))} +
+ +
+ + + +
+
+ ); +}; + +export default Programming; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx new file mode 100644 index 0000000000..7d16ce5e21 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx @@ -0,0 +1,74 @@ +import { + Chip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material'; + +// Field labels (Rubric heading, Grade, Explanation) come from course/assessment/translations; +// only the "Bonus" category chip has no equivalent there and lives in the marketplace translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import useTranslation from 'lib/hooks/useTranslation'; + +import previewTranslations from '../../../translations'; +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type RubricDetail = Extract< + QuestionPreviewData['detail'], + { categories: unknown } +>; + +const RubricBasedResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as RubricDetail; + return ( +
+
+ {detail.categories.map((category) => ( +
+
+ {category.name} + {category.isBonus && ( + + )} +
+
+ + + + {t(translations.grade)} + {t(translations.explanation)} + + + + {category.criteria.map((criterion, index) => ( + + {criterion.grade} + + + + + ))} + +
+
+
+ ))} +
+
+ ); +}; + +export default RubricBasedResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Scribing.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Scribing.tsx new file mode 100644 index 0000000000..53344d8820 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/Scribing.tsx @@ -0,0 +1,43 @@ +import { Typography } from '@mui/material'; + +import Section from 'lib/components/core/layouts/Section'; +import useTranslation from 'lib/hooks/useTranslation'; + +// The "cannot be previewed" empty state is marketplace-preview-specific (the cross-instance +// attachment-URL limitation); no assessment-editor label matches, so it lives in the local keys. +import translations from '../../../translations'; +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type ScribingDetail = Extract< + QuestionPreviewData['detail'], + { imageUrl: string | null } +>; + +const Scribing = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as ScribingDetail; + // A scribing question has no field labels of its own — the background image (or its empty-state + // note) is the whole content. Render it in a title-less Section so it still aligns under the lg=9 + // content column like every other section. + return ( +
+
+ {detail.imageUrl ? ( + {question.title} + ) : ( + + {t(translations.noPreviewImage)} + + )} +
+
+ ); +}; + +export default Scribing; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/TextResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/TextResponse.tsx new file mode 100644 index 0000000000..4433e6c29d --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/TextResponse.tsx @@ -0,0 +1,69 @@ +import { Chip, Typography } from '@mui/material'; + +// Reuse the text-response editor field labels (Attachment settings, Max attachments, Solutions, +// Grade, Explanation, Comprehension) from course/assessment/translations. +import translations from 'course/assessment/translations'; +import Section from 'lib/components/core/layouts/Section'; +import UserHTMLText from 'lib/components/core/UserHTMLText'; +import useTranslation from 'lib/hooks/useTranslation'; + +import { QuestionPreviewData } from '../../../types'; + +import { RendererProps } from './types'; + +type TextResponseDetail = Extract< + QuestionPreviewData['detail'], + { solutions: unknown } +>; + +const TextResponse = ({ question }: RendererProps): JSX.Element => { + const { t } = useTranslation(); + const detail = question.detail as TextResponseDetail; + const showAttachments = detail.maxAttachments > 0; + const showSolutions = detail.solutions.length > 0; + // The comprehension marker rides at the top of the first rendered section so it stays inside the + // lg=9 content column (a bare chip above the sections would misalign). + const comprehensionChip = detail.isComprehension ? ( + + ) : null; + return ( +
+ {showAttachments && ( +
+ {comprehensionChip} + + {t(translations.maxAttachments)}: {detail.maxAttachments} + +
+ )} + + {showSolutions && ( +
+ {!showAttachments && comprehensionChip} + {detail.solutions.map((solution, index) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + + {t(translations.grade)}: {solution.grade} + + {solution.explanation && ( + + )} +
+ ))} +
+ )} +
+ ); +}; + +export default TextResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx new file mode 100644 index 0000000000..0169eadb48 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx @@ -0,0 +1,9 @@ +import { RendererProps } from './types'; + +// Voice questions carry no type-specific setup — the prompt is the base description, which the +// shell already renders alongside the max grade in its "Question details" and "Grading" sections. +// Mirroring the native edit UI (which shows nothing extra for voice), this renderer contributes +// no section. +const VoiceResponse = (_props: RendererProps): JSX.Element | null => null; + +export default VoiceResponse; diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/ForumPostResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/ForumPostResponse.test.tsx new file mode 100644 index 0000000000..9138a20f97 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/ForumPostResponse.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import ForumPostResponse from '../ForumPostResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Discuss', + defaultTitle: 'Question 1', + description: '

Post in the forum

', + staffOnlyComments: '', + maximumGrade: 3, + type: 'ForumPostResponse', + displayType: 'Forum Post Response', + detail: { maxPosts: 3, hasTextResponse: true }, +}; + +it('renders the required post count and the text-response requirement', async () => { + render(); + + // maxPosts is interpolated into a line → match the number within it. + expect(await screen.findByText(/3/)).toBeVisible(); + // hasTextResponse true → the text-response-required line shows. + expect(screen.getByText(/text response/i)).toBeVisible(); + // Requirements now live under the reused "Additional Settings" section. + expect(screen.getByText('Additional Settings')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/MultipleResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/MultipleResponse.test.tsx new file mode 100644 index 0000000000..000cfe7a50 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/MultipleResponse.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import MultipleResponse from '../MultipleResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Capital of France', + defaultTitle: 'Question 1', + description: '

Pick one

', + staffOnlyComments: '', + maximumGrade: 1, + type: 'MultipleResponse', + displayType: 'Multiple Choice', + detail: { + gradingScheme: 'any_correct', // MCQ → single-select (Radio) + options: [ + { + id: 1, + option: '

Paris

', + correct: true, + explanation: '

Correct!

', + weight: 1, + }, + { + id: 2, + option: '

London

', + correct: false, + explanation: '

Wrong city

', + weight: 0, + }, + ], + }, +}; + +it('renders each choice, marks the correct one, and shows explanations', async () => { + render(); + + expect(await screen.findByText('Paris')).toBeVisible(); + expect(screen.getByText('London')).toBeVisible(); + expect(screen.getByText('Correct!')).toBeVisible(); + // Options now live under the reused "Choices" section. + expect(screen.getByTestId('renderer-MultipleResponse')).toBeInTheDocument(); + expect(screen.getByText('Choices')).toBeVisible(); + + // gradingScheme 'any_correct' → MCQ → Radio inputs, correct option checked. + const radios = screen.getAllByRole('radio'); + expect(radios[0]).toBeChecked(); + expect(radios[1]).not.toBeChecked(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Programming.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Programming.test.tsx new file mode 100644 index 0000000000..34feb41492 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Programming.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import Programming from '../Programming'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Sorting in Python', + defaultTitle: 'Question 1', + description: '

Implement sort

', + staffOnlyComments: '', + maximumGrade: 10, + type: 'Programming', + displayType: 'Programming', + detail: { + languageName: 'Python 3.10', + memoryLimit: 32, + timeLimit: 10, + templateFiles: [{ filename: 'main.py', content: 'print(1)' }], + publicTestCases: [ + { + identifier: 'pub_1', + expression: 'sort([3,1,2])', + expected: '[1,2,3]', + hint: 'ascending', + }, + ], + privateTestCases: [ + { + identifier: 'priv_1', + expression: 'sort([])', + expected: '[]', + hint: '', + }, + ], + evaluationTestCases: [], + }, +}; + +it('renders the language, template file, and public/private test-case tables', async () => { + render(); + + expect(await screen.findByText('main.py')).toBeVisible(); + expect(screen.getByText('print(1)')).toBeVisible(); + expect(screen.getByText(/Python 3\.10/)).toBeVisible(); // interpolated into the summary line + expect(screen.getByText('sort([3,1,2])')).toBeVisible(); // public bucket + expect(screen.getByText('sort([])')).toBeVisible(); // private bucket + // Content is grouped under the reused Templates / Test cases sections. + expect(screen.getByText('Templates')).toBeVisible(); + expect(screen.getByText('Test cases')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/RubricBasedResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/RubricBasedResponse.test.tsx new file mode 100644 index 0000000000..d83326adb9 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/RubricBasedResponse.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import RubricBasedResponse from '../RubricBasedResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Essay', + defaultTitle: 'Question 1', + description: '

Write an essay

', + staffOnlyComments: '', + maximumGrade: 7, + type: 'RubricBasedResponse', + displayType: 'Rubric-Based Response', + detail: { + categories: [ + { + name: 'Clarity', + isBonus: false, + criteria: [{ grade: 5, explanation: '

Very clear

' }], + }, + { + name: 'Extra credit', + isBonus: true, + criteria: [{ grade: 2, explanation: '

Nice touch

' }], + }, + ], + }, +}; + +it('renders each category, its criteria, and a bonus marker', async () => { + render(); + + expect(await screen.findByText('Clarity')).toBeVisible(); + expect(screen.getByText('Extra credit')).toBeVisible(); + expect(screen.getByText('Very clear')).toBeVisible(); + expect(screen.getByText('Nice touch')).toBeVisible(); + // isBonus category → a "Bonus" chip/label (match the chosen `bonus` translation). + expect(screen.getByText(/bonus/i)).toBeVisible(); + // Categories now live under the reused "Rubric" section. + expect(screen.getByText('Rubric')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Scribing.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Scribing.test.tsx new file mode 100644 index 0000000000..31751fca9a --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/Scribing.test.tsx @@ -0,0 +1,39 @@ +import { render, screen, waitFor } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import Scribing from '../Scribing'; + +const base = { + id: 3, + title: 'Label the diagram', + defaultTitle: 'Question 1', + description: '

Annotate

', + staffOnlyComments: '', + maximumGrade: 4, + type: 'Scribing', + displayType: 'Scribing', +} as const; + +it('renders the background image when imageUrl is present', async () => { + const question: QuestionPreviewData = { + ...base, + detail: { imageUrl: 'https://example.test/diagram.png' }, + }; + const { container } = render(); + + await waitFor(() => + expect(container.querySelector('img')).toBeInTheDocument(), + ); + expect(container.querySelector('img')).toHaveAttribute( + 'src', + 'https://example.test/diagram.png', + ); +}); + +it('renders an empty-state note when imageUrl is null', async () => { + const question: QuestionPreviewData = { ...base, detail: { imageUrl: null } }; + render(); + + // No image → "not previewable" empty state (match `noPreviewImage`). + expect(await screen.findByText(/preview/i)).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx new file mode 100644 index 0000000000..6f3a705ec4 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx @@ -0,0 +1,43 @@ +// pages/QuestionPreview/renderers/__test__/TextResponse.test.tsx +import { render, screen } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import TextResponse from '../TextResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Explain recursion', + defaultTitle: 'Question 1', + description: '

In your own words

', + staffOnlyComments: '', + maximumGrade: 8, + type: 'TextResponse', + displayType: 'Text Response', + detail: { + hideText: false, + isAttachmentRequired: true, + maxAttachments: 2, + maxAttachmentSize: null, + isComprehension: false, + solutions: [ + { + solutionType: 'exact_match', + solution: '

A function calling itself

', + grade: 8, + explanation: '

Model answer

', + }, + ], + }, +}; + +it('renders solutions and, when attachments are allowed, the attachment line', async () => { + render(); + + expect(await screen.findByText('A function calling itself')).toBeVisible(); + expect(screen.getByText('Model answer')).toBeVisible(); + // maxAttachments > 0 → attachments-allowed line (match the chosen translation). + expect(screen.getByText(/max number of attachments/i)).toBeVisible(); + // Content is grouped under the reused Attachment Settings / Solutions sections. + expect(screen.getByText('Attachment Settings')).toBeVisible(); + expect(screen.getByText('Solutions')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/VoiceResponse.test.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/VoiceResponse.test.tsx new file mode 100644 index 0000000000..5ac86d1222 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/__test__/VoiceResponse.test.tsx @@ -0,0 +1,28 @@ +import { render, screen, waitFor } from 'test-utils'; + +import { QuestionPreviewData } from '../../../../types'; +import VoiceResponse from '../VoiceResponse'; + +const question: QuestionPreviewData = { + id: 3, + title: 'Read aloud', + defaultTitle: 'Question 1', + description: '

Record yourself

', + staffOnlyComments: '', + maximumGrade: 5, + type: 'VoiceResponse', + displayType: 'Voice Response', + detail: {}, // voice carries no type-specific setup +}; + +it('contributes no type-specific section (prompt + grade live in the shell)', async () => { + const { container } = render(); + + // Wait out the I18nProvider's async loading spinner, then confirm the renderer itself added + // nothing — voice questions are carried entirely by the shell's "Question details"/"Grading". + // (`container` still holds provider chrome like the Toastify region, so assert on visible text.) + await waitFor(() => + expect(screen.queryByTestId('CircularProgress')).not.toBeInTheDocument(), + ); + expect(container.textContent).toBe(''); +}); diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/types.ts b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/types.ts new file mode 100644 index 0000000000..a5e013a620 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/types.ts @@ -0,0 +1,5 @@ +import { QuestionPreviewData } from '../../../types'; + +export interface RendererProps { + question: QuestionPreviewData; +} diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index c73d3012df..5e471f2085 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -16,7 +16,7 @@ export default defineMessages({ publishConfirmBody: { id: 'course.marketplace.publishConfirmBody', defaultMessage: - 'This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description.', + 'This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title.', }, removeConfirmTitle: { id: 'course.marketplace.removeConfirmTitle', @@ -65,10 +65,22 @@ export default defineMessages({ id: 'course.marketplace.colActions', defaultMessage: 'Actions', }, + colPublished: { + id: 'course.marketplace.colPublished', + defaultMessage: 'Published at', + }, preview: { id: 'course.marketplace.previewAction', defaultMessage: 'Preview', }, + duplicateAssessment: { + id: 'course.marketplace.duplicateAssessment', + defaultMessage: 'Duplicate Assessment', + }, + viewDetails: { + id: 'course.marketplace.viewDetails', + defaultMessage: 'View question details', + }, searchPlaceholder: { id: 'course.marketplace.searchPlaceholder', defaultMessage: 'Search by title', @@ -84,15 +96,17 @@ export default defineMessages({ defaultMessage: '{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}', }, - duplicateTitle: { - id: 'course.marketplace.duplicateTitle', - defaultMessage: - 'Duplicate assessment{n, plural, one {} other {s}} to your course?', + confirmationQuestion: { + id: 'course.marketplace.confirmationQuestion', + defaultMessage: 'Duplicate items?', }, - duplicateBody: { - id: 'course.marketplace.duplicateBody', - defaultMessage: - '{n, plural, one {This assessment will be copied to your course.} other {These # assessments will be copied to your course.}}', + destinationCourse: { + id: 'course.marketplace.destinationCourse', + defaultMessage: 'Destination Course', + }, + assessmentsHeading: { + id: 'course.marketplace.assessmentsHeading', + defaultMessage: 'Assessments', }, duplicateConfirm: { id: 'course.marketplace.duplicateConfirm', @@ -108,4 +122,28 @@ export default defineMessages({ defaultMessage: '{n, plural, one {Duplicating assessment} other {Duplicating assessments}} failed.', }, + selectToDuplicate: { + id: 'course.marketplace.selectToDuplicate', + defaultMessage: 'Select to duplicate', + }, + emptyNoListings: { + id: 'course.marketplace.emptyNoListings', + defaultMessage: + 'No assessments have been published to the marketplace yet.', + }, + emptyNoMatch: { + id: 'course.marketplace.emptyNoMatch', + defaultMessage: 'No assessments match your search.', + }, + // Preview-only copy with no equivalent in course/assessment/translations. Every other renderer + // label is reused from there; these three have no source and so live locally. + bonus: { + id: 'course.marketplace.bonus', + defaultMessage: 'Bonus', + }, + noPreviewImage: { + id: 'course.marketplace.noPreviewImage', + defaultMessage: + 'The background image for this question cannot be previewed here.', + }, }); diff --git a/client/app/bundles/course/marketplace/types.ts b/client/app/bundles/course/marketplace/types.ts index 58053dc1d8..8465cfd862 100644 --- a/client/app/bundles/course/marketplace/types.ts +++ b/client/app/bundles/course/marketplace/types.ts @@ -8,3 +8,125 @@ export interface MarketplaceListing { previewUrl: string; duplicateUrl: string; } + +export interface DestinationTab { + id: number; + title: string; + categoryId: number; + categoryTitle: string; +} + +export interface MarketplaceIndexData { + listings: MarketplaceListing[]; + destinationTabs: DestinationTab[]; +} + +export interface PreviewChoice { + id: number; + option: string; + correct: boolean; +} + +export interface PreviewQuestionSummary { + id: number; + title: string; + description: string; + staffOnlyComments: string; + maximumGrade: number; + type: string; + unautogradable: boolean; + mcqMrqType?: 'mcq' | 'mrq'; + options?: PreviewChoice[]; +} + +export interface ListingPreviewData { + id: number; + title: string; + description: string; + gradingMode: 'autograded' | 'manual'; + // Absent, not null, when the assessment awards none: show.json.jbuilder emits these keys only + // when the value is > 0, so the details table skips the row instead of printing a bare "0". + baseExp?: number; + bonusExp?: number; + showMcqMrqSolution: boolean; + showRubricToStudents: boolean; + gradedTestCases: string; + typeCounts: Record; + questions: PreviewQuestionSummary[]; +} + +export interface ProgrammingTestCase { + identifier: string; + expression: string; + expected: string; + hint: string; +} + +export interface QuestionPreviewData { + id: number; + title: string; + defaultTitle: string; + description: string; + staffOnlyComments: string; + maximumGrade: number; + // Discriminator. The demodulized actable class name from the backend, e.g. 'Programming'. + // It — NOT the shape of `detail` — decides which `detail` variant is present: the renderer + // dispatcher (QuestionPreview) switches on `type`, and each renderer narrows `detail` with a + // cast (the variants share no literal tag, so TS can't auto-discriminate them). One `type` + // string ⇒ exactly one `detail` variant below. + type: string; + // Human-readable type label for the header chip (e.g. 'Multiple Choice'). Display-only — the + // renderer dispatch keys off `type`, never this. + displayType: string; + // Present variant is fixed by `type` above: + detail: // type === 'MultipleResponse' — both MCQ and MRQ (gradingScheme 'any_correct' ⇒ MCQ / single + // answer, 'all_correct' ⇒ MRQ / multi-answer). `options` carries the answer key + explanations. + | { + gradingScheme: string; + options: (PreviewChoice & { explanation: string; weight: number })[]; + } + // type === 'Programming' — language, limits, template files, and the three test-case buckets + // (public visible to students, private/evaluation hidden). Any bucket may be empty. + | { + languageName: string; + memoryLimit: number | null; + timeLimit: number | null; + templateFiles: { filename: string; content: string }[]; + publicTestCases: ProgrammingTestCase[]; + privateTestCases: ProgrammingTestCase[]; + evaluationTestCases: ProgrammingTestCase[]; + } + // type === 'TextResponse' — covers plain Text Response, File Upload, AND comprehension (one + // actable, disambiguated by flags: isComprehension, and attachment fields for File Upload). + | { + hideText: boolean; + isAttachmentRequired: boolean; + maxAttachments: number; + maxAttachmentSize: number | null; + isComprehension: boolean; + solutions: { + solutionType: string; + solution: string; + grade: number; + explanation: string; + }[]; + } + // type === 'RubricBasedResponse' — grading rubric as categories → criteria (grade + explanation). + | { + categories: { + name: string; + isBonus: boolean; + criteria: { grade: number; explanation: string }[]; + }[]; + } + // type === 'ForumPostResponse' — how many forum posts are required + whether a text answer too. + | { maxPosts: number; hasTextResponse: boolean } + // type === 'VoiceResponse' — no type-specific setup; the whole prompt IS the base `description`, + // so `detail` is an empty object. + | Record + // type === 'Scribing' — the background image students annotate (null if not previewable + // cross-instance; see the attachment-URL limitation in the design spec). + | { imageUrl: string | null } + // Unknown / unsupported `type` — the dispatcher renders nothing. + | null; +} diff --git a/client/locales/en.json b/client/locales/en.json index bd3e1686dd..bd8647ddb5 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6081,7 +6081,7 @@ "defaultMessage": "Publish to Marketplace?" }, "course.marketplace.publishConfirmBody": { - "defaultMessage": "This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title and description." + "defaultMessage": "This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title." }, "course.marketplace.removeConfirmTitle": { "defaultMessage": "Remove from Marketplace?" @@ -6119,9 +6119,18 @@ "course.marketplace.colActions": { "defaultMessage": "Actions" }, + "course.marketplace.colPublished": { + "defaultMessage": "Published at" + }, "course.marketplace.previewAction": { "defaultMessage": "Preview" }, + "course.marketplace.duplicateAssessment": { + "defaultMessage": "Duplicate Assessment" + }, + "course.marketplace.viewDetails": { + "defaultMessage": "View question details" + }, "course.marketplace.searchPlaceholder": { "defaultMessage": "Search by title" }, @@ -6137,11 +6146,14 @@ "course.marketplace.duplicateN": { "defaultMessage": "{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}" }, - "course.marketplace.duplicateTitle": { - "defaultMessage": "Duplicate assessment{n, plural, one {} other {s}} to your course?" + "course.marketplace.confirmationQuestion": { + "defaultMessage": "Duplicate items?" }, - "course.marketplace.duplicateBody": { - "defaultMessage": "{n, plural, one {This assessment will be copied to your course.} other {These # assessments will be copied to your course.}}" + "course.marketplace.destinationCourse": { + "defaultMessage": "Destination Course" + }, + "course.marketplace.assessmentsHeading": { + "defaultMessage": "Assessments" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "Duplicate" @@ -6152,6 +6164,21 @@ "course.marketplace.duplicateFailed": { "defaultMessage": "{n, plural, one {Duplicating assessment} other {Duplicating assessments}} failed." }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "Select to duplicate" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "No assessments have been published to the marketplace yet." + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "No assessments match your search." + }, + "course.marketplace.bonus": { + "defaultMessage": "Bonus" + }, + "course.marketplace.noPreviewImage": { + "defaultMessage": "The background image for this question cannot be previewed here." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "Download has failed. Please try again later." }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 6cb43b7d59..572d6f884d 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6083,9 +6083,18 @@ "course.marketplace.colActions": { "defaultMessage": "작업" }, + "course.marketplace.colPublished": { + "defaultMessage": "게시 일시" + }, "course.marketplace.previewAction": { "defaultMessage": "미리보기" }, + "course.marketplace.duplicateAssessment": { + "defaultMessage": "평가 복제" + }, + "course.marketplace.viewDetails": { + "defaultMessage": "문항 세부 정보 보기" + }, "course.marketplace.searchPlaceholder": { "defaultMessage": "제목으로 검색" }, @@ -6101,11 +6110,14 @@ "course.marketplace.duplicateN": { "defaultMessage": "{n}개 평가 복제" }, - "course.marketplace.duplicateTitle": { - "defaultMessage": "평가 {n}개를 내 강좌로 복제하시겠습니까?" + "course.marketplace.confirmationQuestion": { + "defaultMessage": "항목을 복제하시겠습니까?" }, - "course.marketplace.duplicateBody": { - "defaultMessage": "{n, plural, one {이 평가가 내 강좌로 복사됩니다.} other {이 평가 #개가 내 강좌로 복사됩니다.}}" + "course.marketplace.destinationCourse": { + "defaultMessage": "대상 강좌" + }, + "course.marketplace.assessmentsHeading": { + "defaultMessage": "평가" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "복제" @@ -6116,6 +6128,21 @@ "course.marketplace.duplicateFailed": { "defaultMessage": "{n, plural, one {평가 복제에} other {평가 복제에}} 실패했습니다." }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "복제하려면 선택" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "아직 마켓플레이스에 게시된 평가가 없습니다." + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "검색과 일치하는 평가가 없습니다." + }, + "course.marketplace.bonus": { + "defaultMessage": "보너스" + }, + "course.marketplace.noPreviewImage": { + "defaultMessage": "이 문항의 배경 이미지는 여기에서 미리 볼 수 없습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 4d31ca2b70..812b418098 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6077,9 +6077,18 @@ "course.marketplace.colActions": { "defaultMessage": "操作" }, + "course.marketplace.colPublished": { + "defaultMessage": "发布时间" + }, "course.marketplace.previewAction": { "defaultMessage": "预览" }, + "course.marketplace.duplicateAssessment": { + "defaultMessage": "复制评估" + }, + "course.marketplace.viewDetails": { + "defaultMessage": "查看题目详情" + }, "course.marketplace.searchPlaceholder": { "defaultMessage": "按标题搜索" }, @@ -6095,11 +6104,14 @@ "course.marketplace.duplicateN": { "defaultMessage": "复制 {n} 个评估" }, - "course.marketplace.duplicateTitle": { - "defaultMessage": "要将 {n} 个评估复制到你的课程吗?" + "course.marketplace.confirmationQuestion": { + "defaultMessage": "复制项目?" + }, + "course.marketplace.destinationCourse": { + "defaultMessage": "目标课程" }, - "course.marketplace.duplicateBody": { - "defaultMessage": "{n, plural, one {此评估将被复制到你的课程。} other {这 # 个评估将被复制到你的课程。}}" + "course.marketplace.assessmentsHeading": { + "defaultMessage": "评估" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "复制" @@ -6110,6 +6122,21 @@ "course.marketplace.duplicateFailed": { "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}失败。" }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "选择评估复制" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "尚未有评估发布到市场。" + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "没有符合搜索条件的评估。" + }, + "course.marketplace.bonus": { + "defaultMessage": "奖励" + }, + "course.marketplace.noPreviewImage": { + "defaultMessage": "此题目的背景图片无法在此预览。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, From 2a5e01993f4d3af9f2865f12dd4bf02c7c636300 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:38 +0800 Subject: [PATCH 08/30] feat(marketplace): carry from_tab through the browse flow + breadcrumbs Thread the origin assessment tab (from_tab) through the whole browse flow (index -> listing -> question preview and back) via withFromTab helpers, so a duplication always imports into the tab the user started from no matter how they navigate. Add the route data handles that build the marketplace / listing / question breadcrumbs, preserving from_tab on the crumb links. --- .../marketplace/questions_controller.rb | 5 +- .../course/statistics/aggregate_controller.rb | 2 +- .../marketplace/listings/show.json.jbuilder | 3 +- .../_forum_post_response.json.jbuilder | 3 +- .../details/_multiple_response.json.jbuilder | 3 +- .../details/_programming.json.jbuilder | 3 +- .../_rubric_based_response.json.jbuilder | 3 +- .../questions/details/_scribing.json.jbuilder | 3 +- .../details/_text_response.json.jbuilder | 3 +- .../details/_voice_response.json.jbuilder | 1 + .../marketplace/questions/show.json.jbuilder | 3 +- .../AssessmentsListing.tsx | 18 +---- .../DuplicateItemsConfirmation/index.jsx | 27 +++++++ .../marketplace/__test__/fromTab.test.ts | 21 ++++- .../marketplace/__test__/handles.test.ts | 77 +++++++++++++++++++ .../app/bundles/course/marketplace/fromTab.ts | 12 ++- .../app/bundles/course/marketplace/handles.ts | 49 ++++++++++++ .../ListingPreview/PreviewQuestionCard.tsx | 9 +-- .../pages/ListingPreview/index.tsx | 10 +-- .../MarketplaceIndex/MarketplaceTable.tsx | 2 +- .../__test__/MarketplaceTable.test.tsx | 18 ++--- .../pages/MarketplaceIndex/index.tsx | 11 ++- .../renderers/RubricBasedResponse.tsx | 4 +- client/app/routers/course/marketplace.tsx | 30 ++++++++ .../marketplace/questions_controller_spec.rb | 9 ++- 25 files changed, 263 insertions(+), 66 deletions(-) create mode 100644 client/app/bundles/course/marketplace/__test__/handles.test.ts create mode 100644 client/app/bundles/course/marketplace/handles.ts diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb index f0e08133e9..91f4f06699 100644 --- a/app/controllers/course/assessment/marketplace/questions_controller.rb +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -4,7 +4,8 @@ class Course::Assessment::Marketplace::QuestionsController < Course::Assessment: def show ActsAsTenant.without_tenant do - listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).find_by(id: params[:listing_id]) + listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment). + find_by(id: params[:listing_id]) raise CanCan::AccessDenied unless listing @assessment = listing.assessment @@ -21,4 +22,4 @@ def show def authorize_access! authorize!(:access_marketplace, current_course) end -end \ No newline at end of file +end diff --git a/app/controllers/course/statistics/aggregate_controller.rb b/app/controllers/course/statistics/aggregate_controller.rb index 306984f30e..266a384497 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -169,7 +169,7 @@ def correctness_hash id SQL ) - query.map { |u| [u.id, u.correctness] }.to_h + query.to_h { |u| [u.id, u.correctness] } end def fetch_all_assessment_related_statistics_hash diff --git a/app/views/course/assessment/marketplace/listings/show.json.jbuilder b/app/views/course/assessment/marketplace/listings/show.json.jbuilder index 3cff3f83ac..4a5724ab96 100644 --- a/app/views/course/assessment/marketplace/listings/show.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/show.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true json.id @assessment.id json.title @assessment.title json.description format_ckeditor_rich_text(@assessment.description) @@ -36,4 +37,4 @@ json.questions questions do |question| json.correct option.correct end end -end \ No newline at end of file +end diff --git a/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder index c883a23416..5a526830eb 100644 --- a/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/details/_forum_post_response.json.jbuilder @@ -1,2 +1,3 @@ +# frozen_string_literal: true json.maxPosts question.max_posts -json.hasTextResponse question.has_text_response \ No newline at end of file +json.hasTextResponse question.has_text_response diff --git a/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder index f96d1bf8b6..03518cfbc4 100644 --- a/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/details/_multiple_response.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true json.gradingScheme question.grading_scheme json.options question.options do |option| json.id option.id @@ -5,4 +6,4 @@ json.options question.options do |option| json.correct option.correct json.explanation format_ckeditor_rich_text(option.explanation) json.weight option.weight -end \ No newline at end of file +end diff --git a/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder index 6d2fc9e763..acd4127d3b 100644 --- a/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/details/_programming.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true json.languageName question.language&.name json.memoryLimit question.memory_limit json.timeLimit question.time_limit @@ -17,4 +18,4 @@ grouped = question.test_cases.group_by(&:test_case_type) json.expected tc.expected json.hint tc.hint end -end \ No newline at end of file +end diff --git a/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder index 9da40d08b5..e0428d204b 100644 --- a/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/details/_rubric_based_response.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true json.categories question.categories do |category| json.name category.name json.isBonus category.is_bonus_category @@ -5,4 +6,4 @@ json.categories question.categories do |category| json.grade criterion.grade json.explanation format_ckeditor_rich_text(criterion.explanation) end -end \ No newline at end of file +end diff --git a/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder index 8f067d91fd..ff701b4471 100644 --- a/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/details/_scribing.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Verified against app/views/course/assessment/question/scribing/_scribing_question.json.jbuilder: # scribing exposes its image via `attachment_reference.generate_public_url`, guarded by presence. -json.imageUrl question.attachment_reference&.generate_public_url \ No newline at end of file +json.imageUrl question.attachment_reference&.generate_public_url diff --git a/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder index da7375e5cf..4f508b70d3 100644 --- a/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/details/_text_response.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true json.hideText question.hide_text json.isAttachmentRequired question.is_attachment_required json.maxAttachments question.max_attachments @@ -8,4 +9,4 @@ json.solutions question.solutions do |solution| json.solution format_ckeditor_rich_text(solution.solution) json.grade solution.grade json.explanation format_ckeditor_rich_text(solution.explanation) -end \ No newline at end of file +end diff --git a/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder b/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder index 8d51a9b558..ca1fcb85e8 100644 --- a/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/details/_voice_response.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true # Voice questions have no type-specific setup fields; the base prompt is shown by the shell. # `json.merge!({})` forces the enclosing `json.detail do … end` block to serialize as an empty # object `{}`. Without it the block's scope stays blank and jbuilder emits `null` instead. diff --git a/app/views/course/assessment/marketplace/questions/show.json.jbuilder b/app/views/course/assessment/marketplace/questions/show.json.jbuilder index 8723df023c..555d4a7607 100644 --- a/app/views/course/assessment/marketplace/questions/show.json.jbuilder +++ b/app/views/course/assessment/marketplace/questions/show.json.jbuilder @@ -1,3 +1,4 @@ +# frozen_string_literal: true detail_partials = { 'Course::Assessment::Question::MultipleResponse' => 'multiple_response', 'Course::Assessment::Question::Programming' => 'programming', @@ -26,4 +27,4 @@ if partial end else json.detail nil -end \ No newline at end of file +end diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx index 99242a48d7..4967ea8acd 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/AssessmentsListing.tsx @@ -1,6 +1,5 @@ import { FC } from 'react'; -import { defineMessages } from 'react-intl'; -import { Card, CardContent, ListSubheader } from '@mui/material'; +import { ListSubheader } from '@mui/material'; import DuplicationAssessmentTree, { DuplicationAssessmentTreeNode, @@ -14,17 +13,6 @@ import componentTranslations from 'course/translations'; import { useAppSelector } from 'lib/hooks/store'; import useTranslation from 'lib/hooks/useTranslation'; -const translations = defineMessages({ - defaultCategory: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultCategory', - defaultMessage: 'Default Category', - }, - defaultTab: { - id: 'course.duplication.Duplication.DuplicateItemsConfirmation.AssessmentsListing.defaultTab', - defaultMessage: 'Default Tab', - }, -}); - const AssessmentsListing: FC = () => { const { assessmentsComponent: categories, selectedItems } = useAppSelector( selectDuplicationStore, @@ -96,10 +84,10 @@ const AssessmentsListing: FC = () => { ); }; -type DuplicationCategoryLike = { +interface DuplicationCategoryLike { id: number; title: string; tabs: DuplicationTabData[]; -}; +} export default AssessmentsListing; diff --git a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx index b3aa575d2c..3a94d9d2b9 100644 --- a/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx +++ b/client/app/bundles/course/duplication/pages/Duplication/DuplicateItemsConfirmation/index.jsx @@ -1,6 +1,7 @@ import { Component } from 'react'; import { defineMessages, FormattedMessage } from 'react-intl'; import { connect } from 'react-redux'; +import { Tooltip } from 'react-tooltip'; import { Card, CardContent, ListSubheader } from '@mui/material'; import PropTypes from 'prop-types'; @@ -14,6 +15,7 @@ import AchievementsListing from './AchievementsListing'; import AssessmentsListing from './AssessmentsListing'; import MaterialsListing from './MaterialsListing'; import SurveyListing from './SurveyListing'; +import VideosListing from './VideosListing'; const translations = defineMessages({ confirmationQuestion: { @@ -40,9 +42,34 @@ const translations = defineMessages({ id: 'course.duplication.Duplication.DuplicateItemsConfirmation.failureMessage', defaultMessage: 'Duplication failed.', }, + itemUnpublished: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.itemUnpublished', + defaultMessage: + 'Items are duplicated as unpublished when duplicating to an existing course.', + }, }); class DuplicateItemsConfirmation extends Component { + renderListing() { + return ( + <> +

+ +

+ {this.renderdestinationCourseCard()} + + + + + + + + + + + ); + } + renderdestinationCourseCard() { const { destinationCourses, destinationCourseId } = this.props; const destinationCourse = destinationCourses.find( diff --git a/client/app/bundles/course/marketplace/__test__/fromTab.test.ts b/client/app/bundles/course/marketplace/__test__/fromTab.test.ts index d5d5bce8ac..14d0e852d3 100644 --- a/client/app/bundles/course/marketplace/__test__/fromTab.test.ts +++ b/client/app/bundles/course/marketplace/__test__/fromTab.test.ts @@ -2,13 +2,13 @@ import { readFromTab, withFromTab } from '../fromTab'; describe('withFromTab', () => { it('appends from_tab as the first query param when the path has none', () => { - expect(withFromTab('/courses/1/marketplace', '42')).toBe( + expect(withFromTab('/courses/1/marketplace', 42)).toBe( '/courses/1/marketplace?from_tab=42', ); }); it('appends from_tab with & when the path already has a query string', () => { - expect(withFromTab('/p/1?foo=bar', '42')).toBe('/p/1?foo=bar&from_tab=42'); + expect(withFromTab('/p/1?foo=bar', 42)).toBe('/p/1?foo=bar&from_tab=42'); }); it('returns the path unchanged when from_tab is null', () => { @@ -19,11 +19,24 @@ describe('withFromTab', () => { }); describe('readFromTab', () => { - it('extracts from_tab from a search string', () => { - expect(readFromTab('?from_tab=42&x=1')).toBe('42'); + it('extracts from_tab from a search string as a number', () => { + expect(readFromTab('?from_tab=42&x=1')).toBe(42); }); it('returns null when from_tab is absent', () => { expect(readFromTab('?x=1')).toBeNull(); }); + + it('returns null when from_tab is not a tab id', () => { + expect(readFromTab('?from_tab=abc')).toBeNull(); + expect(readFromTab('?from_tab=')).toBeNull(); + }); + + // A hand-edited URL is reduced to the leading tab id, so reserved characters (`&`, `=`) can + // never survive into a link that `withFromTab` builds from the value. + it('strips trailing junk from a hand-edited from_tab', () => { + const fromTab = readFromTab('?from_tab=7%26admin%3Dtrue'); + expect(fromTab).toBe(7); + expect(withFromTab('/p/1', fromTab)).toBe('/p/1?from_tab=7'); + }); }); diff --git a/client/app/bundles/course/marketplace/__test__/handles.test.ts b/client/app/bundles/course/marketplace/__test__/handles.test.ts new file mode 100644 index 0000000000..8438e419dd --- /dev/null +++ b/client/app/bundles/course/marketplace/__test__/handles.test.ts @@ -0,0 +1,77 @@ +import { Location } from 'react-router-dom'; + +import { CrumbPath } from 'lib/hooks/router/dynamicNest'; + +import { listingHandle, marketplaceHandle } from '../handles'; +import { fetchListing } from '../operations'; + +// The handles always return a `{ getData }` request (never a bare title/null), so narrow the +// DataHandle union to read getData directly. +interface WithGetData { + getData: () => T; +} + +jest.mock('../operations'); + +const asMatch = ( + pathname: string, + params: Record = {}, +): { id: string; pathname: string; params: typeof params; data: unknown } => ({ + id: '', + pathname, + params, + data: undefined, +}); + +const asLocation = (search: string): Location => ({ + pathname: '', + search, + hash: '', + state: null, + key: '', +}); + +describe('marketplaceHandle', () => { + it('links the crumb to the marketplace path carrying from_tab', () => { + const handle = marketplaceHandle( + asMatch('/courses/1/marketplace'), + asLocation('?from_tab=42'), + ) as WithGetData; + + expect(handle.getData()).toEqual({ + content: { + title: expect.anything(), + url: '/courses/1/marketplace?from_tab=42', + }, + }); + }); + + it('links the crumb to the bare marketplace path when there is no from_tab', () => { + const handle = marketplaceHandle( + asMatch('/courses/1/marketplace'), + asLocation(''), + ) as WithGetData; + + expect(handle.getData()).toEqual({ + content: { title: expect.anything(), url: '/courses/1/marketplace' }, + }); + }); +}); + +describe('listingHandle', () => { + it('resolves the listing title and links the crumb carrying from_tab', async () => { + (fetchListing as jest.Mock).mockResolvedValue({ title: 'Graph Theory' }); + + const handle = listingHandle( + asMatch('/courses/1/marketplace/listings/7', { listingId: '7' }), + asLocation('?from_tab=42'), + ) as WithGetData>; + + await expect(handle.getData()).resolves.toEqual({ + content: { + title: 'Graph Theory', + url: '/courses/1/marketplace/listings/7?from_tab=42', + }, + }); + }); +}); diff --git a/client/app/bundles/course/marketplace/fromTab.ts b/client/app/bundles/course/marketplace/fromTab.ts index 81d260005f..ffe3b29bee 100644 --- a/client/app/bundles/course/marketplace/fromTab.ts +++ b/client/app/bundles/course/marketplace/fromTab.ts @@ -3,12 +3,18 @@ // back via breadcrumbs) so duplication imports into that origin tab no matter how the user // navigates. Every intra-marketplace link routes its path through `withFromTab` so the param is // never silently dropped. +// +// The value is a tab id, so it is carried as a `number` end to end: `readFromTab` parses it at the +// URL boundary and drops anything non-numeric, which both saves every consumer from re-parsing and +// makes it impossible for `withFromTab` to interpolate reserved URL characters back into a link. +import { getIdFromUnknown } from 'utilities'; + export const FROM_TAB_PARAM = 'from_tab'; -export const readFromTab = (search: string): string | null => - new URLSearchParams(search).get(FROM_TAB_PARAM); +export const readFromTab = (search: string): number | null => + getIdFromUnknown(new URLSearchParams(search).get(FROM_TAB_PARAM)) ?? null; -export const withFromTab = (path: string, fromTab: string | null): string => { +export const withFromTab = (path: string, fromTab: number | null): string => { if (!fromTab) return path; const separator = path.includes('?') ? '&' : '?'; return `${path}${separator}${FROM_TAB_PARAM}=${fromTab}`; diff --git a/client/app/bundles/course/marketplace/handles.ts b/client/app/bundles/course/marketplace/handles.ts new file mode 100644 index 0000000000..7b8ccc3a9f --- /dev/null +++ b/client/app/bundles/course/marketplace/handles.ts @@ -0,0 +1,49 @@ +import { getIdFromUnknown } from 'utilities'; + +import { CrumbPath, DataHandle } from 'lib/hooks/router/dynamicNest'; + +import { readFromTab, withFromTab } from './fromTab'; +import { fetchListing, fetchQuestion } from './operations'; +import translations from './translations'; + +// Both crumbs link to their own route's pathname, but carry the browse flow's `from_tab` forward +// so returning to the marketplace/listing preserves the origin-tab context (see ./fromTab). +export const marketplaceHandle: DataHandle = (match, location) => { + const fromTab = readFromTab(location.search); + return { + getData: (): CrumbPath => ({ + // Descriptor title; Breadcrumbs runs t() on it. + content: { + title: translations.pageTitle, + url: withFromTab(match.pathname, fromTab), + }, + }), + }; +}; + +export const listingHandle: DataHandle = (match, location) => { + const listingId = getIdFromUnknown(match.params?.listingId); + if (!listingId) throw new Error(`Invalid listing id: ${listingId}`); + const fromTab = readFromTab(location.search); + return { + getData: async (): Promise => ({ + content: { + title: (await fetchListing(listingId)).title, + url: withFromTab(match.pathname, fromTab), + }, + }), + }; +}; + +export const questionHandle: DataHandle = (match) => { + const listingId = getIdFromUnknown(match.params?.listingId); + const questionId = getIdFromUnknown(match.params?.questionId); + if (!listingId || !questionId) + throw new Error('Invalid marketplace question route'); + return { + getData: async (): Promise => { + const q = await fetchQuestion(listingId, questionId); + return q.title ? `${q.defaultTitle}: ${q.title}` : q.defaultTitle; + }, + }; +}; diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx index 912198c849..7b222c650e 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx @@ -33,7 +33,7 @@ interface Props { of: PreviewQuestionSummary; index: number; listingId: string; - fromTab?: string | null; + fromTab?: number | null; } const PreviewQuestionCard = ({ @@ -63,12 +63,7 @@ const PreviewQuestionCard = ({ {q.title}
- + {q.unautogradable && ( { const { listingId } = useParams(); const { t } = useTranslation(); const { courseTitle, courseUrl } = useCourseContext(); - const [params] = useSearchParams(); // `from_tab` rides in from the marketplace index so the duplicate lands in the tab the user came // from; null when they reached the preview directly, which DuplicateConfirmation renders fine. - const fromTab = params.get('from_tab'); - const destinationTabId = parseInt(fromTab ?? '', 10) || null; + const fromTab = readFromTab(useLocation().search); const [duplicating, setDuplicating] = useState(false); return ( @@ -83,7 +81,7 @@ const ListingPreview = (): JSX.Element => { destinationCategory={null} destinationCourse={{ title: courseTitle, url: courseUrl }} destinationTab={null} - destinationTabId={destinationTabId} + destinationTabId={fromTab} listings={[{ id: listing.id, title: listing.title }]} onClose={(): void => setDuplicating(false)} open={duplicating} diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx index 416bd9b286..3ef8876b0f 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx @@ -25,7 +25,7 @@ import { MarketplaceListing } from '../../types'; type SortMode = 'adoptions' | 'newest'; interface Props { - fromTab?: string | null; + fromTab?: number | null; listings: MarketplaceListing[]; onDuplicate: (rows: MarketplaceListing[]) => void; } diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx index aeb5701ce5..88248fc48f 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/MarketplaceTable.test.tsx @@ -2,10 +2,10 @@ import userEvent from '@testing-library/user-event'; import { fireEvent, render, waitFor } from 'test-utils'; import { MarketplaceListing } from '../../../types'; - import MarketplaceTable from '../MarketplaceTable'; // Sort keys disagree so order is meaningful: Graph Theory is most-adopted, Recursion newest. +const GRAPH_THEORY = 'Graph Theory'; const LISTINGS: MarketplaceListing[] = [ { id: 1, @@ -20,7 +20,7 @@ const LISTINGS: MarketplaceListing[] = [ { id: 2, assessmentId: 11, - title: 'Graph Theory', + title: GRAPH_THEORY, questionCount: 3, adoptions: 12, firstPublishedAt: '2026-01-01T00:00:00Z', @@ -54,14 +54,14 @@ it('renders Preview and Duplicate as icon buttons with one-word tooltips/labels' // Default sort = adoptions desc → Graph Theory (12) is the first row. fireEvent.click(duplicates[0]); expect(onDuplicate).toHaveBeenCalledWith([ - expect.objectContaining({ title: 'Graph Theory' }), + expect.objectContaining({ title: GRAPH_THEORY }), ]); }); it('carries from_tab into the preview links when set', async () => { const page = render( , @@ -76,7 +76,7 @@ it('renders one checkbox per row and no select-all header checkbox', async () => const page = render( , ); - await page.findByText('Graph Theory'); + await page.findByText(GRAPH_THEORY); // Only per-row checkboxes — the select-all header checkbox is removed. expect(page.getAllByRole('checkbox')).toHaveLength(LISTINGS.length); @@ -87,7 +87,7 @@ it('keeps the search bar visible and shows an enabled count button on selection' const page = render( , ); - await page.findByText('Graph Theory'); + await page.findByText(GRAPH_THEORY); // Data-row checkboxes follow any header checkbox — click the last one to select a row. const checkboxes = page.getAllByRole('checkbox'); @@ -126,7 +126,7 @@ it('shows a no-match message when the search filters everything, keeping the sea const page = render( , ); - await page.findByText('Graph Theory'); + await page.findByText(GRAPH_THEORY); // userEvent (not fireEvent) for the search field — React 18 startTransition. await userEvent.type(page.getByPlaceholderText('Search by title'), 'zzzzz'); @@ -153,7 +153,7 @@ it('shows the published date, formatted', async () => { const page = render( , ); - await page.findByText('Graph Theory'); + await page.findByText(GRAPH_THEORY); // formatLongDate('2026-06-01T00:00:00Z') under TZ=Asia/Singapore → '01 Jun 2026'. expect(page.getByText('01 Jun 2026')).toBeVisible(); expect(page.getByText('01 Jan 2026')).toBeVisible(); @@ -164,7 +164,7 @@ it('sorts by published date (not adoptions) when Newest is selected', async () = const page = render( , ); - await page.findByText('Graph Theory'); + await page.findByText(GRAPH_THEORY); // Drive the MUI select-mode "Sort by" TextField (idiom mirrored from the sibling // MarketplaceIndex test): mouseDown the labelled control, then click the option. diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx index 4b1f2850fc..fa22509afd 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -1,12 +1,13 @@ import { useState } from 'react'; import { useIntl } from 'react-intl'; -import { useSearchParams } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; import { useCourseContext } from 'course/container/CourseLoader'; import Page from 'lib/components/core/layouts/Page'; import Preload from 'lib/components/wrappers/Preload'; import DuplicateConfirmation from '../../components/DuplicateConfirmation'; +import { readFromTab } from '../../fromTab'; import { fetchListings } from '../../operations'; import translations from '../../translations'; import { DestinationTab, MarketplaceListing } from '../../types'; @@ -16,9 +17,7 @@ import MarketplaceTable from './MarketplaceTable'; const MarketplaceIndex = (): JSX.Element => { const { formatMessage: t } = useIntl(); const { courseTitle, courseUrl } = useCourseContext(); - const [params] = useSearchParams(); - const fromTab = params.get('from_tab'); - const destinationTabId = parseInt(fromTab ?? '', 10) || null; + const fromTab = readFromTab(useLocation().search); const [pending, setPending] = useState([]); const resolveDestination = ( @@ -27,7 +26,7 @@ const MarketplaceIndex = (): JSX.Element => { category: { id: number; title: string } | null; tab: { id: number; title: string } | null; } => { - const match = tabs.find((tab) => tab.id === destinationTabId); + const match = tabs.find((tab) => tab.id === fromTab); if (!match) return { category: null, tab: null }; return { category: { id: match.categoryId, title: match.categoryTitle }, @@ -50,7 +49,7 @@ const MarketplaceIndex = (): JSX.Element => { destinationCategory={destination.category} destinationCourse={{ title: courseTitle, url: courseUrl }} destinationTab={destination.tab} - destinationTabId={destinationTabId} + destinationTabId={fromTab} listings={pending} onClose={(): void => setPending([])} open={pending.length > 0} diff --git a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx index 7d16ce5e21..d20f88fa8e 100644 --- a/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/RubricBasedResponse.tsx @@ -53,8 +53,8 @@ const RubricBasedResponse = ({ question }: RendererProps): JSX.Element => { - {category.criteria.map((criterion, index) => ( - + {category.criteria.map((criterion) => ( + {criterion.grade} diff --git a/client/app/routers/course/marketplace.tsx b/client/app/routers/course/marketplace.tsx index e8a42b7db0..9a7c3f7983 100644 --- a/client/app/routers/course/marketplace.tsx +++ b/client/app/routers/course/marketplace.tsx @@ -1,9 +1,13 @@ import { RouteObject } from 'react-router-dom'; +import { WithRequired } from 'types'; import { Translated } from 'lib/hooks/useTranslation'; const marketplaceRouter: Translated = () => ({ path: 'marketplace', + lazy: async () => ({ + handle: (await import('course/marketplace/handles')).marketplaceHandle, + }), children: [ { index: true, @@ -12,6 +16,32 @@ const marketplaceRouter: Translated = () => ({ .default, }), }, + { + path: 'listings/:listingId', + lazy: async () => ({ + handle: (await import('course/marketplace/handles')).listingHandle, + }), + children: [ + { + index: true, + lazy: async () => ({ + Component: (await import('course/marketplace/pages/ListingPreview')) + .default, + }), + }, + { + path: 'questions/:questionId', + lazy: async (): Promise> => { + const [{ default: Component }, { questionHandle }] = + await Promise.all([ + import('course/marketplace/pages/QuestionPreview'), + import('course/marketplace/handles'), + ]); + return { Component, handle: questionHandle }; + }, + }, + ], + }, ], }); diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index e679abb421..b6d35b3839 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -70,8 +70,13 @@ question = nil listing = ActsAsTenant.with_tenant(source_instance) do assessment = create(:assessment, course: create(:course, instance: source_instance)) - create(:course_assessment_question_programming, assessment: assessment, - test_case_count: 1, private_test_case_count: 1, evaluation_test_case_count: 1) + create( + :course_assessment_question_programming, + assessment: assessment, + test_case_count: 1, + private_test_case_count: 1, + evaluation_test_case_count: 1 + ) question = assessment.questions.first ActsAsTenant.without_tenant do create(:course_assessment_marketplace_listing, assessment: assessment) From 16b9186021729b73b5838861ec21dc171bc8589e Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 16 Jul 2026 20:58:31 +0800 Subject: [PATCH 09/30] feat(marketplace): rework the duplicate confirmation dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static destination summary with an in-dialog tab picker and report duplication results honestly: - Add DestinationTabPicker, a radio tree grouping the current course's tabs by category, so the duplicator chooses the destination tab inside the dialog instead of it being fixed by the launching `from_tab`. The selection seeds from `from_tab` (falling back to the first tab) and re-seeds on each reopen, but a parent re-render never resets a choice mid-decision. - Serve `destinationTabs` from the listing show endpoint too, so the picker is available when duplicating from the listing detail page, not just the marketplace index. - Restyle the dialog: vertically stacked tabs with larger category/tab text, a dense TypeBadge variant, an explicit "Duplicating" heading, the ⊘ "arrives unpublished" hint, and explicit cancel/primary colors. - Report a *completed* duplication (the toast fires from pollJob's completion callback, not on submit) and link to where the copy landed via the job's redirectUrl; reword the failure copy to plain language. Widen the shared toast Toaster type to ReactNode so the toast can carry that link (type-only change, no runtime effect). --- .../marketplace/listings_controller.rb | 1 + .../marketplace/listings/show.json.jbuilder | 9 + .../components/TypeBadge/index.tsx | 13 +- .../components/DestinationTabPicker.tsx | 93 ++++ .../components/DuplicateConfirmation.tsx | 104 +++- .../__test__/DestinationTabPicker.test.tsx | 106 ++++ .../__test__/DuplicationConfirmation.test.tsx | 463 ++++++++++++++++-- .../ListingPreview/__test__/index.test.tsx | 5 + .../pages/ListingPreview/index.tsx | 5 +- .../pages/MarketplaceIndex/index.tsx | 54 +- .../course/marketplace/translations.ts | 31 +- .../app/bundles/course/marketplace/types.ts | 3 + client/app/lib/hooks/toast/toast.tsx | 4 +- client/locales/en.json | 16 +- client/locales/ko.json | 16 +- client/locales/zh.json | 16 +- .../marketplace/listings_controller_spec.rb | 14 + 17 files changed, 833 insertions(+), 120 deletions(-) create mode 100644 client/app/bundles/course/marketplace/components/DestinationTabPicker.tsx create mode 100644 client/app/bundles/course/marketplace/components/__test__/DestinationTabPicker.test.tsx diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 6cb4b5f22c..5e3cdbdd4b 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -32,6 +32,7 @@ def show @assessment = @listing.assessment authorize!(:preview_in_marketplace, @assessment) + @destination_tabs = destination_tabs render 'show' end end diff --git a/app/views/course/assessment/marketplace/listings/show.json.jbuilder b/app/views/course/assessment/marketplace/listings/show.json.jbuilder index 4a5724ab96..92d6b4f730 100644 --- a/app/views/course/assessment/marketplace/listings/show.json.jbuilder +++ b/app/views/course/assessment/marketplace/listings/show.json.jbuilder @@ -3,6 +3,15 @@ json.id @assessment.id json.title @assessment.title json.description format_ckeditor_rich_text(@assessment.description) +# The current course's category/tab structure, so the duplicate confirmation dialog can offer the +# destination tab picker from the listing detail page (the listing itself lives in another course). +json.destinationTabs @destination_tabs do |tab| + json.id tab[:id] + json.title tab[:title] + json.categoryId tab[:category_id] + json.categoryTitle tab[:category_title] +end + json.gradingMode @assessment.autograded? ? 'autograded' : 'manual' json.baseExp @assessment.base_exp if @assessment.base_exp > 0 json.bonusExp @assessment.time_bonus_exp if @assessment.time_bonus_exp > 0 diff --git a/client/app/bundles/course/duplication/components/TypeBadge/index.tsx b/client/app/bundles/course/duplication/components/TypeBadge/index.tsx index 1f4add68ec..d1eeb171df 100644 --- a/client/app/bundles/course/duplication/components/TypeBadge/index.tsx +++ b/client/app/bundles/course/duplication/components/TypeBadge/index.tsx @@ -45,15 +45,18 @@ const translations: Record = }, }); -const TypeBadge: FC<{ text?: string; itemType: DuplicableItemType }> = ({ - text, - itemType, -}) => { +const TypeBadge: FC<{ + text?: string; + itemType: DuplicableItemType; + dense?: boolean; +}> = ({ text, itemType, dense = false }) => { const { t } = useTranslation(); return ( diff --git a/client/app/bundles/course/marketplace/components/DestinationTabPicker.tsx b/client/app/bundles/course/marketplace/components/DestinationTabPicker.tsx new file mode 100644 index 0000000000..b0f90d854c --- /dev/null +++ b/client/app/bundles/course/marketplace/components/DestinationTabPicker.tsx @@ -0,0 +1,93 @@ +import { FC } from 'react'; +import { + Card, + CardContent, + FormControlLabel, + Radio, + RadioGroup, +} from '@mui/material'; + +import TypeBadge from 'course/duplication/components/TypeBadge'; + +import { DestinationTab } from '../types'; + +interface Group { + categoryId: number; + categoryTitle: string; + tabs: DestinationTab[]; +} + +interface DestinationTabPickerProps { + tabs: DestinationTab[]; + value: number | null; + onChange: (tabId: number) => void; +} + +// Group tabs by category in first-seen order (the controller already emits categories then their +// tabs in display order, so this preserves that without re-sorting). Robust to a category's tabs +// arriving non-contiguously. +const groupByCategory = (tabs: DestinationTab[]): Group[] => { + const groups: Group[] = []; + const indexByCategory = new Map(); + tabs.forEach((tab) => { + const existing = indexByCategory.get(tab.categoryId); + if (existing === undefined) { + indexByCategory.set(tab.categoryId, groups.length); + groups.push({ + categoryId: tab.categoryId, + categoryTitle: tab.categoryTitle, + tabs: [tab], + }); + } else { + groups[existing].tabs.push(tab); + } + }); + return groups; +}; + +const DestinationTabPicker: FC = ({ + tabs, + value, + onChange, +}) => { + const groups = groupByCategory(tabs); + + return ( + + + onChange(Number(e.target.value))} + value={value != null ? String(value) : ''} + > + {groups.map((group) => ( +
+
+ + {group.categoryTitle} +
+ {group.tabs.map((tab) => ( + } + label={ + + + {tab.title} + + } + value={String(tab.id)} + /> + ))} +
+ ))} +
+
+
+ ); +}; + +export default DestinationTabPicker; diff --git a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx index 15392be994..5b6518dfdc 100644 --- a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -1,8 +1,10 @@ import { useEffect, useRef, useState } from 'react'; +import { Tooltip } from 'react-tooltip'; import { Card, CardContent, ListSubheader } from '@mui/material'; import { JobStatus } from 'types/jobs'; -import DuplicationAssessmentTree from 'course/duplication/components/DuplicationAssessmentTree'; +import TypeBadge from 'course/duplication/components/TypeBadge'; +import UnpublishedIcon from 'course/duplication/components/UnpublishedIcon'; import Prompt from 'lib/components/core/dialogs/Prompt'; import Link from 'lib/components/core/Link'; import { pollJobRequest } from 'lib/helpers/jobHelpers'; @@ -11,26 +13,26 @@ import useTranslation from 'lib/hooks/useTranslation'; import { duplicateListings } from '../operations'; import translations from '../translations'; -import { MarketplaceListing } from '../types'; +import { DestinationTab, MarketplaceListing } from '../types'; + +import DestinationTabPicker from './DestinationTabPicker'; const JOB_POLL_INTERVAL_MS = 2000; interface Props { listings: Pick[]; - destinationTabId: number | null; + destinationTabs: DestinationTab[]; + initialDestinationTabId: number | null; destinationCourse: { title: string; url: string }; - destinationCategory: { id: number; title: string } | null; - destinationTab: { id: number; title: string } | null; open: boolean; onClose: () => void; } const DuplicateConfirmation = ({ listings, - destinationTabId, + destinationTabs, + initialDestinationTabId, destinationCourse, - destinationCategory, - destinationTab, open, onClose, }: Props): JSX.Element => { @@ -41,12 +43,41 @@ const DuplicateConfirmation = ({ const n = listings.length; + // Default selection is the `from_tab` the user launched from when it names a real tab in this + // course; otherwise fall back to the course's first tab (if any). When the course has no tabs, + // the selection stays null and the backend applies its own default. + const resolveInitial = (): number | null => { + if ( + initialDestinationTabId != null && + destinationTabs.some((tab) => tab.id === initialDestinationTabId) + ) { + return initialDestinationTabId; + } + return destinationTabs[0]?.id ?? null; + }; + + const [selectedTabId, setSelectedTabId] = useState( + resolveInitial(), + ); + + // The pages keep this component mounted and only flip `open`, so `selectedTabId` outlives a close + // — re-seed it each time the dialog opens, or a tab the user picked and then walked away from + // would still be selected next time. + // + // Deps are `[open]` on purpose. Adding `destinationTabs` would compare it by identity, so any + // parent re-render passing a fresh array would re-fire this and reset the radio out from under a + // user mid-decision. Reopening is the only moment the selection should be re-seeded. + useEffect(() => { + if (!open) return; + setSelectedTabId(resolveInitial()); + }, [open]); + const confirm = async (): Promise => { setSubmitting(true); try { const url = await duplicateListings( listings.map((l) => l.id), - destinationTabId, + selectedTabId, ); setJobUrl(url); } catch { @@ -62,11 +93,25 @@ const DuplicateConfirmation = ({ useEffect(() => { if (!jobUrl) return undefined; - const finish = (succeeded: boolean): void => { + // Called only once the job has finished, so this reports what already happened. `redirectUrl` + // points at the destination tab; it is optional on JobCompleted, so the link is conditional. + const finish = (succeeded: boolean, redirectUrl?: string): void => { setJobUrl(null); setSubmitting(false); if (succeeded) { - toast.success(t(translations.duplicateStarted, { n })); + toast.success( + <> + {t(translations.duplicateCompleted, { n })} + {redirectUrl && ( + <> + {' '} + + {t(translations.viewDuplicatedAssessment)} + + + )} + , + ); onClose(); } else { toast.error(t(translations.duplicateFailed, { n })); @@ -78,7 +123,8 @@ const DuplicateConfirmation = ({ pollingRef.current = true; pollJobRequest(jobUrl) .then((response) => { - if (response.status === JobStatus.completed) finish(true); + if (response.status === JobStatus.completed) + finish(true, response.redirectUrl); else if (response.status === JobStatus.errored) finish(false); }) .catch(() => finish(false)) @@ -92,10 +138,12 @@ const DuplicateConfirmation = ({ return ( @@ -111,16 +159,32 @@ const DuplicateConfirmation = ({ - {t(translations.assessmentsHeading)} + {t(translations.pickDestinationTab)} - + + {t(translations.duplicating)} + + + {listings.map((listing) => ( +
+ + + {listing.title} +
+ ))} +
+
+ + {t(translations.itemUnpublished)} +
); }; diff --git a/client/app/bundles/course/marketplace/components/__test__/DestinationTabPicker.test.tsx b/client/app/bundles/course/marketplace/components/__test__/DestinationTabPicker.test.tsx new file mode 100644 index 0000000000..b78449abfb --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/DestinationTabPicker.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render } from 'test-utils'; + +import DestinationTabPicker from '../DestinationTabPicker'; + +const tabs = [ + { id: 10, title: 'Tutorials', categoryId: 1, categoryTitle: 'Week 3' }, + { id: 11, title: 'Problem Sets', categoryId: 1, categoryTitle: 'Week 3' }, + { id: 20, title: 'Lab', categoryId: 2, categoryTitle: 'Week 4' }, +]; + +it('renders an empty radio group when there are no tabs', async () => { + const page = render( + , + ); + + expect(await page.findByRole('radiogroup')).toBeEmptyDOMElement(); +}); + +it('groups tabs under one header per category and renders a radio per tab', async () => { + const page = render( + , + ); + + // I18nProvider (TypeBadge uses it) async-loads messages, so await the first query. + expect(await page.findByText('Week 3')).toBeVisible(); + expect(page.getByText('Week 4')).toBeVisible(); + // The two Week 3 tabs share a single header. + expect(page.getAllByText('Week 3')).toHaveLength(1); + expect(page.getAllByRole('radio')).toHaveLength(3); + // Headers are badged as categories, radios as tabs. RTL's text matcher only sees an element's + // direct text-node children, so TypeBadge's Typography matches 'Category'/'Tab' on its own. + expect(page.getAllByText('Category')).toHaveLength(2); + expect(page.getAllByText('Tab')).toHaveLength(3); +}); + +// The fixture is interleaved AND in descending categoryId order, so first-seen order and any +// sorted order disagree — the flat `tabs` fixture above cannot tell them apart. +it('groups a category under its first-seen header when its tabs arrive non-contiguously', async () => { + const interleavedTabs = [ + { id: 20, title: 'Lab', categoryId: 2, categoryTitle: 'Week 4' }, + { id: 10, title: 'Tutorials', categoryId: 1, categoryTitle: 'Week 3' }, + { id: 21, title: 'Recitation', categoryId: 2, categoryTitle: 'Week 4' }, + ]; + + const page = render( + , + ); + + // Week 4's two tabs are split by a Week 3 tab, but still share one header. + expect(await page.findAllByText('Week 4')).toHaveLength(1); + expect(page.getAllByText('Week 3')).toHaveLength(1); + // Week 4 is seen first, so its group (and both its tabs) comes first. + expect( + page.getAllByRole('radio').map((radio) => radio.getAttribute('value')), + ).toEqual(['20', '21', '10']); +}); + +it('marks the tab whose id equals value as checked', async () => { + const page = render( + , + ); + + expect( + await page.findByRole('radio', { name: /Problem Sets/ }), + ).toBeChecked(); + expect(page.getByRole('radio', { name: /Tutorials/ })).not.toBeChecked(); + expect(page.getByRole('radio', { name: /Lab/ })).not.toBeChecked(); +}); + +it('checks no tab when value is null', async () => { + const page = render( + , + ); + + expect(await page.findAllByRole('radio')).toHaveLength(3); + page + .getAllByRole('radio') + .forEach((radio) => expect(radio).not.toBeChecked()); +}); + +it('fires onChange with the numeric tab id when another tab is chosen', async () => { + const onChange = jest.fn(); + const page = render( + , + ); + + fireEvent.click(await page.findByRole('radio', { name: /Lab/ })); + + expect(onChange).toHaveBeenCalledWith(20); +}); + +it('does not move the selection itself when a tab is clicked', async () => { + const page = render( + , + ); + + fireEvent.click(await page.findByRole('radio', { name: /Lab/ })); + + // Controlled: the parent still says 11, so the checkmark must not move. + expect(page.getByRole('radio', { name: /Problem Sets/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Lab/ })).not.toBeChecked(); +}); diff --git a/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx index 98a2ef0047..9a8fbc3804 100644 --- a/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx +++ b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx @@ -1,67 +1,241 @@ import { createMockAdapter } from 'mocks/axiosMock'; import { fireEvent, render, waitFor } from 'test-utils'; +import TestApp from 'utilities/TestApp'; +import GlobalAPI from 'api'; import CourseAPI from 'api/course'; +import toast from 'lib/hooks/toast'; import DuplicateConfirmation from '../DuplicateConfirmation'; +// The toast message is a ReactNode (it carries a link), so capture it and render it rather than +// mounting a ToastContainer. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + const mock = createMockAdapter(CourseAPI.marketplace.client); -beforeEach(() => mock.reset()); +// 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(); +}); -const listings = [{ id: 1, title: 'Recursion Drills' }]; +const LISTING_TITLE = 'Recursion Drills'; +const listings = [{ id: 1, title: LISTING_TITLE }]; const url = `/courses/${global.courseId}/marketplace/listings/duplicate`; const course = { title: 'Enrollable Course', url: '/courses/4' }; +const REDIRECT_URL = '/courses/4/assessments?category=5&tab=42'; +const destinationTabs = [ + { id: 41, title: 'Tutorials', categoryId: 5, categoryTitle: 'Missions' }, + { id: 42, title: 'Assignments', categoryId: 5, categoryTitle: 'Missions' }, +]; + +const props = { + destinationCourse: course, + destinationTabs, + initialDestinationTabId: 42, + listings, + onClose: jest.fn(), +}; + +const successToastTexts = (): string[] => + (toast.success as unknown as jest.Mock).mock.calls.map(([message]) => { + if (typeof message === 'string') return message; + + const children = (message as { props?: { children?: unknown } }).props + ?.children; + if (Array.isArray(children)) { + return children.filter((child) => typeof child === 'string').join(''); + } + + return typeof children === 'string' ? children : ''; + }); + +it('forgets an abandoned selection and re-seeds the initial tab when reopened', async () => { + const page = render(); + + expect(await page.findByRole('radio', { name: /Assignments/ })).toBeChecked(); + + // The user picks a different tab, then dismisses the dialog without confirming. + fireEvent.click(page.getByRole('radio', { name: /Tutorials/ })); + expect(page.getByRole('radio', { name: /Tutorials/ })).toBeChecked(); + + // The page keeps this component mounted and only flips `open`, so `selectedTabId` outlives the + // close — which is the entire reason the re-seeding effect exists. + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); -it('shows the destination course and assessment tree with real names', async () => { + page.rerender( + + + , + ); + + // Reopening starts from the tab the user launched from, not the choice they walked away from. + expect(await page.findByRole('radio', { name: /Assignments/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Tutorials/ })).not.toBeChecked(); +}); + +it('keeps the user’s selection across a re-render while the dialog stays open', async () => { + const page = render(); + + fireEvent.click(await page.findByRole('radio', { name: /Tutorials/ })); + expect(page.getByRole('radio', { name: /Tutorials/ })).toBeChecked(); + + // A parent re-render must not re-seed the selection out from under the user mid-decision. + page.rerender( + + + , + ); + + expect(await page.findByRole('radio', { name: /Tutorials/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Assignments/ })).not.toBeChecked(); +}); + +it('shows the destination course, the tab picker, and the duplicating list', async () => { const page = render( , ); - // I18nProvider shows a LoadingIndicator until locale messages async-load; - // await the first query to render past it, then the rest are synchronous. - expect(await page.findByText('Enrollable Course')).toBeVisible(); + // I18nProvider shows a LoadingIndicator until locale messages async-load; await the first query + // to render past it, then the rest are synchronous. + expect(await page.findByText('Duplicate items?')).toBeVisible(); + + expect(page.getByText('Destination Course')).toBeVisible(); + expect(page.getByRole('link', { name: 'Enrollable Course' })).toHaveAttribute( + 'href', + '/courses/4', + ); + + expect(page.getByText('Pick destination tab')).toBeVisible(); expect(page.getByText('Missions')).toBeVisible(); + expect(page.getByText('Tutorials')).toBeVisible(); expect(page.getByText('Assignments')).toBeVisible(); - expect(page.getByText('Recursion Drills')).toBeVisible(); - // The old raw-key bug must not recur. - expect( - page.queryByText('course.marketplace.duplicateTitle'), - ).not.toBeInTheDocument(); + + expect(page.getByText('Duplicating')).toBeVisible(); + expect(page.getByText(LISTING_TITLE)).toBeVisible(); +}); + +it('stacks destination tabs vertically with large category and tab text', async () => { + const page = render( + , + ); + + expect(await page.findByText('Duplicate items?')).toBeVisible(); + + expect(page.getByText('Missions').closest('div')).toHaveClass('text-xl'); + + const assignments = page.getByRole('radio', { name: /Assignments/ }); + const tutorials = page.getByRole('radio', { name: /Tutorials/ }); + + expect(assignments.closest('label')?.parentElement).toHaveClass( + 'flex', + 'flex-col', + 'items-start', + ); + expect(assignments.closest('label')).toHaveClass('text-xl'); + expect(tutorials.closest('label')).toHaveClass('text-xl'); +}, 10000); + +// Pins the ⊘ icon and its wiring to the tooltip. NOT the tooltip copy: react-tooltip v5 renders +// nothing until shown, and hovering the anchor does not mount its content under jsdom (verified — +// `fireEvent.mouseEnter` + `findByText` on the message times out). So assert the wiring, which is +// what a dropped `tooltipId` or a dropped would break. +it('badges each item as an assessment and marks it as arriving unpublished', async () => { + const page = render( + , + ); + + expect(await page.findByText(LISTING_TITLE)).toBeVisible(); + expect(page.getByText('Assessment')).toBeVisible(); + expect(page.getByTestId('BlockIcon')).toHaveAttribute( + 'data-tooltip-id', + 'itemUnpublished', + ); +}); + +it('pre-selects the tab the user came from', async () => { + const page = render( + , + ); + + expect(await page.findByRole('radio', { name: /Assignments/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Tutorials/ })).not.toBeChecked(); +}); + +it('falls back to the first tab when the initial id is not a real tab', async () => { + const page = render( + , + ); + + expect(await page.findByRole('radio', { name: /Tutorials/ })).toBeChecked(); + expect(page.getByRole('radio', { name: /Assignments/ })).not.toBeChecked(); }); -it('falls back to Default placeholders when entered without a tab', async () => { +it('falls back to the first tab when entered without a from_tab', async () => { const page = render( , ); - expect(await page.findByText('Default Category')).toBeVisible(); - expect(page.getByText('Default Tab')).toBeVisible(); + expect(await page.findByRole('radio', { name: /Tutorials/ })).toBeChecked(); }); -it('posts a duplication request with the destination tab on confirm', async () => { +it('posts the pre-selected destination tab on confirm', async () => { mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); const page = render( { +it('posts the newly chosen tab after the user changes the selection', async () => { mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); const page = render( , ); - fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + fireEvent.click(await page.findByRole('radio', { name: /Tutorials/ })); + fireEvent.click(page.getByRole('button', { name: /Duplicate/ })); + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + listing_ids: [1], + destination_tab_id: 41, + }); +}); + +it('omits the destination tab entirely when the course has no tabs to pick from', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + + const page = render( + , + ); + + expect(await page.findByText('Recursion Drills')).toBeVisible(); + expect(page.queryAllByRole('radio')).toHaveLength(0); + + fireEvent.click(page.getByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + const body = JSON.parse(mock.history.post[0].data); expect(body).toMatchObject({ listing_ids: [1] }); - expect(body).not.toHaveProperty('destination_tab_id'); // backend then defaults to the first tab + // There is no tab to name, so the key must be absent and the backend picks its own default. + expect(body).not.toHaveProperty('destination_tab_id'); }); +it('duplicates every selected listing and pluralises the completion toast', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'completed' }); + + const page = render( + , + ); + + expect(await page.findByText(LISTING_TITLE)).toBeVisible(); + expect(page.getByText('Graph Traversals')).toBeVisible(); + + fireEvent.click(page.getByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toMatchObject({ + listing_ids: [1, 2], + }); + + await waitFor( + () => expect(successToastTexts()).toContain('Assessments duplicated.'), + { timeout: 6000 }, + ); +}, 10000); + +// The toast fires from pollJob's COMPLETION callback, so it must not claim the work has merely +// "started" — and it must surface the redirectUrl that callback receives, which the dialog used to +// throw away, leaving the user with no idea where the duplicate landed. +it('reports completion and links to where the assessment landed', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock + .onGet('/jobs/9') + .reply(200, { status: 'completed', redirectUrl: REDIRECT_URL }); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + // pollJob polls every 2s — longer than waitFor's 1s default. + await waitFor(() => expect(toast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + + const message = (toast.success as unknown as jest.Mock).mock.calls[0][0]; + const toasted = render(
{message}
); + + expect(await toasted.findByText(/Assessment duplicated\./)).toBeVisible(); + expect(toasted.queryByText(/started/i)).not.toBeInTheDocument(); + expect( + toasted.getByRole('link', { name: 'View assessment' }), + ).toHaveAttribute('href', REDIRECT_URL); +}, 10000); + +it('closes itself once the duplication completes', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock + .onGet('/jobs/9') + .reply(200, { status: 'completed', redirectUrl: REDIRECT_URL }); + const onClose = jest.fn(); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + // The dialog must dismiss itself on completion — the toast (with its link) is what remains. + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1), { + timeout: 6000, + }); +}, 10000); + +it('omits the link when the job returns no redirect url', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'completed' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(toast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + + const message = (toast.success as unknown as jest.Mock).mock.calls[0][0]; + const toasted = render(
{message}
); + + expect(await toasted.findByText(/Assessment duplicated\./)).toBeVisible(); + expect( + toasted.queryByRole('link', { name: 'View assessment' }), + ).not.toBeInTheDocument(); +}, 10000); + +// Guards the reworded failure copy — the old string was a malformed gerund +// ("Duplicating assessment failed."). +it('reports a failed duplication in plain language', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'errored' }); + + const page = render( + , + ); + + fireEvent.click(await page.findByRole('button', { name: /Duplicate/ })); + + await waitFor(() => expect(toast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(toast.error).toHaveBeenCalledWith( + 'Could not duplicate the assessment.', + ); + expect(toast.success).not.toHaveBeenCalled(); +}, 10000); + +it('locks the dialog while the job runs, then unlocks it without closing if the job fails', async () => { + mock.onPost(url).reply(200, { status: 'submitted', jobUrl: '/jobs/9' }); + jobsMock.onGet('/jobs/9').reply(200, { status: 'errored' }); + const onClose = jest.fn(); + + const page = render( + , + ); + + const duplicate = await page.findByRole('button', { name: /Duplicate/ }); + fireEvent.click(duplicate); + + // `Prompt` applies `disabled` to the cancel button as well as the primary one, so an in-flight + // job can be neither double-submitted nor abandoned halfway. + expect(duplicate).toBeDisabled(); + expect(page.getByRole('button', { name: 'Cancel' })).toBeDisabled(); + + fireEvent.click(duplicate); + + await waitFor(() => expect(toast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(mock.history.post).toHaveLength(1); + + // A failed job must leave the dialog open and usable, so the user can retry. + await waitFor(() => expect(duplicate).toBeEnabled()); + expect(page.getByRole('button', { name: 'Cancel' })).toBeEnabled(); + expect(onClose).not.toHaveBeenCalled(); +}, 10000); + // A request that never reaches the queue leaves no job to poll, so nothing else can re-enable the // prompt: the confirm button has to come back by itself for the user to be able to retry. it('re-enables the prompt when the request itself fails', async () => { mock.onPost(url).reply(500); const page = render( { mock.onGet(url).reply(200, { id: 70, title: LISTING_TITLE, + destinationTabs: [], description: '

Awesome description 5

', gradingMode: 'manual', baseExp: 1000, @@ -105,6 +106,7 @@ it('hides the EXP rows when the endpoint omits them', async () => { mock.onGet(url).reply(200, { id: 70, title: LISTING_TITLE, + destinationTabs: [], description: '', gradingMode: 'manual', showMcqMrqSolution: false, @@ -127,6 +129,7 @@ it('carries from_tab into the per-question detail links', async () => { mock.onGet(url).reply(200, { id: 70, title: LISTING_TITLE, + destinationTabs: [], description: '

desc

', gradingMode: 'manual', showMcqMrqSolution: false, @@ -161,6 +164,7 @@ it('navigates back to the marketplace carrying from_tab', async () => { mock.onGet(url).reply(200, { id: 70, title: LISTING_TITLE, + destinationTabs: [], description: '

desc

', gradingMode: 'manual', showMcqMrqSolution: false, @@ -184,6 +188,7 @@ it('renders a back button to the marketplace index', async () => { mock.onGet(url).reply(200, { id: 70, title: LISTING_TITLE, + destinationTabs: [], description: '

desc

', gradingMode: 'manual', showMcqMrqSolution: false, diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx index 427dec6280..a8b6803da7 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -78,10 +78,9 @@ const ListingPreview = (): JSX.Element => { setDuplicating(false)} open={duplicating} diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx index fa22509afd..92fef4f09b 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -10,7 +10,7 @@ import DuplicateConfirmation from '../../components/DuplicateConfirmation'; import { readFromTab } from '../../fromTab'; import { fetchListings } from '../../operations'; import translations from '../../translations'; -import { DestinationTab, MarketplaceListing } from '../../types'; +import { MarketplaceListing } from '../../types'; import MarketplaceTable from './MarketplaceTable'; @@ -20,43 +20,25 @@ const MarketplaceIndex = (): JSX.Element => { const fromTab = readFromTab(useLocation().search); const [pending, setPending] = useState([]); - const resolveDestination = ( - tabs: DestinationTab[], - ): { - category: { id: number; title: string } | null; - tab: { id: number; title: string } | null; - } => { - const match = tabs.find((tab) => tab.id === fromTab); - if (!match) return { category: null, tab: null }; - return { - category: { id: match.categoryId, title: match.categoryTitle }, - tab: { id: match.id, title: match.title }, - }; - }; - return ( } while={fetchListings}> - {({ listings, destinationTabs }): JSX.Element => { - const destination = resolveDestination(destinationTabs); - return ( - - - setPending([])} - open={pending.length > 0} - /> - - ); - }} + {({ listings, destinationTabs }): JSX.Element => ( + + + setPending([])} + open={pending.length > 0} + /> + + )} ); }; diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index 5e471f2085..6150ae6bdf 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -104,23 +104,40 @@ export default defineMessages({ id: 'course.marketplace.destinationCourse', defaultMessage: 'Destination Course', }, - assessmentsHeading: { - id: 'course.marketplace.assessmentsHeading', - defaultMessage: 'Assessments', + pickDestinationTab: { + id: 'course.marketplace.pickDestinationTab', + defaultMessage: 'Pick destination tab', + }, + duplicating: { + id: 'course.marketplace.duplicating', + defaultMessage: 'Duplicating', + }, + // Reuses the duplication bundle's existing id verbatim so formatjs extract dedupes rather than + // minting a marketplace-only duplicate; marketplace renders the ⊘ unpublished tooltip itself now. + itemUnpublished: { + id: 'course.duplication.Duplication.DuplicateItemsConfirmation.itemUnpublished', + defaultMessage: + 'Items are duplicated as unpublished when duplicating to an existing course.', }, duplicateConfirm: { id: 'course.marketplace.duplicateConfirm', defaultMessage: 'Duplicate', }, - duplicateStarted: { - id: 'course.marketplace.duplicateStarted', + // Fired from pollJob's completion callback, so this reports what already happened. The old copy + // said "started", which was both malformed ("Duplicating assessment started.") and untrue. + duplicateCompleted: { + id: 'course.marketplace.duplicateCompleted', defaultMessage: - '{n, plural, one {Duplicating assessment} other {Duplicating assessments}} started.', + '{n, plural, one {Assessment duplicated} other {Assessments duplicated}}.', }, duplicateFailed: { id: 'course.marketplace.duplicateFailed', defaultMessage: - '{n, plural, one {Duplicating assessment} other {Duplicating assessments}} failed.', + '{n, plural, one {Could not duplicate the assessment} other {Could not duplicate the assessments}}.', + }, + viewDuplicatedAssessment: { + id: 'course.marketplace.viewDuplicatedAssessment', + defaultMessage: 'View assessment', }, selectToDuplicate: { id: 'course.marketplace.selectToDuplicate', diff --git a/client/app/bundles/course/marketplace/types.ts b/client/app/bundles/course/marketplace/types.ts index 8465cfd862..a77528b5a8 100644 --- a/client/app/bundles/course/marketplace/types.ts +++ b/client/app/bundles/course/marketplace/types.ts @@ -43,6 +43,9 @@ export interface ListingPreviewData { id: number; title: string; description: string; + // The previewer's own category/tab structure, so the duplicate dialog can offer the destination + // tab picker from the listing detail page (the listing itself lives in another course). + destinationTabs: DestinationTab[]; gradingMode: 'autograded' | 'manual'; // Absent, not null, when the assessment awards none: show.json.jbuilder emits these keys only // when the value is > 0, so the details table skips the row instead of printing a bare "0". diff --git a/client/app/lib/hooks/toast/toast.tsx b/client/app/lib/hooks/toast/toast.tsx index a94f377d10..bafb0c50af 100644 --- a/client/app/lib/hooks/toast/toast.tsx +++ b/client/app/lib/hooks/toast/toast.tsx @@ -16,7 +16,9 @@ import { import { Typography } from '@mui/material'; import { produce } from 'immer'; -type Toaster = (message: string, options?: ToastOptions) => Id; +// `formattedMessage` already renders a ReactNode (and `PromisedToastMessages` already types its +// messages that way), so this only widens the type — nothing changes at runtime. +type Toaster = (message: ReactNode, options?: ToastOptions) => Id; interface PromisedToastMessages { pending?: ReactNode; diff --git a/client/locales/en.json b/client/locales/en.json index bd8647ddb5..172043d410 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6152,17 +6152,23 @@ "course.marketplace.destinationCourse": { "defaultMessage": "Destination Course" }, - "course.marketplace.assessmentsHeading": { - "defaultMessage": "Assessments" + "course.marketplace.pickDestinationTab": { + "defaultMessage": "Pick destination tab" + }, + "course.marketplace.duplicating": { + "defaultMessage": "Duplicating" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "Duplicate" }, - "course.marketplace.duplicateStarted": { - "defaultMessage": "{n, plural, one {Duplicating assessment} other {Duplicating assessments}} started." + "course.marketplace.duplicateCompleted": { + "defaultMessage": "{n, plural, one {Assessment duplicated} other {Assessments duplicated}}." }, "course.marketplace.duplicateFailed": { - "defaultMessage": "{n, plural, one {Duplicating assessment} other {Duplicating assessments}} failed." + "defaultMessage": "{n, plural, one {Could not duplicate the assessment} other {Could not duplicate the assessments}}." + }, + "course.marketplace.viewDuplicatedAssessment": { + "defaultMessage": "View assessment" }, "course.marketplace.selectToDuplicate": { "defaultMessage": "Select to duplicate" diff --git a/client/locales/ko.json b/client/locales/ko.json index 572d6f884d..53901b2d2c 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6116,17 +6116,23 @@ "course.marketplace.destinationCourse": { "defaultMessage": "대상 강좌" }, - "course.marketplace.assessmentsHeading": { - "defaultMessage": "평가" + "course.marketplace.pickDestinationTab": { + "defaultMessage": "대상 탭 선택" + }, + "course.marketplace.duplicating": { + "defaultMessage": "복제 중" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "복제" }, - "course.marketplace.duplicateStarted": { - "defaultMessage": "{n, plural, one {평가 복제가} other {평가 복제가}} 시작되었습니다." + "course.marketplace.duplicateCompleted": { + "defaultMessage": "{n, plural, one {평가가 복제되었습니다} other {평가가 복제되었습니다}}." }, "course.marketplace.duplicateFailed": { - "defaultMessage": "{n, plural, one {평가 복제에} other {평가 복제에}} 실패했습니다." + "defaultMessage": "{n, plural, one {평가를 복제할 수 없습니다} other {평가를 복제할 수 없습니다}}." + }, + "course.marketplace.viewDuplicatedAssessment": { + "defaultMessage": "평가 보기" }, "course.marketplace.selectToDuplicate": { "defaultMessage": "복제하려면 선택" diff --git a/client/locales/zh.json b/client/locales/zh.json index 812b418098..9ca548467d 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6110,17 +6110,23 @@ "course.marketplace.destinationCourse": { "defaultMessage": "目标课程" }, - "course.marketplace.assessmentsHeading": { - "defaultMessage": "评估" + "course.marketplace.pickDestinationTab": { + "defaultMessage": "选择目标标签页" + }, + "course.marketplace.duplicating": { + "defaultMessage": "正在复制" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "复制" }, - "course.marketplace.duplicateStarted": { - "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}已开始。" + "course.marketplace.duplicateCompleted": { + "defaultMessage": "{n, plural, one {评估已复制} other {评估已复制}}。" }, "course.marketplace.duplicateFailed": { - "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}失败。" + "defaultMessage": "{n, plural, one {无法复制评估} other {无法复制评估}}。" + }, + "course.marketplace.viewDuplicatedAssessment": { + "defaultMessage": "查看评估" }, "course.marketplace.selectToDuplicate": { "defaultMessage": "选择评估复制" diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 6404b85db7..f8074447a9 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -146,6 +146,20 @@ expect(question['options']).to be_present end + it 'includes the current course destination tabs so the duplicate dialog can offer a picker' do + get :show, params: { course_id: course, id: listing.id, format: :json } + tabs = response.parsed_body['destinationTabs'] + expect(tabs).to be_present + default_tab = course.assessment_categories.first.tabs.first + row = tabs.find { |tab| tab['id'] == default_tab.id } + expect(row).to include( + 'id' => default_tab.id, + 'title' => default_tab.title, + 'categoryId' => default_tab.category.id, + 'categoryTitle' => default_tab.category.title + ) + end + context 'when the listing is unpublished' do let!(:listing) { create(:course_assessment_marketplace_listing, published: false) } it 'is forbidden' do From 3c63094255ae38e3e8338312e20504af0ef611d0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 16 Jul 2026 21:00:50 +0800 Subject: [PATCH 10/30] fix(marketplace): redirect bare /listings to the marketplace index `marketplace/listings` with no listing id matched no route and 404'd. Add a redirect so it lands on the same page as `marketplace/`. --- client/app/routers/course/marketplace.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/client/app/routers/course/marketplace.tsx b/client/app/routers/course/marketplace.tsx index 9a7c3f7983..a3c54571dc 100644 --- a/client/app/routers/course/marketplace.tsx +++ b/client/app/routers/course/marketplace.tsx @@ -1,4 +1,4 @@ -import { RouteObject } from 'react-router-dom'; +import { Navigate, RouteObject } from 'react-router-dom'; import { WithRequired } from 'types'; import { Translated } from 'lib/hooks/useTranslation'; @@ -16,6 +16,12 @@ const marketplaceRouter: Translated = () => ({ .default, }), }, + { + // `listings` on its own (no id) is not a real page — send it back to the + // marketplace index so it lands in the same place as `marketplace/`. + path: 'listings', + element: , + }, { path: 'listings/:listingId', lazy: async () => ({ From c525319d94b2368f4b58e7c75965cb6d5e8d6b7c Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 16 Jul 2026 21:00:57 +0800 Subject: [PATCH 11/30] feat(marketplace): badge the listing detail page as a preview Add a "Preview" chip beside the title on the read-only listing detail page, so it is never mistaken for the real assessment it mirrors. --- .../ListingPreview/__test__/index.test.tsx | 25 +++++++++++++++++++ .../pages/ListingPreview/index.tsx | 11 +++++++- .../course/marketplace/translations.ts | 4 +++ client/locales/en.json | 3 +++ client/locales/ko.json | 3 +++ client/locales/zh.json | 3 +++ 6 files changed, 48 insertions(+), 1 deletion(-) diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx index 9372febe08..2034045639 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx @@ -204,3 +204,28 @@ it('renders a back button to the marketplace index', async () => { // Page renders the back affordance as an IconButton with this testid when `backTo` is set. expect(screen.getByTestId('ArrowBackIconButton')).toBeInTheDocument(); }); + +it('marks the page title as a preview', async () => { + const url = `/courses/${global.courseId}/marketplace/listings/7`; + mock.onGet(url).reply(200, { + id: 70, + title: LISTING_TITLE, + destinationTabs: [], + description: '

desc

', + gradingMode: 'manual', + baseExp: 0, + bonusExp: 0, + showMcqMrqSolution: false, + showRubricToStudents: false, + gradedTestCases: '', + typeCounts: {}, + questions: [], + }); + + render(, { at: [url] }); + + await waitFor(() => expect(screen.getByText(LISTING_TITLE)).toBeVisible()); + // A "Preview" chip sits beside the title so the read-only listing detail page is never mistaken + // for the real assessment it mirrors. + expect(screen.getByText('Preview')).toBeVisible(); +}); diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx index a8b6803da7..890cad61b5 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -49,7 +49,16 @@ const ListingPreview = (): JSX.Element => { } backTo={withFromTab(`${courseUrl}/marketplace`, fromTab)} className="space-y-5" - title={listing.title} + title={ + + {listing.title} + + + } > {listing.description && ( diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index 6150ae6bdf..7d0b50df5a 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -73,6 +73,10 @@ export default defineMessages({ id: 'course.marketplace.previewAction', defaultMessage: 'Preview', }, + previewBadge: { + id: 'course.marketplace.previewBadge', + defaultMessage: 'Preview', + }, duplicateAssessment: { id: 'course.marketplace.duplicateAssessment', defaultMessage: 'Duplicate Assessment', diff --git a/client/locales/en.json b/client/locales/en.json index 172043d410..43769f2dab 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6125,6 +6125,9 @@ "course.marketplace.previewAction": { "defaultMessage": "Preview" }, + "course.marketplace.previewBadge": { + "defaultMessage": "Preview" + }, "course.marketplace.duplicateAssessment": { "defaultMessage": "Duplicate Assessment" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 53901b2d2c..8a8fd2d48b 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6089,6 +6089,9 @@ "course.marketplace.previewAction": { "defaultMessage": "미리보기" }, + "course.marketplace.previewBadge": { + "defaultMessage": "미리보기" + }, "course.marketplace.duplicateAssessment": { "defaultMessage": "평가 복제" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 9ca548467d..b5b288d56b 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6083,6 +6083,9 @@ "course.marketplace.previewAction": { "defaultMessage": "预览" }, + "course.marketplace.previewBadge": { + "defaultMessage": "预览" + }, "course.marketplace.duplicateAssessment": { "defaultMessage": "复制评估" }, From 87d762ef3eeff40dae352db03b6289d83e91956a Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:05:48 +0800 Subject: [PATCH 12/30] feat(marketplace): per-person marketplace access control backend Gate assessment-marketplace browsing per person rather than per current-course role. A typed allow-list (user / instance / email-domain / everyone rules) grants access to baseline-capable users (course manager/owner or instance instructor/admin anywhere), with individual access blocks as overrides. - AllowlistRule and AccessBlock models, migrations, and per-type uniqueness - RuleMatchQuery / RulePreviewQuery / AccessListQuery for matching and audit - ability component gates :access_marketplace on the allow-list minus blocks - User baseline predicates and delete-user FK handling for both tables - System::Admin CRUD, access-list, and block/unblock endpoints --- .../marketplace_access_blocks_controller.rb | 22 ++ .../admin/marketplace_access_controller.rb | 8 + .../marketplace_allowlist_rules_controller.rb | 52 ++++ .../system/admin/marketplace_access_helper.rb | 13 + ...ssessment_marketplace_ability_component.rb | 35 ++- .../assessment/marketplace/access_block.rb | 23 ++ .../marketplace/access_list_query.rb | 137 +++++++++ .../assessment/marketplace/allowlist_rule.rb | 77 +++++ .../marketplace/rule_match_query.rb | 50 +++ .../marketplace/rule_preview_query.rb | 88 ++++++ app/models/user.rb | 44 +++ .../marketplace_access/index.json.jbuilder | 22 ++ .../_rule.json.jbuilder | 9 + .../index.json.jbuilder | 5 + .../preview.json.jbuilder | 15 + config/locales/en/activerecord/attributes.yml | 7 + config/locales/ko/activerecord/attributes.yml | 4 + config/locales/zh/activerecord/attributes.yml | 4 + config/routes.rb | 7 + ..._assessment_marketplace_allowlist_rules.rb | 55 ++++ db/schema.rb | 31 +- .../marketplace/listings_controller_spec.rb | 109 +++++++ .../marketplace/questions_controller_spec.rb | 5 +- .../assessment_marketplace_component_spec.rb | 5 +- ...rketplace_access_blocks_controller_spec.rb | 55 ++++ .../marketplace_access_controller_spec.rb | 129 ++++++++ ...etplace_allowlist_rules_controller_spec.rb | 291 ++++++++++++++++++ ...se_assessment_marketplace_access_blocks.rb | 8 + ..._assessment_marketplace_allowlist_rules.rb | 28 ++ .../marketplace/access_block_spec.rb | 75 +++++ .../marketplace/access_list_query_spec.rb | 278 +++++++++++++++++ .../marketplace/allowlist_rule_spec.rb | 279 +++++++++++++++++ .../marketplace/rule_match_query_spec.rb | 123 ++++++++ .../assessment_marketplace_ability_spec.rb | 75 +++++ spec/models/user_spec.rb | 61 ++++ 35 files changed, 2225 insertions(+), 4 deletions(-) create mode 100644 app/controllers/system/admin/marketplace_access_blocks_controller.rb create mode 100644 app/controllers/system/admin/marketplace_access_controller.rb create mode 100644 app/controllers/system/admin/marketplace_allowlist_rules_controller.rb create mode 100644 app/helpers/system/admin/marketplace_access_helper.rb create mode 100644 app/models/course/assessment/marketplace/access_block.rb create mode 100644 app/models/course/assessment/marketplace/access_list_query.rb create mode 100644 app/models/course/assessment/marketplace/allowlist_rule.rb create mode 100644 app/models/course/assessment/marketplace/rule_match_query.rb create mode 100644 app/models/course/assessment/marketplace/rule_preview_query.rb create mode 100644 app/views/system/admin/marketplace_access/index.json.jbuilder create mode 100644 app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder create mode 100644 app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder create mode 100644 app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder create mode 100644 db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb create mode 100644 spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb create mode 100644 spec/controllers/system/admin/marketplace_access_controller_spec.rb create mode 100644 spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb create mode 100644 spec/factories/course_assessment_marketplace_access_blocks.rb create mode 100644 spec/factories/course_assessment_marketplace_allowlist_rules.rb create mode 100644 spec/models/course/assessment/marketplace/access_block_spec.rb create mode 100644 spec/models/course/assessment/marketplace/access_list_query_spec.rb create mode 100644 spec/models/course/assessment/marketplace/allowlist_rule_spec.rb create mode 100644 spec/models/course/assessment/marketplace/rule_match_query_spec.rb diff --git a/app/controllers/system/admin/marketplace_access_blocks_controller.rb b/app/controllers/system/admin/marketplace_access_blocks_controller.rb new file mode 100644 index 0000000000..3bb19d952a --- /dev/null +++ b/app/controllers/system/admin/marketplace_access_blocks_controller.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAccessBlocksController < System::Admin::Controller + def create + block = Course::Assessment::Marketplace::AccessBlock.new( + user_id: params[:user_id], creator: current_user + ) + if block.save + render json: { id: block.id, userId: block.user_id }, status: :ok + else + render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request + end + end + + def destroy + block = Course::Assessment::Marketplace::AccessBlock.find(params[:id]) + if block.destroy + head :ok + else + render json: { errors: block.errors.full_messages.to_sentence }, status: :bad_request + end + end +end diff --git a/app/controllers/system/admin/marketplace_access_controller.rb b/app/controllers/system/admin/marketplace_access_controller.rb new file mode 100644 index 0000000000..50f21b0da8 --- /dev/null +++ b/app/controllers/system/admin/marketplace_access_controller.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAccessController < System::Admin::Controller + def index + query = Course::Assessment::Marketplace::AccessListQuery.new + @rows = query.rows + @summary = query.summary + end +end diff --git a/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb new file mode 100644 index 0000000000..80d4b8ff00 --- /dev/null +++ b/app/controllers/system/admin/marketplace_allowlist_rules_controller.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +class System::Admin::MarketplaceAllowlistRulesController < System::Admin::Controller + # `preview` is a collection action with no id, so CanCan's default loader would try + # `find(params[:id])`. It builds its own unsaved rule; System::Admin::Controller's + # `authorize_admin` already gates the whole controller. + load_and_authorize_resource :allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule', + parent: false, except: [:preview] + + def index + # "Everyone" is a page-level mode, not a table row: expose its presence as `@everyone_rule` + # and show only the scoped rules in the table. + @everyone_rule = @allowlist_rules.rule_type_everyone.first + @allowlist_rules = @allowlist_rules.where.not(rule_type: :everyone).includes(:user, :instance) + end + + def create + if @allowlist_rule.save + # `render partial:` (not `render 'rule'`) — the view is the `_rule` partial. Mirrors + # System::Admin::AnnouncementsController#create (`render partial: '.../announcement_data'`). + render partial: 'rule', locals: { rule: @allowlist_rule }, status: :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + def preview + rule = Course::Assessment::Marketplace::AllowlistRule.new(allowlist_rule_params) + unless rule.valid? + render json: { errors: rule.errors.full_messages.to_sentence }, status: :bad_request + return + end + + query = Course::Assessment::Marketplace::RulePreviewQuery.new(rule) + @rows = query.rows + @summary = query.summary + end + + def destroy + if @allowlist_rule.destroy + head :ok + else + render json: { errors: @allowlist_rule.errors.full_messages.to_sentence }, status: :bad_request + end + end + + private + + def allowlist_rule_params + params.require(:allowlist_rule).permit(:rule_type, :user_id, :instance_id, :email_domain, :email) + end +end diff --git a/app/helpers/system/admin/marketplace_access_helper.rb b/app/helpers/system/admin/marketplace_access_helper.rb new file mode 100644 index 0000000000..364d0cf864 --- /dev/null +++ b/app/helpers/system/admin/marketplace_access_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module System::Admin::MarketplaceAccessHelper + # The value half of a rule's label ("Email domain · "), for the audit list's reason column. + # @param [Course::Assessment::Marketplace::AllowlistRule] rule + # @return [String, nil] + def marketplace_rule_label_value(rule) + case rule.rule_type + when 'user' then rule.user&.name + when 'instance' then rule.instance&.name + when 'email_domain' then rule.email_domain + end + end +end diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index e7a7972085..085e64ea8c 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -4,12 +4,45 @@ module Course::AssessmentMarketplaceAbilityComponent def define_permissions allow_admins_publish_to_marketplace if user&.administrator? - allow_managers_access_marketplace if course_user&.manager_or_owner? + # System admins keep marketplace access via `can :manage, :all` (Ability#initialize); do not + # emit a `cannot` for them or it would revoke that. For everyone else, access is per-person. + if course && !user&.administrator? + if can_access_marketplace? + allow_managers_access_marketplace + else + # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, + # which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace` + # regardless of the allow-list. This component runs after that one in the `define_permissions` + # super chain, so a `cannot` here takes precedence. This line is load-bearing. + cannot :access_marketplace, Course, id: course.id + end + end super end private + # Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns + # >=1 course anywhere, OR is an instructor/administrator in any instance) and passes the allow-list + # may browse, whatever their role in the course they are viewing. + def can_access_marketplace? + marketplace_baseline_capable? && marketplace_visible_to_user? + end + + # The two peer baseline capabilities for the marketplace. Either qualifies; the allow-list narrows. + def marketplace_baseline_capable? + user&.course_manager_or_owner? || user&.instance_instructor_or_administrator? + end + + # Part of the TEMPORARY allow-list gate (see the retirement seam on `can_access_marketplace?`). + # When the allow-list is retired this whole method is deleted; the block check goes with it. + def marketplace_visible_to_user? + return true if user&.administrator? + + Course::Assessment::Marketplace::AllowlistRule.grants_access?(user) && + !Course::Assessment::Marketplace::AccessBlock.blocked?(user) + end + def allow_admins_publish_to_marketplace can :publish_to_marketplace, Course::Assessment end diff --git a/app/models/course/assessment/marketplace/access_block.rb b/app/models/course/assessment/marketplace/access_block.rb new file mode 100644 index 0000000000..03841d618a --- /dev/null +++ b/app/models/course/assessment/marketplace/access_block.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::AccessBlock < ApplicationRecord + belongs_to :user, inverse_of: false + belongs_to :creator, class_name: 'User', inverse_of: false + + # Paired with the DB unique index on user_id: a user is blocked at most once. + validates :user_id, uniqueness: true + + # Whether +user+ has been individually disabled from the marketplace. Global (not tenant-scoped), + # mirroring AllowlistRule. + # @param [User] user + # @return [Boolean] + def self.blocked?(user) + return false unless user + + where(user_id: user.id).exists? + end + + # @return [Array] user ids of every block (for per-page status annotation). + def self.blocked_user_ids + pluck(:user_id) + end +end diff --git a/app/models/course/assessment/marketplace/access_list_query.rb b/app/models/course/assessment/marketplace/access_list_query.rb new file mode 100644 index 0000000000..ee0047e9b5 --- /dev/null +++ b/app/models/course/assessment/marketplace/access_list_query.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true +# Computes the marketplace access audit list: every user who is baseline-capable (manages/owns >=1 +# course, OR is an instructor/administrator in any instance) AND cleared by the allow-list, PLUS +# every individually blocked user regardless of rule match — an orphaned block must stay visible and +# clearable. Blocked users are INCLUDED and flagged. Not paginated server-side - the eligible set is +# bounded (managers + instance staff) and the frontend paginates/searches client-side, matching the +# rules page which also fetches its whole list at once. +class Course::Assessment::Marketplace::AccessListQuery + AllowlistRule = Course::Assessment::Marketplace::AllowlistRule + AccessBlock = Course::Assessment::Marketplace::AccessBlock + RuleMatchQuery = Course::Assessment::Marketplace::RuleMatchQuery + + # `allowed_by_rules` holds EVERY rule matching the user, not one precedence winner: the admin uses + # it to answer "if I delete this rule, who loses access?", and one reason answers that wrongly. + Row = Struct.new(:user, :course_count, :instance_role, :allowed_by_rules, :block_id, + :system_admin, keyword_init: true) do + def blocked? + block_id.present? + end + + def system_admin? + system_admin.present? + end + + def access_denied? + blocked? && !system_admin? + end + end + + # @return [Array] + def rows + @rows ||= annotate(listed_users.to_a) + end + + # @return [Hash] + def summary + { + total_with_access: rows.count { |row| !row.access_denied? }, + total_blocked: rows.count(&:access_denied?), + open_to_everyone: everyone? + } + end + + # Baseline-eligible users the allow-list currently clears. A block does not remove someone from + # this set — being blocked is a separate decision layered on top of being allowed. + # @return [Set] + def allowed_user_ids + # System admins hold a blanket `can :manage, :all`, so they have the marketplace whatever the + # rules say. This table is the audit source of truth, so they are always in it — omitting them + # would show an admin as having no access while they in fact bypass every gate. + @allowed_user_ids ||= (everyone? ? baseline_ids.to_set : rules_by_user.keys.to_set) | admin_ids + end + + private + + # Batches every per-user annotation into one query each, keyed by user id. + def annotate(users) + ids = users.map(&:id) + counts = managed_course_counts(ids) + staff = instance_staff_roles(ids) + block_ids = block_ids_by_user(ids) + + users.map do |user| + Row.new(user: user, course_count: counts[user.id] || 0, + instance_role: staff[user.id], allowed_by_rules: rules_by_user[user.id] || [], + block_id: block_ids[user.id], system_admin: admin_ids.include?(user.id)) + end + end + + def managed_course_counts(ids) + CourseUser.managers.where(user_id: ids).group(:user_id).count + end + + def block_ids_by_user(ids) + AccessBlock.where(user_id: ids).pluck(:user_id, :id).to_h + end + + def blocked_ids + @blocked_ids ||= AccessBlock.pluck(:user_id).to_set + end + + def listed_users + User.where(id: (allowed_user_ids | blocked_ids).to_a).includes(:emails).order(:name) + end + + def admin_ids + @admin_ids ||= User.administrator.pluck(:id).to_set + end + + def baseline_ids + @baseline_ids ||= baseline_scope.pluck(:id) + end + + # CourseUser is not tenant-scoped ("any course"); InstanceUser IS, so .unscoped for "any instance". + def baseline_scope + User.where(id: CourseUser.managers.select(:user_id)). + or(User.where(id: instance_staff_scope.select(:user_id))). + or(User.administrator) + end + + def instance_staff_scope + InstanceUser.unscoped.where(role: [:instructor, :administrator]) + end + + def everyone? + return @everyone if defined?(@everyone) + + @everyone = AllowlistRule.rule_type_everyone.exists? + end + + # An `everyone` rule is a page-level mode, not a per-row reason, so it contributes no scoped rules. + def scoped_rules + @scoped_rules ||= if everyone? + [] + else + # `user`/`instance` are read when labelling each row's reasons; preload them + # once here rather than once per rule per row. + AllowlistRule.where.not(rule_type: :everyone). + includes(:user, :instance).order(:id).to_a + end + end + + # user id => [AllowlistRule], every rule matching that user, in rules-table order. + def rules_by_user + @rules_by_user ||= scoped_rules.each_with_object({}) do |rule, map| + RuleMatchQuery.new(rule).user_ids_within(baseline_ids).each do |id| + (map[id] ||= []) << rule + end + end + end + + def instance_staff_roles(ids) + InstanceUser.unscoped.where(user_id: ids, role: [:instructor, :administrator]). + group(:user_id).maximum(:role). + transform_values { |role| InstanceUser.roles.key(role) } + end +end diff --git a/app/models/course/assessment/marketplace/allowlist_rule.rb b/app/models/course/assessment/marketplace/allowlist_rule.rb new file mode 100644 index 0000000000..1dab542d0c --- /dev/null +++ b/app/models/course/assessment/marketplace/allowlist_rule.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord + enum :rule_type, + { user: 0, instance: 1, email_domain: 2, everyone: 3 }, + prefix: true + + belongs_to :user, class_name: 'User', inverse_of: false, optional: true + belongs_to :instance, inverse_of: false, optional: true + + # Transient: the admin form identifies a `user` rule by email (user IDs are not shown anywhere + # in the admin panel). Resolved to the owning user before validation; the stored row keeps the + # `user_id` FK, so the rule means "this person" even if they later change email. + attr_accessor :email + + before_validation :resolve_user_from_email, if: -> { rule_type_user? && email.present? } + + # When an email was supplied, `resolve_user_from_email` reports its own failure; skip the generic + # presence check in that path so the message is exactly "No user with that email." (not a pair). + before_validation :normalize_email_domain, if: :rule_type_email_domain? + before_validation :clear_columns_of_other_rule_types + + validates :user, presence: true, if: -> { rule_type_user? && email.blank? } + validates :instance, presence: true, if: :rule_type_instance? + validates :email_domain, presence: true, if: :rule_type_email_domain? + + validates :user_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + if: :rule_type_user? + validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + if: :rule_type_instance? + validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + if: :rule_type_email_domain? + # "Everyone" is the widest rule; only one may exist. Paired with a partial unique index. + validates :rule_type, uniqueness: true, if: :rule_type_everyone? + + # Whether the marketplace is visible to +user+ per the allow-list. The rules table itself is + # global (not tenant-scoped), but `user.instance_users` IS tenant-scoped (acts_as_tenant), so + # in a request an `instance` rule matches only while browsing the allow-listed instance — + # it grants that instance's users access *there*, not membership-based access everywhere. + # An `everyone` rule grants every authenticated user (the `nil` guard still excludes anonymous). + # Baseline (manager/owner OR instructor/admin) is checked separately in the ability component. + # @param [User] user + # @return [Boolean] + def self.grants_access?(user) + return false unless user + + rule_type_everyone.exists? || + rule_type_user.where(user_id: user.id).exists? || + rule_type_instance.where(instance_id: user.instance_users.select(:instance_id)).exists? || + email_domain_matches?(user) + end + + # @param [User] user + # @return [Boolean] + def self.email_domain_matches?(user) + domains = user.emails.confirmed.pluck(:email).filter_map { |e| e.split('@').last&.downcase }.uniq + return false if domains.empty? + + rule_type_email_domain.where('LOWER(email_domain) IN (?)', domains).exists? + end + + private + + def normalize_email_domain + self.email_domain = email_domain&.strip&.downcase + end + + def resolve_user_from_email + self.user = User.with_email_addresses([email.strip.downcase]).first + errors.add(:base, 'No user with that email.') if user.nil? + end + + def clear_columns_of_other_rule_types + self.user_id = nil unless rule_type_user? + self.instance_id = nil unless rule_type_instance? + self.email_domain = nil unless rule_type_email_domain? + end +end diff --git a/app/models/course/assessment/marketplace/rule_match_query.rb b/app/models/course/assessment/marketplace/rule_match_query.rb new file mode 100644 index 0000000000..9722c37336 --- /dev/null +++ b/app/models/course/assessment/marketplace/rule_match_query.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true +# Which baseline-eligible users does a single allow-list rule match? The rule may be unsaved, so the +# admin can preview a rule's effect before adding it. This is the one place that knows each rule +# type's matching semantics; AccessListQuery and the preview endpoint both go through it. +# +# Deliberately NOT the same as AllowlistRule.grants_access?, which reads the tenant-scoped +# `user.instance_users` at request time. Here `instance` rules match globally via +# InstanceUser.unscoped, because the audit list is not browsing any one instance. +class Course::Assessment::Marketplace::RuleMatchQuery + # @param [Course::Assessment::Marketplace::AllowlistRule] rule persisted or in-memory + def initialize(rule) + @rule = rule + end + + # @param [Array] candidate_user_ids the users to test + # @return [Set] the subset of +candidate_user_ids+ this rule matches + def user_ids_within(candidate_user_ids) + return Set.new if candidate_user_ids.empty? + + case @rule.rule_type + when 'everyone' then candidate_user_ids.to_set + when 'user' then matched_user(candidate_user_ids) + when 'instance' then matched_instance_members(candidate_user_ids) + when 'email_domain' then matched_domain_holders(candidate_user_ids) + else Set.new + end + end + + private + + def matched_user(ids) + (@rule.user_id.present? && ids.include?(@rule.user_id)) ? Set[@rule.user_id] : Set.new + end + + def matched_instance_members(ids) + return Set.new if @rule.instance_id.blank? + + InstanceUser.unscoped.where(user_id: ids, instance_id: @rule.instance_id). + pluck(:user_id).to_set + end + + def matched_domain_holders(ids) + domain = @rule.email_domain&.strip&.downcase + return Set.new if domain.blank? + + User::Email.where.not(confirmed_at: nil).where(user_id: ids). + where('LOWER(SPLIT_PART(email, ?, 2)) = ?', '@', domain). + pluck(:user_id).to_set + end +end diff --git a/app/models/course/assessment/marketplace/rule_preview_query.rb b/app/models/course/assessment/marketplace/rule_preview_query.rb new file mode 100644 index 0000000000..cff62f5f06 --- /dev/null +++ b/app/models/course/assessment/marketplace/rule_preview_query.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true +# Answers "if I add this rule, who gets access?" for a rule that has not been saved, so the admin +# sees the effect before committing. Persists nothing. +class Course::Assessment::Marketplace::RulePreviewQuery + AccessBlock = Course::Assessment::Marketplace::AccessBlock + AccessListQuery = Course::Assessment::Marketplace::AccessListQuery + RuleMatchQuery = Course::Assessment::Marketplace::RuleMatchQuery + + Row = Struct.new(:user, :course_count, :instance_role, :already_has_access, :blocked, + keyword_init: true) + + # @param [Course::Assessment::Marketplace::AllowlistRule] rule an unsaved, valid rule + def initialize(rule) + @rule = rule + @access_list = AccessListQuery.new + end + + # @return [Array] + def rows + # Blocked first, then already-has-access, then the newly granted, each group still by name. + # A stable sort_by on the group rank alone, so the name order the database chose survives + # inside each group. + @rows ||= annotate(matched_users.to_a). + each_with_index.sort_by { |row, index| [group_rank(row), index] }.map(&:first) + end + + # @return [Hash] + def summary + { + matched_count: rows.size, + # A rule grants nothing to someone another rule already clears, and nothing at all to a + # blocked user — adding a rule does not unblock anyone. + new_count: rows.count { |row| !row.already_has_access && !row.blocked }, + blocked_count: rows.count(&:blocked), + open_to_everyone: @access_list.summary[:open_to_everyone] + } + end + + private + + # Same precedence as the row's status marker, which shows Blocked over already-has-access. + def group_rank(row) + return 0 if row.blocked + return 1 if row.already_has_access + + 2 + end + + def matched_ids + @matched_ids ||= RuleMatchQuery.new(@rule).user_ids_within(baseline_ids) + end + + # Only baseline-eligible staff can ever reach the marketplace, so a rule matching anyone else + # grants nothing and must not be counted. + def baseline_ids + @baseline_ids ||= User.where(id: CourseUser.managers.select(:user_id)). + or(User.where(id: instance_staff_scope.select(:user_id))).pluck(:id) + end + + def instance_staff_scope + InstanceUser.unscoped.where(role: [:instructor, :administrator]) + end + + def matched_users + User.where(id: matched_ids.to_a).includes(:emails).order(:name) + end + + def annotate(users) + ids = users.map(&:id) + counts = CourseUser.managers.where(user_id: ids).group(:user_id).count + staff = instance_staff_roles(ids) + allowed = @access_list.allowed_user_ids + blocked = AccessBlock.where(user_id: ids).pluck(:user_id).to_set + + users.map { |user| build_row(user, counts, staff, allowed, blocked) } + end + + def build_row(user, counts, staff, allowed, blocked) + Row.new(user: user, course_count: counts[user.id] || 0, instance_role: staff[user.id], + already_has_access: allowed.include?(user.id), blocked: blocked.include?(user.id)) + end + + def instance_staff_roles(ids) + InstanceUser.unscoped.where(user_id: ids, role: [:instructor, :administrator]). + group(:user_id).maximum(:role). + transform_values { |role| InstanceUser.roles.key(role) } + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 75aae3fc49..41000928d5 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -76,6 +76,22 @@ def deleted has_one :cikgo_user, dependent: :destroy, inverse_of: :user + # Both tables FK to users with no ON DELETE, so without these the admin panel's delete-user + # action dies with PG::ForeignKeyViolation for anyone who is allow-listed or blocked. Destroying + # is the right semantic for both: each row is *about* this user and means nothing without them. + has_one :marketplace_allowlist_rule, class_name: 'Course::Assessment::Marketplace::AllowlistRule', + inverse_of: false, dependent: :destroy + has_one :marketplace_access_block, class_name: 'Course::Assessment::Marketplace::AccessBlock', + inverse_of: false, dependent: :destroy + # Blocks this user ISSUED. Not `dependent:` anything — destroying them would silently restore + # marketplace access for everyone this admin ever blocked, and `creator_id` is NOT NULL so it + # cannot be nullified either. `reassign_issued_marketplace_blocks` hands authorship to the + # Deleted user instead, which keeps the block standing and satisfies the FK. + has_many :issued_marketplace_access_blocks, class_name: 'Course::Assessment::Marketplace::AccessBlock', + foreign_key: :creator_id, inverse_of: false, + dependent: nil + before_destroy :reassign_issued_marketplace_blocks + accepts_nested_attributes_for :emails scope :ordered_by_name, -> { order(:name) } @@ -96,6 +112,26 @@ def built_in? id == User::SYSTEM_USER_ID || id == User::DELETED_USER_ID end + # Whether the user manages or owns at least one course, in any instance. This is the baseline + # capability for the assessment marketplace: browsing is then further gated by the allow-list. + # `course_users` is not tenant-scoped (CourseUser has no acts_as_tenant), so this correctly + # spans all instances. + # + # @return [Boolean] + def course_manager_or_owner? + course_users.managers.exists? + end + + # Whether the user is an instructor or administrator InstanceUser in ANY instance. This is the + # second baseline capability for the assessment marketplace, a peer of course_manager_or_owner?. + # `instance_users` IS tenant-scoped (acts_as_tenant), so bypass the tenant to span all instances. + # + # @return [Boolean] + def instance_instructor_or_administrator? + ActsAsTenant.without_tenant do + instance_users.where(role: [:instructor, :administrator]).exists? + end + end # Pick the default email and set it as primary email. This method would immediately set the # attributes in the database. # @@ -136,6 +172,14 @@ def build_course_user_from_invitation(invitation) private + # Hands any marketplace blocks this user issued to the Deleted user, so destroying an admin does + # not lift the blocks they put in place (nor trip the NOT NULL FK on `creator_id`). + def reassign_issued_marketplace_blocks + return if id == User::DELETED_USER_ID + + issued_marketplace_access_blocks.update_all(creator_id: User::DELETED_USER_ID) + end + # Gets the default email address record. # # @return [User::Email] The user's primary email address record. diff --git a/app/views/system/admin/marketplace_access/index.json.jbuilder b/app/views/system/admin/marketplace_access/index.json.jbuilder new file mode 100644 index 0000000000..332f37027e --- /dev/null +++ b/app/views/system/admin/marketplace_access/index.json.jbuilder @@ -0,0 +1,22 @@ +# frozen_string_literal: true +json.users @rows do |row| + json.id row.user.id + json.name row.user.name + json.email row.user.email + json.courseCount row.course_count + json.instanceRole row.instance_role + json.allowedByRules row.allowed_by_rules do |rule| + json.id rule.id + json.ruleType rule.rule_type + json.labelValue marketplace_rule_label_value(rule) + end + json.systemAdmin row.system_admin? + json.blocked row.blocked? + json.blockId row.block_id +end + +json.summary do + json.totalWithAccess @summary[:total_with_access] + json.totalBlocked @summary[:total_blocked] + json.openToEveryone @summary[:open_to_everyone] +end diff --git a/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder new file mode 100644 index 0000000000..d05e2b8ec3 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/_rule.json.jbuilder @@ -0,0 +1,9 @@ +# frozen_string_literal: true +json.id rule.id +json.ruleType rule.rule_type +json.userId rule.user_id +json.userName rule.user&.name +json.userEmail rule.user&.email +json.instanceId rule.instance_id +json.instanceName rule.instance&.name +json.emailDomain rule.email_domain diff --git a/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder new file mode 100644 index 0000000000..2b07f8a2ad --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/index.json.jbuilder @@ -0,0 +1,5 @@ +# frozen_string_literal: true +json.rules @allowlist_rules do |rule| + json.partial! 'rule', rule: rule +end +json.everyoneRuleId @everyone_rule&.id diff --git a/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder b/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder new file mode 100644 index 0000000000..6bf2b2b200 --- /dev/null +++ b/app/views/system/admin/marketplace_allowlist_rules/preview.json.jbuilder @@ -0,0 +1,15 @@ +# frozen_string_literal: true +json.matchedCount @summary[:matched_count] +json.newCount @summary[:new_count] +json.blockedCount @summary[:blocked_count] +json.openToEveryone @summary[:open_to_everyone] + +json.users @rows do |row| + json.id row.user.id + json.name row.user.name + json.email row.user.email + json.courseCount row.course_count + json.instanceRole row.instance_role + json.alreadyHasAccess row.already_has_access + json.blocked row.blocked +end diff --git a/config/locales/en/activerecord/attributes.yml b/config/locales/en/activerecord/attributes.yml index f558633352..8869d32856 100644 --- a/config/locales/en/activerecord/attributes.yml +++ b/config/locales/en/activerecord/attributes.yml @@ -15,6 +15,13 @@ en: weight: 'Order' course/assessment/category/title: default: 'Assessments' + # Admins read these attribute names verbatim: the allow-list controller renders + # `errors.full_messages.to_sentence` straight into a toast, so without these entries a + # validation failure surfaces as the raw i18n key. + course/assessment/marketplace/allowlist_rule: + user_id: 'User' + instance_id: 'Instance' + email_domain: 'Email domain' course/assessment/question: weight: 'Order' course/assessment/submission: diff --git a/config/locales/ko/activerecord/attributes.yml b/config/locales/ko/activerecord/attributes.yml index 6696c1e361..34ab52f9d3 100644 --- a/config/locales/ko/activerecord/attributes.yml +++ b/config/locales/ko/activerecord/attributes.yml @@ -15,6 +15,10 @@ ko: weight: '순서' course/assessment/category/title: default: '평가' + course/assessment/marketplace/allowlist_rule: + user_id: '사용자' + instance_id: '인스턴스' + email_domain: '이메일 도메인' course/assessment/question: weight: '순서' course/assessment/submission: diff --git a/config/locales/zh/activerecord/attributes.yml b/config/locales/zh/activerecord/attributes.yml index ab0470c80a..875cd5ba32 100644 --- a/config/locales/zh/activerecord/attributes.yml +++ b/config/locales/zh/activerecord/attributes.yml @@ -15,6 +15,10 @@ zh: weight: '权重' course/assessment/category/title: default: '评估' + course/assessment/marketplace/allowlist_rule: + user_id: '用户' + instance_id: '实例' + email_domain: '电子邮箱域名' course/assessment/question: weight: '权重' course/assessment/submission: diff --git a/config/routes.rb b/config/routes.rb index 2b7cc3253a..9ff0447088 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -109,6 +109,13 @@ get '/' => 'admin#index' get 'deployment_info' => 'admin#deployment_info' resources :announcements, only: [:index, :create, :update, :destroy] + resources :marketplace_allowlist_rules, only: [:index, :create, :destroy] do + # Dry run: reports who a prospective rule would let in. POST because it carries a body, + # not because it mutates - the action never saves. + post :preview, on: :collection + end + get 'marketplace_access' => 'marketplace_access#index' + resources :marketplace_access_blocks, only: [:create, :destroy] resources :instances, only: [:index, :create, :update, :destroy] resources :users, only: [:index, :update, :destroy] resources :courses, only: [:index, :destroy] diff --git a/db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb b/db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 0000000000..91e58838c7 --- /dev/null +++ b/db/migrate/20260720154800_create_course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +class CreateCourseAssessmentMarketplaceAllowlistRules < ActiveRecord::Migration[7.2] + def change + create_table :course_assessment_marketplace_allowlist_rules do |t| + t.integer :rule_type, null: false + + t.references :user, + foreign_key: { to_table: :users }, + null: true, + index: true + + t.references :instance, + foreign_key: true, + null: true, + index: true + + t.string :email_domain, null: true + + t.timestamps + end + + add_index :course_assessment_marketplace_allowlist_rules, + :email_domain + + add_index :course_assessment_marketplace_allowlist_rules, + :user_id, + unique: true, + where: "rule_type = 0", + name: "index_marketplace_allowlist_rules_one_per_user" + + add_index :course_assessment_marketplace_allowlist_rules, + :instance_id, + unique: true, + where: "rule_type = 1", + name: "index_marketplace_allowlist_rules_one_per_instance" + + add_index :course_assessment_marketplace_allowlist_rules, + :email_domain, + unique: true, + where: "rule_type = 2", + name: "index_marketplace_allowlist_rules_one_per_email_domain" + + add_index :course_assessment_marketplace_allowlist_rules, + :rule_type, + unique: true, + where: "rule_type = 3", + name: "index_marketplace_allowlist_rules_one_everyone" + + create_table :course_assessment_marketplace_access_blocks do |t| + t.references :user, null: false, foreign_key: true, index: { unique: true } + t.references :creator, null: false, foreign_key: { to_table: :users } + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 00735f0677..fb5c2d5b73 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_07_000002) do +ActiveRecord::Schema[7.2].define(version: 2026_07_20_154800) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -270,6 +270,15 @@ t.index ["question_id"], name: "index_course_assessment_live_feedbacks_on_question_id" end + create_table "course_assessment_marketplace_access_blocks", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "creator_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["creator_id"], name: "idx_on_creator_id_becaf2e041" + t.index ["user_id"], name: "index_course_assessment_marketplace_access_blocks_on_user_id", unique: true + end + create_table "course_assessment_marketplace_adoptions", force: :cascade do |t| t.bigint "listing_id", null: false t.bigint "destination_course_id", null: false @@ -286,6 +295,22 @@ t.index ["updater_id"], name: "fk__cama_updater_id" end + create_table "course_assessment_marketplace_allowlist_rules", force: :cascade do |t| + t.integer "rule_type", null: false + t.bigint "user_id" + t.bigint "instance_id" + t.string "email_domain" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["email_domain"], name: "idx_on_email_domain_6577b88d4e" + t.index ["email_domain"], name: "index_marketplace_allowlist_rules_one_per_email_domain", unique: true, where: "(rule_type = 2)" + t.index ["instance_id"], name: "idx_on_instance_id_77af5cff27" + t.index ["instance_id"], name: "index_marketplace_allowlist_rules_one_per_instance", unique: true, where: "(rule_type = 1)" + t.index ["rule_type"], name: "index_marketplace_allowlist_rules_one_everyone", unique: true, where: "(rule_type = 3)" + t.index ["user_id"], name: "index_course_assessment_marketplace_allowlist_rules_on_user_id" + t.index ["user_id"], name: "index_marketplace_allowlist_rules_one_per_user", unique: true, where: "(rule_type = 0)" + end + create_table "course_assessment_marketplace_listings", force: :cascade do |t| t.bigint "assessment_id", null: false t.boolean "published", default: false, null: false @@ -2012,11 +2037,15 @@ add_foreign_key "course_assessment_live_feedbacks", "course_assessment_questions", column: "question_id" add_foreign_key "course_assessment_live_feedbacks", "course_assessments", column: "assessment_id" add_foreign_key "course_assessment_live_feedbacks", "users", column: "creator_id" + add_foreign_key "course_assessment_marketplace_access_blocks", "users" + add_foreign_key "course_assessment_marketplace_access_blocks", "users", column: "creator_id" add_foreign_key "course_assessment_marketplace_adoptions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_course_assessment_marketplace_adoptions_listing_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "course_assessments", column: "duplicated_assessment_id", name: "fk_cama_duplicated_assessment_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "courses", column: "destination_course_id", name: "fk_cama_destination_course_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "creator_id", name: "fk_course_assessment_marketplace_adoptions_creator_id" add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "instances" + add_foreign_key "course_assessment_marketplace_allowlist_rules", "users" add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index f8074447a9..c652e23d9b 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -12,6 +12,8 @@ before { controller_sign_in(controller, manager.user) } describe 'GET #index' do + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:published) { create(:course_assessment_marketplace_listing, published: true) } let!(:unpublished) { create(:course_assessment_marketplace_listing, published: false) } @@ -69,12 +71,116 @@ end end end + describe 'GET #index visibility gate' do + subject { get :index, params: { course_id: course.id, format: :json } } + + # The suite runs with `use_transactional_fixtures = false` (see spec/rails_helper.rb), so rows + # persist across examples/runs. `:everyone` is a DB-enforced singleton (one row allowed), so any + # leftover row here would either block a later `create(:everyone)` with a uniqueness error or + # spuriously widen access in a sibling example. Mirrors the cleanup in + # spec/models/course/assessment/marketplace/allowlist_rule_spec.rb. + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + context 'when the manager is not on the allow-list' do + before { controller_sign_in(controller, manager.user) } + + it 'denies access' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when an allow-list rule matches the manager' do + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + controller_sign_in(controller, manager.user) + end + + it 'permits access' do + expect { subject }.not_to raise_exception + end + end + + context 'when the user is a system administrator' do + let(:admin) { create(:administrator) } + before do + create(:course_manager, course: course, user: admin) + controller_sign_in(controller, admin) + end + + it 'permits access without an allow-list rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists" do + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, manager.user) + end + + it 'permits a manager who has no matching scoped rule' do + expect { subject }.not_to raise_exception + end + end + + context "when an 'everyone' rule exists but the user is a student" do + let(:student) { create(:course_student, course: course).user } + before do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + controller_sign_in(controller, student) + end + + it 'still denies a non-manager (the manager gate holds)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when the user is an observer here but manages another course' do + let(:roamer) { create(:course_observer, course: course).user } + before do + create(:course_manager, course: create(:course), user: roamer) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: roamer) + controller_sign_in(controller, roamer) + end + + it 'permits browsing (access is per-person, not per-current-course role)' do + expect { subject }.not_to raise_exception + end + end + + context 'when the user manages another course but is not on the allow-list' do + let(:roamer) { create(:course_observer, course: course).user } + before do + create(:course_manager, course: create(:course), user: roamer) + controller_sign_in(controller, roamer) + end + + it 'denies access (allow-list is still required even for a manager elsewhere)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + + context 'when an allow-listed user manages no course' do + let(:pupil) { create(:course_student, course: course).user } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: pupil) + controller_sign_in(controller, pupil) + end + + it 'denies access (must manage at least one course)' do + expect { subject }.to raise_exception(CanCan::AccessDenied) + end + end + end + describe 'POST #duplicate' do # `have_enqueued_job` requires the :test adapter; the test env defaults to :background_thread. # `run_rescue` re-enables handle_access_denied so AccessDenied renders 403 rather than # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + with_active_job_queue_adapter(:test) do let!(:listing) { create(:course_assessment_marketplace_listing, published: true) } let!(:tab) { course.assessment_categories.first.tabs.first } @@ -123,6 +229,8 @@ # propagating (controller specs bypass_rescue by default — see spec/support/controller_exceptions.rb). run_rescue + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) } + let!(:listing) do assessment = create(:assessment, course: create(:course)) create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) @@ -182,6 +290,7 @@ ActsAsTenant.with_tenant(home_instance) do course = create(:course) manager = create(:course_manager, course: course) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) controller_sign_in(controller, manager.user) # Point the request at the home instance's host so `deduce_tenant` resolves it (this # describe is outside `with_tenant`, which would otherwise set the host header for us). diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index b6d35b3839..de6d5949df 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -35,7 +35,10 @@ let(:destination_course) { create(:course) } let(:manager) { create(:course_manager, course: destination_course).user } - before { controller_sign_in(controller, manager) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager) + controller_sign_in(controller, manager) + end it 'serializes the question across instances' do get :show, as: :json, params: { diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb index 6fa1c1f6aa..78cd6db53f 100644 --- a/spec/controllers/course/assessment_marketplace_component_spec.rb +++ b/spec/controllers/course/assessment_marketplace_component_spec.rb @@ -15,7 +15,10 @@ context 'when the user can access the marketplace (course manager)' do let(:user) { create(:course_manager, course: course).user } - before { controller_sign_in(controller, user) } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + controller_sign_in(controller, user) + end it 'exposes an admin sidebar item pointing at the marketplace' do item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace } diff --git a/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb b/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb new file mode 100644 index 0000000000..b320410020 --- /dev/null +++ b/spec/controllers/system/admin/marketplace_access_blocks_controller_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAccessBlocksController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before do + Course::Assessment::Marketplace::AccessBlock.delete_all + controller_sign_in(controller, admin) + end + + describe 'POST #create' do + it 'blocks the user and returns the block id' do + target = create(:user) + expect do + post :create, format: :json, params: { user_id: target.id } + end.to change { Course::Assessment::Marketplace::AccessBlock.count }.by(1) + expect(response).to have_http_status(:ok) + expect(response.parsed_body['userId']).to eq(target.id) + expect(response.parsed_body['id']).to be_present + end + + it 'rejects a duplicate block for the same user' do + target = create(:user) + create(:course_assessment_marketplace_access_block, user: target) + expect do + post :create, format: :json, params: { user_id: target.id } + end.not_to(change { Course::Assessment::Marketplace::AccessBlock.count }) + expect(response).to have_http_status(:bad_request) + end + end + + describe 'DELETE #destroy' do + it 'removes the block' do + block = create(:course_assessment_marketplace_access_block) + expect do + delete :destroy, format: :json, params: { id: block.id } + end.to change { Course::Assessment::Marketplace::AccessBlock.count }.by(-1) + expect(response).to have_http_status(:ok) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + post :create, format: :json, params: { user_id: create(:user).id } + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/controllers/system/admin/marketplace_access_controller_spec.rb b/spec/controllers/system/admin/marketplace_access_controller_spec.rb new file mode 100644 index 0000000000..8d753cfaf6 --- /dev/null +++ b/spec/controllers/system/admin/marketplace_access_controller_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAccessController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern leaves rows behind that collide on + # the next run. + User::Email.where('LOWER(email) LIKE ?', '%schools.gov.sg').delete_all + controller_sign_in(controller, admin) + end + + describe 'GET #index' do + render_views + + it 'lists eligible users with annotations and a summary' do + manager = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + + get :index, format: :json + expect(response).to have_http_status(:ok) + + row = response.parsed_body['users'].find { |u| u['id'] == manager.user.id } + expect(row).to be_present + expect(row['allowedByRules'].map { |r| r['ruleType'] }).to eq(['user']) + expect(row['courseCount']).to eq(1) + expect(row['blocked']).to be(false) + expect(row['systemAdmin']).to be(false) + # System admins are always listed and always count as having access; the test DB accumulates + # them across runs (nothing rolls back), so the total is relative to however many exist. + expect(response.parsed_body['summary']['totalWithAccess']).to eq(1 + User.administrator.count) + expect(response.parsed_body['summary']['openToEveryone']).to be(false) + end + + it 'flags a blocked user with a blockId' do + manager = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: manager.user) + block = create(:course_assessment_marketplace_access_block, user: manager.user) + + get :index, format: :json + row = response.parsed_body['users'].find { |u| u['id'] == manager.user.id } + expect(row['blocked']).to be(true) + expect(row['blockId']).to eq(block.id) + expect(response.parsed_body['summary']['totalWithAccess']).to eq(User.administrator.count) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + get :index, format: :json + expect(response).to have_http_status(:forbidden) + end + end + describe 'GET #index serialization' do + render_views + + it 'serializes every matching rule with its label, and the blocked total' do + user = create(:user, email: 'listed@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + user_rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + domain_rule = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + create(:course_assessment_marketplace_access_block, user: user) + + get :index, format: :json + + expect(response).to have_http_status(:ok) + row = response.parsed_body['users'].find { |u| u['id'] == user.id } + expect(row).not_to be_nil + expect(row['allowedByRules']).to contain_exactly( + { 'id' => user_rule.id, 'ruleType' => 'user', 'labelValue' => user.name }, + { 'id' => domain_rule.id, 'ruleType' => 'email_domain', + 'labelValue' => 'schools.gov.sg' } + ) + expect(response.parsed_body['summary']['totalBlocked']).to eq(1) + end + + it 'serializes an instance rule with the instance name as its label' do + other_instance = create(:instance) + user = create(:user) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == user.id } + expect(row['allowedByRules']).to eq( + ['id' => rule.id, 'ruleType' => 'instance', 'labelValue' => other_instance.name] + ) + end + + it 'serializes an empty rule list for a user listed only because they are blocked' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_access_block, user: cu.user) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == cu.user.id } + expect(row['allowedByRules']).to eq([]) + expect(row['blocked']).to be(true) + end + + it 'serializes a system admin who manages nothing and matches no rule' do + admin = create(:administrator) + + get :index, format: :json + + row = response.parsed_body['users'].find { |u| u['id'] == admin.id } + expect(row).not_to be_nil + expect(row['systemAdmin']).to be(true) + expect(row['allowedByRules']).to eq([]) + expect(row['courseCount']).to eq(0) + end + end + end +end diff --git a/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb new file mode 100644 index 0000000000..4ef36f310c --- /dev/null +++ b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceAllowlistRulesController, type: :controller do + let!(:instance) { Instance.default } + + with_tenant(:instance) do + let(:admin) { create(:administrator) } + before { controller_sign_in(controller, admin) } + + describe 'POST #create' do + # Email-domain rules are unique per domain and specs commit, so the row this example creates + # would collide with itself on the next run. Clear it first. + before do + Course::Assessment::Marketplace::AllowlistRule. + rule_type_email_domain.where(email_domain: 'schools.gov.sg').delete_all + end + + subject do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'email_domain', email_domain: 'schools.gov.sg' } + } + end + + it 'creates an email-domain rule' do + expect { subject }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(1) + expect(response).to have_http_status(:ok) + end + end + + describe 'POST #create for a user rule by email' do + render_views + # No transactional fixtures / DatabaseCleaner here (see GET #index note), so a user with this + # hardcoded email can persist from an earlier run and collide on email uniqueness. Clear it. + before { User::Email.where(email: 'teacher@school.edu').delete_all } + + it 'resolves a confirmed email to the owning user and creates a user rule' do + target = create(:user, email: 'teacher@school.edu') + expect do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' } + } + end.to change { Course::Assessment::Marketplace::AllowlistRule.rule_type_user.count }.by(1) + expect(response).to have_http_status(:ok) + expect(Course::Assessment::Marketplace::AllowlistRule.rule_type_user.last.user).to eq(target) + end + + it 'serializes the resolved user\'s email as userEmail in the rendered rule' do + create(:user, email: 'teacher@school.edu') + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' } + } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['userEmail']).to eq('teacher@school.edu') + end + + it 'rejects an email that matches no user' do + expect do + post :create, format: :json, params: { + allowlist_rule: { rule_type: 'user', email: 'nobody@nowhere.test' } + } + end.not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['errors']).to include('No user with that email.') + end + end + + describe 'GET #index' do + render_views + # This suite runs with `use_transactional_fixtures = false` and no DatabaseCleaner, so rows + # created by earlier local runs of this factory persist in the dev/test DB; scope to a clean + # slate here so the size assertion below is deterministic. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'lists the rules' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe 'GET #index everyone-mode reporting' do + render_views + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) + end + + it 'reports everyoneRuleId null and lists only scoped rules when no everyone rule exists' do + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to be_nil + expect(response.parsed_body['rules'].size).to eq(1) + end + + it 'reports everyoneRuleId and excludes the everyone rule from the list' do + everyone = create(:course_assessment_marketplace_allowlist_rule, :everyone) + get :index, format: :json + expect(response).to have_http_status(:ok) + expect(response.parsed_body['everyoneRuleId']).to eq(everyone.id) + expect(response.parsed_body['rules'].map { |r| r['ruleType'] }).not_to include('everyone') + expect(response.parsed_body['rules'].size).to eq(1) + end + end + + describe "POST #create with rule_type 'everyone'" do + before { Course::Assessment::Marketplace::AllowlistRule.delete_all } + + it 'opens the marketplace to everyone' do + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.to change { Course::Assessment::Marketplace::AllowlistRule.rule_type_everyone.count }.by(1) + expect(response).to have_http_status(:ok) + end + + it 'rejects a second everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect do + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + end.not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + expect(response).to have_http_status(:bad_request) + end + + it 'surfaces the uniqueness error when rejected' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + post :create, format: :json, params: { allowlist_rule: { rule_type: 'everyone' } } + expect(response.parsed_body['errors']).to include('already been taken') + end + end + + describe 'DELETE #destroy' do + let!(:rule) { create(:course_assessment_marketplace_allowlist_rule, :for_email_domain) } + + it 'removes the rule' do + expect { delete :destroy, format: :json, params: { id: rule.id } }. + to change { Course::Assessment::Marketplace::AllowlistRule.count }.by(-1) + expect(response).to have_http_status(:ok) + end + end + + describe 'authorization' do + run_rescue + + it 'forbids a non-administrator' do + controller_sign_in(controller, create(:user)) + get :index, format: :json + expect(response).to have_http_status(:forbidden) + end + end + + describe 'POST #preview' do + render_views + + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern misses rows the index will still + # collide on. + User::Email.where('LOWER(email) LIKE ?', '%preview.test').delete_all + end + + def preview(params) + post :preview, format: :json, params: { allowlist_rule: params } + end + + it 'counts eligible staff a domain rule would match, and how many are new' do + newcomer = create(:user, email: 'newcomer@preview.test') + create(:course_manager, course: create(:course), user: newcomer) + existing = create(:user, email: 'existing@preview.test') + create(:course_manager, course: create(:course), user: existing) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: existing) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response).to have_http_status(:ok) + body = response.parsed_body + expect(body['matchedCount']).to eq(2) + expect(body['newCount']).to eq(1) + expect(body['blockedCount']).to eq(0) + expect(body['openToEveryone']).to be(false) + expect(body['users'].map { |u| u['id'] }).to contain_exactly(newcomer.id, existing.id) + expect(body['users'].find { |u| u['id'] == existing.id }['alreadyHasAccess']).to be(true) + expect(body['users'].find { |u| u['id'] == newcomer.id }['alreadyHasAccess']).to be(false) + end + + it 'persists nothing' do + create(:course_manager, course: create(:course), + user: create(:user, email: 'dryrun@preview.test')) + + expect { preview(rule_type: 'email_domain', email_domain: 'preview.test') }. + not_to(change { Course::Assessment::Marketplace::AllowlistRule.count }) + end + + it 'excludes users who are not baseline-eligible' do + create(:course_student, course: create(:course), + user: create(:user, email: 'student@preview.test')) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response.parsed_body['matchedCount']).to eq(0) + end + + it 'counts a blocked match but never as new' do + blocked = create(:user, email: 'blocked@preview.test') + create(:course_manager, course: create(:course), user: blocked) + create(:course_assessment_marketplace_access_block, user: blocked) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + body = response.parsed_body + expect(body['matchedCount']).to eq(1) + expect(body['newCount']).to eq(0) + # Counted separately from the already-has-access remainder: the UI names the two groups + # apart, and a blocked user is held back by their own block, not by prior access. + expect(body['blockedCount']).to eq(1) + expect(body['users'].first['blocked']).to be(true) + end + + it 'lists blocked, then already-cleared, then newly granted matches' do + # Named so the alphabetical order the query starts from is the exact REVERSE of the + # expected one; without the grouping this example would still pass on a name-sorted list. + blocked = create(:user, name: 'Zoe Blocked', email: 'zoe@preview.test') + create(:course_manager, course: create(:course), user: blocked) + create(:course_assessment_marketplace_access_block, user: blocked) + existing = create(:user, name: 'Mabel Existing', email: 'mabel@preview.test') + create(:course_manager, course: create(:course), user: existing) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: existing) + newcomer = create(:user, name: 'Adam New', email: 'adam@preview.test') + create(:course_manager, course: create(:course), user: newcomer) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response.parsed_body['users'].map { |u| u['id'] }). + to eq([blocked.id, existing.id, newcomer.id]) + end + + it 'reports zero new when the marketplace is already open to everyone' do + create(:course_manager, course: create(:course), + user: create(:user, email: 'open@preview.test')) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + body = response.parsed_body + expect(body['openToEveryone']).to be(true) + expect(body['matchedCount']).to eq(1) + expect(body['newCount']).to eq(0) + end + + it 'returns zero matches for a user rule whose target is not eligible' do + create(:user, email: 'nobody@preview.test') # manages nothing + + preview(rule_type: 'user', email: 'nobody@preview.test') + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['matchedCount']).to eq(0) + end + + it 'rejects an email matching no user' do + preview(rule_type: 'user', email: 'ghost@preview.test') + + expect(response).to have_http_status(:bad_request) + expect(response.parsed_body['errors']).to include('No user with that email.') + end + + it 'rejects a rule that already exists' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'preview.test') + + preview(rule_type: 'email_domain', email_domain: 'preview.test') + + expect(response).to have_http_status(:bad_request) + # Attribute name omitted: StubbedI18nBackend returns the raw key for + # `activerecord.attributes.*`, so full_messages can never render "Email domain" here. + expect(response.parsed_body['errors']).to include('already has a rule.') + end + + it 'denies a non-administrator' do + controller_sign_in(controller, create(:user)) + expect { preview(rule_type: 'email_domain', email_domain: 'preview.test') }. + to raise_exception(CanCan::AccessDenied) + end + end + end +end diff --git a/spec/factories/course_assessment_marketplace_access_blocks.rb b/spec/factories/course_assessment_marketplace_access_blocks.rb new file mode 100644 index 0000000000..471edf6f26 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_access_blocks.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_access_block, + class: 'Course::Assessment::Marketplace::AccessBlock' do + association :user + association :creator, factory: :user + end +end diff --git a/spec/factories/course_assessment_marketplace_allowlist_rules.rb b/spec/factories/course_assessment_marketplace_allowlist_rules.rb new file mode 100644 index 0000000000..ae96fb0911 --- /dev/null +++ b/spec/factories/course_assessment_marketplace_allowlist_rules.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_allowlist_rule, + class: 'Course::Assessment::Marketplace::AllowlistRule' do + # Default to a self-contained email-domain rule so the bare factory is valid under + # `factory_bot:lint`. Traits below override `rule_type` (and supply any needed association). + rule_type { :email_domain } + # Unique per invocation. Specs commit (use_transactional_fixtures is false), and email-domain + # rules are now unique per domain, so a hardcoded default would collide with the row committed + # by the previous run. + sequence(:email_domain) { |n| "domain-#{n}-#{SecureRandom.hex(3)}.test" } + + trait :for_user do + rule_type { :user } + association :user + end + trait :for_instance do + rule_type { :instance } + association :instance + end + trait :for_email_domain do + rule_type { :email_domain } + end + trait :everyone do + rule_type { :everyone } + end + end +end diff --git a/spec/models/course/assessment/marketplace/access_block_spec.rb b/spec/models/course/assessment/marketplace/access_block_spec.rb new file mode 100644 index 0000000000..bb97b9405e --- /dev/null +++ b/spec/models/course/assessment/marketplace/access_block_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AccessBlock, type: :model do + let!(:instance) { Instance.default } + + before { described_class.delete_all } + + with_tenant(:instance) do + describe 'validations' do + it 'is valid with a user and creator' do + block = build(:course_assessment_marketplace_access_block) + expect(block).to be_valid + end + + it 'rejects a second block for the same user' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + duplicate = build(:course_assessment_marketplace_access_block, user: user) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to be_present + end + end + + describe '.blocked?' do + it 'is false for a nil user' do + expect(described_class.blocked?(nil)).to be(false) + end + + it 'is false when the user has no block' do + expect(described_class.blocked?(create(:user))).to be(false) + end + + it 'is true when the user has a block' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + expect(described_class.blocked?(user)).to be(true) + end + end + + describe '.blocked_user_ids' do + it 'returns the user ids of all blocks' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + expect(described_class.blocked_user_ids).to contain_exactly(user.id) + end + end + + describe 'when the admin who issued the block is destroyed' do + # `creator_id` is NOT NULL and FKs to users, so this raised PG::ForeignKeyViolation. The block + # must survive — it is a decision about the BLOCKED person, not about its author — so + # authorship is reassigned to the Deleted user rather than the row being destroyed. + it 'keeps the block and reassigns it to the Deleted user' do + creator = create(:administrator) + block = create(:course_assessment_marketplace_access_block, creator: creator) + + expect { ActsAsTenant.without_tenant { creator.destroy } }. + not_to(change { described_class.count }) + expect(block.reload.creator_id).to eq(User::DELETED_USER_ID) + end + end + + describe 'when the blocked user is destroyed' do + # The blocks table has an FK to users with no ON DELETE, so without a `dependent:` association + # on User the admin panel's delete-user action dies with PG::ForeignKeyViolation. + it 'destroys the block instead of raising a foreign-key violation' do + user = create(:user) + create(:course_assessment_marketplace_access_block, user: user) + + expect { ActsAsTenant.without_tenant { user.destroy } }. + to change { described_class.count }.by(-1) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/access_list_query_spec.rb b/spec/models/course/assessment/marketplace/access_list_query_spec.rb new file mode 100644 index 0000000000..d5a856a4d3 --- /dev/null +++ b/spec/models/course/assessment/marketplace/access_list_query_spec.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AccessListQuery, type: :model do + let!(:instance) { Instance.default } + + # Specs here commit (use_transactional_fixtures is false repo-wide), so rows from previous runs + # persist. User::Email additionally enforces uniqueness, so the allow-listed-domain addresses below + # must be cleared too or re-creating them raises RecordInvalid. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + # LOWER() and no '@' anchor: the uniqueness index is on `lower(email)` while SQL LIKE is + # case-sensitive, so an anchored, case-sensitive pattern silently leaves rows behind that + # collide on the next run. + User::Email.where('LOWER(email) LIKE ?', '%schools.gov.sg').delete_all + end + + with_tenant(:instance) do + it 'excludes a baseline user when no rule matches them' do + create(:course_manager, course: create(:course)) # manager, but no allow-list rule + # System admins are listed unconditionally (they bypass every gate), and the test DB always + # holds at least the seeded one — so this asserts on the non-admin rows. + expect(described_class.new.rows.reject(&:system_admin?)).to be_empty + end + + it 'includes a manager cleared by a user rule, annotated with course count and rule' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.course_count).to eq(1) + expect(row.instance_role).to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['user']) + expect(row.blocked?).to be(false) + end + + it 'includes an instance instructor (managing no course) under an everyone rule' do + user = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.course_count).to eq(0) + expect(row.instance_role).to eq('instructor') + # An everyone rule is a page-level mode, not a per-row reason: rows carry no scoped rules. + expect(row.allowed_by_rules).to be_empty + end + + it 'includes a manager cleared by an email-domain rule' do + user = create(:user, email: 'teacher@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['email_domain']) + end + + it 'excludes a manager whose only allow-listed-domain email is unconfirmed' do + user = create(:user) # has a confirmed primary email at a non-matching domain + create(:course_manager, course: create(:course), user: user) + create(:user_email, :unconfirmed, email: 'pending@schools.gov.sg', + user: user, primary: false) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + expect(described_class.new.rows.map(&:user)).not_to include(user) + end + + it 'includes a manager cleared by an instance rule' do + cu = create(:course_manager, course: create(:course)) + # cu.user has a normal InstanceUser in the default instance via the after_create callback, + # so an instance rule for the default instance clears them. + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: instance) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['instance']) + end + + it 'does not include a non-baseline user even when a user rule targets them' do + cu = create(:course_student, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + expect(described_class.new.rows.map(&:user)).not_to include(cu.user) + end + + it 'keeps a blocked user in the list, flagged with the block id' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + block = create(:course_assessment_marketplace_access_block, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row.blocked?).to be(true) + expect(row.block_id).to eq(block.id) + end + + # Without this, dropping the `instance_id` filter from RuleMatchQuery#matched_instance_members + # would still pass every other example — the instance-rule test above uses only one instance. + it 'does not clear a user via an instance rule scoped to a different instance' do + rule_instance = create(:instance) + member_instance = create(:instance) + cu = create(:course_manager, course: create(:course)) + ActsAsTenant.with_tenant(member_instance) do + create(:instance_user, :instructor, user: cu.user, instance: member_instance) + end + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: rule_instance) + + expect(described_class.new.rows.map(&:user)).not_to include(cu.user) + end + + it 'lists every rule matching a user, not just the highest-precedence one' do + user = create(:user, email: 'both@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + user_rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + domain_rule = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + row = described_class.new.rows.find { |r| r.user == user } + expect(row.allowed_by_rules.map(&:id)).to contain_exactly(user_rule.id, domain_rule.id) + end + + it 'orders a row\'s rules by rule id' do + user = create(:user, email: 'ordered@schools.gov.sg') + create(:course_manager, course: create(:course), user: user) + first = create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + second = create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row.allowed_by_rules.map(&:id)).to eq([first.id, second.id]) + end + + it 'lists a blocked user whose matching rule was removed, so the block stays reachable' do + cu = create(:course_manager, course: create(:course)) + block = create(:course_assessment_marketplace_access_block, user: cu.user) + # No allow-list rule matches them at all — without the block they would not be listed. + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row).not_to be_nil + expect(row.allowed_by_rules).to be_empty + expect(row.block_id).to eq(block.id) + expect(row.blocked?).to be(true) + end + + it 'lists a blocked user who is no longer baseline-eligible at all' do + user = create(:user) # manages nothing, staff nowhere + create(:course_assessment_marketplace_access_block, user: user) + + row = described_class.new.rows.find { |r| r.user == user } + expect(row).not_to be_nil + expect(row.course_count).to eq(0) + expect(row.instance_role).to be_nil + end + + describe '#allowed_user_ids' do + it 'returns baseline users cleared by a rule, and excludes uncleared ones' do + cleared = create(:course_manager, course: create(:course)) + uncleared = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: cleared.user) + + ids = described_class.new.allowed_user_ids + expect(ids).to include(cleared.user.id) + expect(ids).not_to include(uncleared.user.id) + end + + it 'still counts a blocked user as allowed — a block is not an allow-list decision' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + create(:course_assessment_marketplace_access_block, user: cu.user) + + expect(described_class.new.allowed_user_ids).to include(cu.user.id) + end + + # Guards the `everyone?` branch: without it, collapsing the method to `rules_by_user.keys` + # would silently regress open-to-everyone into "only explicitly matched users". + it 'includes every baseline user when an everyone rule exists, not only rule-matched ones' do + first = create(:course_manager, course: create(:course)) + second = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + + ids = described_class.new.allowed_user_ids + expect(ids).to include(first.user.id, second.user.id) + end + end + + describe 'system administrators' do + it 'lists an admin who manages nothing and matches no rule' do + admin = create(:administrator) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row).not_to be_nil + expect(row.system_admin?).to be(true) + expect(row.allowed_by_rules).to be_empty + end + + it 'keeps listing an admin as blocked when a block exists' do + # A block row cannot actually revoke a sysadmin's bypass, but an orphaned one must stay + # visible and clearable — same contract as any other blocked user. + admin = create(:administrator) + create(:course_assessment_marketplace_access_block, user: admin) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row.system_admin?).to be(true) + expect(row.blocked?).to be(true) + end + + it 'still records the rules that match an admin' do + admin = create(:administrator) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: admin) + + row = described_class.new.rows.find { |r| r.user == admin } + expect(row.system_admin?).to be(true) + expect(row.allowed_by_rules.map(&:rule_type)).to eq(['user']) + end + + it 'does not mark a non-admin as one' do + cu = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: cu.user) + + row = described_class.new.rows.find { |r| r.user == cu.user } + expect(row.system_admin?).to be(false) + end + end + + describe '#summary' do + it 'counts effective access and blocked separately, and reports the mode' do + active = create(:course_manager, course: create(:course)) + blocked = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: active.user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: blocked.user) + create(:course_assessment_marketplace_access_block, user: blocked.user) + + # Admins are always listed and always count as having access, and the test DB carries at + # least the seeded one, so the expectation is relative to however many exist. + admins = User.administrator.count + summary = described_class.new.summary + expect(summary[:total_with_access]).to eq(1 + admins) + expect(summary[:total_blocked]).to eq(1) + expect(summary[:open_to_everyone]).to be(false) + end + + it 'counts a blocked system admin as having access, and not as blocked' do + admin = create(:administrator) + create(:course_assessment_marketplace_access_block, user: admin) + + summary = described_class.new.summary + expect(summary[:total_with_access]).to eq(User.administrator.count) + expect(summary[:total_blocked]).to eq(0) + end + + it 'keeps the two counts a partition of the listed rows' do + blocked = create(:course_manager, course: create(:course)) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: blocked.user) + create(:course_assessment_marketplace_access_block, user: blocked.user) + create(:course_assessment_marketplace_access_block, user: create(:administrator)) + + query = described_class.new + summary = query.summary + expect(summary[:total_with_access] + summary[:total_blocked]).to eq(query.rows.count) + end + + it 'reports open_to_everyone when an everyone rule exists' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.new.summary[:open_to_everyone]).to be(true) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb new file mode 100644 index 0000000000..c2f9ab6fcf --- /dev/null +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::AllowlistRule, type: :model do + let!(:instance) { Instance.default } + + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + User::Email.delete_all + end + + with_tenant(:instance) do + describe 'validations' do + it 'requires user for a user rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: nil) + expect(rule).not_to be_valid + expect(rule.errors[:user]).to be_present + end + + it 'requires email_domain for an email_domain rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: nil) + expect(rule).not_to be_valid + expect(rule.errors[:email_domain]).to be_present + end + + it 'requires instance for an instance rule' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: nil) + expect(rule).not_to be_valid + expect(rule.errors[:instance]).to be_present + end + + it 'is valid as an everyone rule with no target fields' do + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(rule).to be_valid + end + + it 'allows only one everyone rule' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + duplicate = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:rule_type]).to be_present + end + end + + describe '.grants_access?' do + it 'is false for a nil user' do + expect(described_class.grants_access?(nil)).to be(false) + end + + it 'is false when no rule matches' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'nomatch.example') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an explicit user rule' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'matches an instance rule when the user belongs to that instance' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, instance: instance) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match an instance rule for another instance under the current tenant' do + user = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) { InstanceUser.create!(user: user) } + create(:course_assessment_marketplace_allowlist_rule, rule_type: :instance, + instance: other_instance) + # `user.instance_users` is tenant-scoped (acts_as_tenant), so an instance rule grants + # access only while browsing the allow-listed instance — membership elsewhere is invisible. + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches an email-domain rule case-insensitively' do + user_email = create(:user_email, email: 'testuser@Schools.GOV.sg') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not match an email-domain rule via an unconfirmed email' do + user = create(:user) + create(:user_email, :unconfirmed, user: user, primary: false, + email: 'unconfirmed@schools.gov.sg') + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + + expect(described_class.grants_access?(user.reload)).to be(false) + end + + it 'does not match a different email domain' do + user_email = create(:user_email, email: 'testuser@other.edu') + user = user_email.user + create(:course_assessment_marketplace_allowlist_rule, :for_email_domain, + email_domain: 'schools.gov.sg') + expect(described_class.grants_access?(user)).to be(false) + end + + it 'matches any user when an everyone rule exists' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(user)).to be(true) + end + + it 'is false for a nil user even when an everyone rule exists' do + create(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.grants_access?(nil)).to be(false) + end + + it 'keeps granting a user rule after the user replaces their email (access is by user_id, not email)' do + user = create(:user) + original_email = user.email + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + expect(described_class.grants_access?(user)).to be(true) + + # Retire the original email and attach a brand-new one — the same person, different address. + user.emails.where(email: original_email).delete_all + create(:user_email, user: user, email: 'moved@newdomain.example') + user.reload + + expect(described_class.grants_access?(user)).to be(true) + end + + it 'does not grant access via a different user\'s rule after this user replaces their email' do + user = create(:user) + other_user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: other_user) + + user.emails.where(email: user.email).delete_all + create(:user_email, user: user, email: 'moved@newdomain.example') + user.reload + + expect(described_class.grants_access?(user)).to be(false) + end + end + + describe 'email resolution for a user rule' do + it 'resolves a confirmed email to the owning user' do + target = create(:user, email: 'teacher@school.edu') + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'teacher@school.edu') + expect(rule).to be_valid + expect(rule.user).to eq(target) + end + + it 'is case-insensitive on the entered email' do + target = create(:user, email: 'teacher@school.edu') + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: ' Teacher@School.EDU ') + expect(rule).to be_valid + expect(rule.user).to eq(target) + end + + it 'is invalid with a clear message when no user has that email' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'nobody@nowhere.test') + expect(rule).not_to be_valid + expect(rule.errors.full_messages).to include('No user with that email.') + end + + it 'does not also add a user-presence error when the email fails to resolve' do + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, + user: nil, email: 'nobody@nowhere.test') + expect(rule).not_to be_valid + expect(rule.errors.full_messages).to eq(['No user with that email.']) + end + end + + describe 'duplicate rules' do + it 'rejects a second user rule for the same user' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: user) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:user_id]).to include('already has a rule.') + end + + it 'allows a user rule for a different user' do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: create(:user)) + expect(build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: create(:user))).to be_valid + end + + it 'rejects a second instance rule for the same instance' do + other_instance = create(:instance) + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:instance_id]).to include('already has a rule.') + end + + it 'rejects a second email-domain rule for the same domain' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email_domain]).to include('already has a rule.') + end + + it 'treats a differently-cased domain as the same rule' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'dupes.test') + duplicate = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: ' DUPES.TEST ') + + expect(duplicate).not_to be_valid + expect(duplicate.errors[:email_domain]).to include('already has a rule.') + end + + it 'normalizes the stored domain to stripped lowercase' do + rule = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: ' MiXeD.TEST ') + expect(rule.reload.email_domain).to eq('mixed.test') + end + + it 'clears the identity columns that do not belong to the rule type' do + rule = create(:course_assessment_marketplace_allowlist_rule, :for_instance, + user: create(:user), email_domain: 'stray.test') + + expect(rule.reload.user_id).to be_nil + expect(rule.email_domain).to be_nil + expect(rule.instance_id).to be_present + end + + # A user rule whose email resolves to nobody keeps user_id NULL, and Rails checks uniqueness + # as `user_id IS NULL` — which matches every instance and email-domain rule unless the check + # is scoped to rule_type. Unscoped, the admin gets a bogus "already has a rule." stacked on + # top of the real reason. (Verified by mutation: dropping `scope: :rule_type` fails this.) + it 'does not report a duplicate for an unresolvable email when other rule types exist' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: create(:instance)) + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: nil, email: 'nobody@nowhere.test') + + expect(rule).not_to be_valid + expect(rule.errors[:user_id]).to be_empty + expect(rule.errors[:base]).to include('No user with that email.') + end + end + + describe 'when the targeted user is destroyed' do + # Same FK trap as the access-blocks table: a user rule pins the user row, so deleting an + # allow-listed user from the admin panel raised PG::ForeignKeyViolation. + it 'destroys the rule instead of raising a foreign-key violation' do + user = create(:user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + + expect { ActsAsTenant.without_tenant { user.destroy } }. + to change { described_class.count }.by(-1) + end + + it 'leaves rules that do not target that user alone' do + create(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'keeps.test') + # Created under the tenant, destroyed without one (as the admin panel does): building a + # user inside `without_tenant` fails its own `instance_users` validation. + bystander = create(:user) + + expect { ActsAsTenant.without_tenant { bystander.destroy } }. + not_to(change { described_class.count }) + end + end + end +end diff --git a/spec/models/course/assessment/marketplace/rule_match_query_spec.rb b/spec/models/course/assessment/marketplace/rule_match_query_spec.rb new file mode 100644 index 0000000000..9fb78a59b0 --- /dev/null +++ b/spec/models/course/assessment/marketplace/rule_match_query_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::RuleMatchQuery, type: :model do + let!(:instance) { Instance.default } + + # Specs commit (use_transactional_fixtures is false repo-wide), so rows from previous runs persist + # and User::Email enforces uniqueness. Clear the fixed-domain addresses this file creates. + # + # The pattern must be LOWER()'d and must not anchor the '@': the uniqueness index is on + # `lower(email)` while SQL LIKE is case-sensitive, and one example deliberately uses a SUBDOMAIN + # (someone@sub.match-query.test). A '%@match-query.test' pattern misses both, leaving rows behind + # that collide on the next run. + before do + User::Email.where('LOWER(email) LIKE ?', '%match-query.test').delete_all + end + + with_tenant(:instance) do + describe 'a user rule' do + it 'matches only the targeted user, and only within the candidate set' do + target = create(:user) + other = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + + expect(described_class.new(rule).user_ids_within([target.id, other.id])). + to eq(Set[target.id]) + end + + it 'returns nothing when the targeted user is outside the candidate set' do + target = create(:user) + other = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + + expect(described_class.new(rule).user_ids_within([other.id])).to be_empty + end + end + + describe 'an instance rule' do + it 'matches candidates belonging to that instance, across tenants' do + member = create(:user) + outsider = create(:user) + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: member, instance: other_instance) + end + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :instance, instance: other_instance) + + expect(described_class.new(rule).user_ids_within([member.id, outsider.id])). + to eq(Set[member.id]) + end + end + + describe 'an email-domain rule' do + it 'matches a candidate holding a confirmed email at that domain' do + user = create(:user, email: 'teacher@match-query.test') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to eq(Set[user.id]) + end + + it 'matches case-insensitively on both the rule and the address' do + user = create(:user, email: 'head@match-query.test') + # Force the stored address to mixed case directly. `create(:user, email: 'HEAD@...')` + # raises RecordNotUnique even on a fresh address: the write path inserts both the given + # and the normalized form, and the two collide under the `lower(email)` unique index. + # Legacy rows can still hold mixed case, and the query's LOWER(SPLIT_PART(...)) on the + # address side exists for exactly them — so this is the only way to reach that branch. + User::Email.where(user_id: user.id). + where('LOWER(email) = ?', 'head@match-query.test'). + update_all(email: 'HEAD@MATCH-QUERY.TEST') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'Match-Query.TEST') + + expect(described_class.new(rule).user_ids_within([user.id])).to eq(Set[user.id]) + end + + it 'ignores an unconfirmed address at that domain' do + user = create(:user) # confirmed primary email at a non-matching domain + create(:user_email, :unconfirmed, email: 'pending@match-query.test', + user: user, primary: false) + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to be_empty + end + + it 'does not match a different domain that merely shares a suffix' do + user = create(:user, email: 'someone@sub.match-query.test') + rule = build(:course_assessment_marketplace_allowlist_rule, + rule_type: :email_domain, email_domain: 'match-query.test') + + expect(described_class.new(rule).user_ids_within([user.id])).to be_empty + end + end + + describe 'an everyone rule' do + it 'matches the whole candidate set' do + a = create(:user) + b = create(:user) + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + + expect(described_class.new(rule).user_ids_within([a.id, b.id])).to eq(Set[a.id, b.id]) + end + end + + it 'returns an empty set for an empty candidate list without querying' do + rule = build(:course_assessment_marketplace_allowlist_rule, :everyone) + expect(described_class.new(rule).user_ids_within([])).to be_empty + end + + it 'treats an unsaved rule identically to a persisted one' do + target = create(:user) + unsaved = build(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: target) + persisted = create(:course_assessment_marketplace_allowlist_rule, + rule_type: :user, user: target) + + expect(described_class.new(unsaved).user_ids_within([target.id])). + to eq(described_class.new(persisted).user_ids_within([target.id])) + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 1dabaf126c..4564124a91 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -19,6 +19,7 @@ context 'when the user is a course manager' do let(:course_user) { create(:course_manager, course: course) } let(:user) { course_user.user } + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) } it { is_expected.to be_able_to(:access_marketplace, course) } it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } @@ -37,5 +38,79 @@ let(:user) { course_user.user } it { is_expected.not_to be_able_to(:access_marketplace, course) } end + + context 'when the user is a course manager but is not allow-listed' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + # Load-bearing at the ability level: without the explicit `cannot`, the blanket + # `can :manage, Course` a manager holds would satisfy `:access_marketplace`. + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an observer here but manages another course (person-level access)' do + let(:course_user) { create(:course_observer, course: course) } + let(:user) { course_user.user } + before do + create(:course_manager, course: create(:course), user: user) + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + end + + it { is_expected.to be_able_to(:access_marketplace, course) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } + it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + end + + context 'when an allow-listed user manages no course at all' do + let(:course_user) { create(:course_observer, course: course) } + let(:user) { course_user.user } + before { create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) } + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an instance instructor who manages no course but is allow-listed' do + let!(:course_user) { create(:course_observer, course: course) } + let!(:user) { course_user.user } + before do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + end + + # Proves the second baseline branch: eligible via instance role, not via managing a course. + it { is_expected.to be_able_to(:access_marketplace, course) } + end + + context 'when the user is an instance instructor who manages no course and is not allow-listed' do + let!(:course_user) { create(:course_observer, course: course) } + let!(:user) { course_user.user } + before do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + end + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + end + + context 'when an eligible, allow-listed manager is individually blocked' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + before do + create(:course_assessment_marketplace_allowlist_rule, rule_type: :user, user: user) + create(:course_assessment_marketplace_access_block, user: user) + end + + it { is_expected.not_to be_able_to(:access_marketplace, course) } + + it 'regains access once the block is removed' do + Course::Assessment::Marketplace::AccessBlock.where(user_id: user.id).delete_all + expect(Ability.new(user, course, course_user)).to be_able_to(:access_marketplace, course) + end + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index b7109e39b9..0e8b01d668 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -44,6 +44,67 @@ end end + describe '#course_manager_or_owner?' do + let(:user) { create(:user) } + + it 'is true when the user manages a course' do + create(:course_manager, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is true when the user owns a course' do + create(:course_owner, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is true when the user manages a course in a different instance' do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:course_manager, course: create(:course), user: user) + end + expect(user.course_manager_or_owner?).to be(true) + end + + it 'is false when the user only has non-manager course roles' do + create(:course_student, course: create(:course), user: user) + create(:course_observer, course: create(:course), user: user) + expect(user.course_manager_or_owner?).to be(false) + end + + it 'is false when the user is in no course' do + expect(user.course_manager_or_owner?).to be(false) + end + end + + describe '#instance_instructor_or_administrator?' do + # Eager: a lazy `let` would first run `create(:user)` inside the `with_tenant(other_instance)` + # block below, and `after_create :create_instance_user` would then give the user a normal + # InstanceUser in *that* instance — colliding with the instructor/administrator one we create. + let!(:user) { create(:user) } + + it 'is true when the user is an instructor in some instance' do + other_instance = create(:instance) + ActsAsTenant.with_tenant(other_instance) do + create(:instance_user, :instructor, user: user, instance: other_instance) + end + expect(user.instance_instructor_or_administrator?).to be(true) + end + + it 'is true when the user is an administrator in some instance' do + another_instance = create(:instance) + ActsAsTenant.with_tenant(another_instance) do + create(:instance_administrator, user: user, instance: another_instance) + end + expect(user.instance_instructor_or_administrator?).to be(true) + end + + it 'is false when the user is only a normal instance member' do + # `create(:user)` already gives a normal InstanceUser in the default instance via the + # after_create callback; there is no instructor/administrator membership anywhere. + expect(user.instance_instructor_or_administrator?).to be(false) + end + end + describe '#emails' do let(:user) { create(:user, emails_count: 5) } it 'unsets other email as primary when a new email is assigned' do From aa8b1a43dbdaf6f086a8437dbb7493cf262f892a Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:09:26 +0800 Subject: [PATCH 13/30] feat(marketplace): system-admin allow-list management UI System::Admin page to manage the marketplace allow-list: add/remove typed rules (specific user, whole instance, or email domain) with a live preview of who each rule would let in, and an open-to-everyone / restrict toggle. --- .../assessment/marketplace/allowlist_rule.rb | 6 +- .../marketplace/rule_preview_query.rb | 2 +- client/app/api/system/Admin.ts | 75 +++ .../course/marketplace/translations.ts | 2 +- .../system/admin/admin/AdminNavigator.tsx | 10 + .../MarketplaceAllowlistModeBanner.tsx | 133 ++++ .../forms/MarketplaceAllowlistRuleForm.tsx | 477 +++++++++++++++ .../MarketplaceAllowlistRuleForm.test.tsx | 575 ++++++++++++++++++ .../tables/MarketplaceAllowlistTable.tsx | 179 ++++++ .../MarketplaceAllowlistTable.test.tsx | 99 +++ .../admin/pages/MarketplaceAllowlistIndex.tsx | 174 ++++++ .../MarketplaceAllowlistIndex.test.tsx | 432 +++++++++++++ client/app/routers/courseless/systemAdmin.tsx | 11 + client/app/types/system/marketplaceAccess.ts | 19 + .../app/types/system/marketplaceAllowlist.ts | 19 + client/locales/en.json | 179 +++++- client/locales/ko.json | 174 ++++++ client/locales/zh.json | 174 ++++++ ...etplace_allowlist_rules_controller_spec.rb | 4 +- .../marketplace/allowlist_rule_spec.rb | 10 +- 20 files changed, 2741 insertions(+), 13 deletions(-) create mode 100644 client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx create mode 100644 client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx create mode 100644 client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx create mode 100644 client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx create mode 100644 client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx create mode 100644 client/app/types/system/marketplaceAccess.ts create mode 100644 client/app/types/system/marketplaceAllowlist.ts diff --git a/app/models/course/assessment/marketplace/allowlist_rule.rb b/app/models/course/assessment/marketplace/allowlist_rule.rb index 1dab542d0c..07f3c3f78c 100644 --- a/app/models/course/assessment/marketplace/allowlist_rule.rb +++ b/app/models/course/assessment/marketplace/allowlist_rule.rb @@ -23,11 +23,11 @@ class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord validates :instance, presence: true, if: :rule_type_instance? validates :email_domain, presence: true, if: :rule_type_email_domain? - validates :user_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + validates :user_id, uniqueness: { scope: :rule_type, message: 'already has the same rule.' }, if: :rule_type_user? - validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + validates :instance_id, uniqueness: { scope: :rule_type, message: 'already has the same rule.' }, if: :rule_type_instance? - validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has a rule.' }, + validates :email_domain, uniqueness: { scope: :rule_type, message: 'already has the same rule.' }, if: :rule_type_email_domain? # "Everyone" is the widest rule; only one may exist. Paired with a partial unique index. validates :rule_type, uniqueness: true, if: :rule_type_everyone? diff --git a/app/models/course/assessment/marketplace/rule_preview_query.rb b/app/models/course/assessment/marketplace/rule_preview_query.rb index cff62f5f06..b58fbb63eb 100644 --- a/app/models/course/assessment/marketplace/rule_preview_query.rb +++ b/app/models/course/assessment/marketplace/rule_preview_query.rb @@ -50,7 +50,7 @@ def matched_ids @matched_ids ||= RuleMatchQuery.new(@rule).user_ids_within(baseline_ids) end - # Only baseline-eligible staff can ever reach the marketplace, so a rule matching anyone else + # Only baseline-eligible users can ever reach the marketplace, so a rule matching anyone else # grants nothing and must not be counted. def baseline_ids @baseline_ids ||= User.where(id: CourseUser.managers.select(:user_id)). diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 40eac58adc..47eb69b80f 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -5,6 +5,11 @@ import { } from 'types/course/announcements'; import { CourseListData } from 'types/system/courses'; import { InstanceListData, InstancePermissions } from 'types/system/instances'; +import { AllowlistRulePreviewData } from 'types/system/marketplaceAccess'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; import { AdminStats, UserListData } from 'types/users'; import BaseSystemAPI from '../Base'; @@ -173,4 +178,74 @@ export default class AdminAPI extends BaseSystemAPI { getDeploymentInfo(): Promise> { return this.client.get(`${AdminAPI.#urlPrefix}/deployment_info`); } + + /** + * Fetches the marketplace allow-list rules. + */ + indexMarketplaceAllowlistRules(): Promise< + AxiosResponse<{ rules: AllowlistRuleData[]; everyoneRuleId: number | null }> + > { + return this.client.get( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + ); + } + + /** + * Creates a marketplace allow-list rule. + */ + createMarketplaceAllowlistRule( + params: AllowlistRuleFormData, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { + allowlist_rule: { + rule_type: params.ruleType, + instance_id: params.instanceId, + email_domain: params.emailDomain, + email: params.email, + }, + }, + ); + } + + /** + * Dry run for a prospective allow-list rule: reports who it would let in, without saving it. + * Runs the same validations as create, so a duplicate rule is reported here as a 400. + */ + previewMarketplaceAllowlistRule( + params: AllowlistRuleFormData, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/preview`, + { + allowlist_rule: { + rule_type: params.ruleType, + instance_id: params.instanceId, + email_domain: params.emailDomain, + email: params.email, + }, + }, + ); + } + + /** + * Opens the marketplace to everyone by creating the single `everyone` allow-list rule. + * Returns the created rule; only its `id` is consumed (to later restrict). + */ + openMarketplaceToEveryone(): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules`, + { allowlist_rule: { rule_type: 'everyone' } }, + ); + } + + /** + * Deletes a marketplace allow-list rule. + */ + deleteMarketplaceAllowlistRule(id: number): Promise { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/${id}`, + ); + } } diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts index 7d0b50df5a..f8180168f5 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -16,7 +16,7 @@ export default defineMessages({ publishConfirmBody: { id: 'course.marketplace.publishConfirmBody', defaultMessage: - 'This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title.', + 'This assessment will be browsable by eligible users, who can preview and duplicate it. It uses this assessment’s own title.', }, removeConfirmTitle: { id: 'course.marketplace.removeConfirmTitle', diff --git a/client/app/bundles/system/admin/admin/AdminNavigator.tsx b/client/app/bundles/system/admin/admin/AdminNavigator.tsx index c445a5b9d1..ddb5875f3c 100644 --- a/client/app/bundles/system/admin/admin/AdminNavigator.tsx +++ b/client/app/bundles/system/admin/admin/AdminNavigator.tsx @@ -5,6 +5,7 @@ import { Category, Chat, Group, + Storefront, } from '@mui/icons-material'; import useTranslation from 'lib/hooks/useTranslation'; @@ -32,6 +33,10 @@ const translations = defineMessages({ id: 'system.admin.admin.AdminNavigator.getHelp', defaultMessage: 'Get Help', }, + marketplace: { + id: 'system.admin.admin.AdminNavigator.marketplace', + defaultMessage: 'Marketplace Access', + }, systemAdminPanel: { id: 'system.admin.admin.AdminNavigator.systemAdminPanel', defaultMessage: 'System Admin Panel', @@ -64,6 +69,11 @@ const AdminNavigator = (): JSX.Element => { title: t(translations.courses), path: '/admin/courses', }, + { + icon: , + title: t(translations.marketplace), + path: '/admin/marketplace_allowlist_rules', + }, { icon: , title: t(translations.getHelp), diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx new file mode 100644 index 0000000000..1dff0bf77f --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAllowlistModeBanner.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert, FormControlLabel, Switch, Typography } from '@mui/material'; + +import Prompt from 'lib/components/core/dialogs/Prompt'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + openToEveryone: boolean; + onOpenToEveryone: () => Promise; + onRestrict: () => Promise; +} + +const translations = defineMessages({ + scopedTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle', + defaultMessage: 'Access is limited to the rules below.', + }, + everyoneTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle', + defaultMessage: + 'The marketplace is open to all eligible users: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + }, + toggleLabel: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel', + defaultMessage: 'Open to everyone', + }, + openConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle', + defaultMessage: 'Open marketplace to everyone?', + }, + openConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody', + defaultMessage: + 'This makes the marketplace visible to all eligible users: course managers/owners and instance instructors/administrators. You can restrict it again at any time; your scoped rules are kept.', + }, + restrictConfirmTitle: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle', + defaultMessage: 'Restrict to scoped rules?', + }, + restrictConfirmBody: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody', + defaultMessage: + 'The marketplace will again be limited to the rules below. Eligible users not covered by a rule will lose access.', + }, + confirmOpen: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen', + defaultMessage: 'Open to everyone', + }, + confirmRestrict: { + id: 'system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict', + defaultMessage: 'Restrict', + }, +}); + +const MarketplaceAllowlistModeBanner = ({ + openToEveryone, + onOpenToEveryone, + onRestrict, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const handleConfirm = async (): Promise => { + setSubmitting(true); + try { + await (openToEveryone ? onRestrict() : onOpenToEveryone()); + setIsConfirmOpen(false); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + setIsConfirmOpen(true)} + /> + } + label={ + + {t(translations.toggleLabel)} + + } + labelPlacement="start" + sx={{ mr: 1 }} + /> + } + className="mb-4 [&_.MuiAlert-action]:items-center [&_.MuiAlert-action]:pt-0" + severity={openToEveryone ? 'success' : 'info'} + > + {openToEveryone + ? t(translations.everyoneTitle) + : t(translations.scopedTitle)} + + + setIsConfirmOpen(false)} + open={isConfirmOpen} + primaryColor={openToEveryone ? 'error' : 'primary'} + primaryDisabled={submitting} + primaryLabel={ + openToEveryone + ? t(translations.confirmRestrict) + : t(translations.confirmOpen) + } + title={ + openToEveryone + ? t(translations.restrictConfirmTitle) + : t(translations.openConfirmTitle) + } + > + {openToEveryone + ? t(translations.restrictConfirmBody) + : t(translations.openConfirmBody)} + + + ); +}; + +export default MarketplaceAllowlistModeBanner; diff --git a/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx new file mode 100644 index 0000000000..f960ed123b --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx @@ -0,0 +1,477 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { + Alert, + Autocomplete, + Box, + Chip, + MenuItem, + TextField, + Typography, +} from '@mui/material'; +import { AxiosError } from 'axios'; +import { AllowlistRulePreviewData } from 'types/system/marketplaceAccess'; +import { + AllowlistRuleFormData, + AllowlistRuleType, +} from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import Prompt from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface InstanceOption { + id: number; + name: string; +} + +interface Props { + open: boolean; + onClose: () => void; + onSubmit: (data: AllowlistRuleFormData) => Promise; +} + +const translations = defineMessages({ + title: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.title', + defaultMessage: 'Add marketplace access rule', + }, + ruleType: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.ruleType', + defaultMessage: 'Rule type', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeUser', + defaultMessage: 'Specific eligible user', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance', + defaultMessage: 'All eligible users in an instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain', + defaultMessage: 'All eligible users with an email domain', + }, + userEmail: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.userEmail', + defaultMessage: 'Eligible user email', + }, + eligibilityHint: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.eligibilityHint', + defaultMessage: + 'Eligible users refer to course managers & owners (of any course) and instance instructors & administrators (of any instance).', + }, + instanceLabel: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.instanceId', + defaultMessage: 'Instance', + }, + fetchInstancesFailure: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.fetchInstancesFailure', + defaultMessage: 'Failed to get instances', + }, + emailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain', + defaultMessage: 'Email domain (e.g. schools.gov.sg)', + }, + next: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.next', + defaultMessage: 'Next', + }, + back: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.back', + defaultMessage: 'Back', + }, + confirmAdd: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd', + defaultMessage: 'Confirm add', + }, + counts: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.counts', + defaultMessage: + 'Grants access to {matched, plural, one {# eligible user} other {# eligible users}}', + }, + countsOfMatched: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched', + defaultMessage: + 'Grants access to {granted} of {matched, plural, one {# eligible user} other {# eligible users}}', + }, + countsExistingClause: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause', + defaultMessage: '{existing} already had access', + }, + countsBlockedClause: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause', + defaultMessage: '{blocked} blocked individually', + }, + noMatches: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.noMatches', + defaultMessage: 'This rule matches nobody eligible right now.', + }, + openToEveryone: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone', + defaultMessage: + 'The marketplace is currently open to everyone; this rule takes effect only if you restrict access again.', + }, + previewFailure: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure', + defaultMessage: 'Could not preview this rule.', + }, + markerNew: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerNew', + defaultMessage: 'New', + }, + markerExisting: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting', + defaultMessage: 'Already has access', + }, + markerBlocked: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked', + defaultMessage: 'Blocked', + }, + managesCourses: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses', + defaultMessage: 'Manages {count, plural, one {# course} other {# courses}}', + }, + colName: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colName', + defaultMessage: 'Name', + }, + colEligibleVia: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia', + defaultMessage: 'Eligible via', + }, + colStatus: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.colStatus', + defaultMessage: 'Status', + }, + searchPlaceholder: { + id: 'system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder', + defaultMessage: 'Search by name or email', + }, +}); + +const MarketplaceAllowlistRuleForm = ({ + open, + onClose, + onSubmit, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [step, setStep] = useState<1 | 2>(1); + const [ruleType, setRuleType] = useState('email_domain'); + const [value, setValue] = useState(''); + const [instanceId, setInstanceId] = useState(null); + const [instances, setInstances] = useState([]); + const [instancesLoaded, setInstancesLoaded] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [previewing, setPreviewing] = useState(false); + const [preview, setPreview] = useState(null); + // A validation verdict (400) blocks the add; a transport failure does not. + const [rejection, setRejection] = useState(null); + const [previewFailed, setPreviewFailed] = useState(false); + + // The instance list is only needed for the `instance` rule type, so fetch it lazily the first + // time that type is selected — keeps the page's initial load free of an unused request. + useEffect(() => { + if (ruleType !== 'instance' || instancesLoaded) return; + SystemAPI.admin + .indexInstances() + .then((response) => { + setInstances( + response.data.instances.map((instance) => ({ + id: instance.id, + name: instance.name, + })), + ); + setInstancesLoaded(true); + }) + // Only a success counts as loaded: leaving the flag down on failure means re-selecting the + // type asks again, instead of stranding the admin at an unexplained empty dropdown. + .catch(() => toast.error(t(translations.fetchInstancesFailure))); + }, [ruleType, instancesLoaded]); + + const buildData = (): AllowlistRuleFormData => { + switch (ruleType) { + case 'user': + return { ruleType, email: value.trim() }; + case 'instance': + return { ruleType, instanceId: instanceId ?? undefined }; + default: + return { ruleType, emailDomain: value.trim() }; + } + }; + + const reset = (): void => { + setStep(1); + setRuleType('email_domain'); + setValue(''); + setInstanceId(null); + setPreview(null); + setRejection(null); + setPreviewFailed(false); + }; + + const handleClose = (): void => { + reset(); + onClose(); + }; + + const goToPreview = async (): Promise => { + setStep(2); + setPreviewing(true); + setPreview(null); + setRejection(null); + setPreviewFailed(false); + + try { + const response = + await SystemAPI.admin.previewMarketplaceAllowlistRule(buildData()); + setPreview(response.data); + } catch (error) { + const response = error instanceof AxiosError ? error.response : undefined; + const message = response?.data?.errors; + if (response?.status === 400 && message) setRejection(message); + else setPreviewFailed(true); + } finally { + setPreviewing(false); + } + }; + + const submit = async (): Promise => { + setSubmitting(true); + await onSubmit(buildData()).finally(() => setSubmitting(false)); + reset(); + }; + + const valueLabel = { + user: t(translations.userEmail), + instance: t(translations.instanceLabel), + email_domain: t(translations.emailDomain), + }[ruleType]; + + const missingValue = + ruleType === 'instance' ? instanceId === null : value.trim() === ''; + + const marker = (user: AllowlistRulePreviewData['users'][number]): string => { + if (user.blocked) return t(translations.markerBlocked); + if (user.alreadyHasAccess) return t(translations.markerExisting); + return t(translations.markerNew); + }; + + // Blocked is the one status that means the rule does not reach this person, so it is the one + // worth colouring; New and Already-has-access are both benign and stay neutral. + const markerColor = ( + user: AllowlistRulePreviewData['users'][number], + ): 'warning' | 'default' => (user.blocked ? 'warning' : 'default'); + + const previewColumns: ColumnTemplate< + AllowlistRulePreviewData['users'][number] + >[] = [ + { + of: 'name', + title: t(translations.colName), + searchable: true, + cell: (user) => ( +
+ + {user.name} + + + + {user.email} + +
+ ), + }, + { + id: 'eligibleVia', + title: t(translations.colEligibleVia), + cell: (user) => + t(translations.managesCourses, { count: user.courseCount }), + }, + { + id: 'status', + title: t(translations.colStatus), + // Fixed width, wide enough for the longest marker: the table sizes columns from the rows on + // the CURRENT page, so a page holding a Blocked chip was laying out differently from a page + // of nothing but New, and the whole table shifted as the admin paged through. + className: 'w-[16rem]', + cell: (user) => ( + + ), + }, + ]; + + const renderCounts = (): JSX.Element => { + if (preview === null) return ; + if (preview.openToEveryone) { + return {t(translations.openToEveryone)}; + } + if (preview.matchedCount === 0) { + return {t(translations.noMatches)}; + } + + // "N are new" was noise when everyone is new (the common case); the useful signal is who the + // rule does NOT reach, so name those groups only when there IS one. A blocked user keeps their + // individual block — the rule grants them nothing — so they are neither granted nor "existing". + const blocked = preview.blockedCount; + const existing = preview.matchedCount - preview.newCount - blocked; + const clauses = [ + existing > 0 && t(translations.countsExistingClause, { existing }), + blocked > 0 && t(translations.countsBlockedClause, { blocked }), + ].filter(Boolean); + + const headline = + clauses.length > 0 + ? t(translations.countsOfMatched, { + granted: preview.newCount, + matched: preview.matchedCount, + }) + : t(translations.counts, { matched: preview.matchedCount }); + + return ( + + {[headline, ...clauses].join(' · ')} + + ); + }; + + const renderStepTwo = (): JSX.Element => { + if (previewing) return ; + if (rejection !== null) return {rejection}; + if (previewFailed) { + return {t(translations.previewFailure)}; + } + + // The prebuilt Table, not a hand-rolled list: a domain or instance rule routinely matches + // hundreds of people, which needs pagination and search, and its real columns keep the three + // headers aligned for free. With nobody matched there is nothing to page or search, so the + // headers and pagination chrome would be furniture around an empty box — the counts line + // already says what happened. + const users = preview?.users ?? []; + + return ( +
+ {renderCounts()} + + {users.length > 0 && ( + user.id.toString()} + pagination={{ initialPageSize: 10, rowsPerPage: [10, 20, 50, 100] }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (user, filterValue?: string): boolean => { + if (!filterValue) return true; + const query = filterValue.toLowerCase().trim(); + return ( + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) + ); + }, + }, + }} + /> + )} + + ); + }; + + return ( + setStep(1)} + onClose={handleClose} + open={open} + primaryDisabled={ + step === 1 + ? missingValue + : submitting || previewing || rejection !== null + } + primaryLabel={ + step === 1 ? t(translations.next) : t(translations.confirmAdd) + } + secondaryLabel={step === 2 ? t(translations.back) : undefined} + title={t(translations.title)} + > + {step === 1 ? ( +
+ { + setRuleType(e.target.value as AllowlistRuleType); + setValue(''); + setInstanceId(null); + }} + select + value={ruleType} + > + {t(translations.typeUser)} + {t(translations.typeInstance)} + + {t(translations.typeEmailDomain)} + + + + {ruleType === 'instance' ? ( + instance.name} + isOptionEqualToValue={(instance, chosen): boolean => + instance.id === chosen.id + } + onChange={(_, instance): void => + setInstanceId(instance?.id ?? null) + } + options={instances} + renderInput={(inputProps): JSX.Element => ( + + )} + renderOption={(optionProps, instance): JSX.Element => ( + + {instance.name} + + )} + value={ + instances.find((instance) => instance.id === instanceId) ?? null + } + /> + ) : ( + setValue(e.target.value)} + value={value} + /> + )} + + {/* `caption` renders inline by default, which drops the parent's vertical rhythm. */} + + {t(translations.eligibilityHint)} + +
+ ) : ( +
{renderStepTwo()}
+ )} +
+ ); +}; + +export default MarketplaceAllowlistRuleForm; diff --git a/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx b/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx new file mode 100644 index 0000000000..f9ca903b31 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx @@ -0,0 +1,575 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { act, fireEvent, render, waitFor } from 'test-utils'; + +import SystemAPI from 'api/system'; +import { LOADING_INDICATOR_TEST_ID } from 'lib/components/core/LoadingIndicator'; + +import MarketplaceAllowlistRuleForm from '../MarketplaceAllowlistRuleForm'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => mock.reset()); + +const PREVIEW_URL = '/admin/marketplace_allowlist_rules/preview'; +const INSTANCES_URL = '/admin/instances'; +const NUS_DOMAIN = 'nus.edu.sg'; +const EMAIL_DOMAIN_SUBTITLE = 'Email domain (e.g. schools.gov.sg)'; +const CONFIRM_ADD = 'Confirm add'; +const GRANT_ACCESS_TO_STAFF = 'Grants access to 1 eligible user'; + +const previewUser = { + id: 1, + name: 'Jane Tan', + email: 'jane@nus.edu.sg', + courseCount: 2, + instanceRole: null, + alreadyHasAccess: false, + blocked: false, +}; + +const renderForm = ( + onSubmit = jest.fn().mockResolvedValue(undefined), + onClose = jest.fn(), +): { + page: ReturnType; + onSubmit: jest.Mock; + onClose: jest.Mock; +} => { + const page = render( + , + ); + return { page, onSubmit, onClose }; +}; + +const fillDomainAndAdvance = async ( + page: ReturnType, + domain = NUS_DOMAIN, +): Promise => { + // findBy, not getBy: test-utils' render mounts providers asynchronously, so the dialog's fields + // are not in the DOM on the first tick. + await userEvent.type( + await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE), + domain, + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); +}; + +it('previews the rule once when advancing to step 2', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 12, + newCount: 5, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'Grants access to 5 of 12 eligible users · 7 already had access', + ), + ).toBeVisible(); + // Settle any post-response re-render before pinning the count: a duplicate request fired from an + // effect would be recorded AFTER the counts paint, so asserting at paint time would miss exactly + // the failure this guards against. + await act(async () => { + await Promise.resolve(); + }); + expect(mock.history.post).toHaveLength(1); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'email_domain', email_domain: NUS_DOMAIN }, + }); +}); + +it('lists the matched people with links and a new marker', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 2, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [ + previewUser, + { + ...previewUser, + id: 2, + name: 'Kumar Raj', + email: 'kumar@nus.edu.sg', + // Distinct from Jane's 2 so each row's count is queryable on its own; also covers the + // singular arm of the `{count, plural, ...}` message. + courseCount: 1, + alreadyHasAccess: true, + }, + ], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + const link = await page.findByRole('link', { name: 'Jane Tan' }); + expect(link).toHaveAttribute('href', '/users/1'); + expect(page.getByText('New')).toBeVisible(); + expect(page.getByText('Already has access')).toBeVisible(); + expect(page.getByText('Manages 2 courses')).toBeVisible(); + expect(page.getByText('Manages 1 course')).toBeVisible(); + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); +}); + +it('heads the preview list with its three columns', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Name')).toBeVisible(); + expect(page.getByText('Eligible via')).toBeVisible(); + expect(page.getByText('Status')).toBeVisible(); +}); + +it('drops the table entirely when nobody is matched', async () => { + // Column headers and pagination chrome around an empty box say nothing the counts line has not + // already said. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + blockedCount: 0, + openToEveryone: false, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + await page.findByText('This rule matches nobody eligible right now.'); + expect(page.queryByRole('table')).not.toBeInTheDocument(); + expect(page.queryByText('Eligible via')).not.toBeInTheDocument(); + expect( + page.queryByPlaceholderText('Search by name or email'), + ).not.toBeInTheDocument(); +}); + +it('marks a blocked match', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 0, + blockedCount: 1, + openToEveryone: false, + users: [{ ...previewUser, blocked: true }], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Blocked')).toBeVisible(); +}); + +it('names blocked matches apart from those who already had access', async () => { + // The counts line used to derive its "already had access" number as matched - new, which swept + // blocked people into it and claimed the rule granted them access. They are held back by their + // own block, which the rule does not lift, so they are neither granted nor pre-existing. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 10, + newCount: 6, + blockedCount: 3, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'Grants access to 6 of 10 eligible users · 1 already had access · 3 blocked individually', + ), + ).toBeVisible(); +}); + +it('omits the already-had-access clause when every exclusion is a block', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 200, + newCount: 197, + blockedCount: 3, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'Grants access to 197 of 200 eligible users · 3 blocked individually', + ), + ).toBeVisible(); +}); + +it('prefers the blocked marker over already-has-access', async () => { + // A blocked person may also already hold access; "Blocked" is the marker that matters, because + // the rule will not let them in either way. Without this the two branches could be swapped and + // every other example would still pass. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 0, + blockedCount: 1, + openToEveryone: false, + users: [{ ...previewUser, alreadyHasAccess: true, blocked: true }], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Blocked')).toBeVisible(); + expect(page.queryByText('Already has access')).not.toBeInTheDocument(); +}); + +it('shows a loading state while the preview is in flight', async () => { + let release = (): void => {}; + mock.onPost(PREVIEW_URL).reply( + () => + new Promise((resolve) => { + release = (): void => + resolve([ + 200, + { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }, + ]); + }), + ); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByTestId(LOADING_INDICATOR_TEST_ID)).toBeVisible(); + // Confirming before the verdict lands would create a rule the admin never previewed. + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeDisabled(); + + release(); + + expect(await page.findByText(GRANT_ACCESS_TO_STAFF)).toBeVisible(); + expect(page.queryByTestId(LOADING_INDICATOR_TEST_ID)).not.toBeInTheDocument(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('flags a zero-match rule with a warning severity, and keeps it addable', async () => { + // The rule matching nobody reports a problem, so the alert is a warning, not an info note; but a + // zero-match rule is still legitimate (e.g. pre-provisioning a domain before its staff exist), so + // the add stays enabled. Asserting the severity, not just the text, is what pins info→warning. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + blockedCount: 0, + openToEveryone: false, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + const message = await page.findByText( + 'This rule matches nobody eligible right now.', + ); + expect(message).toBeVisible(); + expect(message.closest('.MuiAlert-root')).toHaveClass( + 'MuiAlert-standardWarning', + ); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('explains that the rule is inert while the marketplace is open to everyone', async () => { + // matchedCount 0 as well, so this also pins the branch ORDER: the open-to-everyone message must + // win over the "matches nobody" one, which is the more useful thing to say here. + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 0, + newCount: 0, + blockedCount: 0, + openToEveryone: true, + users: [], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText( + 'The marketplace is currently open to everyone; this rule takes effect only if you restrict access again.', + ), + ).toBeVisible(); +}); + +it('blocks a duplicate rule and reports the server message', async () => { + mock.onPost(PREVIEW_URL).reply(400, { + errors: 'Email domain already has the same rule.', + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + + expect( + await page.findByText('Email domain already has the same rule.'), + ).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeDisabled(); + expect(onSubmit).not.toHaveBeenCalled(); +}); + +it('still allows adding when the preview request itself fails', async () => { + // A preview outage is not a verdict on the rule; it must not block creation. + mock.onPost(PREVIEW_URL).reply(500); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Could not preview this rule.')).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('treats a 400 with no message as an outage, not a verdict', async () => { + // Only a 400 that says what is wrong is a rejection. A bare 400 is a broken response, and must + // take the soft path rather than silently blocking creation with no explanation. + mock.onPost(PREVIEW_URL).reply(400, {}); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + + expect(await page.findByText('Could not preview this rule.')).toBeVisible(); + expect(page.getByRole('button', { name: CONFIRM_ADD })).toBeEnabled(); +}); + +it('submits the rule from step 2', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText(GRANT_ACCESS_TO_STAFF); + + fireEvent.click(page.getByRole('button', { name: CONFIRM_ADD })); + + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith({ + ruleType: 'email_domain', + emailDomain: NUS_DOMAIN, + }), + ); +}); + +it('keeps the entered value when going back to step 1', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText(GRANT_ACCESS_TO_STAFF); + + fireEvent.click(page.getByRole('button', { name: 'Back' })); + + expect(await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE)).toHaveValue( + NUS_DOMAIN, + ); +}); + +it('resets to a clean step 1 when cancelled', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 4, + newCount: 2, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onClose } = renderForm(); + await fillDomainAndAdvance(page); + await page.findByText( + 'Grants access to 2 of 4 eligible users · 2 already had access', + ); + + fireEvent.click(page.getByRole('button', { name: 'Cancel' })); + + expect(onClose).toHaveBeenCalled(); + + // The dialog stays mounted (its `open` belongs to the parent), so the reset is observable: back + // at step 1, value cleared, cached preview discarded. Without this the next open would resume + // mid-flow, showing a preview of a rule the admin already abandoned. + expect(await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE)).toHaveValue(''); + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + expect( + page.queryByText( + 'Grants access to 2 of 4 eligible users · 2 already had access', + ), + ).not.toBeInTheDocument(); +}); + +it('previews a user rule from an email address', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + + fireEvent.mouseDown(await page.findByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + // Surrounding whitespace is a paste artefact, not part of the address. + await userEvent.type( + page.getByLabelText('Eligible user email'), + ' jane@nus.edu.sg ', + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + + await page.findByText(GRANT_ACCESS_TO_STAFF); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'user', email: 'jane@nus.edu.sg' }, + }); +}); + +it('previews an instance rule, loading the instance list lazily and once', async () => { + mock.onGet(INSTANCES_URL).reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 3, + newCount: 3, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page } = renderForm(); + + // The instance list is not fetched until the instance rule type is chosen. + expect(await page.findByLabelText('Rule type')).toBeVisible(); + expect(mock.history.get).toHaveLength(0); + + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible users in an instance' }), + ); + + await waitFor(() => + expect( + mock.history.get.filter((r) => r.url === INSTANCES_URL), + ).toHaveLength(1), + ); + + // An instance rule has no value until an instance is actually picked. + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + + await page.findByText('Grants access to 3 eligible users'); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'instance', instance_id: 2 }, + }); + expect(mock.history.get.filter((r) => r.url === INSTANCES_URL)).toHaveLength( + 1, + ); +}); + +it('reports a failed instance-list fetch, and retries on the next selection', async () => { + mock.onGet(INSTANCES_URL).replyOnce(500); + mock.onGet(INSTANCES_URL).reply(200, { + instances: [{ id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }], + }); + + const { page } = renderForm(); + + // By role, not by label: while the select's menu is closing it is still mounted, and its listbox + // carries the same "Rule type" label as the field itself. + const chooseRuleType = (name: string): void => { + fireEvent.mouseDown(page.getByRole('combobox', { name: 'Rule type' })); + fireEvent.click(page.getByRole('option', { name })); + }; + + await page.findByLabelText('Rule type'); + chooseRuleType('All eligible users in an instance'); + + expect(await page.findByText('Failed to get instances')).toBeVisible(); + fireEvent.mouseDown(await page.findByRole('combobox', { name: 'Instance' })); + expect(page.getByText('No options')).toBeVisible(); + + // The failure must not be recorded as a load, or the admin would be stuck with an empty dropdown + // for the rest of the dialog's life with no way to ask again. + chooseRuleType('Specific eligible user'); + chooseRuleType('All eligible users in an instance'); + + fireEvent.mouseDown(await page.findByRole('combobox', { name: 'Instance' })); + expect(await page.findByRole('option', { name: 'Alpha' })).toBeVisible(); +}); + +it('clears the entered value when the rule type changes', async () => { + const { page } = renderForm(); + + await userEvent.type( + await page.findByLabelText(EMAIL_DOMAIN_SUBTITLE), + NUS_DOMAIN, + ); + + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + // A domain is not a plausible email, so it must not carry over into the new field. + expect(page.getByLabelText('Eligible user email')).toHaveValue(''); + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); +}); + +it('does not submit from step 1', async () => { + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [previewUser], + }); + + const { page, onSubmit } = renderForm(); + await fillDomainAndAdvance(page); + + // Next only previews; the rule is created solely by the step 2 confirmation. + await page.findByText(GRANT_ACCESS_TO_STAFF); + expect(onSubmit).not.toHaveBeenCalled(); + expect(page.queryByRole('button', { name: 'Next' })).not.toBeInTheDocument(); +}); + +it('disables Next until a value is entered', async () => { + const { page } = renderForm(); + + expect(await page.findByRole('button', { name: 'Next' })).toBeDisabled(); + + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); diff --git a/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx new file mode 100644 index 0000000000..2cafeca79b --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/MarketplaceAllowlistTable.tsx @@ -0,0 +1,179 @@ +import { ReactNode } from 'react'; +import { defineMessages } from 'react-intl'; +import { StorefrontOutlined, WarningAmber } from '@mui/icons-material'; +import { Tooltip, Typography } from '@mui/material'; +import { AllowlistRuleData } from 'types/system/marketplaceAllowlist'; + +import DeleteButton from 'lib/components/core/buttons/DeleteButton'; +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + rules: AllowlistRuleData[]; + onDelete: (id: number) => Promise; + disabled?: boolean; + action?: ReactNode; + /** + * Rule id => number of listed users that rule grants access to. Null until the access list below + * has loaded — an unknown count must NOT render as zero, or every rule flashes a warning on load. + * A loaded map with no entry for a rule means it genuinely matches nobody: that is the warning. + */ + matchCounts?: Map | null; +} + +const translations = defineMessages({ + colType: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colType', + defaultMessage: 'Type', + }, + colTarget: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colTarget', + defaultMessage: 'Grants access to', + }, + colActions: { + id: 'system.admin.admin.MarketplaceAllowlistTable.colActions', + defaultMessage: 'Actions', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeUser', + defaultMessage: 'User', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeInstance', + defaultMessage: 'Instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain', + defaultMessage: 'Email domain', + }, + deleteConfirm: { + id: 'system.admin.admin.MarketplaceAllowlistTable.deleteConfirm', + defaultMessage: 'Remove this marketplace access rule?', + }, + emptyTitle: { + id: 'system.admin.admin.MarketplaceAllowlistTable.emptyTitle', + defaultMessage: 'No access rules yet', + }, + emptyHint: { + id: 'system.admin.admin.MarketplaceAllowlistTable.emptyHint', + defaultMessage: + 'The marketplace stays hidden from everyone except system administrators. Add a rule to grant access.', + }, + zeroMatchWarning: { + id: 'system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning', + defaultMessage: + 'No eligible users currently match this rule, so it grants access to nobody.', + }, +}); + +const MarketplaceAllowlistTable = ({ + rules, + onDelete, + disabled = false, + action, + matchCounts = null, +}: Props): JSX.Element => { + const { t } = useTranslation(); + + const typeLabels: Record = { + user: t(translations.typeUser), + instance: t(translations.typeInstance), + email_domain: t(translations.typeEmailDomain), + }; + + const targetOf = (rule: AllowlistRuleData): string => { + switch (rule.ruleType) { + case 'instance': + return rule.instanceName ?? `#${rule.instanceId}`; + default: + return rule.emailDomain ?? ''; + } + }; + + const renderUserTarget = (rule: AllowlistRuleData): JSX.Element => ( + + + {rule.userName ?? `#${rule.userId}`} + + {rule.userEmail && ` (${rule.userEmail})`} + + ); + + // A loaded map (not null) with no entry for this rule means no listed user is granted by it, i.e. + // it matches nobody. Null is "not loaded yet", which must stay silent. + const matchesNobody = (rule: AllowlistRuleData): boolean => + matchCounts !== null && !matchCounts.has(rule.id); + + const renderTarget = (rule: AllowlistRuleData): JSX.Element => ( + + {matchesNobody(rule) && ( + + + + )} + {rule.ruleType === 'user' ? renderUserTarget(rule) : targetOf(rule)} + + ); + + const columns: ColumnTemplate[] = [ + { + of: 'ruleType', + title: t(translations.colType), + cell: (rule) => typeLabels[rule.ruleType], + }, + { + id: 'target', + title: t(translations.colTarget), + cell: (rule) => renderTarget(rule), + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (rule) => ( + => onDelete(rule.id)} + /> + ), + }, + ]; + + const emptyState = ( +
+ + + + {t(translations.emptyTitle)} + + + + {t(translations.emptyHint)} + +
+ ); + + return ( +
+ {action &&
{action}
} + +
+
rule.id.toString()} + renderEmpty={emptyState} + /> + + + ); +}; + +export default MarketplaceAllowlistTable; diff --git a/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx b/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx new file mode 100644 index 0000000000..4afb0d35ed --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/__test__/MarketplaceAllowlistTable.test.tsx @@ -0,0 +1,99 @@ +import { render } from 'test-utils'; + +import MarketplaceAllowlistTable from '../MarketplaceAllowlistTable'; + +const ZERO_MATCH_WARNING = + 'No eligible users currently match this rule, so it grants access to nobody.'; + +const DOMAIN_RULE = { + id: 10, + ruleType: 'email_domain' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: 'typo.edu.sg', +}; + +const USER_RULE = { + id: 11, + ruleType: 'user' as const, + userId: 7, + userName: 'Jane Tan', + userEmail: 'jane@nus.edu.sg', + instanceId: null, + instanceName: null, + emailDomain: null, +}; + +const INSTANCE_RULE = { + id: 12, + ruleType: 'instance' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: 3, + instanceName: 'NUS', + emailDomain: null, +}; + +const renderTable = ( + matchCounts: Map | null, + rules: (typeof DOMAIN_RULE | typeof USER_RULE | typeof INSTANCE_RULE)[] = [ + DOMAIN_RULE, + ], +): ReturnType => + render( + , + ); + +it('warns on a rule that a loaded access list grants to nobody', async () => { + // Empty map = the list has loaded and this rule has no entry, so it matches nobody. The tooltip + // text is reachable by accessible name (aria-label) without hovering. + const page = renderTable(new Map()); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + // The icon only qualifies the target; the value itself is still shown. + expect(page.getByText('typo.edu.sg')).toBeVisible(); +}); + +it('does not warn on a rule that grants access to at least one person', async () => { + const page = renderTable(new Map([[10, 3]])); + + expect(await page.findByText('typo.edu.sg')).toBeVisible(); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('shows no warning before the access list has loaded', async () => { + // Null = unknown, not zero. A warning here would flash an icon on every rule on first paint — + // the regression this guards against. + const page = renderTable(null); + + expect(await page.findByText('typo.edu.sg')).toBeVisible(); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('warns on a zero-match user rule, not only email-domain rules', async () => { + // The condition is matchCounts.has(id), uniform across rule types. Narrowing it to email_domain + // would leave a user rule that manages nobody just as invisible as it is today. + const page = renderTable(new Map(), [USER_RULE]); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + expect(page.getByRole('link', { name: 'Jane Tan' })).toBeInTheDocument(); +}); + +it('warns on a zero-match instance rule, completing the three rule types', async () => { + // matchesNobody keys off matchCounts.has(id) and never branches on ruleType, so the instance + // path must warn identically. This also exercises the only otherwise-untested target branch: + // targetOf's instanceName render. + const page = renderTable(new Map(), [INSTANCE_RULE]); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); + // The icon only qualifies the target; the instance name is still shown. + expect(page.getByText('NUS')).toBeVisible(); +}); diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx new file mode 100644 index 0000000000..4f9921a1b0 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx @@ -0,0 +1,174 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages, injectIntl, WrappedComponentProps } from 'react-intl'; +import { Typography } from '@mui/material'; +import { AxiosError } from 'axios'; +import { + AllowlistRuleData, + AllowlistRuleFormData, +} from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import AddButton from 'lib/components/core/buttons/AddButton'; +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import toast from 'lib/hooks/toast'; + +import MarketplaceAllowlistRuleForm from '../components/forms/MarketplaceAllowlistRuleForm'; +import MarketplaceAllowlistModeBanner from '../components/MarketplaceAllowlistModeBanner'; +import MarketplaceAllowlistTable from '../components/tables/MarketplaceAllowlistTable'; + +type Props = WrappedComponentProps; + +const translations = defineMessages({ + addRule: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.addRule', + defaultMessage: 'Add access rule', + }, + eligibility: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.eligibility', + defaultMessage: + 'Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below.', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.fetchFailure', + defaultMessage: 'Failed to load marketplace access rules.', + }, + createSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createSuccess', + defaultMessage: 'Access rule added.', + }, + createFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.createFailure', + defaultMessage: 'Failed to add access rule.', + }, + deleteSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess', + defaultMessage: 'Access rule removed.', + }, + deleteFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.deleteFailure', + defaultMessage: 'Failed to remove access rule.', + }, + openSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openSuccess', + defaultMessage: 'Marketplace opened to all eligible users.', + }, + openFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.openFailure', + defaultMessage: 'Failed to open the marketplace to everyone.', + }, + restrictSuccess: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess', + defaultMessage: 'Marketplace restricted to the scoped rules.', + }, + restrictFailure: { + id: 'system.admin.admin.MarketplaceAllowlistIndex.restrictFailure', + defaultMessage: 'Failed to restrict the marketplace.', + }, +}); + +const MarketplaceAllowlistIndex: FC = ({ intl }) => { + const [isLoading, setIsLoading] = useState(true); + const [isFormOpen, setIsFormOpen] = useState(false); + const [rules, setRules] = useState([]); + const [everyoneRuleId, setEveryoneRuleId] = useState(null); + + useEffect(() => { + SystemAPI.admin + .indexMarketplaceAllowlistRules() + .then((response) => { + setRules(response.data.rules); + setEveryoneRuleId(response.data.everyoneRuleId ?? null); + }) + .catch(() => toast.error(intl.formatMessage(translations.fetchFailure))) + .finally(() => setIsLoading(false)); + }, []); + + const openToEveryone = everyoneRuleId !== null; + + const handleCreate = async (data: AllowlistRuleFormData): Promise => { + try { + const response = + await SystemAPI.admin.createMarketplaceAllowlistRule(data); + setRules((current) => [...current, response.data]); + toast.success(intl.formatMessage(translations.createSuccess)); + setIsFormOpen(false); + } catch (error) { + // Surface the server's reason (e.g. the duplicate-rule message) — the generic fallback + // would discard exactly the message that was written for this case. + const message = + error instanceof AxiosError ? error.response?.data?.errors : undefined; + toast.error(message ?? intl.formatMessage(translations.createFailure)); + } + }; + + const handleDelete = async (id: number): Promise => { + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(id); + setRules((current) => current.filter((rule) => rule.id !== id)); + toast.success(intl.formatMessage(translations.deleteSuccess)); + } catch { + toast.error(intl.formatMessage(translations.deleteFailure)); + } + }; + + const handleOpenToEveryone = async (): Promise => { + try { + const response = await SystemAPI.admin.openMarketplaceToEveryone(); + setEveryoneRuleId(response.data.id); + toast.success(intl.formatMessage(translations.openSuccess)); + } catch { + toast.error(intl.formatMessage(translations.openFailure)); + } + }; + + const handleRestrict = async (): Promise => { + if (everyoneRuleId === null) return; + try { + await SystemAPI.admin.deleteMarketplaceAllowlistRule(everyoneRuleId); + setEveryoneRuleId(null); + toast.success(intl.formatMessage(translations.restrictSuccess)); + } catch { + toast.error(intl.formatMessage(translations.restrictFailure)); + } + }; + + if (isLoading) return ; + + return ( + + + {intl.formatMessage(translations.eligibility)} + + + + setIsFormOpen(true)} + > + {intl.formatMessage(translations.addRule)} + + } + disabled={openToEveryone} + onDelete={handleDelete} + rules={rules} + /> + + setIsFormOpen(false)} + onSubmit={handleCreate} + open={isFormOpen} + /> + + ); +}; + +export default injectIntl(MarketplaceAllowlistIndex); diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx new file mode 100644 index 0000000000..e5d57e0e69 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx @@ -0,0 +1,432 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import SystemAPI from 'api/system'; + +import MarketplaceAllowlistIndex from '../MarketplaceAllowlistIndex'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => { + mock.reset(); +}); + +const INDEX_URL = '/admin/marketplace_allowlist_rules'; +const EMAIL_DOMAIN = 'schools.gov.sg'; +const NUS_DOMAIN = 'nus.edu.sg'; +const EMAIL_DOMAIN_SUBTITLE = 'Email domain (e.g. schools.gov.sg)'; +const OPEN_TO_EVERYONE = 'Open to everyone'; +const ADD_ACCESS_RULE = 'Add access rule'; +const allowlistGetCount = (): number => + mock.history.get.filter((request) => request.url === INDEX_URL).length; +const RULES = [ + { + id: 1, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: EMAIL_DOMAIN, + }, +]; +const PREVIEW_URL = '/admin/marketplace_allowlist_rules/preview'; +const ONE_MATCH_PREVIEW = { + matchedCount: 1, + newCount: 1, + blockedCount: 0, + openToEveryone: false, + users: [ + { + id: 9, + name: 'Jane Tan', + email: `jane@${NUS_DOMAIN}`, + courseCount: 2, + instanceRole: null, + alreadyHasAccess: false, + blocked: false, + }, + ], +}; +// Step 2's preview is a POST too, so `mock.history.post[0]` is the preview, not the create. +const createPosts = (): typeof mock.history.post => + mock.history.post.filter((request) => request.url === INDEX_URL); + +/** + * Click step 2's "Confirm add". The button is disabled while the preview request is in flight, so + * a click fired the moment it appears is swallowed — wait for it to enable first. + */ +const confirmAdd = async (page: ReturnType): Promise => { + const button = await page.findByRole('button', { name: 'Confirm add' }); + await waitFor(() => expect(button).toBeEnabled()); + fireEvent.click(button); +}; + +it('renders the allow-list rules from the API', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + const page = render(, { at: [INDEX_URL] }); + + // Await the fetch firing before asserting the rendered row, so mount + request and the + // subsequent re-render each get their own waitFor budget (a single window is flaky under load). + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); +}); + +it('creates an email-domain rule from the add dialog', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(INDEX_URL).reply(200, { + id: 2, + ruleType: 'email_domain', + userId: null, + userName: null, + instanceId: null, + instanceName: null, + emailDomain: NUS_DOMAIN, + }); + mock.onPost(PREVIEW_URL).reply(200, ONE_MATCH_PREVIEW); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + // Rule type defaults to Email domain; fill the value field. (Search fields need userEvent — + // see client/CLAUDE-testing.md; a plain TextField accepts userEvent.type too.) + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'email_domain', email_domain: NUS_DOMAIN }, + }); + await waitFor(() => expect(page.getByText(NUS_DOMAIN)).toBeVisible()); +}); + +it('deletes a rule after confirmation', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + fireEvent.click(page.getByTestId('DeleteIconButton')); + fireEvent.click(page.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + await waitFor(() => + expect(page.queryByText(EMAIL_DOMAIN)).not.toBeInTheDocument(), + ); +}); + +it('opens the marketplace to everyone from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: null }); + mock.onPost(INDEX_URL).reply(200, { id: 99 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Scoped state: the banner switch is off; flipping it on prompts to open. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + + // Confirm inside the dialog (its primary button shares the label, so scope to the dialog). + const dialog = page.getByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: OPEN_TO_EVERYONE }), + ); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ + allowlist_rule: { rule_type: 'everyone' }, + }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all eligible users: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); +}); + +it('restricts the marketplace to scoped rules from the banner', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => + expect( + page.getByText( + 'The marketplace is open to all eligible users: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive.', + ), + ).toBeVisible(), + ); + + // Open state: the banner switch is on; flipping it off prompts to restrict. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${INDEX_URL}/42`); + await waitFor(() => + expect( + page.getByText('Access is limited to the rules below.'), + ).toBeVisible(), + ); +}); + +it('disables adding and removing rules while the marketplace is open to everyone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + + // Open-to-everyone means the scoped rules are preserved but inactive: no add, no delete. + expect(page.getByRole('button', { name: ADD_ACCESS_RULE })).toBeDisabled(); + expect(page.getByTestId('DeleteIconButton')).toBeDisabled(); +}); + +it('disables Next until a required value is entered', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + + // Default rule type is email_domain → value required → Add disabled while empty. + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + // Entering the required value enables it. + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); + +it('creates a user rule from an email', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(INDEX_URL).reply(200, { + id: 4, + ruleType: 'user', + userId: 7, + userName: 'Teacher', + userEmail: 'teacher@school.edu', + instanceId: null, + instanceName: null, + emailDomain: null, + }); + mock.onPost(PREVIEW_URL).reply(200, ONE_MATCH_PREVIEW); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + await userEvent.type( + page.getByLabelText('Eligible user email'), + 'teacher@school.edu', + ); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'user', email: 'teacher@school.edu' }, + }); + + const link = await page.findByRole('link', { name: 'Teacher' }); + expect(link).toHaveAttribute('href', '/users/7'); + expect(page.getByText('(teacher@school.edu)')).toBeVisible(); +}); + +it('clears the entered value when the rule type changes', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + + // Enter an email domain, then switch the rule type to "Specific eligible user". + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click(page.getByRole('option', { name: 'Specific eligible user' })); + + // The new value field must start empty, not carry over NUS_DOMAIN. + expect(page.getByLabelText('Eligible user email')).toHaveValue(''); +}); + +it('shows who is eligible for the marketplace', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByText( + 'Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below.', + ), + ).toBeVisible(); +}); + +it('renders a user rule as a link to the user with their email', async () => { + mock.onGet(INDEX_URL).reply(200, { + rules: [ + { + id: 5, + ruleType: 'user', + userId: 42, + userName: 'Administrator', + userEmail: 'admin@org.sg', + instanceId: null, + instanceName: null, + emailDomain: null, + }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: 'Administrator' }); + expect(link).toHaveAttribute('href', '/users/42'); + expect(page.getByText('(admin@org.sg)')).toBeVisible(); +}); + +it('creates an instance rule by picking an instance from the dropdown', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + mock.onPost(INDEX_URL).reply(200, { + id: 6, + ruleType: 'instance', + userId: null, + userName: null, + userEmail: null, + instanceId: 2, + instanceName: 'Alpha', + emailDomain: null, + }); + mock.onPost(PREVIEW_URL).reply(200, ONE_MATCH_PREVIEW); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible users in an instance' }), + ); + + // Selecting the instance rule type lazily fetches the instance list. + await waitFor(() => + expect(mock.history.get.some((r) => r.url === '/admin/instances')).toBe( + true, + ), + ); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(createPosts()).toHaveLength(1)); + expect(JSON.parse(createPosts()[0].data)).toEqual({ + allowlist_rule: { rule_type: 'instance', instance_id: 2 }, + }); + await waitFor(() => expect(page.getByText('Alpha')).toBeVisible()); +}); + +it('renders a user rule without an email suffix when none is present', async () => { + mock.onGet(INDEX_URL).reply(200, { + rules: [ + { + id: 8, + ruleType: 'user', + userId: 12, + userName: 'No Email User', + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: null, + }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: 'No Email User' }); + expect(link).toHaveAttribute('href', '/users/12'); + // Guard: no ` (…)` suffix — the cell's text is exactly the user name. + // (A `/\(.*\)/` regex would false-match the eligibility subtitle's "(of any course)"; + // the exact textContent check is robust and still fails if the guard is dropped, since a + // null email would render "No Email User (null)".) + expect(link.parentElement?.textContent).toBe('No Email User'); +}); + +it('disables Next for an instance rule until an instance is picked', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onGet('/admin/instances').reply(200, { + instances: [ + { id: 1, name: 'Default', host: 'coursemology.org' }, + { id: 2, name: 'Alpha', host: 'alpha.coursemology.org' }, + ], + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + fireEvent.mouseDown(page.getByLabelText('Rule type')); + fireEvent.click( + page.getByRole('option', { name: 'All eligible users in an instance' }), + ); + await waitFor(() => + expect(mock.history.get.some((r) => r.url === '/admin/instances')).toBe( + true, + ), + ); + + expect(page.getByRole('button', { name: 'Next' })).toBeDisabled(); + + const combobox = await page.findByRole('combobox', { name: 'Instance' }); + fireEvent.mouseDown(combobox); + fireEvent.click(page.getByRole('option', { name: 'Alpha' })); + + expect(page.getByRole('button', { name: 'Next' })).toBeEnabled(); +}); + +it('keeps the Open to everyone toggle label on a single line', async () => { + // The open-state banner body is long enough to wrap, which used to drag the toggle label with it. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + + const label = await page.findByText(OPEN_TO_EVERYONE); + expect(label).toHaveClass('whitespace-nowrap'); +}); + +it('surfaces the server message when a rule is rejected as a duplicate', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(PREVIEW_URL).reply(200, ONE_MATCH_PREVIEW); + mock.onPost(INDEX_URL).reply(400, { + errors: 'Email domain already has the same rule.', + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + + fireEvent.click(page.getByText(ADD_ACCESS_RULE)); + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + // The specific message, not the generic "Failed to add access rule." + expect( + await page.findByText('Email domain already has the same rule.'), + ).toBeVisible(); +}); diff --git a/client/app/routers/courseless/systemAdmin.tsx b/client/app/routers/courseless/systemAdmin.tsx index 79edf10de6..2e1bba29aa 100644 --- a/client/app/routers/courseless/systemAdmin.tsx +++ b/client/app/routers/courseless/systemAdmin.tsx @@ -67,6 +67,17 @@ const systemAdminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'marketplace_allowlist_rules', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceAllowlistIndex' */ + 'bundles/system/admin/admin/pages/MarketplaceAllowlistIndex' + ) + ).default, + }), + }, { path: 'get_help', lazy: async (): Promise> => ({ diff --git a/client/app/types/system/marketplaceAccess.ts b/client/app/types/system/marketplaceAccess.ts new file mode 100644 index 0000000000..70949eb99a --- /dev/null +++ b/client/app/types/system/marketplaceAccess.ts @@ -0,0 +1,19 @@ +export interface MarketplaceRulePreviewUser { + id: number; + name: string; + email: string; + courseCount: number; + instanceRole: 'instructor' | 'administrator' | null; + alreadyHasAccess: boolean; + blocked: boolean; +} + +export interface AllowlistRulePreviewData { + matchedCount: number; + /** Matched users who are neither already cleared by another rule nor blocked. */ + newCount: number; + /** Matched users held back by an individual block, which a rule does not lift. */ + blockedCount: number; + openToEveryone: boolean; + users: MarketplaceRulePreviewUser[]; +} diff --git a/client/app/types/system/marketplaceAllowlist.ts b/client/app/types/system/marketplaceAllowlist.ts new file mode 100644 index 0000000000..bae03b1f71 --- /dev/null +++ b/client/app/types/system/marketplaceAllowlist.ts @@ -0,0 +1,19 @@ +export type AllowlistRuleType = 'user' | 'instance' | 'email_domain'; + +export interface AllowlistRuleData { + id: number; + ruleType: AllowlistRuleType; + userId: number | null; + userName: string | null; + userEmail: string | null; + instanceId: number | null; + instanceName: string | null; + emailDomain: string | null; +} + +export interface AllowlistRuleFormData { + ruleType: AllowlistRuleType; + email?: string; + instanceId?: number; + emailDomain?: string; +} diff --git a/client/locales/en.json b/client/locales/en.json index 43769f2dab..eac6c85da4 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6081,7 +6081,7 @@ "defaultMessage": "Publish to Marketplace?" }, "course.marketplace.publishConfirmBody": { - "defaultMessage": "This assessment will be browsable by course managers, who can preview and duplicate it. It uses this assessment’s own title." + "defaultMessage": "This assessment will be browsable by eligible users, who can preview and duplicate it. It uses this assessment’s own title." }, "course.marketplace.removeConfirmTitle": { "defaultMessage": "Remove from Marketplace?" @@ -8837,6 +8837,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "Get Help" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "Marketplace Access" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "Unable to fetch announcements" }, @@ -8921,6 +8924,180 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "Renamed {field} from {prevValue} to {newValue}" }, + "system.admin.admin.MarketplaceAllowlistIndex.addRule": { + "defaultMessage": "Add access rule" + }, + "system.admin.admin.MarketplaceAllowlistIndex.eligibility": { + "defaultMessage": "Available to course managers & owners (of any course) and instance instructors & administrators (of any instance). They must also match one of the rules below." + }, + "system.admin.admin.MarketplaceAllowlistIndex.fetchFailure": { + "defaultMessage": "Failed to load marketplace access rules." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createSuccess": { + "defaultMessage": "Access rule added." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createFailure": { + "defaultMessage": "Failed to add access rule." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess": { + "defaultMessage": "Access rule removed." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteFailure": { + "defaultMessage": "Failed to remove access rule." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openSuccess": { + "defaultMessage": "Marketplace opened to all eligible users." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openFailure": { + "defaultMessage": "Failed to open the marketplace to everyone." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess": { + "defaultMessage": "Marketplace restricted to the scoped rules." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictFailure": { + "defaultMessage": "Failed to restrict the marketplace." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle": { + "defaultMessage": "Access is limited to the rules below." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle": { + "defaultMessage": "The marketplace is open to all eligible users: course managers/owners and instance instructors/administrators. The rules below are preserved but inactive." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel": { + "defaultMessage": "Open to everyone" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle": { + "defaultMessage": "Open marketplace to everyone?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody": { + "defaultMessage": "This makes the marketplace visible to all eligible users: course managers/owners and instance instructors/administrators. You can restrict it again at any time; your scoped rules are kept." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle": { + "defaultMessage": "Restrict to scoped rules?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody": { + "defaultMessage": "The marketplace will again be limited to the rules below. Eligible users not covered by a rule will lose access." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen": { + "defaultMessage": "Open to everyone" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict": { + "defaultMessage": "Restrict" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.title": { + "defaultMessage": "Add marketplace access rule" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.ruleType": { + "defaultMessage": "Rule type" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeUser": { + "defaultMessage": "Specific eligible user" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance": { + "defaultMessage": "All eligible users in an instance" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain": { + "defaultMessage": "All eligible users with an email domain" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.userEmail": { + "defaultMessage": "Eligible user email" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.eligibilityHint": { + "defaultMessage": "Eligible users refer to course managers & owners (of any course) and instance instructors & administrators (of any instance)." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.instanceId": { + "defaultMessage": "Instance" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.fetchInstancesFailure": { + "defaultMessage": "Failed to get instances" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain": { + "defaultMessage": "Email domain (e.g. schools.gov.sg)" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.next": { + "defaultMessage": "Next" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.back": { + "defaultMessage": "Back" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd": { + "defaultMessage": "Confirm add" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.counts": { + "defaultMessage": "Grants access to {matched, plural, one {# eligible user} other {# eligible users}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched": { + "defaultMessage": "Grants access to {granted} of {matched, plural, one {# eligible user} other {# eligible users}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause": { + "defaultMessage": "{existing} already had access" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause": { + "defaultMessage": "{blocked} blocked individually" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.noMatches": { + "defaultMessage": "This rule matches nobody eligible right now." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone": { + "defaultMessage": "The marketplace is currently open to everyone; this rule takes effect only if you restrict access again." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure": { + "defaultMessage": "Could not preview this rule." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerNew": { + "defaultMessage": "New" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting": { + "defaultMessage": "Already has access" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked": { + "defaultMessage": "Blocked" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses": { + "defaultMessage": "Manages {count, plural, one {# course} other {# courses}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colName": { + "defaultMessage": "Name" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia": { + "defaultMessage": "Eligible via" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colStatus": { + "defaultMessage": "Status" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder": { + "defaultMessage": "Search by name or email" + }, + "system.admin.admin.MarketplaceAllowlistTable.colType": { + "defaultMessage": "Type" + }, + "system.admin.admin.MarketplaceAllowlistTable.colTarget": { + "defaultMessage": "Grants access to" + }, + "system.admin.admin.MarketplaceAllowlistTable.colActions": { + "defaultMessage": "Actions" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeUser": { + "defaultMessage": "User" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstance": { + "defaultMessage": "Instance" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain": { + "defaultMessage": "Email domain" + }, + "system.admin.admin.MarketplaceAllowlistTable.deleteConfirm": { + "defaultMessage": "Remove this marketplace access rule?" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyTitle": { + "defaultMessage": "No access rules yet" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyHint": { + "defaultMessage": "The marketplace stays hidden from everyone except system administrators. Add a rule to grant access." + }, + "system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning": { + "defaultMessage": "No eligible users currently match this rule, so it grants access to nobody." + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "Delete User" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 8a8fd2d48b..a3b23c3163 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8813,6 +8813,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "도움 받기" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "마켓플레이스 접근" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "공지사항을 가져올 수 없습니다." }, @@ -8897,6 +8900,177 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "{field}이(가) {prevValue}에서 {newValue}로 변경되었습니다." }, + "system.admin.admin.MarketplaceAllowlistIndex.addRule": { + "defaultMessage": "접근 규칙 추가" + }, + "system.admin.admin.MarketplaceAllowlistIndex.eligibility": { + "defaultMessage": "모든 과정의 관리자 및 소유자, 그리고 모든 인스턴스의 강사 및 관리자가 사용할 수 있습니다. 단, 아래 규칙 중 하나와도 일치해야 합니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.fetchFailure": { + "defaultMessage": "마켓플레이스 접근 규칙을 불러오지 못했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createSuccess": { + "defaultMessage": "접근 규칙이 추가되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.createFailure": { + "defaultMessage": "접근 규칙 추가에 실패했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess": { + "defaultMessage": "접근 규칙이 제거되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteFailure": { + "defaultMessage": "접근 규칙 제거에 실패했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openSuccess": { + "defaultMessage": "마켓플레이스가 모든 과정 관리자에게 공개되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.openFailure": { + "defaultMessage": "마켓플레이스를 모두에게 공개하지 못했습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess": { + "defaultMessage": "마켓플레이스가 범위가 지정된 규칙으로 제한되었습니다." + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictFailure": { + "defaultMessage": "마켓플레이스를 제한하지 못했습니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle": { + "defaultMessage": "접근이 아래 규칙으로 제한됩니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle": { + "defaultMessage": "마켓플레이스가 모든 자격 있는 직원(과정 관리자/소유자 및 인스턴스 강사/관리자)에게 열려 있습니다. 아래 규칙은 유지되지만 비활성 상태입니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel": { + "defaultMessage": "모두에게 공개" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle": { + "defaultMessage": "마켓플레이스를 모두에게 공개하시겠습니까?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody": { + "defaultMessage": "이렇게 하면 마켓플레이스가 모든 자격 있는 직원(과정 관리자/소유자 및 인스턴스 강사/관리자)에게 표시됩니다. 언제든지 다시 제한할 수 있으며, 범위가 지정된 규칙은 유지됩니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle": { + "defaultMessage": "범위가 지정된 규칙으로 제한하시겠습니까?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody": { + "defaultMessage": "마켓플레이스가 다시 아래 규칙으로 제한됩니다. 규칙에 해당하지 않는 자격 있는 직원은 접근 권한을 잃게 됩니다." + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen": { + "defaultMessage": "모두에게 공개" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict": { + "defaultMessage": "제한" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.title": { + "defaultMessage": "마켓플레이스 접근 규칙 추가" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.ruleType": { + "defaultMessage": "규칙 유형" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeUser": { + "defaultMessage": "특정 자격 있는 직원" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance": { + "defaultMessage": "인스턴스 내 모든 자격 있는 직원" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain": { + "defaultMessage": "특정 이메일 도메인의 모든 자격 있는 직원" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.userEmail": { + "defaultMessage": "자격 있는 직원 이메일" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.instanceId": { + "defaultMessage": "인스턴스" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.fetchInstancesFailure": { + "defaultMessage": "인스턴스를 가져오지 못했습니다." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain": { + "defaultMessage": "이메일 도메인 (예: schools.gov.sg)" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.next": { + "defaultMessage": "다음" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.back": { + "defaultMessage": "뒤로" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd": { + "defaultMessage": "추가 확인" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.counts": { + "defaultMessage": "{matched}명의 자격 있는 직원에게 접근 권한을 부여합니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched": { + "defaultMessage": "{matched}명의 자격 있는 직원 중 {granted}명에게 접근 권한을 부여합니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause": { + "defaultMessage": "{existing}명은 이미 접근 권한이 있었습니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause": { + "defaultMessage": "{blocked}명은 개별적으로 차단되었습니다" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.noMatches": { + "defaultMessage": "현재 이 규칙과 일치하는 자격 있는 직원이 없습니다." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone": { + "defaultMessage": "마켓플레이스가 현재 모두에게 공개되어 있습니다. 이 규칙은 접근을 다시 제한할 경우에만 적용됩니다." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure": { + "defaultMessage": "이 규칙을 미리 볼 수 없습니다." + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerNew": { + "defaultMessage": "신규" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting": { + "defaultMessage": "이미 접근 권한 있음" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked": { + "defaultMessage": "차단됨" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses": { + "defaultMessage": "{count, plural, one {#개 과정} other {#개 과정}} 관리" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colName": { + "defaultMessage": "이름" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia": { + "defaultMessage": "자격 경로" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colStatus": { + "defaultMessage": "상태" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder": { + "defaultMessage": "이름 또는 이메일로 검색" + }, + "system.admin.admin.MarketplaceAllowlistTable.colType": { + "defaultMessage": "유형" + }, + "system.admin.admin.MarketplaceAllowlistTable.colTarget": { + "defaultMessage": "접근 권한 대상" + }, + "system.admin.admin.MarketplaceAllowlistTable.colActions": { + "defaultMessage": "작업" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeUser": { + "defaultMessage": "사용자" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstance": { + "defaultMessage": "인스턴스" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain": { + "defaultMessage": "이메일 도메인" + }, + "system.admin.admin.MarketplaceAllowlistTable.deleteConfirm": { + "defaultMessage": "이 마켓플레이스 접근 규칙을 제거하시겠습니까?" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyTitle": { + "defaultMessage": "아직 접근 규칙이 없습니다" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyHint": { + "defaultMessage": "시스템 관리자를 제외한 모든 사용자에게 마켓플레이스가 숨겨진 상태로 유지됩니다. 규칙을 추가하여 접근 권한을 부여하세요." + }, + "system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning": { + "defaultMessage": "현재 이 규칙과 일치하는 자격 있는 직원이 없어 아무에게도 접근 권한이 부여되지 않습니다." + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "사용자 삭제" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index b5b288d56b..6b1418329b 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8807,6 +8807,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "获取帮助" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "市场访问" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "无法获取公告" }, @@ -8891,6 +8894,177 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "已将 {field} 从 {prevValue} 重命名为 {newValue}" }, + "system.admin.admin.MarketplaceAllowlistIndex.addRule": { + "defaultMessage": "添加访问规则" + }, + "system.admin.admin.MarketplaceAllowlistIndex.eligibility": { + "defaultMessage": "任何课程的管理员及拥有者,以及任何实例的教师及管理员均可使用,但仍须匹配以下规则之一。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.fetchFailure": { + "defaultMessage": "加载市场访问规则失败。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.createSuccess": { + "defaultMessage": "访问规则已添加。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.createFailure": { + "defaultMessage": "添加访问规则失败。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteSuccess": { + "defaultMessage": "访问规则已移除。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.deleteFailure": { + "defaultMessage": "移除访问规则失败。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.openSuccess": { + "defaultMessage": "市场已向所有课程管理员开放。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.openFailure": { + "defaultMessage": "未能将市场向所有人开放。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictSuccess": { + "defaultMessage": "市场已限制为已设定范围的规则。" + }, + "system.admin.admin.MarketplaceAllowlistIndex.restrictFailure": { + "defaultMessage": "未能限制市场。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.scopedTitle": { + "defaultMessage": "访问权限仅限于以下规则。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.everyoneTitle": { + "defaultMessage": "市场目前向所有符合条件的职员开放:课程管理员/拥有者以及实例教师/管理员。以下规则会被保留,但暂不生效。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.toggleLabel": { + "defaultMessage": "对所有人开放" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmTitle": { + "defaultMessage": "要将市场向所有人开放吗?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.openConfirmBody": { + "defaultMessage": "这将使市场对所有符合条件的职员可见:课程管理员/拥有者以及实例教师/管理员。你可以随时重新限制访问,已设定范围的规则会被保留。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmTitle": { + "defaultMessage": "要限制为已设定范围的规则吗?" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.restrictConfirmBody": { + "defaultMessage": "市场将再次仅限于以下规则。未被任何规则覆盖的符合条件职员将失去访问权限。" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmOpen": { + "defaultMessage": "对所有人开放" + }, + "system.admin.admin.MarketplaceAllowlistModeBanner.confirmRestrict": { + "defaultMessage": "限制" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.title": { + "defaultMessage": "添加市场访问规则" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.ruleType": { + "defaultMessage": "规则类型" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeUser": { + "defaultMessage": "特定符合条件的职员" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeInstance": { + "defaultMessage": "某个实例中的所有符合条件职员" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.typeEmailDomain": { + "defaultMessage": "拥有特定邮箱域名的所有符合条件职员" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.userEmail": { + "defaultMessage": "符合条件职员的邮箱" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.instanceId": { + "defaultMessage": "实例" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.fetchInstancesFailure": { + "defaultMessage": "获取实例失败" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.emailDomain": { + "defaultMessage": "邮箱域名(例如 schools.gov.sg)" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.next": { + "defaultMessage": "下一步" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.back": { + "defaultMessage": "返回" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.confirmAdd": { + "defaultMessage": "确认添加" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.counts": { + "defaultMessage": "为 {matched} 名符合条件的职员授予访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsOfMatched": { + "defaultMessage": "在 {matched} 名符合条件职员中,为 {granted} 名授予访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsExistingClause": { + "defaultMessage": "{existing} 人已拥有访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.countsBlockedClause": { + "defaultMessage": "{blocked} 人被单独屏蔽" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.noMatches": { + "defaultMessage": "此规则目前未匹配到任何符合条件的职员。" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.openToEveryone": { + "defaultMessage": "市场目前对所有人开放;此规则仅在你重新限制访问后才会生效。" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.previewFailure": { + "defaultMessage": "无法预览此规则。" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerNew": { + "defaultMessage": "新" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerExisting": { + "defaultMessage": "已拥有访问权限" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.markerBlocked": { + "defaultMessage": "已屏蔽" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.managesCourses": { + "defaultMessage": "管理 {count, plural, one {# 门课程} other {# 门课程}}" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colName": { + "defaultMessage": "姓名" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colEligibleVia": { + "defaultMessage": "资格来源" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.colStatus": { + "defaultMessage": "状态" + }, + "system.admin.admin.MarketplaceAllowlistRuleForm.searchPlaceholder": { + "defaultMessage": "按姓名或邮箱搜索" + }, + "system.admin.admin.MarketplaceAllowlistTable.colType": { + "defaultMessage": "类型" + }, + "system.admin.admin.MarketplaceAllowlistTable.colTarget": { + "defaultMessage": "授权对象" + }, + "system.admin.admin.MarketplaceAllowlistTable.colActions": { + "defaultMessage": "操作" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeUser": { + "defaultMessage": "用户" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeInstance": { + "defaultMessage": "实例" + }, + "system.admin.admin.MarketplaceAllowlistTable.typeEmailDomain": { + "defaultMessage": "邮箱域名" + }, + "system.admin.admin.MarketplaceAllowlistTable.deleteConfirm": { + "defaultMessage": "要移除此市场访问规则吗?" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyTitle": { + "defaultMessage": "尚无访问规则" + }, + "system.admin.admin.MarketplaceAllowlistTable.emptyHint": { + "defaultMessage": "除系统管理员外,市场对所有人保持隐藏。添加规则以授予访问权限。" + }, + "system.admin.admin.MarketplaceAllowlistTable.zeroMatchWarning": { + "defaultMessage": "目前没有符合条件的职员匹配此规则,因此不会授予任何人访问权限。" + }, "system.admin.admin.UsersButton.deleteTooltip": { "defaultMessage": "删除用户" }, diff --git a/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb index 4ef36f310c..72fca6680b 100644 --- a/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb +++ b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb @@ -169,7 +169,7 @@ def preview(params) post :preview, format: :json, params: { allowlist_rule: params } end - it 'counts eligible staff a domain rule would match, and how many are new' do + it 'counts eligible users a domain rule would match, and how many are new' do newcomer = create(:user, email: 'newcomer@preview.test') create(:course_manager, course: create(:course), user: newcomer) existing = create(:user, email: 'existing@preview.test') @@ -278,7 +278,7 @@ def preview(params) expect(response).to have_http_status(:bad_request) # Attribute name omitted: StubbedI18nBackend returns the raw key for # `activerecord.attributes.*`, so full_messages can never render "Email domain" here. - expect(response.parsed_body['errors']).to include('already has a rule.') + expect(response.parsed_body['errors']).to include('already has the same rule.') end it 'denies a non-administrator' do diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb index c2f9ab6fcf..912b4deaf6 100644 --- a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -182,7 +182,7 @@ rule_type: :user, user: user) expect(duplicate).not_to be_valid - expect(duplicate.errors[:user_id]).to include('already has a rule.') + expect(duplicate.errors[:user_id]).to include('already has the same rule.') end it 'allows a user rule for a different user' do @@ -199,7 +199,7 @@ rule_type: :instance, instance: other_instance) expect(duplicate).not_to be_valid - expect(duplicate.errors[:instance_id]).to include('already has a rule.') + expect(duplicate.errors[:instance_id]).to include('already has the same rule.') end it 'rejects a second email-domain rule for the same domain' do @@ -209,7 +209,7 @@ rule_type: :email_domain, email_domain: 'dupes.test') expect(duplicate).not_to be_valid - expect(duplicate.errors[:email_domain]).to include('already has a rule.') + expect(duplicate.errors[:email_domain]).to include('already has the same rule.') end it 'treats a differently-cased domain as the same rule' do @@ -219,7 +219,7 @@ rule_type: :email_domain, email_domain: ' DUPES.TEST ') expect(duplicate).not_to be_valid - expect(duplicate.errors[:email_domain]).to include('already has a rule.') + expect(duplicate.errors[:email_domain]).to include('already has the same rule.') end it 'normalizes the stored domain to stripped lowercase' do @@ -239,7 +239,7 @@ # A user rule whose email resolves to nobody keeps user_id NULL, and Rails checks uniqueness # as `user_id IS NULL` — which matches every instance and email-domain rule unless the check - # is scoped to rule_type. Unscoped, the admin gets a bogus "already has a rule." stacked on + # is scoped to rule_type. Unscoped, the admin gets a bogus "already has the same rule." stacked on # top of the real reason. (Verified by mutation: dropping `scope: :rule_type` fails this.) it 'does not report a duplicate for an unresolvable email when other rule types exist' do create(:course_assessment_marketplace_allowlist_rule, From 19c9327cf22ec418d71e5f53bf1d8e40ffb31418 Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:09:44 +0800 Subject: [PATCH 14/30] feat(marketplace): access audit list and per-user block controls Add the access audit section to the allow-list page: an audit list of everyone with effective access (filterable by status and granting rule), block/unblock controls per user, and the rule-match counts that flag allow-list rules which currently grant access to nobody. --- client/app/api/system/Admin.ts | 35 +- .../components/MarketplaceAccessFilter.tsx | 154 +++ .../components/MarketplaceAccessSection.tsx | 642 +++++++++++ .../MarketplaceAccessSection.test.tsx | 997 ++++++++++++++++++ .../admin/pages/MarketplaceAllowlistIndex.tsx | 29 + .../MarketplaceAllowlistIndex.test.tsx | 203 ++++ client/app/types/system/marketplaceAccess.ts | 34 + client/locales/en.json | 120 +++ client/locales/ko.json | 120 +++ client/locales/zh.json | 120 +++ .../marketplace/allowlist_rule_spec.rb | 6 +- 11 files changed, 2458 insertions(+), 2 deletions(-) create mode 100644 client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx create mode 100644 client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx create mode 100644 client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 47eb69b80f..13243af9a8 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -5,7 +5,10 @@ import { } from 'types/course/announcements'; import { CourseListData } from 'types/system/courses'; import { InstanceListData, InstancePermissions } from 'types/system/instances'; -import { AllowlistRulePreviewData } from 'types/system/marketplaceAccess'; +import { + AllowlistRulePreviewData, + MarketplaceAccessData, +} from 'types/system/marketplaceAccess'; import { AllowlistRuleData, AllowlistRuleFormData, @@ -248,4 +251,34 @@ export default class AdminAPI extends BaseSystemAPI { `${AdminAPI.#urlPrefix}/marketplace_allowlist_rules/${id}`, ); } + + /** + * Fetches the marketplace access audit list (everyone with effective access, blocked flagged). + */ + indexMarketplaceAccess(): Promise> { + return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_access`); + } + + /** + * Blocks (disables) a user's marketplace access. Returns the created block's id. + */ + blockMarketplaceUser( + userId: number, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_access_blocks`, + { + user_id: userId, + }, + ); + } + + /** + * Removes a block, re-enabling the user's marketplace access. + */ + unblockMarketplaceUser(blockId: number): Promise { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_access_blocks/${blockId}`, + ); + } } diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx new file mode 100644 index 0000000000..7cef895e33 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { FilterList } from '@mui/icons-material'; +import { + Badge, + Button, + Checkbox, + Divider, + FormControlLabel, + IconButton, + Menu, + Tooltip, + Typography, +} from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +export interface RuleOption { + id: number; + label: string; +} + +interface Props { + showActive: boolean; + showBlocked: boolean; + onToggleActive: () => void; + onToggleBlocked: () => void; + /** Empty when the marketplace is open to everyone — the rule group is then meaningless. */ + ruleOptions: RuleOption[]; + /** + * Ids the admin has UNchecked. Tracking exclusions rather than inclusions means a newly added + * rule is filtered in by default, with no state to resynchronise when `ruleOptions` changes. + */ + uncheckedRuleIds: Set; + onToggleRule: (id: number) => void; + onClear: () => void; +} + +const translations = defineMessages({ + trigger: { + id: 'system.admin.admin.MarketplaceAccessFilter.trigger', + defaultMessage: 'Filter', + }, + status: { + id: 'system.admin.admin.MarketplaceAccessFilter.status', + defaultMessage: 'Status', + }, + active: { + id: 'system.admin.admin.MarketplaceAccessFilter.active', + defaultMessage: 'Active', + }, + blocked: { + id: 'system.admin.admin.MarketplaceAccessFilter.blocked', + defaultMessage: 'Blocked', + }, + allowedByRule: { + id: 'system.admin.admin.MarketplaceAccessFilter.allowedByRule', + defaultMessage: 'Allowed by rule', + }, + clearAll: { + id: 'system.admin.admin.MarketplaceAccessFilter.clearAll', + defaultMessage: 'Clear all', + }, +}); + +const MarketplaceAccessFilter = ({ + showActive, + showBlocked, + onToggleActive, + onToggleBlocked, + ruleOptions, + uncheckedRuleIds, + onToggleRule, + onClear, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [anchor, setAnchor] = useState(null); + + const activeCount = + (showActive ? 0 : 1) + (showBlocked ? 0 : 1) + uncheckedRuleIds.size; + + const label = t(translations.trigger); + + return ( + <> + + + setAnchor(event.currentTarget)} + > + + + + + + setAnchor(null)} + open={Boolean(anchor)} + > +
+ + {t(translations.status)} + + + + } + label={t(translations.active)} + /> + + + } + label={t(translations.blocked)} + /> + + {ruleOptions.length > 0 && ( + <> + + + + {t(translations.allowedByRule)} + + + {ruleOptions.map((option) => ( + onToggleRule(option.id)} + /> + } + label={option.label} + /> + ))} + + )} + + +
+
+ + ); +}; + +export default MarketplaceAccessFilter; diff --git a/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx b/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx new file mode 100644 index 0000000000..662f904d8b --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx @@ -0,0 +1,642 @@ +import { useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button, Chip, Typography } from '@mui/material'; +import { + AllowedByRule, + MarketplaceAccessUser, +} from 'types/system/marketplaceAccess'; +import { AllowlistRuleData } from 'types/system/marketplaceAllowlist'; + +import SystemAPI from 'api/system'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import { DEFAULT_TABLE_ROWS_PER_PAGE } from 'lib/constants/sharedConstants'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import MarketplaceAccessFilter, { RuleOption } from './MarketplaceAccessFilter'; + +/** + * Filter id for the synthetic "System admin" option. Negative so it can never collide with a real + * allow-list rule id, which is what the other options carry. + */ +const SYSTEM_ADMIN_OPTION_ID = -1; + +interface Props { + /** Owned by the page, not this section: the toggle and this list must never disagree. */ + openToEveryone: boolean; + /** Bumped by the page on every rule mutation; a change refetches the list. */ + ruleVersion: number; + /** The page's current scoped rules, used to label the filter's rule checkboxes. */ + rules: AllowlistRuleData[]; + /** + * Published after each fetch: rule id => number of listed users that rule grants access to. The + * rules table above the section consumes it to flag rules that match nobody. A rule granting zero + * people contributes no key, so a zero-match rule is simply absent from the map. + */ + onMatchCounts?: (counts: Map) => void; +} + +const translations = defineMessages({ + heading: { + id: 'system.admin.admin.MarketplaceAccessSection.heading', + defaultMessage: 'People matched by these rules', + }, + summary: { + id: 'system.admin.admin.MarketplaceAccessSection.summary', + defaultMessage: 'Total with access: {count} · {mode}', + }, + summaryWithBlocked: { + id: 'system.admin.admin.MarketplaceAccessSection.summaryWithBlocked', + defaultMessage: + 'Total with access: {count} · Total blocked: {blocked} · {mode}', + }, + filteredCounts: { + id: 'system.admin.admin.MarketplaceAccessSection.filteredCounts', + defaultMessage: 'Filtered: {count} with access · {blocked} blocked', + }, + modeOpen: { + id: 'system.admin.admin.MarketplaceAccessSection.modeOpen', + defaultMessage: 'Open to everyone', + }, + modeScoped: { + id: 'system.admin.admin.MarketplaceAccessSection.modeScoped', + defaultMessage: 'Scoped to the rules above', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.fetchFailure', + defaultMessage: 'Failed to load the marketplace access list.', + }, + colName: { + id: 'system.admin.admin.MarketplaceAccessSection.colName', + defaultMessage: 'Name', + }, + colEmail: { + id: 'system.admin.admin.MarketplaceAccessSection.colEmail', + defaultMessage: 'Email', + }, + colEligibleVia: { + id: 'system.admin.admin.MarketplaceAccessSection.colEligibleVia', + defaultMessage: 'Eligible via', + }, + colAllowedBy: { + id: 'system.admin.admin.MarketplaceAccessSection.colAllowedBy', + defaultMessage: 'Allowed by', + }, + colStatus: { + id: 'system.admin.admin.MarketplaceAccessSection.colStatus', + defaultMessage: 'Status', + }, + colActions: { + id: 'system.admin.admin.MarketplaceAccessSection.colActions', + defaultMessage: 'Actions', + }, + managesCourses: { + id: 'system.admin.admin.MarketplaceAccessSection.managesCourses', + defaultMessage: 'Manages {count, plural, one {# course} other {# courses}}', + }, + instanceInstructor: { + id: 'system.admin.admin.MarketplaceAccessSection.instanceInstructor', + defaultMessage: 'Instance instructor', + }, + instanceAdministrator: { + id: 'system.admin.admin.MarketplaceAccessSection.instanceAdministrator', + defaultMessage: 'Instance administrator', + }, + allowedEveryone: { + id: 'system.admin.admin.MarketplaceAccessSection.allowedEveryone', + defaultMessage: 'Everyone', + }, + allowedNothing: { + id: 'system.admin.admin.MarketplaceAccessSection.allowedNothing', + defaultMessage: 'No matching rule', + }, + systemAdmin: { + id: 'system.admin.admin.MarketplaceAccessSection.systemAdmin', + defaultMessage: 'System admin', + }, + typeUser: { + id: 'system.admin.admin.MarketplaceAccessSection.typeUser', + defaultMessage: 'User', + }, + typeInstance: { + id: 'system.admin.admin.MarketplaceAccessSection.typeInstance', + defaultMessage: 'Instance', + }, + typeEmailDomain: { + id: 'system.admin.admin.MarketplaceAccessSection.typeEmailDomain', + defaultMessage: 'Email domain', + }, + statusActive: { + id: 'system.admin.admin.MarketplaceAccessSection.statusActive', + defaultMessage: 'Active', + }, + statusBlocked: { + id: 'system.admin.admin.MarketplaceAccessSection.statusBlocked', + defaultMessage: 'Blocked', + }, + disable: { + id: 'system.admin.admin.MarketplaceAccessSection.disable', + defaultMessage: 'Block', + }, + reEnable: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnable', + defaultMessage: 'Unblock', + }, + disableSuccess: { + id: 'system.admin.admin.MarketplaceAccessSection.disableSuccess', + defaultMessage: 'Access blocked for this user.', + }, + disableFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.disableFailure', + defaultMessage: 'Failed to block access.', + }, + reEnableSuccess: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnableSuccess', + defaultMessage: 'Access unblocked for this user.', + }, + reEnableFailure: { + id: 'system.admin.admin.MarketplaceAccessSection.reEnableFailure', + defaultMessage: 'Failed to unblock access.', + }, + searchPlaceholder: { + id: 'system.admin.admin.MarketplaceAccessSection.searchPlaceholder', + defaultMessage: 'Search by name or email', + }, + dormantHeading: { + id: 'system.admin.admin.MarketplaceAccessSection.dormantHeading', + defaultMessage: 'Dormant blocks ({count})', + }, + dormantExplanation: { + id: 'system.admin.admin.MarketplaceAccessSection.dormantExplanation', + defaultMessage: + 'These people are blocked but no rule currently grants them access. The block denies ' + + 'nothing today — but it would take effect again if a rule starts matching them, so clear ' + + 'it if it is no longer wanted.', + }, + clearBlock: { + id: 'system.admin.admin.MarketplaceAccessSection.clearBlock', + defaultMessage: 'Clear block', + }, +}); + +const MarketplaceAccessSection = ({ + openToEveryone, + ruleVersion, + rules, + onMatchCounts, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [users, setUsers] = useState([]); + const [showActive, setShowActive] = useState(true); + const [showBlocked, setShowBlocked] = useState(true); + const [uncheckedRuleIds, setUncheckedRuleIds] = useState>( + new Set(), + ); + + useEffect(() => { + let cancelled = false; + setIsRefreshing(true); + + SystemAPI.admin + .indexMarketplaceAccess() + .then((response) => { + if (cancelled) return; + setUsers(response.data.users); + // Publish per-rule grant counts for the rules table above. Built from rows, so a rule that + // grants access to nobody contributes no key at all — its absence is the zero-match signal. + const counts = new Map(); + response.data.users.forEach((user) => { + user.allowedByRules.forEach((rule) => { + counts.set(rule.id, (counts.get(rule.id) ?? 0) + 1); + }); + }); + onMatchCounts?.(counts); + }) + .catch(() => { + if (cancelled) return; + toast.error(t(translations.fetchFailure)); + }) + .finally(() => { + if (cancelled) return; + setIsLoading(false); + setIsRefreshing(false); + }); + + return () => { + cancelled = true; + }; + }, [ruleVersion]); + + const handleDisable = async (user: MarketplaceAccessUser): Promise => { + try { + const response = await SystemAPI.admin.blockMarketplaceUser(user.id); + setUsers((current) => + current.map((u) => + u.id === user.id + ? { ...u, blocked: true, blockId: response.data.id } + : u, + ), + ); + toast.success(t(translations.disableSuccess)); + } catch { + toast.error(t(translations.disableFailure)); + } + }; + + /** + * Whether anything currently grants this person access, ignoring any block. Mirrors the server's + * own notion of "allowed": the role, the everyone-mode, or at least one matching rule. Note that + * everyone-mode deliberately sends no per-row rules, so an empty `allowedByRules` is NOT on its + * own a signal that someone has no access. + */ + const isAllowed = (user: MarketplaceAccessUser): boolean => + user.systemAdmin || openToEveryone || user.allowedByRules.length > 0; + + /** Blocked, but nothing would grant them access anyway — the block denies nothing today. */ + const isDormantBlock = (user: MarketplaceAccessUser): boolean => + user.blocked && !isAllowed(user); + + const handleReEnable = async (user: MarketplaceAccessUser): Promise => { + if (user.blockId === null) return; + try { + await SystemAPI.admin.unblockMarketplaceUser(user.blockId); + setUsers((current) => + // Someone listed ONLY because they were blocked has no reason to stay once the block goes — + // patching the row in place would leave them as "Active · No matching rule", counted as + // having access they do not have. Mirrors the server: listed iff allowed OR blocked. + current.flatMap((u) => { + if (u.id !== user.id) return [u]; + return isAllowed(u) ? [{ ...u, blocked: false, blockId: null }] : []; + }), + ); + toast.success(t(translations.reEnableSuccess)); + } catch { + toast.error(t(translations.reEnableFailure)); + } + }; + + const eligibleVia = (user: MarketplaceAccessUser): string => { + // A system admin's eligibility comes from the role, not from courses or instance membership — + // and they are listed even when they have neither, where the other branches say nothing. + if (user.systemAdmin) return t(translations.systemAdmin); + + const parts: string[] = []; + if (user.courseCount > 0) { + parts.push(t(translations.managesCourses, { count: user.courseCount })); + } + if (user.instanceRole === 'instructor') { + parts.push(t(translations.instanceInstructor)); + } + if (user.instanceRole === 'administrator') { + parts.push(t(translations.instanceAdministrator)); + } + return parts.length > 0 ? parts.join('; ') : '—'; + }; + + const typeLabels: Record = { + user: t(translations.typeUser), + instance: t(translations.typeInstance), + email_domain: t(translations.typeEmailDomain), + }; + + const ruleLabel = (rule: AllowedByRule): string => + `${typeLabels[rule.ruleType]} (${rule.labelValue ?? `#${rule.id}`})`; + + // Every reason, not one winner: the admin reads this column to decide which rules are safe to + // delete, and a single reason answers that question wrongly. + const renderAllowedBy = (user: MarketplaceAccessUser): JSX.Element => { + // Ahead of both other branches: the role is why they have access, and it outlives any rule + // change — saying "Everyone" or naming a rule would misattribute it. + if (user.systemAdmin) return {t(translations.systemAdmin)}; + if (openToEveryone) return {t(translations.allowedEveryone)}; + if (user.allowedByRules.length === 0) { + return {t(translations.allowedNothing)}; + } + + return ( +
+ {user.allowedByRules.map((rule) => ( + {ruleLabel(rule)} + ))} +
+ ); + }; + + const ruleOptionLabel = (rule: AllowlistRuleData): string => { + switch (rule.ruleType) { + case 'user': + return `${typeLabels.user} (${rule.userName ?? `#${rule.userId}`})`; + case 'instance': + return `${typeLabels.instance} (${ + rule.instanceName ?? `#${rule.instanceId}` + })`; + default: + return `${typeLabels.email_domain} (${rule.emailDomain ?? ''})`; + } + }; + + // Open to everyone means every row is granted by the mode, not by a rule, so the group is hidden. + // System admin is a reason in its own right, so it gets an option whenever any listed user is + // one — including in everyone-mode, where their access still comes from the role, not the mode. + const ruleOptions: RuleOption[] = [ + ...(users.some((user) => user.systemAdmin) + ? [{ id: SYSTEM_ADMIN_OPTION_ID, label: t(translations.systemAdmin) }] + : []), + ...(openToEveryone + ? [] + : rules.map((rule) => ({ id: rule.id, label: ruleOptionLabel(rule) }))), + ]; + + const toggleRule = (id: number): void => + setUncheckedRuleIds((current) => { + const next = new Set(current); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + + const clearFilters = (): void => { + setShowActive(true); + setShowBlocked(true); + setUncheckedRuleIds(new Set()); + }; + + const matchesFilter = (user: MarketplaceAccessUser): boolean => { + if (user.blocked ? !showBlocked : !showActive) return false; + if (uncheckedRuleIds.size === 0) return true; + + // Being a system admin is a reason alongside the rules, so an admin survives the filter while + // that option stays checked — without this they carry no reasons at all and would vanish the + // moment any rule box is unchecked. + const reasonIds = user.allowedByRules.map((rule) => rule.id); + if (user.systemAdmin) reasonIds.push(SYSTEM_ADMIN_OPTION_ID); + // Everyone-mode grants access outside the rules, so only the admin option can filter there. + if (openToEveryone && !user.systemAdmin) return true; + + return reasonIds.some((id) => !uncheckedRuleIds.has(id)); + }; + + const columns: ColumnTemplate[] = [ + { + of: 'name', + title: t(translations.colName), + searchable: true, + cell: (user) => ( + + {user.name} + + ), + }, + { + of: 'email', + title: t(translations.colEmail), + searchable: true, + cell: (user) => user.email, + }, + { + id: 'eligibleVia', + title: t(translations.colEligibleVia), + cell: (user) => eligibleVia(user), + }, + { + id: 'allowedBy', + title: t(translations.colAllowedBy), + cell: (user) => renderAllowedBy(user), + }, + { + id: 'status', + title: t(translations.colStatus), + // This column's content changes with row STATE (Active↔Blocked), so its intrinsic width + // changes as people are blocked, shifting every column to its left. A width on the cell + // itself does NOT fix that: under table-layout:auto a cell width is only a suggestion, and + // the browser still distributes slack using each column's max-content width. Pinning the + // width on a wrapper INSIDE the cell makes that max-content constant, which is what actually + // holds the layout still. `whitespace-nowrap` keeps an overlong translation overflowing + // visibly rather than wrapping and silently reintroducing the shift. + className: 'whitespace-nowrap', + cell: (user) => ( +
+ +
+ ), + }, + { + id: 'action', + title: t(translations.colActions), + // Same reasoning as `status` above: Block↔Unblock. Sized to `Unblock`, the wider of the + // two, so flipping a row never moves its neighbours. + className: 'whitespace-nowrap', + cell: (user) => + // No action for a system admin: `can :manage, :all` outranks the allow-list, so a block + // would not actually revoke anything — the row would read "Blocked" while they kept full + // access. Better to offer nothing than an action that silently does nothing. + user.systemAdmin ? null : ( +
+ {/* + `min-w-0 px-0` on both: MUI gives a Button horizontal padding and a 64px min-width, so + the label sits inset from the cell edge (misaligned with the `Actions` header) by an + amount that DIFFERS per label — `Block` is narrower than the min-width and gets + centred in the leftover space, `Unblock` is not. Stripping both makes the button hug + its text, so header and both states start at the same x. + */} + {user.blocked ? ( + + ) : ( + + )} +
+ ), + }, + ]; + + // No status or reason columns: every row here is dormant-blocked and allowed by nothing, so + // those cells would repeat the section heading on every line. + const dormantColumns: ColumnTemplate[] = [ + { + of: 'name', + title: t(translations.colName), + cell: (user) => ( + + {user.name} + + ), + }, + { + of: 'email', + title: t(translations.colEmail), + cell: (user) => user.email, + }, + { + id: 'action', + title: t(translations.colActions), + className: 'whitespace-nowrap', + cell: (user) => ( +
+ +
+ ), + }, + ]; + + if (isLoading) return ; + + // Derived from the rows, not the server summary: block/unblock patch rows locally without a + // refetch, so a summary-bound count would drift the moment an admin disables someone. + // Split first: a dormant block is not a person with access, so it is counted out of the headline + // totals and out of the main table, and gets its own section below. + const dormantUsers = users.filter(isDormantBlock); + const accessUsers = users.filter((user) => !isDormantBlock(user)); + const totalWithAccess = accessUsers.filter((user) => !user.blocked).length; + const totalBlocked = accessUsers.filter((user) => user.blocked).length; + const filteredUsers = accessUsers.filter(matchesFilter); + // Only the filter menu is observable here — the search box lives inside Table and narrows the + // rows after this point, so a search alone does not surface the line. + const isFiltered = filteredUsers.length < accessUsers.length; + const mode = openToEveryone + ? t(translations.modeOpen) + : t(translations.modeScoped); + + // Remount the main table when the filter state changes so pagination snaps back to the first + // page: an admin on page 2 who narrows the filter below one page of results would otherwise be + // stranded on an empty page. The shared Table keeps pagination internal with no external setter + // and does not auto-reset the page index on a data change, so a key change is the only in-section + // lever. Keyed on the filter state alone (not the fetched data), so a background refetch does not + // disturb the current page. The dormant table below is unfiltered and needs none of this. + const filterKey = `${showActive}:${showBlocked}:${[...uncheckedRuleIds] + .sort((a, b) => a - b) + .join(',')}`; + + return ( +
+ {t(translations.heading)} + + + {totalBlocked > 0 + ? t(translations.summaryWithBlocked, { + count: totalWithAccess, + blocked: totalBlocked, + mode, + }) + : t(translations.summary, { count: totalWithAccess, mode })} + + + {/* + Only while the filter is narrowing: unfiltered, this line would repeat the totals verbatim. + The totals above stay put as the audit anchor — this answers the narrower question the + filter poses ("of the people this rule lets in, how many are blocked?"), which nothing else + on the page reports. + */} + {isFiltered && ( + + {t(translations.filteredCounts, { + count: filteredUsers.filter((user) => !user.blocked).length, + blocked: filteredUsers.filter((user) => user.blocked).length, + })} + + )} + +
+
user.id.toString()} + pagination={{ + initialPageSize: 20, + rowsPerPage: [10, 20, 50, DEFAULT_TABLE_ROWS_PER_PAGE], + showAllRows: true, + }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (user, filterValue?: string): boolean => { + if (!filterValue) return true; + const query = filterValue.toLowerCase().trim(); + return ( + user.name.toLowerCase().includes(query) || + user.email.toLowerCase().includes(query) + ); + }, + }, + }} + toolbar={{ + show: true, + buttons: [ + setShowActive((on) => !on)} + onToggleBlocked={(): void => setShowBlocked((on) => !on)} + onToggleRule={toggleRule} + ruleOptions={ruleOptions} + showActive={showActive} + showBlocked={showBlocked} + uncheckedRuleIds={uncheckedRuleIds} + />, + ], + }} + /> + + + {dormantUsers.length > 0 && ( +
+ + {t(translations.dormantHeading, { count: dormantUsers.length })} + + + + {t(translations.dormantExplanation)} + + +
+
user.id.toString()} + pagination={{ + initialPageSize: 10, + rowsPerPage: [10, 20, 50, DEFAULT_TABLE_ROWS_PER_PAGE], + }} + /> + + + )} + + ); +}; + +export default MarketplaceAccessSection; diff --git a/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx new file mode 100644 index 0000000000..99e07a0187 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx @@ -0,0 +1,997 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { act, fireEvent, render, waitFor, within } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +import SystemAPI from 'api/system'; + +import MarketplaceAccessSection from '../MarketplaceAccessSection'; + +const mock = createMockAdapter(SystemAPI.admin.client); +beforeEach(() => mock.reset()); + +const ACCESS_URL = '/admin/marketplace_access'; +const BLOCKS_URL = '/admin/marketplace_access_blocks'; +const NUS_LABEL = 'nus.edu.sg'; +const DORMANT_DAN = 'Dormant Dan'; +const ROOT_ADMIN = 'Root Admin'; +const EMAIL_NUS_LABEL = 'Email domain (nus.edu.sg)'; +const SYSTEM_ADMIN = 'System admin'; +const FETCH_FAILURE = 'Failed to load the marketplace access list.'; + +const activeUser = { + id: 1, + name: 'Jane Tan', + email: 'jane@nus.edu.sg', + courseCount: 3, + instanceRole: null, + allowedByRules: [ + { id: 10, ruleType: 'email_domain' as const, labelValue: NUS_LABEL }, + ], + systemAdmin: false, + blocked: false, + blockId: null, +}; + +/** Blocked AND still allowed by a rule — a LIVE block, so they belong in the main table. */ +const blockedUser = { + id: 2, + name: 'Kumar Raj', + email: 'kumar@sch.edu.sg', + courseCount: 0, + instanceRole: 'instructor' as const, + allowedByRules: [ + { id: 10, ruleType: 'email_domain' as const, labelValue: NUS_LABEL }, + ], + systemAdmin: false, + blocked: true, + blockId: 55, +}; + +/** + * Blocked with nothing granting them access — their rule was deleted while the block stood. The + * block denies nothing today, so this one belongs in the dormant section, not the main table. + */ +const dormantUser = { + id: 4, + name: DORMANT_DAN, + email: 'dan@sch.edu.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [], + systemAdmin: false, + blocked: true, + blockId: 77, +}; + +const adminUser = { + id: 3, + name: ROOT_ADMIN, + email: 'root@coursemology.org', + courseCount: 0, + instanceRole: null, + allowedByRules: [], + systemAdmin: true, + blocked: false, + blockId: null, +}; + +const DOMAIN_RULE = { + id: 10, + ruleType: 'email_domain' as const, + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: NUS_LABEL, +}; + +const USER_RULE = { + id: 11, + ruleType: 'user' as const, + userId: 1, + userName: 'Jane Tan', + userEmail: 'jane@nus.edu.sg', + instanceId: null, + instanceName: null, + emailDomain: null, +}; + +const openFilter = async (page: ReturnType): Promise => { + fireEvent.click(page.getByRole('button', { name: 'Filter' })); + await page.findByRole('menu'); +}; + +const closeFilter = async (page: ReturnType): Promise => { + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(page.queryByRole('menu')).not.toBeInTheDocument()); +}; + +/** Open the filter, toggle one checkbox by its accessible name, then close it. */ +const toggleFilter = async ( + page: ReturnType, + checkboxName: string, +): Promise => { + await openFilter(page); + fireEvent.click(page.getByRole('checkbox', { name: checkboxName })); + await closeFilter(page); +}; + +const renderSection = (props?: { + openToEveryone?: boolean; + ruleVersion?: number; + rules?: (typeof DOMAIN_RULE | typeof USER_RULE)[]; +}): ReturnType => + render( + , + ); + +const accessGetCount = (): number => + mock.history.get.filter((request) => request.url === ACCESS_URL).length; + +it('renders the access list with annotations and a summary', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); + expect(page.getByText('Manages 3 courses')).toBeVisible(); + expect(page.getByText('Instance instructor')).toBeVisible(); + // Both fixtures are allowed by the same rule, so the label appears once per row. + expect(page.getAllByText(EMAIL_NUS_LABEL)).toHaveLength(2); + expect(page.getByText('Active')).toBeVisible(); + expect(page.getByText('Blocked')).toBeVisible(); +}); + +it('names the blocked total in the subtitle when anyone is blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect( + await page.findByText( + 'Total with access: 1 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); +}); + +it('omits the blocked segment when nobody is blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + expect( + await page.findByText('Total with access: 1 · Scoped to the rules above'), + ).toBeVisible(); +}); + +it('reads the mode from props rather than the fetched summary', async () => { + // The parent owns the toggle, so a stale server summary must not win. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ openToEveryone: true }); + + expect( + await page.findByText('Total with access: 1 · Open to everyone'), + ).toBeVisible(); +}); + +it('shows Everyone as the reason when the marketplace is open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + + expect(await page.findByText('Everyone')).toBeVisible(); + expect(page.queryByText(EMAIL_NUS_LABEL)).not.toBeInTheDocument(); +}); + +it('lists every rule that grants a user access', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...activeUser, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: NUS_LABEL }, + { id: 11, ruleType: 'user', labelValue: 'Jane Tan' }, + ], + }, + ], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText(EMAIL_NUS_LABEL)).toBeVisible(); + expect(page.getByText('User (Jane Tan)')).toBeVisible(); +}); + +it('moves a block with no matching rule into the dormant list', async () => { + // Their rule was deleted while the block stood. The block denies nothing today, so they are not + // "people with access" — but it must stay visible and clearable, because re-adding a matching + // rule would silently leave them blocked. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, dormantUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + expect(page.getByText('Dormant blocks (1)')).toBeVisible(); + expect(page.getByText(DORMANT_DAN)).toBeVisible(); + // Counted out of the headline totals, which describe people with access. + expect( + page.getByText('Total with access: 1 · Scoped to the rules above'), + ).toBeVisible(); +}); + +it('keeps a block that a rule still backs in the main table', async () => { + // This block IS denying access right now, so it belongs with the people it applies to. + mock.onGet(ACCESS_URL).reply(200, { + users: [blockedUser], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Kumar Raj'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); + expect( + page.getByText( + 'Total with access: 0 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); +}); + +it('shows no dormant section when there are no dormant blocks', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('treats a block as dormant only outside everyone-mode', async () => { + // Everyone-mode grants access outside the rules, so an empty allowedByRules is not "no access" — + // the block is live and the row stays in the main table. + mock.onGet(ACCESS_URL).reply(200, { + users: [dormantUser], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + await page.findByText(DORMANT_DAN); + + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('clears a dormant block and drops the row', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, dormantUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + mock.onDelete(`${BLOCKS_URL}/77`).reply(200); + + const page = renderSection(); + await page.findByText(DORMANT_DAN); + + fireEvent.click(page.getByRole('button', { name: 'Clear block' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${BLOCKS_URL}/77`); + + // Nothing grants them access, so clearing the block removes their last reason to be listed. + await waitFor(() => + expect(page.queryByText(DORMANT_DAN)).not.toBeInTheDocument(), + ); + expect(page.queryByText(/^Dormant blocks/)).not.toBeInTheDocument(); +}); + +it('pins the width of the two state-driven columns', async () => { + // Status and Actions are the only columns whose content changes with row STATE + // (Active↔Blocked, Block↔Unblock), so under table-layout:auto they resize as people are + // blocked and shift every column to their left. The width must sit on a wrapper INSIDE the cell, + // not on the cell: a table cell's width is only a suggestion under auto layout, so a cell-level + // class leaves the shift in place. jsdom does no layout, so this asserts the wrapper exists and + // is pinned in BOTH states; the visual claim is covered by manual verification. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + // Both status states, so a width applied to only one branch of the ternary would fail. + expect(page.getByText('Blocked').closest('div.w-28')).toBeInTheDocument(); + expect(page.getByText('Active').closest('div.w-28')).toBeInTheDocument(); + + // Both action states, for the same reason. + const reEnable = page.getByRole('button', { name: 'Unblock' }); + const disable = page.getByRole('button', { name: 'Block' }); + expect(reEnable.closest('div.w-24')).toBeInTheDocument(); + expect(disable.closest('div.w-24')).toBeInTheDocument(); + + // MUI's button padding and 64px min-width inset each label from the cell edge by a per-label + // amount, so the two states and the column header start at different x without these. + expect(reEnable).toHaveClass('min-w-0', 'px-0'); + expect(disable).toHaveClass('min-w-0', 'px-0'); +}); + +it('labels a system admin in both reason columns', async () => { + // The admin manages nothing and matches no rule, so without the systemAdmin branch these cells + // would read '—' and 'No matching rule' for someone who in fact bypasses every gate. + mock.onGet(ACCESS_URL).reply(200, { + users: [adminUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText(ROOT_ADMIN); + + expect(page.getAllByText(SYSTEM_ADMIN)).toHaveLength(2); + expect(page.queryByText('No matching rule')).not.toBeInTheDocument(); +}); + +it('labels a system admin as such even when open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [adminUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true }); + await page.findByText(ROOT_ADMIN); + + expect(page.getAllByText(SYSTEM_ADMIN)).toHaveLength(2); + expect(page.queryByText('Everyone')).not.toBeInTheDocument(); +}); + +it('reports filtered counts only while the filter narrows the set', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + // Unfiltered: the line would only repeat the totals, so it is absent. + expect(page.queryByText(/^Filtered:/)).not.toBeInTheDocument(); + + await toggleFilter(page, 'Active'); + + expect( + await page.findByText('Filtered: 0 with access · 1 blocked'), + ).toBeVisible(); + // The totals stay put as the audit anchor rather than being rewritten by the filter. + expect( + page.getByText( + 'Total with access: 1 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); + + await toggleFilter(page, 'Active'); + + await waitFor(() => + expect(page.queryByText(/^Filtered:/)).not.toBeInTheDocument(), + ); +}); + +it('offers no disable action for a system admin', async () => { + // Blocking an admin cannot revoke anything (`can :manage, :all` outranks the allow-list), so the + // action would be a lie — the row would say "Blocked" while they kept full access. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText(ROOT_ADMIN); + + // Exactly one Block button, and it belongs to the non-admin. + expect(page.getAllByRole('button', { name: 'Block' })).toHaveLength(1); + expect( + page.queryByRole('button', { name: 'Unblock' }), + ).not.toBeInTheDocument(); +}); + +it('filters system admins in and out via their own option', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText(ROOT_ADMIN); + + await toggleFilter(page, SYSTEM_ADMIN); + await waitFor(() => + expect(page.queryByText(ROOT_ADMIN)).not.toBeInTheDocument(), + ); + expect(page.getByText('Jane Tan')).toBeVisible(); + + await toggleFilter(page, SYSTEM_ADMIN); + await waitFor(() => expect(page.getByText(ROOT_ADMIN)).toBeVisible()); +}); + +it('keeps a system admin listed when a rule box is unchecked', async () => { + // An admin carries no rules, so treating rules as the only reasons would drop them from the + // table the moment any rule filter is touched. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, adminUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText(ROOT_ADMIN); + + await toggleFilter(page, EMAIL_NUS_LABEL); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText(ROOT_ADMIN)).toBeVisible(); +}); + +it('offers no system-admin option when nobody listed is one', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + await openFilter(page); + + expect( + page.queryByRole('checkbox', { name: SYSTEM_ADMIN }), + ).not.toBeInTheDocument(); +}); + +it('links each name to that user', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + + const link = await page.findByRole('link', { name: 'Jane Tan' }); + expect(link).toHaveAttribute('href', '/users/1'); +}); + +it('refetches the list when the rule version changes', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + expect(accessGetCount()).toBe(1); + + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => expect(accessGetCount()).toBe(2)); + expect(await page.findByText('Kumar Raj')).toBeVisible(); +}); + +it('does not refetch when unrelated props change', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + expect(accessGetCount()).toBe(1); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => + expect( + page.getByText('Total with access: 1 · Open to everyone'), + ).toBeVisible(), + ); + expect(accessGetCount()).toBe(1); +}); + +it('toasts when the fetch fails', async () => { + mock.onGet(ACCESS_URL).reply(500); + + const page = renderSection(); + + expect(await page.findByText(FETCH_FAILURE)).toBeVisible(); +}); + +it('does not toast a failure from a fetch superseded by a rule-version change', async () => { + let failFirstFetch: (reason?: unknown) => void = () => {}; + mock + .onGet(ACCESS_URL) + .replyOnce( + () => + new Promise((_, reject) => { + failFirstFetch = reject; + }), + ) + .onGet(ACCESS_URL) + .reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection(); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + await page.findByText('Jane Tan'); + + await act(async () => { + failFirstFetch(new Error('superseded')); + }); + + expect(page.queryByText(FETCH_FAILURE)).not.toBeInTheDocument(); +}); + +it('disables an active user and flips the row to Blocked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + mock.onPost(BLOCKS_URL).reply(200, { id: 77, userId: 1 }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + fireEvent.click(page.getByRole('button', { name: 'Block' })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + expect(JSON.parse(mock.history.post[0].data)).toEqual({ user_id: 1 }); + + expect(await page.findByRole('button', { name: 'Unblock' })).toBeVisible(); + expect(page.getByText('Blocked')).toBeVisible(); +}); + +it('updates the subtitle counts after a local disable, without refetching', async () => { + // Block/unblock patch rows in place, so counts must come from the rows, not the server summary. + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + mock.onPost(BLOCKS_URL).reply(200, { id: 77, userId: 1 }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + fireEvent.click(page.getByRole('button', { name: 'Block' })); + + expect( + await page.findByText( + 'Total with access: 0 · Total blocked: 1 · Scoped to the rules above', + ), + ).toBeVisible(); + expect(accessGetCount()).toBe(1); +}); + +it('re-enables a blocked user and flips the row to Active', async () => { + // A rule still allows them, so unblocking leaves them listed — the row flips rather than going. + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...blockedUser, + allowedByRules: [ + { + id: 10, + ruleType: 'email_domain' as const, + labelValue: NUS_LABEL, + }, + ], + }, + ], + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: false }, + }); + mock.onDelete(`${BLOCKS_URL}/55`).reply(200); + + const page = renderSection(); + await page.findByText('Kumar Raj'); + + fireEvent.click(page.getByRole('button', { name: 'Unblock' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${BLOCKS_URL}/55`); + + expect(await page.findByRole('button', { name: 'Block' })).toBeVisible(); + expect(page.getByText('Active')).toBeVisible(); +}); + +it('keeps an unblocked user listed in everyone-mode, where rules are empty by design', async () => { + // Everyone-mode grants access outside the rules, so an empty allowedByRules is NOT a signal that + // they have no access — dropping on empty alone would wrongly remove them here. + mock.onGet(ACCESS_URL).reply(200, { + users: [dormantUser], // no rules at all, so only the everyone-mode branch can keep them + summary: { totalWithAccess: 0, totalBlocked: 1, openToEveryone: true }, + }); + mock.onDelete(`${BLOCKS_URL}/77`).reply(200); + + const page = renderSection({ openToEveryone: true }); + await page.findByText(DORMANT_DAN); + + fireEvent.click(page.getByRole('button', { name: 'Unblock' })); + + expect(await page.findByRole('button', { name: 'Block' })).toBeVisible(); + expect(page.getByText(DORMANT_DAN)).toBeVisible(); +}); + +it('searches by name and email', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await userEvent.type( + page.getByPlaceholderText('Search by name or email'), + 'kumar@', + ); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it('shows both active and blocked users by default', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it('shows only blocked users when Active is unchecked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it('filters to the users a specific rule grants access to', async () => { + const otherUser = { + ...activeUser, + id: 3, + name: 'Wei Ling', + email: 'wei@moe.gov.sg', + allowedByRules: [ + { id: 11, ruleType: 'user' as const, labelValue: 'Wei Ling' }, + ], + }; + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, otherUser], + summary: { totalWithAccess: 2, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ rules: [DOMAIN_RULE, USER_RULE] }); + await page.findByText('Jane Tan'); + + // Uncheck the user rule; only the domain-granted user should remain. + await toggleFilter(page, 'User (Jane Tan)'); + + await waitFor(() => + expect(page.queryByText('Wei Ling')).not.toBeInTheDocument(), + ); + // Assert on the email, not the name: 'Jane Tan' is also the user rule's checkbox label, so a + // name query would match two elements whenever the filter menu is open. + expect(page.getByText('jane@nus.edu.sg')).toBeVisible(); +}); + +it('hides the rule group when the marketplace is open to everyone', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: true }, + }); + + const page = renderSection({ openToEveryone: true, rules: [DOMAIN_RULE] }); + await page.findByText('Jane Tan'); + await openFilter(page); + + // Scoped to the menu: the table also has a Status column header. + expect(within(page.getByRole('menu')).getByText('Status')).toBeVisible(); + expect(page.queryByText('Allowed by rule')).not.toBeInTheDocument(); + expect( + page.queryByRole('checkbox', { name: EMAIL_NUS_LABEL }), + ).not.toBeInTheDocument(); +}); + +it('badges the filter button while any box is unchecked', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + await openFilter(page); + + fireEvent.click(page.getByRole('checkbox', { name: 'Active' })); + + // Scope to the badge: a bare '1' would also match pagination and count text. + expect( + await page.findByText('1', { selector: '.MuiBadge-badge' }), + ).toBeVisible(); +}); + +it('composes the filter with the search field', async () => { + const otherBlocked = { + ...blockedUser, + id: 4, + name: 'Siti Nur', + email: 'siti@sch.edu.sg', + blockId: 56, + }; + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser, otherBlocked], + summary: { totalWithAccess: 1, totalBlocked: 2, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + + await userEvent.type( + page.getByPlaceholderText('Search by name or email'), + 'siti', + ); + + await waitFor(() => + expect(page.queryByText('Kumar Raj')).not.toBeInTheDocument(), + ); + expect(page.getByText('Siti Nur')).toBeVisible(); +}); + +it('restores everything when the filter is cleared', async () => { + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = renderSection(); + await page.findByText('Jane Tan'); + + await toggleFilter(page, 'Active'); + await waitFor(() => + expect(page.queryByText('Jane Tan')).not.toBeInTheDocument(), + ); + + await openFilter(page); + fireEvent.click(page.getByRole('button', { name: 'Clear all' })); + await closeFilter(page); + + expect(await page.findByText('Jane Tan')).toBeVisible(); + expect(page.getByText('Kumar Raj')).toBeVisible(); +}); + +it("publishes each rule's grant count after the access list loads", async () => { + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [ + { + ...activeUser, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: NUS_LABEL }, + { id: 11, ruleType: 'user', labelValue: 'Jane Tan' }, + ], + }, + blockedUser, // allowedByRules: [rule 10] + ], + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalled()); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + // Rule 10 grants both listed users; rule 11 grants only the first. + expect(counts.get(10)).toBe(2); + expect(counts.get(11)).toBe(1); +}); + +it('omits a rule that grants access to nobody from the published counts', async () => { + // A zero-match rule contributes no key — its absence is what the rules table reads as "nobody". + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], // allowedByRules: [rule 10] only + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalled()); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + expect(counts.has(11)).toBe(false); + expect(counts.get(10)).toBe(1); +}); + +it('republishes counts when the rule version changes', async () => { + const onMatchCounts = jest.fn(); + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser], // rule 10 grants 1 + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render( + , + ); + await page.findByText('Jane Tan'); + await waitFor(() => expect(onMatchCounts).toHaveBeenCalledTimes(1)); + + mock.onGet(ACCESS_URL).reply(200, { + users: [activeUser, blockedUser], // rule 10 now grants 2 + summary: { totalWithAccess: 1, totalBlocked: 1, openToEveryone: false }, + }); + + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + await waitFor(() => expect(onMatchCounts).toHaveBeenCalledTimes(2)); + const counts: Map = + onMatchCounts.mock.calls[onMatchCounts.mock.calls.length - 1][0]; + expect(counts.get(10)).toBe(2); +}); + +it('returns to the first page when the filter narrows the result set', async () => { + // 21 people are granted by the domain rule and 4 by the user rule, so the list spans two pages at + // the default page size of 20. An admin on page 2 who filters out the domain rule drops to a + // single page — the table must snap back to page 1 rather than strand them on an empty page 2. + const domainUsers = Array.from({ length: 21 }, (_, i) => ({ + id: i + 1, + name: `Domain User ${i + 1}`, + email: `domain${i + 1}@nus.edu.sg`, + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 10, ruleType: 'email_domain', labelValue: NUS_LABEL }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + })); + const userRuleUsers = Array.from({ length: 4 }, (_, i) => ({ + id: 100 + i, + name: `Rule User ${i + 1}`, + email: `rule${i + 1}@moe.gov.sg`, + courseCount: 1, + instanceRole: null, + allowedByRules: [{ id: 11, ruleType: 'user', labelValue: 'Jane Tan' }], + systemAdmin: false, + blocked: false, + blockId: null, + })); + mock.onGet(ACCESS_URL).reply(200, { + users: [...domainUsers, ...userRuleUsers], + summary: { totalWithAccess: 25, totalBlocked: 0, openToEveryone: false }, + }); + + const page = renderSection({ rules: [DOMAIN_RULE, USER_RULE] }); + await page.findByText('Domain User 1'); + + // Go to page 2 — the four user-rule people live here, past the first 20 domain users. + fireEvent.click(page.getByRole('button', { name: 'Go to next page' })); + await page.findByText('Rule User 1'); + expect(page.queryByText('Domain User 1')).not.toBeInTheDocument(); + + // Filter out the domain rule: only the four user-rule people remain — a single page. + await toggleFilter(page, EMAIL_NUS_LABEL); + + // Snapped back to page 1: the remaining people are visible, not stranded behind an empty page 2. + expect(await page.findByText('Rule User 1')).toBeVisible(); + expect(page.getByText('Rule User 4')).toBeVisible(); +}); diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx index 4f9921a1b0..b26e49ce85 100644 --- a/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceAllowlistIndex.tsx @@ -14,6 +14,7 @@ import LoadingIndicator from 'lib/components/core/LoadingIndicator'; import toast from 'lib/hooks/toast'; import MarketplaceAllowlistRuleForm from '../components/forms/MarketplaceAllowlistRuleForm'; +import MarketplaceAccessSection from '../components/MarketplaceAccessSection'; import MarketplaceAllowlistModeBanner from '../components/MarketplaceAllowlistModeBanner'; import MarketplaceAllowlistTable from '../components/tables/MarketplaceAllowlistTable'; @@ -72,6 +73,14 @@ const MarketplaceAllowlistIndex: FC = ({ intl }) => { const [isFormOpen, setIsFormOpen] = useState(false); const [rules, setRules] = useState([]); const [everyoneRuleId, setEveryoneRuleId] = useState(null); + // Bumped on every rule mutation. Adding a domain rule changes who is in the access list in ways + // the client cannot compute locally, so the list must refetch rather than patch itself. + const [ruleVersion, setRuleVersion] = useState(0); + // Published by the access section after each fetch; passed to the rules table so it can flag rules + // that grant access to nobody. Null until the first fetch resolves (unknown ≠ zero). + const [matchCounts, setMatchCounts] = useState | null>( + null, + ); useEffect(() => { SystemAPI.admin @@ -85,12 +94,21 @@ const MarketplaceAllowlistIndex: FC = ({ intl }) => { }, []); const openToEveryone = everyoneRuleId !== null; + const invalidateAccessList = (): void => { + // Blank the counts until the refetch this triggers resolves: they are derived from the access + // list, so between a mutation and the fresh fetch they are stale. After a Restrict, the + // everyone-mode counts are an empty map that would mark every scoped rule as matching nobody. + // Null means "unknown, don't warn", same as before the first load. + setMatchCounts(null); + setRuleVersion((version) => version + 1); + }; const handleCreate = async (data: AllowlistRuleFormData): Promise => { try { const response = await SystemAPI.admin.createMarketplaceAllowlistRule(data); setRules((current) => [...current, response.data]); + invalidateAccessList(); toast.success(intl.formatMessage(translations.createSuccess)); setIsFormOpen(false); } catch (error) { @@ -106,6 +124,7 @@ const MarketplaceAllowlistIndex: FC = ({ intl }) => { try { await SystemAPI.admin.deleteMarketplaceAllowlistRule(id); setRules((current) => current.filter((rule) => rule.id !== id)); + invalidateAccessList(); toast.success(intl.formatMessage(translations.deleteSuccess)); } catch { toast.error(intl.formatMessage(translations.deleteFailure)); @@ -116,6 +135,7 @@ const MarketplaceAllowlistIndex: FC = ({ intl }) => { try { const response = await SystemAPI.admin.openMarketplaceToEveryone(); setEveryoneRuleId(response.data.id); + invalidateAccessList(); toast.success(intl.formatMessage(translations.openSuccess)); } catch { toast.error(intl.formatMessage(translations.openFailure)); @@ -127,6 +147,7 @@ const MarketplaceAllowlistIndex: FC = ({ intl }) => { try { await SystemAPI.admin.deleteMarketplaceAllowlistRule(everyoneRuleId); setEveryoneRuleId(null); + invalidateAccessList(); toast.success(intl.formatMessage(translations.restrictSuccess)); } catch { toast.error(intl.formatMessage(translations.restrictFailure)); @@ -158,6 +179,7 @@ const MarketplaceAllowlistIndex: FC = ({ intl }) => { } disabled={openToEveryone} + matchCounts={openToEveryone ? null : matchCounts} onDelete={handleDelete} rules={rules} /> @@ -167,6 +189,13 @@ const MarketplaceAllowlistIndex: FC = ({ intl }) => { onSubmit={handleCreate} open={isFormOpen} /> + + ); }; diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx index e5d57e0e69..e0af0e7b98 100644 --- a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx @@ -9,6 +9,10 @@ import MarketplaceAllowlistIndex from '../MarketplaceAllowlistIndex'; const mock = createMockAdapter(SystemAPI.admin.client); beforeEach(() => { mock.reset(); + mock.onGet('/admin/marketplace_access').reply(200, { + users: [], + summary: { totalWithAccess: 0, openToEveryone: false }, + }); }); const INDEX_URL = '/admin/marketplace_allowlist_rules'; @@ -17,6 +21,8 @@ const NUS_DOMAIN = 'nus.edu.sg'; const EMAIL_DOMAIN_SUBTITLE = 'Email domain (e.g. schools.gov.sg)'; const OPEN_TO_EVERYONE = 'Open to everyone'; const ADD_ACCESS_RULE = 'Add access rule'; +const ZERO_MATCH_WARNING = + 'No eligible users currently match this rule, so it grants access to nobody.'; const allowlistGetCount = (): number => mock.history.get.filter((request) => request.url === INDEX_URL).length; const RULES = [ @@ -48,6 +54,10 @@ const ONE_MATCH_PREVIEW = { }, ], }; +const accessGetCount = (): number => + mock.history.get.filter( + (request) => request.url === '/admin/marketplace_access', + ).length; // Step 2's preview is a POST too, so `mock.history.post[0]` is the preview, not the create. const createPosts = (): typeof mock.history.post => mock.history.post.filter((request) => request.url === INDEX_URL); @@ -410,6 +420,81 @@ it('keeps the Open to everyone toggle label on a single line', async () => { expect(label).toHaveClass('whitespace-nowrap'); }); +it('refreshes the access list after a rule is added', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: [] }); + mock.onPost(PREVIEW_URL).reply(200, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + mock.onPost(INDEX_URL).reply(200, { + id: 2, + ruleType: 'email_domain', + userId: null, + userName: null, + userEmail: null, + instanceId: null, + instanceName: null, + emailDomain: NUS_DOMAIN, + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(allowlistGetCount()).toBe(1)); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByText('Add access rule')); + await userEvent.type(page.getByLabelText(EMAIL_DOMAIN_SUBTITLE), NUS_DOMAIN); + fireEvent.click(page.getByRole('button', { name: 'Next' })); + await confirmAdd(page); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after a rule is deleted', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByTestId('DeleteIconButton')); + fireEvent.click(page.getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after the marketplace is opened to everyone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: null }); + mock.onPost(INDEX_URL).reply(200, { id: 99 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click( + within(dialog).getByRole('button', { name: OPEN_TO_EVERYONE }), + ); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + +it('refreshes the access list after the marketplace is restricted again', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + await waitFor(() => expect(accessGetCount()).toBe(2)); +}); + it('surfaces the server message when a rule is rejected as a duplicate', async () => { mock.onGet(INDEX_URL).reply(200, { rules: [] }); mock.onPost(PREVIEW_URL).reply(200, ONE_MATCH_PREVIEW); @@ -430,3 +515,121 @@ it('surfaces the server message when a rule is rejected as a duplicate', async ( await page.findByText('Email domain already has the same rule.'), ).toBeVisible(); }); + +it('flags a rule that the loaded access list grants to nobody', async () => { + // beforeEach returns no access-list users, so the single email-domain rule matches nobody. + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(ZERO_MATCH_WARNING)).toBeInTheDocument(); +}); + +it('does not flag a rule that the access list grants to someone', async () => { + mock.onGet(INDEX_URL).reply(200, { rules: RULES }); + mock.onGet('/admin/marketplace_access').reply(200, { + users: [ + { + id: 1, + name: 'Jane Tan', + email: 'jane@schools.gov.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 1, ruleType: 'email_domain', labelValue: EMAIL_DOMAIN }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + }, + ], + summary: { totalWithAccess: 1, totalBlocked: 0, openToEveryone: false }, + }); + + const page = render(, { at: [INDEX_URL] }); + // Wait for the access list to render (counts are published only after it resolves). + await page.findByText('jane@schools.gov.sg'); + + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('suppresses zero-match warnings while the marketplace is open to everyone', async () => { + // Everyone-mode empties scoped_rules, so every rule would report zero — but the mode banner + // already says the rules are moot, so the page passes null and shows no icons at all. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(page.getByText(EMAIL_DOMAIN)).toBeVisible()); + await waitFor(() => expect(accessGetCount()).toBe(1)); + + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); + +it('does not flash zero-match warnings while a refetch after restrict is in flight', async () => { + // Restrict flips openToEveryone off and triggers a refetch. Until it resolves, the previously + // published counts are stale: everyone-mode publishes an empty map, which would mark every scoped + // rule as matching nobody. invalidateAccessList must blank matchCounts to null so no false warning + // shows in that window. + mock.onGet(INDEX_URL).reply(200, { rules: RULES, everyoneRuleId: 42 }); + mock.onDelete(`${INDEX_URL}/42`).reply(200); + + let releaseSecond = (): void => {}; + let accessCalls = 0; + mock.onGet('/admin/marketplace_access').reply(() => { + accessCalls += 1; + if (accessCalls === 1) { + // Everyone-mode: users carry no per-rule reasons, so the published map is empty. + return [ + 200, + { users: [], summary: { totalWithAccess: 0, openToEveryone: true } }, + ]; + } + // Second fetch (after restrict) stays pending until released. + return new Promise((resolve) => { + releaseSecond = (): void => + resolve([ + 200, + { + users: [ + { + id: 1, + name: 'Jane', + email: 'jane@schools.gov.sg', + courseCount: 1, + instanceRole: null, + allowedByRules: [ + { id: 1, ruleType: 'email_domain', labelValue: EMAIL_DOMAIN }, + ], + systemAdmin: false, + blocked: false, + blockId: null, + }, + ], + summary: { + totalWithAccess: 1, + totalBlocked: 0, + openToEveryone: false, + }, + }, + ]); + }); + }); + + const page = render(, { at: [INDEX_URL] }); + await waitFor(() => expect(accessGetCount()).toBe(1)); + await page.findByText(EMAIL_DOMAIN); + + // Restrict: toggle off, then confirm in the dialog. + fireEvent.click(page.getByRole('checkbox', { name: OPEN_TO_EVERYONE })); + const dialog = page.getByRole('dialog'); + fireEvent.click(within(dialog).getByRole('button', { name: 'Restrict' })); + + // Refetch is now in flight (second GET pending). No stale zero-match warning may show. + await waitFor(() => expect(accessGetCount()).toBe(2)); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); + + // Let the refetch resolve; Jane matches rule 1, so still no warning. + releaseSecond(); + await page.findByText('jane@schools.gov.sg'); + expect(page.queryByLabelText(ZERO_MATCH_WARNING)).not.toBeInTheDocument(); +}); diff --git a/client/app/types/system/marketplaceAccess.ts b/client/app/types/system/marketplaceAccess.ts index 70949eb99a..6c3ddd9681 100644 --- a/client/app/types/system/marketplaceAccess.ts +++ b/client/app/types/system/marketplaceAccess.ts @@ -1,3 +1,37 @@ +import { AllowlistRuleType } from 'types/system/marketplaceAllowlist'; + +/** + * One rule granting a user access. A user may be granted by several rules at once — the audit list + * shows all of them, because the admin uses that column to decide which rules are safe to delete. + */ +export interface AllowedByRule { + id: number; + ruleType: AllowlistRuleType; + labelValue: string | null; +} + +export interface MarketplaceAccessUser { + id: number; + name: string; + email: string; + courseCount: number; + instanceRole: 'instructor' | 'administrator' | null; + allowedByRules: AllowedByRule[]; + /** System admins bypass every gate, so they are listed and labelled regardless of the rules. */ + systemAdmin: boolean; + blocked: boolean; + blockId: number | null; +} + +export interface MarketplaceAccessData { + users: MarketplaceAccessUser[]; + summary: { + totalWithAccess: number; + totalBlocked: number; + openToEveryone: boolean; + }; +} + export interface MarketplaceRulePreviewUser { id: number; name: string; diff --git a/client/locales/en.json b/client/locales/en.json index eac6c85da4..8012bddd2f 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8924,6 +8924,126 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "Renamed {field} from {prevValue} to {newValue}" }, + "system.admin.admin.MarketplaceAccessFilter.trigger": { + "defaultMessage": "Filter" + }, + "system.admin.admin.MarketplaceAccessFilter.status": { + "defaultMessage": "Status" + }, + "system.admin.admin.MarketplaceAccessFilter.active": { + "defaultMessage": "Active" + }, + "system.admin.admin.MarketplaceAccessFilter.blocked": { + "defaultMessage": "Blocked" + }, + "system.admin.admin.MarketplaceAccessFilter.allowedByRule": { + "defaultMessage": "Allowed by rule" + }, + "system.admin.admin.MarketplaceAccessFilter.clearAll": { + "defaultMessage": "Clear all" + }, + "system.admin.admin.MarketplaceAccessSection.heading": { + "defaultMessage": "People matched by these rules" + }, + "system.admin.admin.MarketplaceAccessSection.summary": { + "defaultMessage": "Total with access: {count} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.summaryWithBlocked": { + "defaultMessage": "Total with access: {count} · Total blocked: {blocked} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.filteredCounts": { + "defaultMessage": "Filtered: {count} with access · {blocked} blocked" + }, + "system.admin.admin.MarketplaceAccessSection.modeOpen": { + "defaultMessage": "Open to everyone" + }, + "system.admin.admin.MarketplaceAccessSection.modeScoped": { + "defaultMessage": "Scoped to the rules above" + }, + "system.admin.admin.MarketplaceAccessSection.fetchFailure": { + "defaultMessage": "Failed to load the marketplace access list." + }, + "system.admin.admin.MarketplaceAccessSection.colName": { + "defaultMessage": "Name" + }, + "system.admin.admin.MarketplaceAccessSection.colEmail": { + "defaultMessage": "Email" + }, + "system.admin.admin.MarketplaceAccessSection.colEligibleVia": { + "defaultMessage": "Eligible via" + }, + "system.admin.admin.MarketplaceAccessSection.colAllowedBy": { + "defaultMessage": "Allowed by" + }, + "system.admin.admin.MarketplaceAccessSection.colStatus": { + "defaultMessage": "Status" + }, + "system.admin.admin.MarketplaceAccessSection.colActions": { + "defaultMessage": "Actions" + }, + "system.admin.admin.MarketplaceAccessSection.managesCourses": { + "defaultMessage": "Manages {count, plural, one {# course} other {# courses}}" + }, + "system.admin.admin.MarketplaceAccessSection.instanceInstructor": { + "defaultMessage": "Instance instructor" + }, + "system.admin.admin.MarketplaceAccessSection.instanceAdministrator": { + "defaultMessage": "Instance administrator" + }, + "system.admin.admin.MarketplaceAccessSection.allowedEveryone": { + "defaultMessage": "Everyone" + }, + "system.admin.admin.MarketplaceAccessSection.allowedNothing": { + "defaultMessage": "No matching rule" + }, + "system.admin.admin.MarketplaceAccessSection.systemAdmin": { + "defaultMessage": "System admin" + }, + "system.admin.admin.MarketplaceAccessSection.typeUser": { + "defaultMessage": "User" + }, + "system.admin.admin.MarketplaceAccessSection.typeInstance": { + "defaultMessage": "Instance" + }, + "system.admin.admin.MarketplaceAccessSection.typeEmailDomain": { + "defaultMessage": "Email domain" + }, + "system.admin.admin.MarketplaceAccessSection.statusActive": { + "defaultMessage": "Active" + }, + "system.admin.admin.MarketplaceAccessSection.statusBlocked": { + "defaultMessage": "Blocked" + }, + "system.admin.admin.MarketplaceAccessSection.disable": { + "defaultMessage": "Block" + }, + "system.admin.admin.MarketplaceAccessSection.reEnable": { + "defaultMessage": "Unblock" + }, + "system.admin.admin.MarketplaceAccessSection.disableSuccess": { + "defaultMessage": "Access blocked for this user." + }, + "system.admin.admin.MarketplaceAccessSection.disableFailure": { + "defaultMessage": "Failed to block access." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableSuccess": { + "defaultMessage": "Access unblocked for this user." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableFailure": { + "defaultMessage": "Failed to unblock access." + }, + "system.admin.admin.MarketplaceAccessSection.searchPlaceholder": { + "defaultMessage": "Search by name or email" + }, + "system.admin.admin.MarketplaceAccessSection.dormantHeading": { + "defaultMessage": "Dormant blocks ({count})" + }, + "system.admin.admin.MarketplaceAccessSection.dormantExplanation": { + "defaultMessage": "These people are blocked but no rule currently grants them access. The block denies nothing today — but it would take effect again if a rule starts matching them, so clear it if it is no longer wanted." + }, + "system.admin.admin.MarketplaceAccessSection.clearBlock": { + "defaultMessage": "Clear block" + }, "system.admin.admin.MarketplaceAllowlistIndex.addRule": { "defaultMessage": "Add access rule" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index a3b23c3163..12a54cffc1 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8900,6 +8900,126 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "{field}이(가) {prevValue}에서 {newValue}로 변경되었습니다." }, + "system.admin.admin.MarketplaceAccessFilter.trigger": { + "defaultMessage": "필터" + }, + "system.admin.admin.MarketplaceAccessFilter.status": { + "defaultMessage": "상태" + }, + "system.admin.admin.MarketplaceAccessFilter.active": { + "defaultMessage": "활성" + }, + "system.admin.admin.MarketplaceAccessFilter.blocked": { + "defaultMessage": "차단됨" + }, + "system.admin.admin.MarketplaceAccessFilter.allowedByRule": { + "defaultMessage": "허용 규칙" + }, + "system.admin.admin.MarketplaceAccessFilter.clearAll": { + "defaultMessage": "모두 지우기" + }, + "system.admin.admin.MarketplaceAccessSection.heading": { + "defaultMessage": "접근 권한이 있는 사용자" + }, + "system.admin.admin.MarketplaceAccessSection.summary": { + "defaultMessage": "총 접근 가능 인원: {count} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.summaryWithBlocked": { + "defaultMessage": "총 접근 가능 인원: {count} · 총 차단 인원: {blocked} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.filteredCounts": { + "defaultMessage": "필터링됨: 접근 가능 {count} · 차단 {blocked}" + }, + "system.admin.admin.MarketplaceAccessSection.modeOpen": { + "defaultMessage": "모두에게 공개" + }, + "system.admin.admin.MarketplaceAccessSection.modeScoped": { + "defaultMessage": "위 규칙으로 제한됨" + }, + "system.admin.admin.MarketplaceAccessSection.fetchFailure": { + "defaultMessage": "마켓플레이스 접근 목록을 불러오지 못했습니다." + }, + "system.admin.admin.MarketplaceAccessSection.colName": { + "defaultMessage": "이름" + }, + "system.admin.admin.MarketplaceAccessSection.colEmail": { + "defaultMessage": "이메일" + }, + "system.admin.admin.MarketplaceAccessSection.colEligibleVia": { + "defaultMessage": "적격 사유" + }, + "system.admin.admin.MarketplaceAccessSection.colAllowedBy": { + "defaultMessage": "허용 근거" + }, + "system.admin.admin.MarketplaceAccessSection.colStatus": { + "defaultMessage": "상태" + }, + "system.admin.admin.MarketplaceAccessSection.colActions": { + "defaultMessage": "작업" + }, + "system.admin.admin.MarketplaceAccessSection.managesCourses": { + "defaultMessage": "{count, plural, one {#개 과정 관리 중} other {#개 과정 관리 중}}" + }, + "system.admin.admin.MarketplaceAccessSection.instanceInstructor": { + "defaultMessage": "인스턴스 강사" + }, + "system.admin.admin.MarketplaceAccessSection.instanceAdministrator": { + "defaultMessage": "인스턴스 관리자" + }, + "system.admin.admin.MarketplaceAccessSection.allowedEveryone": { + "defaultMessage": "모든 사용자" + }, + "system.admin.admin.MarketplaceAccessSection.allowedNothing": { + "defaultMessage": "일치하는 규칙 없음" + }, + "system.admin.admin.MarketplaceAccessSection.systemAdmin": { + "defaultMessage": "시스템 관리자" + }, + "system.admin.admin.MarketplaceAccessSection.typeUser": { + "defaultMessage": "사용자" + }, + "system.admin.admin.MarketplaceAccessSection.typeInstance": { + "defaultMessage": "인스턴스" + }, + "system.admin.admin.MarketplaceAccessSection.typeEmailDomain": { + "defaultMessage": "이메일 도메인" + }, + "system.admin.admin.MarketplaceAccessSection.statusActive": { + "defaultMessage": "활성" + }, + "system.admin.admin.MarketplaceAccessSection.statusBlocked": { + "defaultMessage": "차단됨" + }, + "system.admin.admin.MarketplaceAccessSection.disable": { + "defaultMessage": "차단" + }, + "system.admin.admin.MarketplaceAccessSection.reEnable": { + "defaultMessage": "차단 해제" + }, + "system.admin.admin.MarketplaceAccessSection.disableSuccess": { + "defaultMessage": "이 사용자의 접근이 차단되었습니다." + }, + "system.admin.admin.MarketplaceAccessSection.disableFailure": { + "defaultMessage": "접근 차단에 실패했습니다." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableSuccess": { + "defaultMessage": "이 사용자의 접근 차단이 해제되었습니다." + }, + "system.admin.admin.MarketplaceAccessSection.reEnableFailure": { + "defaultMessage": "접근 차단 해제에 실패했습니다." + }, + "system.admin.admin.MarketplaceAccessSection.searchPlaceholder": { + "defaultMessage": "이름 또는 이메일 검색" + }, + "system.admin.admin.MarketplaceAccessSection.dormantHeading": { + "defaultMessage": "휴면 차단 ({count})" + }, + "system.admin.admin.MarketplaceAccessSection.dormantExplanation": { + "defaultMessage": "이 사용자들은 차단되어 있지만, 현재 어떤 규칙도 이들에게 접근 권한을 부여하지 않습니다. 이 차단은 현재로서는 아무것도 막고 있지 않지만, 이후 어떤 규칙이 이들과 일치하게 되면 다시 효력을 발휘하므로, 더 이상 필요하지 않다면 차단을 해제하세요." + }, + "system.admin.admin.MarketplaceAccessSection.clearBlock": { + "defaultMessage": "차단 제거" + }, "system.admin.admin.MarketplaceAllowlistIndex.addRule": { "defaultMessage": "접근 규칙 추가" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 6b1418329b..93e61c5e29 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8894,6 +8894,126 @@ "system.admin.admin.InstancesTable.updateSuccess": { "defaultMessage": "已将 {field} 从 {prevValue} 重命名为 {newValue}" }, + "system.admin.admin.MarketplaceAccessFilter.trigger": { + "defaultMessage": "筛选" + }, + "system.admin.admin.MarketplaceAccessFilter.status": { + "defaultMessage": "状态" + }, + "system.admin.admin.MarketplaceAccessFilter.active": { + "defaultMessage": "活跃" + }, + "system.admin.admin.MarketplaceAccessFilter.blocked": { + "defaultMessage": "已屏蔽" + }, + "system.admin.admin.MarketplaceAccessFilter.allowedByRule": { + "defaultMessage": "允许规则" + }, + "system.admin.admin.MarketplaceAccessFilter.clearAll": { + "defaultMessage": "全部清除" + }, + "system.admin.admin.MarketplaceAccessSection.heading": { + "defaultMessage": "拥有访问权限的用户" + }, + "system.admin.admin.MarketplaceAccessSection.summary": { + "defaultMessage": "拥有访问权限总数:{count} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.summaryWithBlocked": { + "defaultMessage": "拥有访问权限总数:{count} · 屏蔽总数:{blocked} · {mode}" + }, + "system.admin.admin.MarketplaceAccessSection.filteredCounts": { + "defaultMessage": "筛选结果:可访问 {count} · 已屏蔽 {blocked}" + }, + "system.admin.admin.MarketplaceAccessSection.modeOpen": { + "defaultMessage": "对所有人开放" + }, + "system.admin.admin.MarketplaceAccessSection.modeScoped": { + "defaultMessage": "仅限于上方规则" + }, + "system.admin.admin.MarketplaceAccessSection.fetchFailure": { + "defaultMessage": "无法加载市场访问权限列表。" + }, + "system.admin.admin.MarketplaceAccessSection.colName": { + "defaultMessage": "姓名" + }, + "system.admin.admin.MarketplaceAccessSection.colEmail": { + "defaultMessage": "电子邮件" + }, + "system.admin.admin.MarketplaceAccessSection.colEligibleVia": { + "defaultMessage": "资格来源" + }, + "system.admin.admin.MarketplaceAccessSection.colAllowedBy": { + "defaultMessage": "允许依据" + }, + "system.admin.admin.MarketplaceAccessSection.colStatus": { + "defaultMessage": "状态" + }, + "system.admin.admin.MarketplaceAccessSection.colActions": { + "defaultMessage": "操作" + }, + "system.admin.admin.MarketplaceAccessSection.managesCourses": { + "defaultMessage": "{count, plural, one {管理 # 门课程} other {管理 # 门课程}}" + }, + "system.admin.admin.MarketplaceAccessSection.instanceInstructor": { + "defaultMessage": "实例教师" + }, + "system.admin.admin.MarketplaceAccessSection.instanceAdministrator": { + "defaultMessage": "实例管理员" + }, + "system.admin.admin.MarketplaceAccessSection.allowedEveryone": { + "defaultMessage": "每个人" + }, + "system.admin.admin.MarketplaceAccessSection.allowedNothing": { + "defaultMessage": "没有匹配的规则" + }, + "system.admin.admin.MarketplaceAccessSection.systemAdmin": { + "defaultMessage": "系统管理员" + }, + "system.admin.admin.MarketplaceAccessSection.typeUser": { + "defaultMessage": "用户" + }, + "system.admin.admin.MarketplaceAccessSection.typeInstance": { + "defaultMessage": "实例" + }, + "system.admin.admin.MarketplaceAccessSection.typeEmailDomain": { + "defaultMessage": "电子邮件域名" + }, + "system.admin.admin.MarketplaceAccessSection.statusActive": { + "defaultMessage": "活跃" + }, + "system.admin.admin.MarketplaceAccessSection.statusBlocked": { + "defaultMessage": "已屏蔽" + }, + "system.admin.admin.MarketplaceAccessSection.disable": { + "defaultMessage": "屏蔽" + }, + "system.admin.admin.MarketplaceAccessSection.reEnable": { + "defaultMessage": "取消屏蔽" + }, + "system.admin.admin.MarketplaceAccessSection.disableSuccess": { + "defaultMessage": "已屏蔽该用户的访问权限。" + }, + "system.admin.admin.MarketplaceAccessSection.disableFailure": { + "defaultMessage": "屏蔽访问权限失败。" + }, + "system.admin.admin.MarketplaceAccessSection.reEnableSuccess": { + "defaultMessage": "已取消屏蔽该用户的访问权限。" + }, + "system.admin.admin.MarketplaceAccessSection.reEnableFailure": { + "defaultMessage": "取消屏蔽访问权限失败。" + }, + "system.admin.admin.MarketplaceAccessSection.searchPlaceholder": { + "defaultMessage": "搜索姓名或电子邮件" + }, + "system.admin.admin.MarketplaceAccessSection.dormantHeading": { + "defaultMessage": "休眠屏蔽({count})" + }, + "system.admin.admin.MarketplaceAccessSection.dormantExplanation": { + "defaultMessage": "这些用户已被屏蔽,但目前没有任何规则授予他们访问权限。该屏蔽目前不会阻止任何事情——但如果日后有规则与他们匹配,它将再次生效,因此如果不再需要,请清除该屏蔽。" + }, + "system.admin.admin.MarketplaceAccessSection.clearBlock": { + "defaultMessage": "清除屏蔽" + }, "system.admin.admin.MarketplaceAllowlistIndex.addRule": { "defaultMessage": "添加访问规则" }, diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb index 912b4deaf6..539d3c683a 100644 --- a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -4,9 +4,13 @@ RSpec.describe Course::Assessment::Marketplace::AllowlistRule, type: :model do let!(:instance) { Instance.default } + HARDCODED_EMAIL_DOMAINS = ['schools.gov.sg', 'other.edu', 'school.edu', 'newdomain.example'].freeze + before do Course::Assessment::Marketplace::AllowlistRule.delete_all - User::Email.delete_all + HARDCODED_EMAIL_DOMAINS.each do |domain| + User::Email.where('LOWER(email) LIKE ?', "%#{domain}").delete_all + end end with_tenant(:instance) do From cc1a85ecd1f30c2a3a9db02c56327ef615ba788b Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:21 +0800 Subject: [PATCH 15/30] fix(cikgo): skip the destroy push when the course is gone --- .../course/lesson_plan/item/cikgo_push_concern.rb | 6 +++++- .../course/lesson_plan/lesson_plan_item_spec.rb | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb b/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb index 85fcc9d791..f010f4733c 100644 --- a/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb +++ b/app/models/concerns/course/lesson_plan/item/cikgo_push_concern.rb @@ -53,8 +53,12 @@ def update_payload } end + # `course&.` because the destroy push runs in `after_destroy_commit`: when the item goes away as + # part of its whole course being destroyed, the callback fires after that transaction has + # committed, so reloading `course` yields nil and there is nothing left to push to. Everywhere + # else `belongs_to :course` guarantees it is present. def push(method) - return unless pushable?(actable) && course.component_enabled?(Course::StoriesComponent) + return unless pushable?(actable) && course&.component_enabled?(Course::StoriesComponent) Cikgo::ResourcesService.push_resources!(course, [{ method: method, id: id.to_s }.merge(send("#{method}_payload"))]) rescue StandardError => e diff --git a/spec/models/course/lesson_plan/lesson_plan_item_spec.rb b/spec/models/course/lesson_plan/lesson_plan_item_spec.rb index 53d2a96be9..037ae0805a 100644 --- a/spec/models/course/lesson_plan/lesson_plan_item_spec.rb +++ b/spec/models/course/lesson_plan/lesson_plan_item_spec.rb @@ -67,6 +67,21 @@ end end + describe 'callbacks from Course::LessonPlan::Item::CikgoPushConcern' do + # The push runs in `after_destroy_commit`, so it fires only once the whole destroy has + # committed — by which point the course row is gone and `item.course` reloads to nil. A bare + # item cannot reproduce it: `pushable?` short-circuits on a nil actable. It needs a real + # pushable actable, whose cascade reaches the item through the assessment's `acts_as` + # belongs_to — which is also why `destroyed_by_association` is nil and unusable as the guard. + it 'does not raise when the item is destroyed along with its course' do + course_to_destroy = create(:course) + create(:assessment, :published, course: course_to_destroy) + + expect { course_to_destroy.destroy }.not_to raise_error + expect(Course.exists?(course_to_destroy.id)).to be false + end + end + context 'when actable object is declared to have a todo' do describe 'callbacks from Course::LessonPlan::ItemTodoConcern' do let(:course) { create(:course) } From 020dab6c60738fbe83a115d6a8cb70cedd878424 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:21 +0800 Subject: [PATCH 16/30] fix(toast): let an updated toast render a React node --- .../lib/hooks/toast/__test__/toast.test.tsx | 23 +++++++++++++++++++ client/app/lib/hooks/toast/loadingToast.ts | 4 +++- client/app/lib/hooks/toast/toast.tsx | 4 +++- 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 client/app/lib/hooks/toast/__test__/toast.test.tsx diff --git a/client/app/lib/hooks/toast/__test__/toast.test.tsx b/client/app/lib/hooks/toast/__test__/toast.test.tsx new file mode 100644 index 0000000000..047701704f --- /dev/null +++ b/client/app/lib/hooks/toast/__test__/toast.test.tsx @@ -0,0 +1,23 @@ +import { toast as toastify } from 'react-toastify'; +import { render, screen } from '@testing-library/react'; + +import toast from '../toast'; + +jest.mock('react-toastify', () => ({ toast: { update: jest.fn() } })); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('does not wrap ReactNode update messages in revoked Immer proxies', async () => { + toast.update('toast-id', { + render: Assessment duplicated., + type: 'success', + }); + + const renderedMessage = (toastify.update as jest.Mock).mock.calls[0][1] + .render; + + expect(() => render(renderedMessage)).not.toThrow(); + expect(await screen.findByText('Assessment duplicated.')).toBeVisible(); +}); diff --git a/client/app/lib/hooks/toast/loadingToast.ts b/client/app/lib/hooks/toast/loadingToast.ts index c614ef937f..11acedef79 100644 --- a/client/app/lib/hooks/toast/loadingToast.ts +++ b/client/app/lib/hooks/toast/loadingToast.ts @@ -1,8 +1,10 @@ +import { ReactNode } from 'react'; + import { DEFAULT_TOAST_TIMEOUT_MS } from 'lib/components/wrappers/ToastProvider'; import toast from './toast'; -type Updater = (message: string) => void; +type Updater = (message: ReactNode) => void; export interface LoadingToast { update: Updater; diff --git a/client/app/lib/hooks/toast/toast.tsx b/client/app/lib/hooks/toast/toast.tsx index bafb0c50af..6252f0c89b 100644 --- a/client/app/lib/hooks/toast/toast.tsx +++ b/client/app/lib/hooks/toast/toast.tsx @@ -69,8 +69,10 @@ const customize = ( ): O | undefined => { if (!options) return undefined; + const render = isUpdateOptions(options) ? options.render : undefined; + return produce(options, (draft) => { - if (isUpdateOptions(draft)) draft.render = formattedMessage(draft.render); + if (isUpdateOptions(draft)) draft.render = formattedMessage(render); draft.icon = getIconForToastType(draft.type ?? 'default'); }); From d3d3eaa13df68e5a1017fae3e982eff0f0bff325 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:21 +0800 Subject: [PATCH 17/30] fix(admin): keep the nav tab selected on nested admin routes --- .../admin/components/AdminNavigablePage.tsx | 8 +++- .../__test__/AdminNavigablePage.test.tsx | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 client/app/bundles/system/admin/components/__test__/AdminNavigablePage.test.tsx diff --git a/client/app/bundles/system/admin/components/AdminNavigablePage.tsx b/client/app/bundles/system/admin/components/AdminNavigablePage.tsx index c0649bbd58..3bcc202271 100644 --- a/client/app/bundles/system/admin/components/AdminNavigablePage.tsx +++ b/client/app/bundles/system/admin/components/AdminNavigablePage.tsx @@ -17,13 +17,19 @@ interface AdminNavigablePageProps { const AdminNavigablePage = (props: AdminNavigablePageProps): JSX.Element => { const location = useLocation(); const navigate = useNavigate(); + const activePath = + props.paths.find( + (path) => + location.pathname === path.path || + location.pathname.startsWith(`${path.path}/`), + )?.path ?? false; return ( navigate(value)} - value={location.pathname} + value={activePath} > {props.paths.map((path) => ( { + const page = render( + + , + title: 'Marketplace Listings', + path: '/admin/marketplace_listings', + }, + { + icon: , + title: 'Get Help', + path: '/admin/get_help', + }, + ]} + /> + } + path="/admin" + > + Listing detail} + path="marketplace_listings/:listingId" + /> + + , + { at: ['/admin/marketplace_listings/1'] }, + ); + + expect(await page.findByText('Listing detail')).toBeInTheDocument(); + expect( + page.getByRole('tab', { name: 'Marketplace Listings' }), + ).toHaveAttribute('aria-selected', 'true'); +}); From d3555676fa860003482935f49fb5a9a17d18fbcd Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:31 +0800 Subject: [PATCH 18/30] feat(marketplace): record an immutable snapshot per published version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A version IS its publication datetime, and its snapshot is duplicated into a `preview` container course so a published version can never change under the courses that adopted it. Non-admins cannot edit container content. Browse, preview and duplicate still read the mutable authoring copy — serving the snapshots to them is the next PR. --- .../marketplace/listings_controller.rb | 22 +- .../marketplace/questions_controller.rb | 9 +- .../marketplace_listings_controller.rb | 2 +- .../assessment/marketplace/duplication_job.rb | 2 +- ...ssessment_marketplace_ability_component.rb | 65 +++- app/models/course.rb | 1 + .../course/assessment/marketplace/listing.rb | 109 +++++- .../assessment/marketplace/listing_version.rb | 73 ++++ .../marketplace/preview_container_service.rb | 58 +++ .../assessment/marketplace/publish_service.rb | 133 +++++++ .../marketplace/listings/index.json.jbuilder | 2 +- ..._course_assessment_marketplace_listings.rb | 37 +- ...course_assessment_marketplace_adoptions.rb | 4 + ...tplace_versioning_and_preview_container.rb | 53 +++ db/schema.rb | 45 ++- .../assessments_marketplace_spec.rb | 2 +- .../marketplace/listings_controller_spec.rb | 5 +- .../marketplace/questions_controller_spec.rb | 14 +- .../marketplace_listings_controller_spec.rb | 6 +- ...assessment_marketplace_listing_versions.rb | 12 + .../course_assessment_marketplace_listings.rb | 19 +- .../marketplace/duplication_job_spec.rb | 7 +- .../assessment/marketplace/listing_spec.rb | 366 +++++++++++++++++- .../marketplace/listing_version_spec.rb | 234 +++++++++++ .../assessment_marketplace_ability_spec.rb | 69 +++- spec/models/course_spec.rb | 19 + .../preview_container_service_spec.rb | 87 +++++ .../marketplace/publish_service_spec.rb | 205 ++++++++++ 28 files changed, 1591 insertions(+), 69 deletions(-) create mode 100644 app/models/course/assessment/marketplace/listing_version.rb create mode 100644 app/services/course/assessment/marketplace/preview_container_service.rb create mode 100644 app/services/course/assessment/marketplace/publish_service.rb create mode 100644 db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb create mode 100644 spec/factories/course_assessment_marketplace_listing_versions.rb create mode 100644 spec/models/course/assessment/marketplace/listing_version_spec.rb create mode 100644 spec/services/course/assessment/marketplace/preview_container_service_spec.rb create mode 100644 spec/services/course/assessment/marketplace/publish_service_spec.rb diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 5e3cdbdd4b..3ef8152e88 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -7,9 +7,10 @@ def index # Preload `lesson_plan_item` — `title` is not a column on Course::Assessment; it lives on # the acting-as record. @listings = Course::Assessment::Marketplace::Listing.published. - includes(assessment: :lesson_plan_item).to_a + where.not(authoring_assessment_id: nil). + includes(authoring_assessment: :lesson_plan_item).to_a @adoption_counts = adoption_counts(@listings.map(&:id)) - @question_counts = question_counts(@listings.map(&:assessment_id)) + @question_counts = question_counts(@listings.map(&:authoring_assessment_id)) @destination_tabs = destination_tabs end end @@ -27,11 +28,15 @@ def duplicate def show ActsAsTenant.without_tenant do - @listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment).find_by(id: params[:id]) + @listing = Course::Assessment::Marketplace::Listing.published. + includes(:authoring_assessment).find_by(id: params[:id]) raise CanCan::AccessDenied unless @listing - @assessment = @listing.assessment - authorize!(:preview_in_marketplace, @assessment) + @assessment = @listing.authoring_assessment + # This page renders the authoring copy, which an orphaned listing no longer has — see `index`. + raise CanCan::AccessDenied unless @assessment + + authorize!(:preview_in_marketplace, @listing) @destination_tabs = destination_tabs render 'show' end @@ -68,11 +73,14 @@ def destination_tabs def authorized_listings listings = ActsAsTenant.without_tenant do - Course::Assessment::Marketplace::Listing.published.where(id: duplicate_params[:listing_ids]).includes(:assessment) + # 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) end raise CanCan::AccessDenied if listings.empty? - listings.each { |listing| authorize!(:duplicate_from_marketplace, listing.assessment) } + listings.each { |listing| authorize!(:duplicate_from_marketplace, listing) } authorize!(:duplicate_to, current_course) listings end diff --git a/app/controllers/course/assessment/marketplace/questions_controller.rb b/app/controllers/course/assessment/marketplace/questions_controller.rb index 91f4f06699..ccbba11729 100644 --- a/app/controllers/course/assessment/marketplace/questions_controller.rb +++ b/app/controllers/course/assessment/marketplace/questions_controller.rb @@ -4,12 +4,15 @@ class Course::Assessment::Marketplace::QuestionsController < Course::Assessment: def show ActsAsTenant.without_tenant do - listing = Course::Assessment::Marketplace::Listing.published.includes(:assessment). + listing = Course::Assessment::Marketplace::Listing.published.includes(:authoring_assessment). find_by(id: params[:listing_id]) raise CanCan::AccessDenied unless listing - @assessment = listing.assessment - authorize!(:preview_in_marketplace, @assessment) + @assessment = listing.authoring_assessment + # An orphaned listing has nothing left to preview — see ListingsController#index. + raise CanCan::AccessDenied unless @assessment + + authorize!(:preview_in_marketplace, listing) @question = @assessment.questions.includes(:actable).find(params[:id]) @question_assessment = @question.question_assessments.find_by!(assessment: @assessment) diff --git a/app/controllers/course/assessment/marketplace_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb index a05ec766cb..de9462916f 100644 --- a/app/controllers/course/assessment/marketplace_listings_controller.rb +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -3,7 +3,7 @@ class Course::Assessment::MarketplaceListingsController < Course::Assessment::Co before_action :authorize_publish_to_marketplace! def create - listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment) + listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(authoring_assessment: @assessment) now = Time.zone.now listing.published = true listing.first_published_at ||= now diff --git a/app/jobs/course/assessment/marketplace/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb index 77817351fd..14bfdbc0ac 100644 --- a/app/jobs/course/assessment/marketplace/duplication_job.rb +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -36,7 +36,7 @@ def find_tab(destination_course, destination_tab_id) end def duplicate_listing(listing, destination_course, current_user) - source = listing.assessment + source = listing.authoring_assessment Course::Duplication::ObjectDuplicationService.duplicate_objects( source.course, destination_course, source, current_user: current_user ) diff --git a/app/models/components/course/assessment_marketplace_ability_component.rb b/app/models/components/course/assessment_marketplace_ability_component.rb index 085e64ea8c..6dadf1c41f 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -2,26 +2,41 @@ module Course::AssessmentMarketplaceAbilityComponent include AbilityHost::Component + # Question types whose create/edit/delete are frozen inside a `preview` course. Mirrors the set + # granted in Course::Assessment::AssessmentAbility#allow_manage_questions. + PREVIEW_FROZEN_QUESTION_TYPES = [ + Course::Assessment::Question::ForumPostResponse, + Course::Assessment::Question::MultipleResponse, + Course::Assessment::Question::TextResponse, + Course::Assessment::Question::Programming, + Course::Assessment::Question::RubricBasedResponse, + Course::Assessment::Question::Scribing, + Course::Assessment::Question::VoiceResponse + ].freeze + def define_permissions allow_admins_publish_to_marketplace if user&.administrator? # System admins keep marketplace access via `can :manage, :all` (Ability#initialize); do not # emit a `cannot` for them or it would revoke that. For everyone else, access is per-person. - if course && !user&.administrator? - if can_access_marketplace? - allow_managers_access_marketplace - else - # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, - # which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace` - # regardless of the allow-list. This component runs after that one in the `define_permissions` - # super chain, so a `cannot` here takes precedence. This line is load-bearing. - cannot :access_marketplace, Course, id: course.id - end - end + define_non_admin_course_permissions if course && !user&.administrator? super end private + def define_non_admin_course_permissions + if can_access_marketplace? + allow_managers_access_marketplace + else + # `Course::CourseAbilityComponent` grants managers/owners a blanket `can :manage, Course`, + # which (CanCan's `:manage` matches any action) would otherwise satisfy `:access_marketplace` + # regardless of the allow-list. This component runs after that one in the `define_permissions` + # super chain, so a `cannot` here takes precedence. This line is load-bearing. + cannot :access_marketplace, Course, id: course.id + end + restrict_preview_course_content if course.preview? + end + # Access is per-person, not per-current-course-role: anyone who is baseline-capable (manages/owns # >=1 course anywhere, OR is an instructor/administrator in any instance) and passes the allow-list # may browse, whatever their role in the course they are viewing. @@ -34,7 +49,7 @@ def marketplace_baseline_capable? user&.course_manager_or_owner? || user&.instance_instructor_or_administrator? end - # Part of the TEMPORARY allow-list gate (see the retirement seam on `can_access_marketplace?`). + # Part of the temporary allow-list gate (see the retirement seam on `can_access_marketplace?`). # When the allow-list is retired this whole method is deleted; the block check goes with it. def marketplace_visible_to_user? return true if user&.administrator? @@ -47,13 +62,27 @@ def allow_admins_publish_to_marketplace can :publish_to_marketplace, Course::Assessment end + # In a `preview` sandbox course, freeze assessment content for everyone except system administrators + # (who hold `can :manage, :all`). Previewers are enrolled as `manager` — the lowest role that can + # attempt, grade and publish — which also carries `can :manage, Course::Assessment` and question + # management, so revoke exactly the destructive/content verbs and leave that loop intact. + # + # These `cannot`s only take precedence because this runs after Course::AssessmentsAbilityComponent + # and Course::CourseAbilityComponent in the `define_permissions` super chain: AbilityHost.components + # is ordered by file path, and `_` (0x5F) sorts before `s` (0x73). Do not rename or move this file. + def restrict_preview_course_content + assessments_in_course = { tab: { category: { course_id: course.id } } } + cannot [:update, :destroy], Course::Assessment, assessments_in_course + cannot :delete_all_submissions, Course::Assessment, assessments_in_course + cannot :delete_submission, Course::Assessment::Submission, assessment: assessments_in_course + PREVIEW_FROZEN_QUESTION_TYPES.each do |question_class| + cannot [:create, :update, :destroy], question_class + end + end + def allow_managers_access_marketplace can :access_marketplace, Course, id: course.id - can :duplicate_from_marketplace, Course::Assessment do |assessment| - assessment.marketplace_listing&.published? || false - end - can :preview_in_marketplace, Course::Assessment do |assessment| - assessment.marketplace_listing&.published? || false - end + can :duplicate_from_marketplace, Course::Assessment::Marketplace::Listing, &:published? + can :preview_in_marketplace, Course::Assessment::Marketplace::Listing, &:published? end end diff --git a/app/models/course.rb b/app/models/course.rb index 18a719b889..491d26e4bd 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -25,6 +25,7 @@ class Course < ApplicationRecord # rubocop:disable Metrics/ClassLength validates :gamified, inclusion: { in: [true, false] } validates :published, inclusion: { in: [true, false] } validates :enrollable, inclusion: { in: [true, false] } + validates :preview, inclusion: { in: [true, false] } validates :time_zone, length: { maximum: 255 }, allow_nil: true validates :creator, presence: true validates :updater, presence: true diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb index 0722ac6492..62740e8faa 100644 --- a/app/models/course/assessment/marketplace/listing.rb +++ b/app/models/course/assessment/marketplace/listing.rb @@ -1,11 +1,24 @@ # frozen_string_literal: true class Course::Assessment::Marketplace::Listing < ApplicationRecord - belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: :marketplace_listing + # 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. + belongs_to :authoring_assessment, class_name: 'Course::Assessment', + inverse_of: :marketplace_listing, optional: true belongs_to :publisher, class_name: 'User', inverse_of: false + belongs_to :current_version, class_name: 'Course::Assessment::Marketplace::ListingVersion', + inverse_of: false, optional: true + belongs_to :source_course, class_name: 'Course', inverse_of: false, optional: true + belongs_to :source_instance, class_name: 'Instance', inverse_of: false, optional: true + belongs_to :fallback_maintainer, class_name: 'User', inverse_of: false, optional: true has_many :adoptions, class_name: 'Course::Assessment::Marketplace::Adoption', inverse_of: :listing, dependent: :destroy + has_many :versions, class_name: 'Course::Assessment::Marketplace::ListingVersion', + inverse_of: :listing, dependent: :destroy - validates :assessment_id, uniqueness: true + # `allow_nil` is load-bearing: an orphaned listing has a null authoring assessment, and without + # this the second orphan would collide with the first. + validates :authoring_assessment_id, uniqueness: true, allow_nil: true validates :publisher, presence: true validates :creator, presence: true validates :updater, presence: true @@ -15,4 +28,96 @@ class Course::Assessment::Marketplace::Listing < ApplicationRecord def adoption_count adoptions.distinct.count(:destination_course_id) end + + # Every listing with the associations the system-admin management view reads. Tenant-free because + # snapshots live in the container course (its own preview instance) while listings span every one. + # @return [Array] + def self.for_admin_index + ActsAsTenant.without_tenant do + # The authoring assessment's own course and instance are preloaded because the view links to it + # by absolute url: a cross-instance assessment path only resolves on its instance's host. + includes(:source_course, :source_instance, + { current_version: { assessment: :lesson_plan_item } }, + { authoring_assessment: { lesson_plan_item: { course: :instance } } }). + order(id: :desc).to_a + 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. + # @return [Boolean] + def orphaned? + authoring_assessment_id.nil? + end + + # Restorable = orphaned AND still holding a snapshot to duplicate a fresh authoring copy from. + # A listing that is not orphaned already has one; one without a version has nothing to copy. + # @return [Boolean] + def restorable? + orphaned? && current_version_id.present? + end + + # An unlisted listing kept its authoring copy but was taken off the marketplace. Distinct from + # orphaned, which is about the authoring copy rather than visibility — and an orphaned listing keeps + # its snapshots and its `published` flag, so neither state collapses into the other. + # @return [Boolean] + def unlisted? + !orphaned? && !published? + end + + # Permanent deletion is offered only for a listing already off the marketplace — orphaned or + # unlisted. Requiring the unlist first keeps the reversible step ahead of the irreversible one, + # and leaves an unlisted listing's source assessment untouched, so it can be published again. + # @return [Boolean] + def purgeable? + orphaned? || unlisted? + end + + # Whether the authoring copy lives in the marketplace's container course rather than in a course + # somebody owns — true for a listing rebuilt after orphaning, and for one authored in the + # container directly. It is the only thing on the record that says where the copy an admin would + # edit actually is: `RestoreAuthoringJob` leaves the provenance fields on the origin course. + # + # `without_tenant` is load-bearing, not defensive. `Course` is `acts_as_tenant :instance` and the + # container lives in the dedicated preview instance, so under every real admin request the tenant + # scope filters it out and `authoring_assessment.course` returns nil rather than raising, making this + # answer `false` for exactly the listings it identifies. Same reason `.for_admin_index` is tenant-free. + # @return [Boolean] + def marketplace_hosted? + ActsAsTenant.without_tenant { authoring_assessment&.course&.preview? } || false + end + + # Whether the original source assessment is gone — either there is no authoring copy at all (never + # rebuilt after orphaning, or the rebuild failed), or there is one but it now lives in the + # marketplace container while the listing was published elsewhere. `RestoreAuthoringJob` produces + # the second case: it duplicates into the container but leaves provenance on the origin course. + # + # `source_course&.preview?` keeps this false for a listing authored in the container directly, + # where the container legitimately is the source course and nothing was ever lost. + # `without_tenant` for the reason `marketplace_hosted?` gives. + # @return [Boolean] + def source_assessment_deleted? + return true if orphaned? + + ActsAsTenant.without_tenant { marketplace_hosted? && !source_course&.preview? } + end + + # Whether the original source course is gone. `source_course_id`'s FK is `on_delete: :nullify`, so + # the id disappears when the course is destroyed while the denormalised `source_course_name` + # survives. Requiring the name too tells a real deletion apart from a legacy row that never + # recorded provenance at all — both have a nil id, only the deleted one carries a name. + # @return [Boolean] + def source_course_deleted? + source_course_id.nil? && source_course_name.present? + end + + # Visibility only: whether the listing is on the marketplace. The two deletion facts + # (`source_assessment_deleted?`, `source_course_deleted?`) are deliberately separate predicates + # rather than states here — a listing whose authoring copy was rebuilt into the container is + # visible and has a deleted origin at the same time, which one enum value cannot carry. + # @return [String] one of 'unlisted', 'published' + def admin_state + published? ? 'published' : 'unlisted' + end end diff --git a/app/models/course/assessment/marketplace/listing_version.rb b/app/models/course/assessment/marketplace/listing_version.rb new file mode 100644 index 0000000000..3ffe3ae94b --- /dev/null +++ b/app/models/course/assessment/marketplace/listing_version.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true +class Course::Assessment::Marketplace::ListingVersion < ApplicationRecord + belongs_to :listing, class_name: 'Course::Assessment::Marketplace::Listing', + inverse_of: :versions + belongs_to :assessment, class_name: 'Course::Assessment', inverse_of: false + belongs_to :published_by, class_name: 'User', inverse_of: false + + validates :published_at, presence: true, uniqueness: { scope: :listing_id } + validates :assessment, presence: true + validates :published_by, presence: true + validates :creator, presence: true + validates :updater, presence: true + + scope :ordered, -> { order(published_at: :asc) } + + # Version identity for a set of container assessments. Publishing duplicates the title verbatim and + # every snapshot lands in the same tab of the one container course, so nothing on the assessment row + # says which listing it belongs to — it can only be read back from here. The listing join supplies + # provenance that survives origin-course deletion, which snapshot is current, and whether it is listed. + # + # Two kinds are labelled: a snapshot has a version row and yields its publication datetime; a + # restored working copy has none — it is the listing's `authoring_assessment` — and yields + # `published_at: nil`, which the client renders as an "Authoring" chip. Without that second lookup + # it would be the one assessment in the container with no chip at all. + # + # @param [Array] assessment_ids + # @return [Hash{Integer => Hash}] keyed by assessment id, each holding `:listing_id`, + # `:published_at`, `:source`, `:latest` and `:listed`; assessments that are neither a snapshot + # nor a working copy are absent from the hash. + def self.labels_for_assessments(assessment_ids) + return {} if assessment_ids.empty? + + snapshot_labels(assessment_ids).merge(working_copy_labels(assessment_ids)) + end + + # `:id` must be a symbol: both joined tables have an `id`, and Rails qualifies symbols to this + # model's own table while passing strings through verbatim — `'id'` would reach Postgres + # unqualified and be rejected as ambiguous. `listed` reads the listing's `published` column + # directly rather than `admin_state`, so this query is not coupled to what that method means. + # + # @param [Array] assessment_ids + # @return [Hash{Integer => Hash}] + def self.snapshot_labels(assessment_ids) + joins(:listing). + where(assessment_id: assessment_ids). + pluck(:assessment_id, :listing_id, :id, :published_at, + 'course_assessment_marketplace_listings.source_course_name', + 'course_assessment_marketplace_listings.current_version_id', + 'course_assessment_marketplace_listings.published'). + to_h do |(assessment_id, listing_id, version_id, published_at, source, current_version_id, listed)| + [assessment_id, listing_id: listing_id, published_at: published_at, source: source, + latest: version_id == current_version_id, listed: listed] + end + end + private_class_method :snapshot_labels + + # The working copy is not a version, so `latest` is unconditionally false — the listing's + # `current_version` always points at a snapshot, never at this. `listed` belongs to the listing, + # so it is reported here exactly as it is on that listing's snapshots. + # + # @param [Array] assessment_ids + # @return [Hash{Integer => Hash}] + def self.working_copy_labels(assessment_ids) + Course::Assessment::Marketplace::Listing. + where(authoring_assessment_id: assessment_ids). + pluck(:authoring_assessment_id, :id, :source_course_name, :published). + to_h do |assessment_id, listing_id, source, listed| + [assessment_id, listing_id: listing_id, published_at: nil, source: source, + latest: false, listed: listed] + end + end + private_class_method :working_copy_labels +end diff --git a/app/services/course/assessment/marketplace/preview_container_service.rb b/app/services/course/assessment/marketplace/preview_container_service.rb new file mode 100644 index 0000000000..2312331c1b --- /dev/null +++ b/app/services/course/assessment/marketplace/preview_container_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# Provisions (idempotently) the single dedicated preview instance and the one content-frozen +# container course that backs the marketplace. Behaviour keys off `Course#preview`, never off a +# specific instance id. +# +# The container stores every published version snapshot (see PublishService), and those same rows are +# what previewers attempt hands-on — a snapshot is the preview copy, so a preview can never differ +# from what a duplicate gives you. The content-freeze in AssessmentMarketplaceAbilityComponent then +# doubles as both the previewer sandbox guard and the snapshots' immutability guarantee. +class Course::Assessment::Marketplace::PreviewContainerService + PREVIEW_INSTANCE_HOST = 'preview.coursemology.org' + PREVIEW_INSTANCE_NAME = 'Marketplace Preview' + PREVIEW_COURSE_TITLE = 'Marketplace Preview Sandbox' + + class << self + # @return [Instance] the dedicated non-default preview instance. + # + # `save!(validate: false)` because `Instance#host` gsubs `coursemology.org` for the environment's + # default host, and hostname validation reads through that overridden accessor rather than the raw + # column — so a `*.coursemology.org` host validated against a `localhost:PORT` dev/test default + # always fails on the injected colon. `db/seeds.rb` works around it the same way. + def preview_instance + Instance.find_by(host: PREVIEW_INSTANCE_HOST) || + Instance.new(host: PREVIEW_INSTANCE_HOST, name: PREVIEW_INSTANCE_NAME).tap do |instance| + instance.save!(validate: false) + end + end + + # @return [Course] the single `preview: true` container course in the preview instance. + def container_course + instance = preview_instance + ActsAsTenant.with_tenant(instance) do + Course.find_by(preview: true) || create_container_course(instance) + end + end + + private + + # `published/gamified/enrollable: false` keep the container out of every listing, level and + # self-enrolment path: it holds the marketplace's snapshots, so it must never surface as a + # course in its own right. Previewers are attached to it explicitly, one at a time. + def create_container_course(instance) + User.with_stamper(User.system) do + Course.create!( + instance: instance, + title: PREVIEW_COURSE_TITLE, + description: 'System container for marketplace version snapshots and hands-on previews.', + preview: true, + published: false, + gamified: false, + enrollable: false, + creator: User.system, + updater: User.system + ) + end + end + end +end diff --git a/app/services/course/assessment/marketplace/publish_service.rb b/app/services/course/assessment/marketplace/publish_service.rb new file mode 100644 index 0000000000..5e0769c6f7 --- /dev/null +++ b/app/services/course/assessment/marketplace/publish_service.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +# Publishes an assessment to the marketplace (copy-on-publish): (re)activate the +# listing, capture provenance, snapshot the authoring assessment into the hidden container course, +# and point `current_version` at the snapshot. `.publish` cuts v1 on first publish only — re-listing +# an already-versioned listing does not cut a version; `.publish_new_version` is that explicit action. +class Course::Assessment::Marketplace::PublishService + # @param [Course::Assessment] assessment the source assessment being published + # @param [User] publisher the user triggering the publish + # @return [Course::Assessment::Marketplace::Listing] + def self.publish(assessment, publisher) + new(assessment, publisher).publish + end + + # Deliberate version cut. Snapshots whatever the authoring copy currently is into + # the container as a new version and advances `current_version`. Prior snapshots are retained — + # they are what comments and contributions will anchor to. + # + # @param [Course::Assessment::Marketplace::Listing] listing + # @param [User] publisher + # @return [Course::Assessment::Marketplace::ListingVersion] + def self.publish_new_version(listing, publisher) + raise ArgumentError, 'cannot cut a version from an orphaned listing' if listing.authoring_assessment.nil? + + new(listing.authoring_assessment, publisher).cut_next_version!(listing) + end + + def initialize(assessment, publisher) + @assessment = assessment + @publisher = publisher + end + + # @return [Course::Assessment::Marketplace::Listing] + def publish + with_publish_context do + listing = activate_listing + cut_first_version!(listing) if listing.current_version_id.nil? + listing + end + end + + # @param [Course::Assessment::Marketplace::Listing] listing + # @return [Course::Assessment::Marketplace::ListingVersion] + def cut_next_version!(listing) + with_publish_context do + snapshot = snapshot_into_container(@assessment) + # One instant, written to both rows. Two `Time.zone.now` calls would let the version row and + # the listing disagree by milliseconds — and different surfaces read different ones. + published_at = Time.zone.now + version = listing.versions.create!(published_at: published_at, assessment: snapshot, + published_by: @publisher, + creator: @publisher, updater: @publisher) + listing.update!(current_version: version, last_published_at: published_at) + version + end + end + + private + + # Runs the publish body without a tenant (the container lives in the dedicated preview + # instance, never the caller's; callers may be scoped to any instance) and with the stamper + # set so nested creator/updater resolve on the listing, version, and snapshot copy. + def with_publish_context(&block) + ActsAsTenant.without_tenant do + User.with_stamper(@publisher) do + Course::Assessment::Marketplace::Listing.transaction(&block) + end + end + end + + # @return [Course::Assessment::Marketplace::Listing] + def activate_listing + 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 + listing.publisher ||= @publisher + capture_provenance(listing) + listing.save! + listing + end + + # Denormalized so the identity survives origin-course deletion: the course row is what + # gets deleted, so its title is copied rather than read through `source_course`. + def capture_provenance(listing) + course = @assessment.course + listing.source_course ||= course + listing.source_instance ||= course.instance + listing.source_course_name ||= course.title + listing.fallback_maintainer ||= course.course_users.find_by(role: :owner)&.user + end + + # `published_at` is the listing's first-publication date rather than the moment of the cut: when v1 + # is cut, the listing's first publication is when its content became available. Baking it in here is + # what removed the read-time v1 special case on ListingVersion. `activate_listing` runs first and + # always leaves the date set, so there is no nil to fall back from. + def cut_first_version!(listing) + snapshot = snapshot_into_container(listing.authoring_assessment) + version = listing.versions.create!(published_at: listing.first_published_at, + assessment: snapshot, published_by: @publisher, + creator: @publisher, updater: @publisher) + listing.update!(current_version: version) + version + end + + # The snapshot is simultaneously the row previewers attempt hands-on — see + # PreviewContainerService. Its immutability is enforced by the container's `preview` freeze, not + # by convention. `duplicate_objects` performs no ability checks, so the freeze cannot block the + # publish that populates the container. + # + # @return [Course::Assessment] the immutable snapshot living in the container course + def snapshot_into_container(assessment) + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( + assessment.course, container, assessment, current_user: @publisher + ) + reparent_into_container_tab(copy, container) + # A published snapshot is a standalone assessment, not a link-sibling of the origin. See + # Course::Assessment#detach_from_link_tree!. + copy.detach_from_link_tree! + copy + end + + def reparent_into_container_tab(copy, container) + tab = container.assessment_categories.first.tabs.first + return if copy.tab_id == tab.id + + copy.tab = tab + copy.folder.parent = tab.category.folder + copy.save! + end +end diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder index ead481dbe7..5b67c87681 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.assessment + assessment = listing.authoring_assessment json.id listing.id json.assessmentId assessment.id json.title assessment.title diff --git a/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb index e1b094eb9a..ac664e27fc 100644 --- a/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb +++ b/db/migrate/20260707000001_create_course_assessment_marketplace_listings.rb @@ -1,15 +1,37 @@ class CreateCourseAssessmentMarketplaceListings < ActiveRecord::Migration[7.2] def change create_table :course_assessment_marketplace_listings do |t| - t.references :assessment, null: false, - foreign_key: { to_table: :course_assessments, - name: 'fk_course_assessment_marketplace_listings_assessment_id', - on_delete: :cascade }, - index: { name: 'fk__course_assessment_marketplace_listings_assessment_id', - unique: true } + # Nullified, never cascaded: the listing outlives deletion of its origin assessment, keeping its + # snapshots so a fresh authoring copy can be rebuilt from the latest one. The unique index is + # partial for the same reason — every orphaned row holds NULL here. + t.references :authoring_assessment, null: true, + foreign_key: { to_table: :course_assessments, + name: 'fk_caml_authoring_assessment_id', + on_delete: :nullify }, + index: false t.boolean :published, null: false, default: false t.datetime :first_published_at t.datetime :last_published_at + # Provenance. The id nullifies when the origin course is deleted; the denormalised name is what + # survives to identify where the content came from afterwards. + t.references :source_course, null: true, + foreign_key: { to_table: :courses, + name: 'fk_caml_source_course_id', + on_delete: :nullify }, + index: { name: 'fk__caml_source_course_id' } + t.string :source_course_name + t.references :source_instance, null: true, + foreign_key: { to_table: :instances, + name: 'fk_caml_source_instance_id', + on_delete: :nullify }, + index: { name: 'fk__caml_source_instance_id' } + # The snapshot the marketplace treats as current. Its FK is added alongside the versions table + # (20260728000000): the two tables reference each other, so one direction has to come second. + t.references :current_version, null: true, index: { name: 'fk__caml_current_version_id' } + t.references :fallback_maintainer, null: true, + foreign_key: { to_table: :users, + name: 'fk_caml_fallback_maintainer_id' }, + index: { name: 'fk__caml_fallback_maintainer_id' } t.references :publisher, null: false, foreign_key: { to_table: :users, name: 'fk_course_assessment_marketplace_listings_publisher_id' }, @@ -24,6 +46,9 @@ def change index: { name: 'fk__course_assessment_marketplace_listings_updater_id' } t.timestamps null: false end + add_index :course_assessment_marketplace_listings, :authoring_assessment_id, + unique: true, where: 'authoring_assessment_id IS NOT NULL', + name: 'index_caml_on_authoring_assessment_id' add_index :course_assessment_marketplace_listings, :published, name: 'index_course_assessment_marketplace_listings_on_published' end diff --git a/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb index 1f7eee7c3d..10baa3b467 100644 --- a/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb +++ b/db/migrate/20260707000002_create_course_assessment_marketplace_adoptions.rb @@ -17,6 +17,10 @@ def change on_delete: :cascade }, index: { name: 'fk__cama_duplicated_assessment_id', unique: true } + # A datetime, not a version number: this is the content vintage the copy was made from, compared + # against the listing's current version's `published_at`. Stored as a value rather than an FK so + # a copy still knows how old its content is even if the version row is purged with its listing. + t.datetime :adopted_version_at t.references :creator, null: false, foreign_key: { to_table: :users, name: 'fk_course_assessment_marketplace_adoptions_creator_id' }, diff --git a/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb b/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb new file mode 100644 index 0000000000..7ad7e060e5 --- /dev/null +++ b/db/migrate/20260728000000_add_marketplace_versioning_and_preview_container.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true +# The immutable snapshot store: one row per published version, each pointing at a copy of the +# assessment held in the `preview` container course. +# +# The listings and adoptions tables declare their versioning columns directly rather than being +# altered here — the whole marketplace reaches master in one release, so there is nothing deployed +# to retrofit. +class AddMarketplaceVersioningAndPreviewContainer < ActiveRecord::Migration[7.2] + def change + create_versions_table + # Deferred out of the listings migration: listings and versions reference each other, so the + # second direction can only be added once both tables exist. + add_foreign_key :course_assessment_marketplace_listings, + :course_assessment_marketplace_listing_versions, + column: :current_version_id, name: 'fk_caml_current_version_id', + on_delete: :nullify + # Marks the single container course holding every snapshot. Marketplace behaviour keys off this + # flag, never off a specific instance id. + add_column :courses, :preview, :boolean, default: false, null: false + end + + private + + def create_versions_table + create_table :course_assessment_marketplace_listing_versions do |t| + t.references :listing, null: false, + foreign_key: { to_table: :course_assessment_marketplace_listings, + name: 'fk_camlv_listing_id', + on_delete: :cascade }, + index: { name: 'fk__camlv_listing_id' } + # A version IS its publication datetime. There is no ordinal: an + # integer would name a series the system cannot navigate — there is no rollback — and the + # stable internal referent is already this row's primary key. + t.datetime :published_at, null: false + t.references :assessment, null: false, + foreign_key: { to_table: :course_assessments, + name: 'fk_camlv_assessment_id' }, + index: { name: 'fk__camlv_assessment_id' } + t.references :published_by, null: false, + foreign_key: { to_table: :users, name: 'fk_camlv_published_by' }, + index: { name: 'fk__camlv_published_by' } + t.references :creator, null: false, + foreign_key: { to_table: :users, name: 'fk_camlv_creator_id' }, + index: { name: 'fk__camlv_creator_id' } + t.references :updater, null: false, + foreign_key: { to_table: :users, name: 'fk_camlv_updater_id' }, + index: { name: 'fk__camlv_updater_id' } + t.timestamps null: false + end + add_index :course_assessment_marketplace_listing_versions, [:listing_id, :published_at], + unique: true, name: 'index_camlv_on_listing_id_and_published_at' + end +end diff --git a/db/schema.rb b/db/schema.rb index fb5c2d5b73..2c376be91e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_20_154800) do +ActiveRecord::Schema[7.2].define(version: 2026_07_28_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -283,6 +283,7 @@ t.bigint "listing_id", null: false t.bigint "destination_course_id", null: false t.bigint "duplicated_assessment_id", null: false + t.datetime "adopted_version_at" t.bigint "creator_id", null: false t.bigint "updater_id", null: false t.datetime "created_at", null: false @@ -311,20 +312,46 @@ t.index ["user_id"], name: "index_marketplace_allowlist_rules_one_per_user", unique: true, where: "(rule_type = 0)" end - create_table "course_assessment_marketplace_listings", force: :cascade do |t| + create_table "course_assessment_marketplace_listing_versions", force: :cascade do |t| + t.bigint "listing_id", null: false + t.datetime "published_at", null: false t.bigint "assessment_id", null: false + t.bigint "published_by_id", null: false + t.bigint "creator_id", null: false + t.bigint "updater_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["assessment_id"], name: "fk__camlv_assessment_id" + t.index ["creator_id"], name: "fk__camlv_creator_id" + t.index ["listing_id", "published_at"], name: "index_camlv_on_listing_id_and_published_at", unique: true + t.index ["listing_id"], name: "fk__camlv_listing_id" + t.index ["published_by_id"], name: "fk__camlv_published_by" + t.index ["updater_id"], name: "fk__camlv_updater_id" + end + + create_table "course_assessment_marketplace_listings", force: :cascade do |t| + t.bigint "authoring_assessment_id" t.boolean "published", default: false, null: false t.datetime "first_published_at" t.datetime "last_published_at" + t.bigint "source_course_id" + t.string "source_course_name" + t.bigint "source_instance_id" + t.bigint "current_version_id" + t.bigint "fallback_maintainer_id" t.bigint "publisher_id", null: false t.bigint "creator_id", null: false t.bigint "updater_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["assessment_id"], name: "fk__course_assessment_marketplace_listings_assessment_id", unique: true + t.index ["authoring_assessment_id"], name: "index_caml_on_authoring_assessment_id", unique: true, where: "(authoring_assessment_id IS NOT NULL)" t.index ["creator_id"], name: "fk__course_assessment_marketplace_listings_creator_id" + t.index ["current_version_id"], name: "fk__caml_current_version_id" + t.index ["fallback_maintainer_id"], name: "fk__caml_fallback_maintainer_id" t.index ["published"], name: "index_course_assessment_marketplace_listings_on_published" t.index ["publisher_id"], name: "fk__course_assessment_marketplace_listings_publisher_id" + t.index ["source_course_id"], name: "fk__caml_source_course_id" + t.index ["source_instance_id"], name: "fk__caml_source_instance_id" t.index ["updater_id"], name: "fk__course_assessment_marketplace_listings_updater_id" end @@ -1718,6 +1745,7 @@ t.text "user_suspension_message" t.boolean "is_suspended", default: false, null: false t.text "course_suspension_message" + t.boolean "preview", default: false, null: false t.index ["creator_id"], name: "fk__courses_creator_id" t.index ["instance_id"], name: "fk__courses_instance_id" t.index ["registration_key"], name: "index_courses_on_registration_key", unique: true @@ -2046,8 +2074,17 @@ add_foreign_key "course_assessment_marketplace_adoptions", "users", column: "updater_id", name: "fk_course_assessment_marketplace_adoptions_updater_id" add_foreign_key "course_assessment_marketplace_allowlist_rules", "instances" add_foreign_key "course_assessment_marketplace_allowlist_rules", "users" - add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "assessment_id", name: "fk_course_assessment_marketplace_listings_assessment_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_listing_versions", "course_assessment_marketplace_listings", column: "listing_id", name: "fk_camlv_listing_id", on_delete: :cascade + add_foreign_key "course_assessment_marketplace_listing_versions", "course_assessments", column: "assessment_id", name: "fk_camlv_assessment_id" + add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "creator_id", name: "fk_camlv_creator_id" + add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "published_by_id", name: "fk_camlv_published_by" + add_foreign_key "course_assessment_marketplace_listing_versions", "users", column: "updater_id", name: "fk_camlv_updater_id" + add_foreign_key "course_assessment_marketplace_listings", "course_assessment_marketplace_listing_versions", column: "current_version_id", name: "fk_caml_current_version_id", on_delete: :nullify + add_foreign_key "course_assessment_marketplace_listings", "course_assessments", column: "authoring_assessment_id", name: "fk_caml_authoring_assessment_id", on_delete: :nullify + add_foreign_key "course_assessment_marketplace_listings", "courses", column: "source_course_id", name: "fk_caml_source_course_id", on_delete: :nullify + add_foreign_key "course_assessment_marketplace_listings", "instances", column: "source_instance_id", name: "fk_caml_source_instance_id", on_delete: :nullify add_foreign_key "course_assessment_marketplace_listings", "users", column: "creator_id", name: "fk_course_assessment_marketplace_listings_creator_id" + add_foreign_key "course_assessment_marketplace_listings", "users", column: "fallback_maintainer_id", name: "fk_caml_fallback_maintainer_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "publisher_id", name: "fk_course_assessment_marketplace_listings_publisher_id" add_foreign_key "course_assessment_marketplace_listings", "users", column: "updater_id", name: "fk_course_assessment_marketplace_listings_updater_id" add_foreign_key "course_assessment_plagiarism_checks", "course_assessments", column: "assessment_id", name: "fk_course_assessment_plagiarism_checks_assessment_id" diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index 8c0b966a9a..98bc1d0c3d 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -23,7 +23,7 @@ end it 'reports isPublishedToMarketplace true once a published listing exists' do - create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) get :show, as: :json, params: { course_id: course, id: assessment } expect(JSON.parse(response.body)).to include('isPublishedToMarketplace' => true) end diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index c652e23d9b..d4170c53e8 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -55,7 +55,8 @@ it 'reports the actual question count for a listing (not the 0 fallback)' do assessment_with_questions = create(:assessment, :with_mcq_question, question_count: 3, course: course) - listing = create(:course_assessment_marketplace_listing, published: true, assessment: assessment_with_questions) + listing = create(:course_assessment_marketplace_listing, published: true, + authoring_assessment: assessment_with_questions) get :index, params: { course_id: course, format: :json } row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } expect(row['questionCount']).to eq(3) @@ -234,7 +235,7 @@ let!(:listing) do assessment = create(:assessment, course: create(:course)) create(:course_assessment_question_multiple_response, :multiple_choice, assessment: assessment) - create(:course_assessment_marketplace_listing, assessment: assessment, published: true) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) end it 'renders the assessment config read-only' do diff --git a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb index de6d5949df..e8d4134ec7 100644 --- a/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/questions_controller_spec.rb @@ -23,7 +23,7 @@ # NOTE: the factory has no :published trait — `published { true }` is a default attribute # (spec/factories/course_assessment_marketplace_listings.rb). Do NOT pass `:published`. ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: source_assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: source_assessment) end end let(:question) { source_assessment.questions.first } @@ -82,7 +82,7 @@ ) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -102,7 +102,7 @@ create(:course_assessment_question_text_response, :exact_match_solution, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -121,7 +121,7 @@ create(:course_assessment_question_rubric_based_response, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -140,7 +140,7 @@ create(:course_assessment_question_forum_post_response, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -157,7 +157,7 @@ create(:course_assessment_question_voice_response, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end @@ -175,7 +175,7 @@ create(:course_assessment_question_scribing, assessment: assessment) question = assessment.questions.first ActsAsTenant.without_tenant do - create(:course_assessment_marketplace_listing, assessment: assessment) + create(:course_assessment_marketplace_listing, authoring_assessment: assessment) end end diff --git a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb index 2f261b11b0..6b5a2ef0c2 100644 --- a/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb @@ -24,7 +24,7 @@ context 'when the assessment was previously published then removed (re-publish)' do let!(:listing) do - create(:course_assessment_marketplace_listing, assessment: assessment, published: false, + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: false, first_published_at: 3.days.ago, last_published_at: 3.days.ago) end @@ -52,7 +52,9 @@ end describe 'DELETE #destroy' do - let!(:listing) { create(:course_assessment_marketplace_listing, assessment: assessment, published: true) } + let!(:listing) do + create(:course_assessment_marketplace_listing, authoring_assessment: assessment, published: true) + end it 'soft-removes: keeps the row, sets published false' do delete :destroy, params: { course_id: course, assessment_id: assessment, format: :json } diff --git a/spec/factories/course_assessment_marketplace_listing_versions.rb b/spec/factories/course_assessment_marketplace_listing_versions.rb new file mode 100644 index 0000000000..0e48566bce --- /dev/null +++ b/spec/factories/course_assessment_marketplace_listing_versions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true +FactoryBot.define do + factory :course_assessment_marketplace_listing_version, + class: Course::Assessment::Marketplace::ListingVersion do + listing { association :course_assessment_marketplace_listing } + assessment + published_by { listing.publisher } + # Distinct per row: `published_at` is unique per listing, and a factory that stamped the same + # instant twice would collide the moment a spec cut two versions of one listing. + sequence(:published_at) { |n| n.minutes.ago } + end +end diff --git a/spec/factories/course_assessment_marketplace_listings.rb b/spec/factories/course_assessment_marketplace_listings.rb index 6ea0a819ff..39eba4c625 100644 --- a/spec/factories/course_assessment_marketplace_listings.rb +++ b/spec/factories/course_assessment_marketplace_listings.rb @@ -5,10 +5,25 @@ transient do course { nil } end - assessment { association :assessment, course: course || create(:course) } - publisher { assessment.course.creator } + authoring_assessment { association :assessment, course: course || create(:course) } + publisher { authoring_assessment.course.creator } published { true } first_published_at { Time.zone.now } last_published_at { Time.zone.now } + + # Mirrors the post-Slice-2 shape: a listing whose served content is a snapshot distinct from the + # authoring copy. The stand-in snapshot is created in the origin's own course rather than the + # shared preview container, so unrelated specs neither pay for container (and preview-instance) + # creation nor grow it. The real container path is covered by `publish_service_spec.rb`. + trait :versioned do + after(:create) do |listing, _evaluator| + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: listing.first_published_at || Time.zone.now, + published_by: listing.publisher) + listing.update!(current_version: version) + end + end end end diff --git a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb index 5faf1b7cf8..3870659d9f 100644 --- a/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb +++ b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb @@ -6,7 +6,9 @@ with_tenant(:instance) do let(:source_course) { create(:course) } let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } - let(:listing) { create(:course_assessment_marketplace_listing, assessment: source_assessment, published: true) } + let(:listing) do + create(:course_assessment_marketplace_listing, authoring_assessment: source_assessment, published: true) + end let(:destination_course) { create(:course) } let(:destination_tab) { destination_course.assessment_categories.first.tabs.first } let(:user) { create(:administrator) } @@ -48,7 +50,8 @@ def run it 'duplicates every listing when given several ids' do other = create(:course_assessment_marketplace_listing, - assessment: create(:assessment, :with_mcq_question, course: source_course), published: true) + authoring_assessment: create(:assessment, :with_mcq_question, course: source_course), + published: true) expect do described_class.perform_now([listing.id, other.id], destination_course, destination_tab.id, current_user: user) end.to change { destination_course.assessments.count }.by(2). diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb index 7eeb9a26f3..46aff30f53 100644 --- a/spec/models/course/assessment/marketplace/listing_spec.rb +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -4,8 +4,9 @@ RSpec.describe Course::Assessment::Marketplace::Listing, type: :model do let!(:instance) { Instance.default } with_tenant(:instance) do - it { is_expected.to belong_to(:assessment).class_name('Course::Assessment') } + it { is_expected.to belong_to(:authoring_assessment).class_name('Course::Assessment').optional } it { is_expected.to belong_to(:publisher).class_name('User') } + it { is_expected.to belong_to(:source_instance).class_name('Instance').optional } it do is_expected.to have_many(:adoptions). class_name('Course::Assessment::Marketplace::Adoption').dependent(:destroy) @@ -16,9 +17,10 @@ it { is_expected.to validate_presence_of(:publisher) } - it 'validates uniqueness of assessment_id' do + it 'validates uniqueness of authoring_assessment_id' do existing = create(:course_assessment_marketplace_listing) - dup = build(:course_assessment_marketplace_listing, assessment: existing.assessment) + dup = build(:course_assessment_marketplace_listing, + authoring_assessment: existing.authoring_assessment) expect(dup).not_to be_valid end end @@ -43,5 +45,363 @@ expect(subject.adoption_count).to eq(2) end end + + describe 'versioning associations (additive; all nullable)' do + let(:listing) { create(:course_assessment_marketplace_listing) } + + it 'is valid without any versioning fields set' do + expect(listing.current_version).to be_nil + expect(listing.source_course).to be_nil + expect(listing.source_instance).to be_nil + expect(listing.fallback_maintainer).to be_nil + expect(listing).to be_valid + end + + # Nullify rather than cascade: losing the instance a listing was published from must not take + # the listing, its version chain and every adopter's adoption row with it. + it 'keeps the listing but nullifies the reference when the source instance is deleted' do + origin_instance = create(:instance) + listing.update!(source_instance: origin_instance) + + expect { origin_instance.destroy! }. + not_to(change { described_class.where(id: listing.id).count }) + expect(listing.reload.source_instance_id).to be_nil + end + + it 'has many ordered versions and can point at a current version' do + earlier = 2.days.ago.change(usec: 0) + later = 1.day.ago.change(usec: 0) + v1 = create(:course_assessment_marketplace_listing_version, listing: listing, published_at: earlier) + v2 = create(:course_assessment_marketplace_listing_version, listing: listing, published_at: later) + listing.update!(current_version: v2) + expect(listing.versions.ordered).to eq([v1, v2]) + expect(listing.current_version).to eq(v2) + end + + it 'destroys its versions when destroyed' do + create(:course_assessment_marketplace_listing_version, listing: listing) + expect { listing.destroy }. + to change { Course::Assessment::Marketplace::ListingVersion.count }.by(-1) + end + + it 'does not destroy versions belonging to another listing' do + other_listing = create(:course_assessment_marketplace_listing) + other_version = create(:course_assessment_marketplace_listing_version, + listing: other_listing) + create(:course_assessment_marketplace_listing_version, listing: listing) + + expect { listing.destroy }. + to change { Course::Assessment::Marketplace::ListingVersion.count }.by(-1) + expect(other_version.reload).to be_persisted + end + + it 'optionally references a source course, fallback maintainer, and provenance' do + course = create(:course) + maintainer = create(:user) + listing.update!(source_course: course, source_course_name: 'Intro to AI', + fallback_maintainer: maintainer) + expect(listing.source_course).to eq(course) + expect(listing.fallback_maintainer).to eq(maintainer) + expect(listing.source_course_name).to eq('Intro to AI') + end + end + + describe 'the :versioned factory trait' do + it 'cuts a v1 whose assessment is distinct from the authoring copy' do + listing = create(:course_assessment_marketplace_listing, :versioned) + + expect(listing.current_version).to be_present + expect(listing.current_version.published_at).to be_within(1.second).of(listing.first_published_at) + expect(listing.current_version.assessment).not_to eq(listing.authoring_assessment) + end + + it 'leaves the listing unversioned when the trait is not applied' do + expect(create(:course_assessment_marketplace_listing).current_version).to be_nil + end + end + + describe 'maintenance predicates' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } + + def orphan!(target = listing) + target.authoring_assessment.destroy! + target.reload + end + + describe '#orphaned?' do + it 'is false while the authoring assessment exists' do + expect(listing).not_to be_orphaned + end + + it 'is true once the authoring assessment is deleted' do + expect(orphan!).to be_orphaned + end + end + + describe '#restorable?' do + it 'is true for an orphaned listing that still has a version' do + expect(orphan!).to be_restorable + end + + it 'is false while the listing still has an authoring copy' do + expect(listing).not_to be_restorable + end + + it 'is false for an orphaned listing with no version to restore from' do + unversioned = create(:course_assessment_marketplace_listing) + expect(orphan!(unversioned)).not_to be_restorable + end + end + + describe '#unlisted?' do + it 'is true once a listing with an authoring copy is taken off the marketplace' do + listing.update!(published: false) + expect(listing).to be_unlisted + end + + it 'is false while the listing is published' do + expect(listing).not_to be_unlisted + end + + # Orphaning is about the authoring copy, unlisting about marketplace visibility, and an + # orphaned listing keeps serving its snapshot — so the two states are reported separately + # rather than one collapsing into the other. + it 'is false for an orphaned listing even though it has no authoring copy' do + expect(orphan!).not_to be_unlisted + end + end + + # Purge is offered for a listing that is NOT on the marketplace — orphaned or unlisted — + # regardless of adoption history: a deliberate admin deletion of an adopted listing must be + # allowed to proceed. + describe '#purgeable?' do + it 'is true for an orphaned listing with no adoptions' do + expect(orphan!).to be_purgeable + end + + it 'is true for an unlisted listing with no adoptions' do + listing.update!(published: false) + expect(listing).to be_purgeable + end + + # Unlisting is the reversible step and has to be taken first; it is also what makes the + # deletion recoverable, since the source assessment survives and can be published again. + it 'is false for a published listing' do + expect(listing).not_to be_purgeable + end + + it 'is true for an orphaned listing that has been adopted' do + create(:course_assessment_marketplace_adoption, listing: listing) + expect(orphan!).to be_purgeable + end + + it 'is true for an unlisted listing that has been adopted' do + create(:course_assessment_marketplace_adoption, listing: listing) + listing.update!(published: false) + + expect(listing).to be_purgeable + end + end + end + + describe '#admin_state' do + let(:origin_course) { create(:course) } + # Eager: the deleted-course example destroys `origin_course`, and a lazily built listing would + # then try to create its authoring assessment inside a course that no longer exists. + let!(:listing) do + create(:course_assessment_marketplace_listing, course: origin_course, source_course: origin_course) + end + + it 'is published while the listing is listed and still has its authoring copy' do + expect(listing.admin_state).to eq('published') + end + + it 'is unlisted once the listing is unpublished' do + listing.update!(published: false) + expect(listing.admin_state).to eq('unlisted') + end + + # Visibility did not change: `admin_state` no longer tracks the authoring copy at all, so a + # deleted origin assessment leaves a published listing published. The deletion fact now lives + # on `#source_assessment_deleted?` instead (see below). + it 'stays published when the authoring assessment is deleted' do + listing.authoring_assessment.destroy! + expect(listing.reload.admin_state).to eq('published') + end + + # Same reasoning for a deleted origin course: visibility is untouched. + it 'stays published when the origin course is deleted' do + origin_course.destroy! + expect(listing.reload.admin_state).to eq('published') + end + + it 'reports unlisted, not a deletion fact, when an unpublished listing loses its copy' do + listing.update!(published: false) + listing.authoring_assessment.destroy! + expect(listing.reload.admin_state).to eq('unlisted') + end + end + + # These two predicates carry the deletion facts that used to live inside `admin_state` as + # 'orphaned_assessment_deleted' / 'orphaned_course_deleted'. Split out because a listing whose + # authoring copy was rebuilt into the marketplace container is visible (published) AND has a + # deleted origin at the same time — one enum value cannot report both. + describe '#source_assessment_deleted?' do + let(:origin_course) { create(:course) } + let!(:listing) do + create(:course_assessment_marketplace_listing, :versioned, course: origin_course, + source_course: origin_course) + end + + it 'is false for a normal published listing with an intact authoring copy' do + expect(listing).not_to be_source_assessment_deleted + end + + it 'is false for an unlisted listing with an intact authoring copy' do + listing.update!(published: false) + expect(listing).not_to be_source_assessment_deleted + end + + it 'is true once the authoring assessment is destroyed (no rebuild yet)' do + listing.authoring_assessment.destroy! + expect(listing.reload).to be_source_assessment_deleted + end + + # `RestoreAuthoringJob` always duplicates into the container and leaves `source_course` + # pointing at the ORIGIN, so this is the rebuilt case: published and marketplace-hosted, but + # the original is still gone. + it 'is true once the authoring copy is rebuilt into the marketplace container' do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + rebuilt = ActsAsTenant.with_tenant(container.instance) { create(:assessment, course: container) } + listing.update!(authoring_assessment: rebuilt) + + expect(listing).to be_source_assessment_deleted + expect(listing).to be_marketplace_hosted + end + + # The case the predicate exists to get right: authored in the container DIRECTLY, so the + # container legitimately IS the source course and nothing was ever lost. + it 'is false for a listing authored in the container directly' do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + container_assessment = ActsAsTenant.with_tenant(container.instance) do + create(:assessment, course: container) + end + direct = create(:course_assessment_marketplace_listing, authoring_assessment: container_assessment, + source_course: container, + publisher: create(:user)) + + expect(direct).not_to be_source_assessment_deleted + expect(direct).to be_marketplace_hosted + end + end + + describe '#source_course_deleted?' do + let(:origin_course) { create(:course) } + let!(:listing) do + create(:course_assessment_marketplace_listing, course: origin_course, source_course: origin_course, + source_course_name: origin_course.title) + end + + it 'is false while the origin course exists' do + expect(listing).not_to be_source_course_deleted + end + + it 'is false when only the authoring assessment is deleted, since the origin course survives' do + listing.authoring_assessment.destroy! + expect(listing.reload).not_to be_source_course_deleted + end + + # The FK nullifies `source_course_id` on course deletion; `source_course_name` is denormalised + # and survives, which is what tells a real deletion apart from a legacy row lacking provenance. + it 'is true once the origin course itself is destroyed' do + origin_course.destroy! + listing.reload + + expect(listing.source_course_id).to be_nil + expect(listing).to be_source_course_deleted + expect(listing).to be_source_assessment_deleted + end + + it 'is false for a legacy listing that never recorded a source course at all' do + legacy = create(:course_assessment_marketplace_listing) + expect(legacy).not_to be_source_course_deleted + end + end + + # Orthogonal to `admin_state`: this reports WHERE the authoring copy lives, while `admin_state` + # reports marketplace visibility. A rebuilt listing can go on to be unlisted, so neither answer + # can be read off the other. + describe '#marketplace_hosted?' do + it 'is false while the authoring copy lives in an ordinary course' do + listing = create(:course_assessment_marketplace_listing) + expect(listing).not_to be_marketplace_hosted + end + + # Keyed off `Course#preview`, never off a specific instance id — the same rule + # PreviewContainerService documents, so a container in any instance reports correctly. + it 'is true once the authoring copy lives in a preview container course' do + container = create(:course, preview: true) + listing = create(:course_assessment_marketplace_listing, course: container) + + expect(listing).to be_marketplace_hosted + end + + it 'stays true for a marketplace-hosted listing that is later unlisted' do + listing = create(:course_assessment_marketplace_listing, course: create(:course, preview: true)) + listing.update!(published: false) + + expect(listing.admin_state).to eq('unlisted') + expect(listing).to be_marketplace_hosted + end + + # The regression this method's `without_tenant` exists for. The real container lives in the + # dedicated preview instance, so every admin request asks from a different tenant — and a + # tenant-scoped `Course` lookup returns nil rather than raising, which would answer `false` for + # precisely the listings it identifies. The examples above use a same-instance container. + it 'sees the container even when the caller is tenanted to another instance' do + preview_instance = create(:instance) + container = ActsAsTenant.with_tenant(preview_instance) { create(:course, preview: true) } + copy = ActsAsTenant.with_tenant(preview_instance) { create(:assessment, course: container) } + # `publisher` passed explicitly: the factory default reads `authoring_assessment.course.creator`, + # which is itself tenant-scoped and would blow up here for the very reason under test. + listing = create(:course_assessment_marketplace_listing, authoring_assessment: copy, + publisher: create(:user)) + + expect(listing).to be_marketplace_hosted + end + + it 'is false for an orphaned listing, which has no authoring copy at all' do + listing = create(:course_assessment_marketplace_listing, course: create(:course, preview: true)) + listing.authoring_assessment.destroy! + + expect(listing.reload).not_to be_marketplace_hosted + end + end + + describe 'orphaning when the authoring assessment is deleted' do + let!(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + let!(:adoption) { create(:course_assessment_marketplace_adoption, listing: listing) } + + it 'survives with a null authoring assessment, keeping its versions and adoptions' do + expect { listing.authoring_assessment.destroy! }. + not_to(change { described_class.where(id: listing.id).count }) + + expect(listing.reload.authoring_assessment_id).to be_nil + expect(listing.versions.count).to eq(1) + expect(listing.adoptions).to include(adoption) + end + + it 'permits a second orphaned listing to coexist' do + first = create(:course_assessment_marketplace_listing) + second = create(:course_assessment_marketplace_listing) + first.authoring_assessment.destroy! + + expect { second.authoring_assessment.destroy! }. + not_to(change { described_class.where(id: [first.id, second.id]).count }) + + expect(first.reload.authoring_assessment_id).to be_nil + expect(second.reload.authoring_assessment_id).to be_nil + end + end end end diff --git a/spec/models/course/assessment/marketplace/listing_version_spec.rb b/spec/models/course/assessment/marketplace/listing_version_spec.rb new file mode 100644 index 0000000000..c1ec8b127d --- /dev/null +++ b/spec/models/course/assessment/marketplace/listing_version_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ListingVersion, type: :model do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:listing) { create(:course_assessment_marketplace_listing) } + + describe 'validations' do + it 'is valid with the factory' do + expect(build(:course_assessment_marketplace_listing_version, listing: listing)).to be_valid + end + + it 'requires a published_at' do + version = build(:course_assessment_marketplace_listing_version, listing: listing, published_at: nil) + expect(version).not_to be_valid + expect(version.errors[:published_at]).to be_present + end + + it 'requires an assessment' do + version = build(:course_assessment_marketplace_listing_version, listing: listing, assessment: nil) + expect(version).not_to be_valid + expect(version.errors[:assessment]).to be_present + end + + it 'requires a publisher' do + version = build(:course_assessment_marketplace_listing_version, listing: listing, published_by: nil) + expect(version).not_to be_valid + expect(version.errors[:published_by]).to be_present + end + + it 'enforces published_at uniqueness scoped to the listing' do + published = 3.days.ago.change(usec: 0) + create(:course_assessment_marketplace_listing_version, listing: listing, published_at: published) + duplicate = build(:course_assessment_marketplace_listing_version, listing: listing, + published_at: published) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:published_at]).to be_present + end + + it 'allows the same published_at on a different listing' do + published = 3.days.ago.change(usec: 0) + create(:course_assessment_marketplace_listing_version, listing: listing, published_at: published) + other = build(:course_assessment_marketplace_listing_version, + listing: create(:course_assessment_marketplace_listing), published_at: published) + expect(other).to be_valid + end + end + + describe 'associations' do + it 'belongs to a listing, snapshot assessment, and publisher' do + version = create(:course_assessment_marketplace_listing_version, listing: listing) + expect(version.listing).to eq(listing) + expect(version.assessment).to be_a(Course::Assessment) + expect(version.published_by).to be_a(User) + end + end + + describe '.ordered' do + it 'orders by ascending published_at' do + later = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: 1.day.ago) + earlier = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: 5.days.ago) + expect(listing.versions.ordered).to eq([earlier, later]) + end + end + + describe '.labels_for_assessments' do + let(:listing) do + create(:course_assessment_marketplace_listing, source_course_name: 'MP Allowlist Source Course') + end + let(:published) { 4.days.ago.change(usec: 0) } + let!(:version) do + create(:course_assessment_marketplace_listing_version, listing: listing, published_at: published) + end + + it 'maps a snapshot to its listing, vintage and denormalised provenance' do + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listing_id]).to eq(listing.id) + expect(labels[version.assessment_id][:published_at]).to be_within(1.second).of(published) + expect(labels[version.assessment_id][:source]).to eq('MP Allowlist Source Course') + end + + it 'omits assessments that are not snapshots' do + plain = create(:assessment) + + labels = described_class.labels_for_assessments([version.assessment_id, plain.id]) + + expect(labels.keys).to eq([version.assessment_id]) + end + + it 'issues no query for an empty id list' do + expect(described_class).not_to receive(:joins) + expect(described_class.labels_for_assessments([])).to eq({}) + end + + # The restored working copy lives in the container beside the snapshots and is NOT a version, + # so it has no row here — it is found through the listing's authoring_assessment_id instead. + # Without this it is the one assessment in the container with no chip at all. + it 'labels the listing authoring copy with a null vintage' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy) + + labels = described_class.labels_for_assessments([working_copy.id]) + + expect(labels[working_copy.id][:published_at]).to be_nil + expect(labels[working_copy.id][:listing_id]).to eq(listing.id) + end + + it 'labels a snapshot and a working copy in one call' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy) + + labels = described_class.labels_for_assessments([version.assessment_id, working_copy.id]) + + expect(labels.keys).to contain_exactly(version.assessment_id, working_copy.id) + end + + it 'omits an assessment that is neither a snapshot nor a working copy' do + plain = create(:assessment) + + expect(described_class.labels_for_assessments([plain.id])).to eq({}) + end + + # `current_version_id` is the pointer the marketplace actually serves from, so the flag reads it + # rather than recomputing MAX(published_at). The two can disagree: an unlisted listing still has + # a newest snapshot, and a listing can be pointed back at an older cut deliberately. + it 'marks the current version as the latest' do + listing.update!(current_version: version) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:latest]).to be(true) + end + + it 'does not mark a superseded snapshot as the latest' do + pointed_at = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: 1.day.ago) + listing.update!(current_version: pointed_at) + + labels = described_class.labels_for_assessments([version.assessment_id, pointed_at.assessment_id]) + + expect(labels[version.assessment_id][:latest]).to be(false) + expect(labels[pointed_at.assessment_id][:latest]).to be(true) + end + + it 'marks nothing as the latest when the listing has no current version' do + listing.update!(current_version: nil) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:latest]).to be(false) + end + + # The working copy is not a version at all — it has no row in this table — so it can never be + # the latest one, even while the listing points at a perfectly good current version. + it 'never marks the authoring copy as the latest' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy, current_version: version) + + labels = described_class.labels_for_assessments([working_copy.id]) + + expect(labels[working_copy.id][:latest]).to be(false) + end + + # A listing id is a primary key: deleting a neighbouring listing renumbers nothing, and the flag + # is read off THIS listing's own pointer. This is the case that motivated the feature — an admin + # deleted listing 3 and expected listing 4 to become 3. + it 'is unaffected by the deletion of another listing' do + listing.update!(current_version: version) + other = create(:course_assessment_marketplace_listing) + create(:course_assessment_marketplace_listing_version, listing: other) + other.destroy! + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listing_id]).to eq(listing.id) + expect(labels[version.assessment_id][:latest]).to be(true) + end + + it 'reports a published listing as listed' do + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listed]).to be(true) + end + + it 'reports an unlisted listing as not listed' do + listing.update!(published: false) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listed]).to be(false) + end + + # `listed` reads the `published` COLUMN, never `admin_state`: an orphaned listing has lost its + # authoring copy but goes on serving its last snapshot and stays published. Reading the raw + # column avoids coupling this query to what `admin_state` currently means. + it 'still reports an orphaned but published listing as listed' do + listing.update!(authoring_assessment: nil) + + labels = described_class.labels_for_assessments([version.assessment_id]) + + expect(labels[version.assessment_id][:listed]).to be(true) + end + + # Listing state belongs to the LISTING, so the working copy carries it too — its row is as + # unlisted as every snapshot of the same listing. + it 'reports the listing state on the authoring copy too' do + working_copy = create(:assessment) + listing.update!(authoring_assessment: working_copy, published: false) + + labels = described_class.labels_for_assessments([working_copy.id]) + + expect(labels[working_copy.id][:listed]).to be(false) + end + end + + # `published_at` is a plain column. The v1 special case that used to live in a method here moved + # to write time in PublishService#cut_first_version!, where the listing's first-publication date + # is what actually dates the content. + describe '#published_at' do + it 'reads the column verbatim, with no version-dependent branch' do + published = 3.months.ago.change(usec: 0) + listing.update!(first_published_at: 1.year.ago) + version = create(:course_assessment_marketplace_listing_version, listing: listing, + published_at: published) + + expect(version.published_at).to be_within(1.second).of(published) + end + end + end +end diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 4564124a91..0606e471b9 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -5,8 +5,7 @@ let!(:instance) { Instance.default } with_tenant(:instance) do let(:course) { create(:course) } - let(:listing) { create(:course_assessment_marketplace_listing, published: true) } - let(:published_assessment) { listing.assessment } + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } subject { Ability.new(user, course, course_user) } @@ -23,14 +22,20 @@ it { is_expected.to be_able_to(:access_marketplace, course) } it { is_expected.not_to be_able_to(:publish_to_marketplace, build(:assessment)) } - it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } - it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, listing) } + it { is_expected.to be_able_to(:preview_in_marketplace, listing) } it 'cannot duplicate/preview an unpublished listing' do - unpublished = create(:course_assessment_marketplace_listing, published: false).assessment + unpublished = create(:course_assessment_marketplace_listing, :versioned, published: false) expect(subject).not_to be_able_to(:duplicate_from_marketplace, unpublished) expect(subject).not_to be_able_to(:preview_in_marketplace, unpublished) end + + it 'authorizes a listing whose served snapshot sits in a course the user cannot reach' do + remote = create(:course_assessment_marketplace_listing, :versioned, published: true) + expect(subject).to be_able_to(:duplicate_from_marketplace, remote) + expect(subject).to be_able_to(:preview_in_marketplace, remote) + end end context 'when the user is a course student' do @@ -57,8 +62,8 @@ end it { is_expected.to be_able_to(:access_marketplace, course) } - it { is_expected.to be_able_to(:duplicate_from_marketplace, published_assessment) } - it { is_expected.to be_able_to(:preview_in_marketplace, published_assessment) } + it { is_expected.to be_able_to(:duplicate_from_marketplace, listing) } + it { is_expected.to be_able_to(:preview_in_marketplace, listing) } end context 'when an allow-listed user manages no course at all' do @@ -112,5 +117,55 @@ expect(Ability.new(user, course, course_user)).to be_able_to(:access_marketplace, course) end end + + context 'when the course is a preview (content-frozen) sandbox' do + let(:course) { create(:course, preview: true) } + let(:assessment) { create(:assessment, course: course) } + + context 'and the user is the previewer (a course manager)' do + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + it 'preserves the attempt + publish loop' do + expect(subject).to be_able_to(:attempt, assessment) + expect(subject).to be_able_to(:publish_grades, assessment) + end + + it 'freezes the assessment content (no edit/delete)' do + expect(subject).not_to be_able_to(:update, assessment) + expect(subject).not_to be_able_to(:destroy, assessment) + end + + it 'freezes question authoring' do + expect(subject).not_to be_able_to(:create, Course::Assessment::Question::MultipleResponse) + end + + it 'forbids deleting submissions in the sandbox' do + expect(subject).not_to be_able_to(:delete_all_submissions, assessment) + end + end + + context 'and the user is a system administrator' do + let(:user) { create(:administrator) } + let(:course_user) { nil } + + it 'is exempt — retains full content management' do + expect(subject).to be_able_to(:update, assessment) + expect(subject).to be_able_to(:destroy, assessment) + end + end + end + + context 'when a course manager is in a NON-preview course' do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:course_user) { create(:course_manager, course: course) } + let(:user) { course_user.user } + + it 'retains normal content management (the freeze is preview-scoped)' do + expect(subject).to be_able_to(:update, assessment) + expect(subject).to be_able_to(:destroy, assessment) + end + end end end diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index 262e63dc09..24fe667da2 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -349,5 +349,24 @@ it { is_expected.to eq(course.course_users.student.count) } end end + + describe 'the preview flag' do + it 'defaults to false for a new course' do + expect(build(:course).preview).to eq(false) + end + + it 'is invalid when preview is nil' do + course = build(:course) + course.preview = nil + expect(course).not_to be_valid + expect(course.errors[:preview]).to be_present + end + + it 'is valid when preview is true' do + course = build(:course) + course.preview = true + expect(course).to be_valid + end + end end end diff --git a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb new file mode 100644 index 0000000000..dc5af62571 --- /dev/null +++ b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PreviewContainerService, type: :service do + # The dedicated preview instance/course are cross-tenant singletons created by the service itself, + # so this spec runs under the default tenant and lets the service switch tenants internally. + let!(:default_instance) { Instance.default } + + with_tenant(:default_instance) do + describe '.preview_instance' do + it 'returns the dedicated non-default preview instance, idempotently' do + first = described_class.preview_instance + second = described_class.preview_instance + + expect(second).to eq(first) + expect(first).not_to be_default + expect(first.read_attribute(:host)).to eq(described_class::PREVIEW_INSTANCE_HOST) + expect(first.name).to eq(described_class::PREVIEW_INSTANCE_NAME) + end + end + + describe '.container_course' do + it 'returns a single preview-flagged container course in the preview instance, idempotently' do + first = described_class.container_course + second = described_class.container_course + + expect(second).to eq(first) + expect(first).to be_preview + expect(first.instance).to eq(described_class.preview_instance) + expect(first.title).to eq(described_class::PREVIEW_COURSE_TITLE) + end + + it 'does not create a second container course on the second call' do + described_class.container_course + expect { described_class.container_course }. + not_to(change do + ActsAsTenant.with_tenant(described_class.preview_instance) do + Course.where(preview: true).count + end + end) + end + + # The container holds every published version snapshot, so it must never surface as a course + # in its own right — not in a listing, not via self-enrolment, not to any user but the system + # one. Previewers are attached explicitly, one at a time. + it 'is unpublished, ungamified and not self-enrollable' do + container = described_class.container_course + + expect(container.published).to be(false) + expect(container.gamified).to be(false) + expect(container.enrollable).to be(false) + end + + # Only `creator` is asserted. `updater` is deliberately NOT an invariant: the container is a + # long-lived singleton that every publish snapshots into, and those writes re-stamp it with + # the publisher. Asserting `updater == User.system` only passes on a container no one has + # published into yet. + it 'is created by the system user' do + container = described_class.container_course + + expect(container.creator).to eq(User.system) + end + + it 'is not publicly accessible' do + container = described_class.container_course + + ActsAsTenant.without_tenant do + expect(Course.publicly_accessible).not_to include(container) + end + end + + it 'enrolls only the system user, so no other user ever sees it' do + container = described_class.container_course + # Enroll other_user in an unrelated real course so the negative assertion is non-vacuous: + # containing_user DOES surface a course they belong to, yet never the container. + other_course = create(:course) + other_user = create(:course_manager, course: other_course).user + + ActsAsTenant.without_tenant do + expect(Course.containing_user(User.system)).to include(container) + expect(Course.containing_user(other_user)).to include(other_course) + expect(Course.containing_user(other_user)).not_to include(container) + end + end + end + end +end diff --git a/spec/services/course/assessment/marketplace/publish_service_spec.rb b/spec/services/course/assessment/marketplace/publish_service_spec.rb new file mode 100644 index 0000000000..f13491e697 --- /dev/null +++ b/spec/services/course/assessment/marketplace/publish_service_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PublishService, type: :service do + let(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:publisher) { create(:user) } + + def container + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + describe '.publish' do + it 'activates the listing and cuts version 1 published by the publisher' do + listing = described_class.publish(assessment, publisher) + expect(listing.published).to be(true) + expect(listing.current_version).to be_present + expect(listing.current_version.published_at).to eq(listing.first_published_at) + expect(listing.current_version.published_by).to eq(publisher) + end + + it 'snapshots a distinct copy of the assessment into the container course' do + listing = described_class.publish(assessment, publisher) + snapshot = listing.current_version.assessment + ActsAsTenant.without_tenant do + expect(snapshot).not_to eq(assessment) + expect(snapshot.course).to eq(container) + end + end + + it 'creates exactly one version row' do + expect { described_class.publish(assessment, publisher) }. + to change { Course::Assessment::Marketplace::ListingVersion.count }.by(1) + end + + it 'captures denormalized provenance from the source course' do + listing = described_class.publish(assessment, publisher) + expect(listing.source_course).to eq(course) + expect(listing.source_course_name).to eq(course.title) + expect(listing.fallback_maintainer).to eq(course.course_users.find_by(role: :owner).user) + end + + it 'does not cut a second version when re-published (first-publish only this slice)' do + described_class.publish(assessment, publisher) + expect { described_class.publish(assessment, publisher) }. + not_to(change { Course::Assessment::Marketplace::ListingVersion.count }) + end + + it 'preserves first_published_at and bumps last_published_at on re-publish' do + old = 3.days.ago + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + published: false, + first_published_at: old, + last_published_at: old) + result = described_class.publish(assessment, publisher) + expect(result.id).to eq(listing.id) + expect(result.first_published_at).to be_within(1.second).of(old) + expect(result.last_published_at).to be > old + end + end + + describe '.publish_new_version' do + let!(:listing) { described_class.publish(assessment, publisher) } + let(:cutter) { create(:user) } + + it 'cuts the next version from the authoring copy and advances current_version' do + version = described_class.publish_new_version(listing.reload, cutter) + + expect(version.published_at).to eq(listing.reload.last_published_at) + expect(version.published_by).to eq(cutter) + expect(listing.reload.current_version).to eq(version) + end + + it 'snapshots into the container as a copy distinct from the authoring assessment' do + version = described_class.publish_new_version(listing.reload, cutter) + + ActsAsTenant.without_tenant do + expect(version.assessment).not_to eq(listing.authoring_assessment) + expect(version.assessment.course).to eq(container) + end + end + + it 'retains the previous snapshot' do + v1 = listing.current_version + + expect { described_class.publish_new_version(listing.reload, cutter) }. + to change { listing.reload.versions.count }.by(1) + expect(v1.reload).to be_persisted + end + + it 'adds exactly one assessment to the container per cut' do + expect { described_class.publish_new_version(listing.reload, cutter) }. + to change { ActsAsTenant.without_tenant { container.assessments.count } }.by(1) + end + + it 'keeps advancing past the second cut' do + described_class.publish_new_version(listing.reload, cutter) + third = described_class.publish_new_version(listing.reload, cutter) + + expect(third.published_at).to eq(listing.reload.last_published_at) + end + + it 'bumps last_published_at' do + listing.update!(last_published_at: 3.days.ago) + + expect { described_class.publish_new_version(listing.reload, cutter) }. + to(change { listing.reload.last_published_at }) + end + + it 'raises when the listing is orphaned' do + listing.update!(authoring_assessment: nil) + + expect { described_class.publish_new_version(listing.reload, cutter) }. + to raise_error(ArgumentError, /orphaned/) + end + end + + describe 'provenance capture' do + it 'records the source course name at publish' do + listing = described_class.publish(assessment, publisher) + + expect(listing.source_course).to eq(course) + expect(listing.source_course_name).to eq(course.title) + end + + # `Course` is tenanted by instance, so the origin instance is what makes the recorded course id + # resolvable at all — and what tells two courses of the same name in different instances apart. + it 'records the source instance at publish' do + listing = described_class.publish(assessment, publisher) + + expect(listing.source_instance).to eq(instance) + end + + it 'repairs a source instance missing from an existing listing on re-publish' do + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + published: true) + listing.update_columns(source_instance_id: nil) + + described_class.publish(assessment, publisher) + + expect(listing.reload.source_instance).to eq(instance) + end + + # `||=`, like every sibling provenance field: provenance is what was true at first publish, and + # a later re-publish (possibly from a course moved between instances) must not rewrite history. + it 'does not overwrite a source instance already captured when re-published' do + origin_instance = create(:instance) + listing = create(:course_assessment_marketplace_listing, authoring_assessment: assessment, + published: false, + source_instance: origin_instance) + + described_class.publish(assessment, publisher) + + expect(listing.reload.source_instance).to eq(origin_instance) + end + end + + describe 'version publication dates' do + let(:publisher) { create(:user) } + + it 'dates v1 from the listing first-publication date, not the moment of the cut' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + + expect(listing.current_version.published_at). + to be_within(1.second).of(listing.first_published_at) + end + + it 'dates a later cut from the moment of the cut' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + listing.update!(first_published_at: 30.days.ago) + + version = described_class.publish_new_version(listing, publisher) + + expect(version.published_at).to be_within(5.seconds).of(Time.zone.now) + end + + # One `Time.zone.now`, written twice. Two separate calls would leave the version row and the + # listing disagreeing by milliseconds, and the admin table reads one while the history reads + # the other. + it 'writes the identical instant to the version row and the listing' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + + version = described_class.publish_new_version(listing, publisher) + + expect(version.published_at).to eq(listing.reload.last_published_at) + end + + it 'orders successive cuts by ascending published_at' do + assessment = create(:assessment) + listing = described_class.publish(assessment, publisher) + second = described_class.publish_new_version(listing, publisher) + + expect(listing.versions.ordered.last).to eq(second) + expect(listing.reload.current_version).to eq(second) + end + end + end +end From c8f5be9eae1adaa51a055beb8c8145378a73db0d Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:36 +0800 Subject: [PATCH 19/30] feat(marketplace): rebuild or purge a listing that lost its source Deleting the source assessment now orphans the listing instead of destroying it: its snapshots survive, and the authoring copy is rebuilt in the container automatically, which returns the listing to the marketplace and lets a new version be published. --- .../marketplace/restore_authoring_job.rb | 59 +++++++ app/models/course/assessment.rb | 109 +++++++++++- .../assessment/marketplace/purge_service.rb | 56 ++++++ .../marketplace/restore_authoring_job_spec.rb | 156 ++++++++++++++++ spec/models/course/assessment_spec.rb | 167 ++++++++++++++++++ .../marketplace/purge_service_spec.rb | 134 ++++++++++++++ 6 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 app/jobs/course/assessment/marketplace/restore_authoring_job.rb create mode 100644 app/services/course/assessment/marketplace/purge_service.rb create mode 100644 spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb create mode 100644 spec/services/course/assessment/marketplace/purge_service_spec.rb diff --git a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb new file mode 100644 index 0000000000..ce40f81851 --- /dev/null +++ b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +# Un-orphans a listing: duplicates the listing's latest snapshot into the marketplace's +# own container course as a NEW, editable assessment and points `authoring_assessment` at it, so the +# listing returns to the marketplace and `PublishService.publish_new_version` works again. +# +# The copy is a NEW assessment sitting alongside the immutable snapshots — never one of them. Editing +# a snapshot in place would mutate a published version for every adopter with no version cut and make +# `adoptions.adopted_version` a lie. +# +# The container's content freeze does not obstruct this: `restrict_preview_course_content` is reached +# only via `define_non_admin_course_permissions`, guarded by `!user&.administrator?` +# (assessment_marketplace_ability_component.rb:21, :37), and every marketplace write is already +# admin-only. So an admin can edit the working copy and cut v(n+1) from it in place. +class Course::Assessment::Marketplace::RestoreAuthoringJob < ApplicationJob + include TrackableJob + include Rails.application.routes.url_helpers + + queue_as :duplication + + protected + + def perform_tracked(listing_id, options = {}) + current_user = options[:current_user] + # The container lives in the dedicated preview instance, never the caller's. + ActsAsTenant.without_tenant do + listing = Course::Assessment::Marketplace::Listing.find(listing_id) + # Re-checked here and not only in the controller: the listing can be republished (which + # restores an authoring copy on its own) between enqueue and perform, and this job is the only + # writer of `authoring_assessment`. It is a column read on a loaded record. + return unless listing.orphaned? + raise ArgumentError, 'listing is not restorable' unless listing.restorable? + + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + copy = restore_authoring_copy(listing, container, current_user) + redirect_to course_assessment_url(container, copy, host: container.instance.host) + end + end + + private + + # @return [Course::Assessment] the fresh authoring copy + def restore_authoring_copy(listing, container, current_user) + # The SNAPSHOT, so the restored copy is exactly the content of the latest published version. + source = listing.current_version.assessment + User.with_stamper(current_user) do + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( + source.course, container, source, current_user: current_user + ) + # The restored copy is a standalone assessment, not a link-sibling of the container snapshot + # and of every adopter's copy. See Course::Assessment#detach_from_link_tree!. + copy.detach_from_link_tree! + # `source_course` and `source_course_name` are deliberately left untouched: they record where + # the content originally came from, a historical fact. Restoring is a maintenance action on the + # listing, not a republish from a new origin, so rewriting provenance would falsify its history. + listing.update!(authoring_assessment: copy) + copy + end + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index b4f5584474..40aaa29003 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -19,6 +19,9 @@ class Course::Assessment < ApplicationRecord after_create :set_linkable_tree_id after_commit :grade_with_new_test_cases, on: :update before_save :save_tab + # See #rebuild_marketplace_listing_authoring for why the pair is split across the two callbacks. + before_destroy :remember_orphaned_marketplace_listing + after_commit :rebuild_marketplace_listing_authoring, on: :destroy enum :randomization, { prepared: 0 } @@ -82,8 +85,12 @@ class Course::Assessment < ApplicationRecord has_one :gradebook_assessment_contribution, class_name: 'Course::Gradebook::AssessmentContribution', dependent: :destroy, inverse_of: :assessment + # `dependent: :nullify`, NOT `:destroy`: deleting the source assessment must ORPHAN the listing, + # not destroy it along with its version chain and every adopter's adoption record. The model + # callback fires before the DB, so this and the FK's `on_delete: :nullify` must move together. has_one :marketplace_listing, class_name: 'Course::Assessment::Marketplace::Listing', - inverse_of: :assessment, dependent: :destroy + foreign_key: :authoring_assessment_id, + inverse_of: :authoring_assessment, dependent: :nullify has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', inverse_of: :assessment, dependent: :destroy has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy @@ -126,6 +133,19 @@ class Course::Assessment < ApplicationRecord merge(Course::LessonPlan::Item.ordered_by_date_and_title) end) + # Every assessment title already taken in a course, downcased for case-insensitive comparison. + # + # @param [Course] course + # @param [Integer, nil] except_id an assessment to leave out — the in-place update overwrites its + # own title and must not collide with itself. + # @return [Array] + def self.titles_in_course(course, except_id: nil) + scope = course.assessments.joins(:lesson_plan_item) + scope = scope.where.not(id: except_id) if except_id + + scope.pluck('LOWER(course_lesson_plan_items.title)') + end + # @!method with_submissions_by(creator) # Includes the submissions by the provided user. # @param [User] user The user to preload submissions for. @@ -185,6 +205,36 @@ def to_partial_path 'course/assessment/assessments/assessment' end + # Splits this assessment's submissions into those by real students of its course and everything + # else, which is what decides whether its content may be replaced in place. + # + # A LEFT JOIN, deliberately: a submission whose author has since left the course has no + # `course_users` row, and an INNER JOIN would drop it from BOTH counts - making a copy look + # untouched when it is not. It lands in `other`. + # + # Submissions carry no `course_user_id`, so course membership is resolved through + # `creator_id` + `course_id`. `role = 0` is `student` on Course::CourseUser's enum. + # + # Workflow state is deliberately not considered: an untouched `attempting` draft is still a + # student's attempt, and destroying it would be destroying their work. + # + # @return [Hash{Symbol => Integer}] + def submission_counts_by_author + sql = self.class.sanitize_sql_array([<<-SQL.squish, course.id, id]) + SELECT + COUNT(*) FILTER (WHERE cu.id IS NOT NULL AND cu.role = 0 AND cu.phantom = FALSE) + AS student_count, + COUNT(*) FILTER (WHERE cu.id IS NULL OR cu.role <> 0 OR cu.phantom = TRUE) + AS other_count + FROM course_assessment_submissions s + LEFT JOIN course_users cu ON cu.user_id = s.creator_id AND cu.course_id = ? AND cu.deleted_at IS NULL + WHERE s.assessment_id = ? + SQL + row = self.class.connection.select_one(sql) + + { student: row['student_count'].to_i, other: row['other_count'].to_i } + end + # Update assessment mode from params. # # @param [Hash] params Params with autograded mode from user. @@ -337,8 +387,65 @@ def all_linked_assessments ([self] + linked_assessments.includes(:course, :submissions)).uniq end + # Makes this assessment a standalone link tree of one. + # + # Marketplace distribution is not "linking". `initialize_duplicate` propagates the source's + # `linkable_tree_id` and rebuilds `linked_assessments` — right for course duplication, wrong here: + # without this the container snapshot, the origin, and every adopter's copy become mutual + # `linked_assessments`, exposing ids across unrelated courses and crashing onward duplication. + # + # Called on the snapshot at publish time and on the adopted copy at duplication time. + def detach_from_link_tree! + links.destroy_all + reverse_links.destroy_all + update_column(:linkable_tree_id, id) + end + + # Whether this assessment is a published version of some listing — one of the immutable snapshots + # `Course::Assessment::Marketplace::PublishService` duplicates into the container course. + # + # A snapshot is an existing listing's content, never a source assessment, so it must not be + # publishable in its own right. + # + # Deliberately keyed on the version rows rather than on the container's `preview` flag: an + # assessment authored directly in the container is not a snapshot and stays publishable. + # + # @return [Boolean] + def marketplace_snapshot? + Course::Assessment::Marketplace::ListingVersion.exists?(assessment_id: id) + end + private + # The listing this assessment authors, if losing it would orphan a listing that can be rebuilt. + def remember_orphaned_marketplace_listing + listing = marketplace_listing + @orphaned_marketplace_listing_id = listing&.current_version_id ? listing.id : nil + end + + # Rebuilds the listing's authoring copy in the marketplace container as soon as its source is gone. + # Every course-facing path reads that copy, so until it is back the listing is off the marketplace + # and cannot publish a new version — which an admin would otherwise learn only by visiting the + # listings table. Doing it automatically makes the manual "Rebuild source assessment" a retry only. + # + # This is the single choke point for both ways a listing loses its source: a course deletion + # cascades to its assessments through Ruby `dependent: :destroy` (course -> categories -> tabs -> + # assessments), so it arrives here too. + # + # `after_commit`, never `after_destroy`: a course deletion destroys every one of its assessments + # inside one transaction, so a job enqueued mid-destroy could be picked up before the deletion is + # durable — or after a sibling's failure rolled the whole thing back, rebuilding a listing that was + # never orphaned at all. + # + # `User.system` because there is no acting user in a cascade, and the rebuild has to be attributable + # to something: the job stamps the duplicated copy's creator and the listing's updater. + def rebuild_marketplace_listing_authoring + return if @orphaned_marketplace_listing_id.nil? + + Course::Assessment::Marketplace::RestoreAuthoringJob. + perform_later(@orphaned_marketplace_listing_id, current_user: User.system) + end + # Parents the assessment under its duplicated parent tab, if it exists. # # @return [Course::Assessment::Tab] The duplicated assessment's tab diff --git a/app/services/course/assessment/marketplace/purge_service.rb b/app/services/course/assessment/marketplace/purge_service.rb new file mode 100644 index 0000000000..823435c666 --- /dev/null +++ b/app/services/course/assessment/marketplace/purge_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# Permanently deletes a marketplace listing together with the container snapshots it owns. +# +# Only ever allowed for a listing that is OFF the marketplace — orphaned or unlisted +# (`Listing#purgeable?`). A published listing must be unlisted (`published: false`) first. +# +# Purging destroys the listing's adoption rows too (`has_many :adoptions, dependent: :destroy`), but +# NOT the adopters' own duplicated assessments: `Adoption belongs_to :duplicated_assessment` carries a +# plain FK with no `dependent:` option, so destroying the adoption row never reaches into the +# destination course that assessment lives in. A purge must never touch another course's content. +# +# Purging an unlisted listing destroys the listing, its versions and their container snapshots, but +# NOT the authoring assessment those snapshots were copied from — so unlike the orphaned case the +# content survives and can be published afresh. +class Course::Assessment::Marketplace::PurgeService + # @param [Course::Assessment::Marketplace::Listing] listing + # @raise [ArgumentError] if the listing is not purgeable + # @return [void] + def self.purge!(listing) + new(listing).purge! + end + + def initialize(listing) + @listing = listing + end + + # @return [void] + def purge! + raise ArgumentError, 'only an orphaned or unlisted listing can be permanently deleted' unless + @listing.purgeable? + + # The snapshots live in the hidden container course, which sits in the dedicated preview + # instance — never the caller's. + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::Listing.transaction do + snapshot_ids = @listing.versions.pluck(:assessment_id) + @listing.destroy! + destroy_snapshots(snapshot_ids) + end + end + nil + end + + private + + # Ordering is load-bearing, and it is why the ids are collected before the listing is destroyed: + # `course_assessment_marketplace_listing_versions.assessment_id` carries a plain FK with no + # `on_delete`, so destroying a snapshot while its version row still references it raises + # PG::ForeignKeyViolation. Destroy the listing first (its `versions` cascade), then the snapshots. + # + # Skipping this second step would leak the snapshots: nothing else references them, so the + # container course would grow forever with no reclaim path. + def destroy_snapshots(snapshot_ids) + Course::Assessment.where(id: snapshot_ids).each(&:destroy!) + end +end diff --git a/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb new file mode 100644 index 0000000000..3471c0c1ef --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::RestoreAuthoringJob, type: :job do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + # Published through the real service so the snapshot genuinely lives in the container course in + # the preview instance: restoring must duplicate ACROSS instances, exactly as adoption does. + let(:listing) { Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) } + let(:user) { create(:administrator) } + + def container + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + # Deleting the authoring assessment nullifies `authoring_assessment_id` (`dependent: :nullify`), + # which is what "orphaned" means. + def orphan! + listing + source_assessment.destroy! + listing.reload + end + + def run + described_class.perform_now(listing.id, current_user: user) + end + + context 'when the listing is orphaned with a version' do + before { orphan! } + + it 'duplicates the snapshot into the container course' do + expect { run }.to change { container.assessments.count }.by(1) + end + + # A NEW assessment beside the snapshots, never one of them. Editing a snapshot would mutate a + # published version for every adopter with no version cut. + it 'creates a new assessment rather than reusing the snapshot' do + snapshot = listing.current_version.assessment + + run + + copy = listing.reload.authoring_assessment + expect(copy.id).not_to eq(snapshot.id) + expect(snapshot.reload).to be_persisted + expect(listing.current_version.reload.assessment_id).to eq(snapshot.id) + end + + # Pinned deliberately: this holds only because ObjectDuplicationService's object-mode default is + # `unpublish_all: true` and no caller overrides it. A published working copy in the container + # would be visible to PR8 previewers, so this must fail loudly if that default ever changes. + it 'lands the working copy as a draft' do + run + + expect(listing.reload.authoring_assessment.published).to be(false) + end + + it 'points the listing at the new copy, un-orphaning it' do + run + + copy = listing.reload.authoring_assessment + expect(listing.reload.authoring_assessment).to eq(copy) + expect(listing).not_to be_orphaned + end + + it 'carries the snapshot content into the copy' do + snapshot_title = ActsAsTenant.without_tenant { listing.current_version.assessment.title } + + run + + copy = listing.reload.authoring_assessment + expect(copy.title).to eq(snapshot_title) + expect(copy.questions.count).to eq(1) + end + + # Restoring maintenance access is not a course adopting the content. + it 'records no adoption' do + expect { run }.not_to change(Course::Assessment::Marketplace::Adoption, :count) + end + + # Same reason the adopted copy is detached: without this the restored copy, the container + # snapshot and every adopter's copy become mutual `linked_assessments`. + it 'leaves the restored copy in a link tree of its own' do + run + + copy = listing.reload.authoring_assessment + expect(copy.linkable_tree_id).to eq(copy.id) + expect(copy.all_linked_assessments).to contain_exactly(copy) + end + + # Provenance describes where the content originally came from. A maintenance action must not + # rewrite that historical fact. + it 'leaves the provenance fields untouched' do + provenance = [:source_course_id, :source_course_name] + before_restore = listing.slice(*provenance) + + run + + expect(listing.reload.slice(*provenance)).to eq(before_restore) + expect(listing.source_course).to eq(source_course) + end + + # The end-to-end proof for `#marketplace_hosted?`: the copy lands in the real container, so the + # admin table can tell a rebuilt listing from one that still has its own source course. + it 'reports the listing as marketplace-hosted afterwards' do + expect { run }.to change { listing.reload.marketplace_hosted? }.from(false).to(true) + end + + it 'leaves the current version untouched — restoring is not a republish' do + expect { run }.not_to(change { listing.reload.current_version_id }) + end + + it 'lets the listing cut a new version again' do + run + + expect do + Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) + end.to(change { listing.reload.current_version_id }) + end + end + + describe 'guards' do + # `perform_now` cannot be asserted with `raise_error`: TrackableJob installs + # `rescue_from(StandardError)`, so a refusal surfaces as an errored Job record instead. + def run_and_capture(target = listing) + job = described_class.new(target.id, current_user: user) + job.perform_now + job.job + end + + # Completes rather than errors, and rebuilds nothing: the end state this job exists to reach is + # the one it found. That race is ordinary now that the rebuild is enqueued automatically when an + # assessment is deleted — a republish can restore the authoring copy while the job sits in the + # queue — so it must not surface as a failure to whoever is watching. + it 'leaves a listing that already has an authoring copy alone' do + listing + + expect { run_and_capture }.not_to(change { container.assessments.count }) + expect(run_and_capture.status).to eq('completed') + end + + it 'refuses an orphaned listing with no version to restore from' do + versionless = create(:course_assessment_marketplace_listing, course: source_course) + versionless.authoring_assessment.destroy! + versionless.reload + + expect { run_and_capture(versionless) }. + not_to(change { container.assessments.count }) + expect(run_and_capture(versionless).status).to eq('errored') + end + end + end +end diff --git a/spec/models/course/assessment_spec.rb b/spec/models/course/assessment_spec.rb index 6d8b480816..a6946e6720 100644 --- a/spec/models/course/assessment_spec.rb +++ b/spec/models/course/assessment_spec.rb @@ -436,5 +436,172 @@ expect(result).not_to have_key(empty_assessment.id) end end + + describe '.titles_in_course' do + let(:course) { create(:course) } + + it 'returns the downcased titles of every assessment in the course' do + create(:assessment, course: course, title: 'Lab 3') + create(:assessment, course: course, title: 'Tutorial 1') + + expect(described_class.titles_in_course(course)). + to contain_exactly('lab 3', 'tutorial 1') + end + + # A duplicate title two tabs away is exactly as confusing as one in the same tab, so the whole + # course is the collision scope. + it 'spans every tab and category in the course' do + other_category = create(:course_assessment_category, course: course) + other_tab = create(:course_assessment_tab, category: other_category) + create(:assessment, course: course, title: 'Lab 3') + create(:assessment, course: course, tab: other_tab, title: 'Lab 4') + + expect(described_class.titles_in_course(course)). + to contain_exactly('lab 3', 'lab 4') + end + + it 'ignores assessments in other courses' do + create(:assessment, course: course, title: 'Lab 3') + create(:assessment, course: create(:course), title: 'Foreign Lab') + + expect(described_class.titles_in_course(course)).to eq(['lab 3']) + end + + # The in-place update overwrites an assessment's OWN title, so it must not collide with itself. + it 'excludes the named assessment' do + create(:assessment, course: course, title: 'Lab 3') + self_assessment = create(:assessment, course: course, title: 'Lab 4') + + expect(described_class.titles_in_course(course, except_id: self_assessment.id)). + to eq(['lab 3']) + end + + it 'returns an empty array for a course with no assessments' do + expect(described_class.titles_in_course(create(:course))).to eq([]) + end + end + + describe '#submission_counts_by_author' do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, :with_mcq_question, course: course) } + + it 'is all zeroes for an assessment nobody has attempted' do + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 0) + end + + # A real student's work blocks the in-place update in ANY workflow state - an untouched + # `attempting` draft is still their attempt. + it 'counts a non-phantom student attempt, even while merely attempting' do + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: assessment, creator: student.user) + + expect(assessment.submission_counts_by_author).to eq(student: 1, other: 0) + end + + it 'counts a submitted student submission' do + student = create(:course_student, course: course) + create(:submission, :submitted, assessment: assessment, creator: student.user) + + expect(assessment.submission_counts_by_author).to eq(student: 1, other: 0) + end + + # An instructor's own test run must not permanently cost them the update option. + it 'counts a manager test run as other, not student' do + manager = create(:course_manager, course: course) + create(:submission, :attempting, assessment: assessment, creator: manager.user) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + it 'counts a phantom student test run as other, not student' do + phantom = create(:course_student, :phantom, course: course) + create(:submission, :attempting, assessment: assessment, creator: phantom.user) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + # A submission whose author has since left the course has no course_user row to classify it. + # It must land in `other` rather than vanishing from both counts. + it 'counts a submission by a departed user as other' do + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: assessment, creator: student.user) + student.destroy! + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + it 'counts a submission by a soft-deleted course user as other' do + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: assessment, creator: student.user) + student.update!(deleted_at: Time.zone.now) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 1) + end + + it 'ignores submissions on a different assessment' do + other_assessment = create(:assessment, :with_mcq_question, course: course) + student = create(:course_student, course: course) + create(:submission, :attempting, assessment: other_assessment, creator: student.user) + + expect(assessment.submission_counts_by_author).to eq(student: 0, other: 0) + end + end + + # Deleting the source assessment ORPHANS its listing rather than destroying it: the marketplace + # goes on serving the last snapshot, but nobody can publish a new version of it again. Rebuilding + # the authoring copy is what restores that, and it is automatic so an admin never has to notice + # the breakage first — the "Rebuild source assessment" action remains only as a manual retry. + describe 'automatic marketplace authoring rebuild' do + with_active_job_queue_adapter(:test) do + let(:listing) do + create(:course_assessment_marketplace_listing, :versioned, course: course) + end + let(:listing_without_version) do + create(:course_assessment_marketplace_listing, course: course) + end + + it 'enqueues a rebuild when the source assessment is deleted' do + listed_assessment = listing.authoring_assessment + + expect { listed_assessment.destroy! }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing.id, current_user: User.system) + end + + # A course deletion cascades to its assessments through Ruby `dependent: :destroy`, so the one + # hook on the assessment covers both ways a listing can lose its source. + # + # The snapshot is placed OUTSIDE the origin course, which is where a real one lives (the + # marketplace container). The `:versioned` factory's same-course stand-in cannot be used here: + # deleting the course would try to delete the snapshot too and trip the version's foreign key, + # a collision the production layout makes impossible. + it 'enqueues a rebuild when the whole source course is deleted' do + version = create(:course_assessment_marketplace_listing_version, + listing: listing_without_version, + assessment: create(:assessment, course: create(:course)), + published_at: Time.zone.now, + published_by: listing_without_version.publisher) + listing_without_version.update!(current_version: version) + + expect { course.destroy! }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing_without_version.id, current_user: User.system) + end + + # There is nothing to rebuild FROM: the rebuild duplicates the latest snapshot, and this + # listing has never published one. It stays orphaned, and the admin's only route is deletion. + it 'enqueues nothing for a listing that has never published a version' do + versionless = create(:course_assessment_marketplace_listing, course: course) + + expect { versionless.authoring_assessment.destroy! }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + end + + it 'enqueues nothing when the assessment authors no listing at all' do + expect { assessment.destroy! }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + end + end + end end end diff --git a/spec/services/course/assessment/marketplace/purge_service_spec.rb b/spec/services/course/assessment/marketplace/purge_service_spec.rb new file mode 100644 index 0000000000..db821b4225 --- /dev/null +++ b/spec/services/course/assessment/marketplace/purge_service_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::PurgeService, type: :service do + let!(:instance) { Instance.default } + with_tenant(:instance) do + # The `:versioned` trait stands the snapshot up in the origin's own course rather than the shared + # preview container (see the factory) — the rows and the FK graph under test are identical, and + # unrelated specs then do not pay for container provisioning. + let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } + let(:snapshot) { listing.current_version.assessment } + + def orphan! + listing.authoring_assessment.destroy! + listing.reload + end + + # `orphan!` deletes the authoring assessment, which enqueues the automatic `RestoreAuthoringJob` + # rebuild. Under the test env's default :background_thread adapter that job would run CONCURRENTLY + # with the purge under test: it duplicates the snapshot — link-rowing it, so destroying the snapshot + # trips course_assessment_links' foreign key — and un-orphans the listing, so `purgeable?` may be + # false by the time the purge reads it. Enqueue without performing; the rebuild has its own spec. + with_active_job_queue_adapter(:test) do + describe '.purge!' do + context 'when the listing is orphaned with no adoptions' do + before { snapshot && orphan! } + + it 'deletes the listing' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1) + end + + it 'deletes its versions' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::ListingVersion.where(listing_id: listing.id).count }.by(-1) + end + + # Without this the container course would grow forever: nothing else references a snapshot + # once its version row is gone, so there would be no reclaim path. + it 'deletes the container snapshot assessments' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + end + + it 'deletes every snapshot, not only the current one' do + older = create(:assessment, course: snapshot.course) + older_published_at = listing.current_version.published_at - 1.day + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: older, published_at: older_published_at, + published_by: listing.publisher) + + expect { described_class.purge!(listing) }. + to change { Course::Assessment.where(id: [snapshot.id, older.id]).count }.by(-2) + end + + it 'leaves an unrelated listing and its snapshot alone' do + other = create(:course_assessment_marketplace_listing, :versioned) + other_snapshot = other.current_version.assessment + + described_class.purge!(listing) + + expect(other.reload).to be_persisted + expect(other_snapshot.reload).to be_persisted + end + end + + # Unlisted rather than orphaned: the authoring copy is still there, so the source assessment + # outlives the purge and the listing can simply be published again. + context 'when the listing is unlisted with no adoptions' do + before do + snapshot + listing.update!(published: false) + end + + it 'deletes the listing and its snapshots' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + end + + it 'leaves the authoring assessment alone, so the listing can be published again' do + authoring = listing.authoring_assessment + + described_class.purge!(listing) + + expect(authoring.reload).to be_persisted + end + end + + # Publishing is the state that has to be undone first; unlisting is reversible, purging is not. + context 'when the listing is still published' do + it 'raises and deletes nothing' do + snapshot + expect { described_class.purge!(listing) }.to raise_error(ArgumentError) + expect(listing.reload).to be_persisted + expect(snapshot.reload).to be_persisted + end + end + + context 'when the unlisted listing has adoptions' do + before { listing.update!(published: false) } + + it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + + # A purge must never reach into another course's content — the adopter's own copy is not the + # listing's or the container's to delete. + expect(duplicated_assessment.reload).to be_persisted + end + end + + context 'when the orphaned listing has adoptions' do + before { orphan! } + + it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + + expect(duplicated_assessment.reload).to be_persisted + end + end + end + end + end +end From fe709cae4deacc659b9ceb0e62555070347ca034 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 30 Jul 2026 10:51:08 +0800 Subject: [PATCH 20/30] fix(spec): stop the marketplace specs polluting the shared test DB Nothing rolls back in this suite, so two unscoped `delete_all`s leaked into every later spec file and failed them under some seeds: `User::Email.delete_all` stripped the address off every earlier file's users (surfacing as `SMTP To address may not be blank` in the mailer specs and a nil `user.email` in the external-assessment import specs), and allow-list rows left behind granted `:access_marketplace` to users the ability spec asserts cannot have it. Scope the email cleanup to the domains that file hardcodes, and clear the allow-list tables in the ability spec the way access_list_query_spec.rb already does. --- .../course/assessment/marketplace/allowlist_rule_spec.rb | 3 +++ spec/models/course/assessment_marketplace_ability_spec.rb | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb index 539d3c683a..ff04ee5fe4 100644 --- a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -4,6 +4,9 @@ RSpec.describe Course::Assessment::Marketplace::AllowlistRule, type: :model do let!(:instance) { Instance.default } + # Only this file's own domains: an unscoped `User::Email.delete_all` also strips every earlier spec + # file's users, surfacing as `SMTP To address may not be blank` in the mailer specs. `LOWER() LIKE` + # because the uniqueness index is on `lower(email)` while SQL LIKE is case-sensitive. HARDCODED_EMAIL_DOMAINS = ['schools.gov.sg', 'other.edu', 'school.edu', 'newdomain.example'].freeze before do diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 0606e471b9..8b81eedb78 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -3,6 +3,14 @@ RSpec.describe Course::Assessment::Marketplace, type: :model do let!(:instance) { Instance.default } + + # Nothing rolls back here, so a leaked `everyone` rule from an earlier spec file would grant + # `:access_marketplace` to the not-allow-listed users below. Same cleanup as access_list_query_spec. + before do + Course::Assessment::Marketplace::AllowlistRule.delete_all + Course::Assessment::Marketplace::AccessBlock.delete_all + end + with_tenant(:instance) do let(:course) { create(:course) } let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } From cabf96277f4e98953f4cec971bc551a7a3746196 Mon Sep 17 00:00:00 2001 From: lws49 Date: Sun, 2 Aug 2026 03:21:25 +0800 Subject: [PATCH 21/30] fix(duplication): stop assessment links propagating across instances A link row is only ever read back through Course, which is acts_as_tenant :instance, so a row pointing into another instance resolves its course to nil for every later reader: the next duplication dies in Course::LessonPlan::Item#link_default_reference_time, and a plagiarism run either dies in Course::SsidFolderConcern#sync_assessment_ssid_folder or silently uploads that assessment's submissions to SSID. Filter at the single write site instead of at each reader, and against the destination course's instance rather than the current tenant -- marketplace publish, adoption and restore all run without a tenant, so there is none to compare against on exactly the paths this matters for. The filter judges the SOURCE of each candidate link and never the copy. A duplicate made in the same run lands in the destination course, so the links among the copies survive a cross-instance course duplication even though the links back to the originals do not; only a lone assessment moved across the boundary arrives with nothing. linkable_tree_id propagation is deliberately untouched: assessments imported from the marketplace keep the source's duplication root, so they stay comparable to it and to each other. This retires detach_from_link_tree!, whose linkable_tree_id reset was the part that broke that comparability. The server-side guard on update_assessment_links is deliberately not here: it needs a call on whether to mirror the picker's predicate exactly or check the instance only, and the strict reading breaks an existing spec. --- .../marketplace/restore_authoring_job.rb | 3 - app/models/course/assessment.rb | 53 +++++---- .../assessment/marketplace/publish_service.rb | 3 - .../marketplace/restore_authoring_job_spec.rb | 18 ++- .../course/assessment/duplication_spec.rb | 105 ++++++++++++++++++ .../marketplace/publish_service_spec.rb | 13 +++ 6 files changed, 164 insertions(+), 31 deletions(-) diff --git a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb index ce40f81851..63c1299df5 100644 --- a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb +++ b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb @@ -46,9 +46,6 @@ def restore_authoring_copy(listing, container, current_user) copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( source.course, container, source, current_user: current_user ) - # The restored copy is a standalone assessment, not a link-sibling of the container snapshot - # and of every adopter's copy. See Course::Assessment#detach_from_link_tree!. - copy.detach_from_link_tree! # `source_course` and `source_course_name` are deliberately left untouched: they record where # the content originally came from, a historical fact. Restoring is a maintenance action on the # listing, not a republish from a new origin, so rewriting provenance would falsify its history. diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 40aaa29003..4b9ba173d4 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -335,12 +335,19 @@ def initialize_duplicate(duplicator, other) # rubocop:disable Metrics/AbcSize,Me # the new assessment has links to all linked assessments of the original assessment, # as well as the duplicates of those linked assessments if they are duplicated # in the same process (i.e course duplication) + # + # Links that would cross an instance boundary are dropped rather than carried over. A link row is + # only ever read back through `Course`, which is `acts_as_tenant :instance`, so a row pointing into + # another instance resolves its course to nil for every later reader: the next duplication dies in + # `Course::LessonPlan::Item#link_default_reference_time`, and a plagiarism run either dies in + # `Course::SsidFolderConcern#sync_assessment_ssid_folder` or silently uploads that assessment's + # submissions to SSID. The picker never offers a cross-instance candidate either + # (`Course::Plagiarism::AssessmentsController#linked_and_unlinked_assessments` filters on + # `instance_id`), so this keeps the write side consistent with the read side. linked_assessments = other.all_linked_assessments.flat_map do |assessment| - if duplicator.duplicated?(assessment) - [assessment, duplicator.duplicate(assessment)] - else - assessment - end + # A duplicate lands in the destination course, so it stays linkable however its source is judged. + copies = duplicator.duplicated?(assessment) ? [duplicator.duplicate(assessment)] : [] + copies + (linkable_within_destination?(assessment, duplicator) ? [assessment] : []) end self.linked_assessments = linked_assessments.reject { |assessment| assessment == self } @@ -387,20 +394,6 @@ def all_linked_assessments ([self] + linked_assessments.includes(:course, :submissions)).uniq end - # Makes this assessment a standalone link tree of one. - # - # Marketplace distribution is not "linking". `initialize_duplicate` propagates the source's - # `linkable_tree_id` and rebuilds `linked_assessments` — right for course duplication, wrong here: - # without this the container snapshot, the origin, and every adopter's copy become mutual - # `linked_assessments`, exposing ids across unrelated courses and crashing onward duplication. - # - # Called on the snapshot at publish time and on the adopted copy at duplication time. - def detach_from_link_tree! - links.destroy_all - reverse_links.destroy_all - update_column(:linkable_tree_id, id) - end - # Whether this assessment is a published version of some listing — one of the immutable snapshots # `Course::Assessment::Marketplace::PublishService` duplicates into the container course. # @@ -491,6 +484,28 @@ def set_linkable_tree_id update_column(:linkable_tree_id, id) end + # Whether a link to `assessment` can survive duplication into this duplicator's destination course. + # + # Compares against the DESTINATION's instance rather than the current tenant: marketplace publish, + # adoption and restore all run inside `ActsAsTenant.without_tenant`, so there is no tenant to compare + # against on exactly the paths this matters for. + # + # `assessment.course` is nil in two situations and both mean "not linkable" — the tenant scope + # filtered a foreign course out, or `all_linked_assessments` preloaded it as nil for the same reason. + # Ordinary course duplication runs under a tenant and reaches the answer through that nil; the + # marketplace paths run tenant-free and reach it through a real `instance_id` mismatch. Both give the + # same verdict, which is why the safe-navigation is load-bearing rather than defensive. + # + # @param [Course::Assessment] assessment + # @param [Duplicator] duplicator + # @return [Boolean] + def linkable_within_destination?(assessment, duplicator) + destination_course = duplicator.options[:destination_course] + return true if destination_course.nil? + + assessment.course&.instance_id == destination_course.instance_id + end + def tab_in_same_course return unless tab_id_changed? diff --git a/app/services/course/assessment/marketplace/publish_service.rb b/app/services/course/assessment/marketplace/publish_service.rb index 5e0769c6f7..a95f16d629 100644 --- a/app/services/course/assessment/marketplace/publish_service.rb +++ b/app/services/course/assessment/marketplace/publish_service.rb @@ -116,9 +116,6 @@ def snapshot_into_container(assessment) assessment.course, container, assessment, current_user: @publisher ) reparent_into_container_tab(copy, container) - # A published snapshot is a standalone assessment, not a link-sibling of the origin. See - # Course::Assessment#detach_from_link_tree!. - copy.detach_from_link_tree! copy end diff --git a/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb index 3471c0c1ef..5931dd4467 100644 --- a/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb +++ b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb @@ -81,14 +81,20 @@ def run expect { run }.not_to change(Course::Assessment::Marketplace::Adoption, :count) end - # Same reason the adopted copy is detached: without this the restored copy, the container - # snapshot and every adopter's copy become mutual `linked_assessments`. - it 'leaves the restored copy in a link tree of its own' do + # Restoring duplicates the snapshot WITHIN the container course, so the instance filter in + # `#initialize_duplicate` keeps that one link. It is inert: a later adoption crosses from the + # preview instance into the adopter's, and is filtered there — proven by + # `publish_service_spec`'s "gives the snapshot the source root and no links". + it 'keeps the source root and links only to the snapshot it was restored from' do run - copy = listing.reload.authoring_assessment - expect(copy.linkable_tree_id).to eq(copy.id) - expect(copy.all_linked_assessments).to contain_exactly(copy) + listing.reload + copy = listing.authoring_assessment + snapshot = listing.current_version.assessment + ActsAsTenant.without_tenant do + expect(copy.linkable_tree_id).to eq(snapshot.linkable_tree_id) + expect(copy.linked_assessments).to contain_exactly(snapshot) + end end # Provenance describes where the content originally came from. A maintenance action must not diff --git a/spec/models/course/assessment/duplication_spec.rb b/spec/models/course/assessment/duplication_spec.rb index 814b45ce6d..61e5447564 100644 --- a/spec/models/course/assessment/duplication_spec.rb +++ b/spec/models/course/assessment/duplication_spec.rb @@ -43,6 +43,111 @@ end end + # A link that crosses an instance boundary must not survive duplication. Every later reader + # resolves a link through `Course`, which is `acts_as_tenant :instance`, so such a row comes + # back with a nil course: the next duplication dies in + # `Course::LessonPlan::Item#link_default_reference_time` and a plagiarism run dies in + # `Course::SsidFolderConcern#sync_assessment_ssid_folder`. + context 'when a link crosses an instance boundary' do + let(:other_instance) { create(:instance) } + let!(:foreign_assessment) do + ActsAsTenant.with_tenant(other_instance) do + create(:assessment, course: create(:course), start_at: Time.zone.now) + end + end + + before do + Course::Assessment::Link.create!(assessment: assessment_b, + linked_assessment: foreign_assessment) + end + + subject do + duplicator = Duplicator.new([], { + time_shift: 2.days, + destination_course: source_course + }) + duplicate_b = duplicator.duplicate(assessment_b) + duplicate_b.save! + duplicate_b + end + + it 'drops the cross-instance link and keeps the same-instance ones' do + expect(subject.linked_assessments). + to contain_exactly(assessment_a, assessment_b, assessment_c) + end + + it 'leaves the source assessment its own cross-instance link untouched' do + subject + expect(assessment_b.reload.linked_assessments).to include(foreign_assessment) + end + + it 'still inherits linkable_tree_id' do + expect(subject.linkable_tree_id).to eq(assessment_b.id) + end + end + + # `CourseDuplicationService#duplicate_course` accepts a `destination_instance_id`, so a course + # can be moved to another instance. A lone assessment then arrives with no links at all. + context 'when the destination course is in another instance' do + let(:other_instance) { create(:instance) } + let(:foreign_course) do + ActsAsTenant.with_tenant(other_instance) { create(:course) } + end + + # Tenant-free, mirroring `Course::DuplicationJob:14` — a cross-instance duplication cannot run + # under either instance's tenant, because the conditional extension resolves the destination + # course by id (`extensions/conditional/active_record/base.rb:105`). It also means the filter + # reaches its verdict here by comparing two real `instance_id`s, where the context above + # reaches the same verdict through a tenant-scoped nil. + subject do + ActsAsTenant.without_tenant do + duplicator = Duplicator.new([], { + time_shift: 2.days, + destination_course: foreign_course + }) + duplicate_b = duplicator.duplicate(assessment_b) + duplicate_b.save! + duplicate_b + end + end + + it 'arrives with no links' do + expect(subject.linked_assessments).to be_empty + end + + it 'still inherits linkable_tree_id across the boundary' do + expect(subject.linkable_tree_id).to eq(assessment_b.id) + end + end + + # Copies made in the same run land in the destination course, so the links among THEM survive + # the boundary. Only the links back to the source instance are dropped. + context 'when a whole course is duplicated into another instance' do + let(:other_instance) { create(:instance) } + let(:new_course) do + # Both forced first: `create(:administrator)` needs a tenant, and the block below has none. + duplicator_user = admin + destination_instance_id = other_instance.id + ActsAsTenant.without_tenant do + Course::Duplication::CourseDuplicationService.duplicate_course( + source_course, + current_user: duplicator_user, + new_start_at: (source_course.start_at + 3.days).iso8601, + new_title: "#{source_course.title} copy", + destination_instance_id: destination_instance_id + ) + end + end + + it 'keeps the links between the copies and drops only the ones back to the source' do + duplicate_b = new_course.assessments.find_by(title: assessment_b.title) + duplicate_c = new_course.assessments.find_by(title: assessment_c.title) + + expect(duplicate_b.linked_assessments).to contain_exactly(duplicate_c) + expect(duplicate_c.linked_assessments).to contain_exactly(duplicate_b) + end + end + context 'when duplicating a course with multiple linked assessments' do let(:time_shift) { 3.days } let(:new_course) do diff --git a/spec/services/course/assessment/marketplace/publish_service_spec.rb b/spec/services/course/assessment/marketplace/publish_service_spec.rb index f13491e697..be5f7df759 100644 --- a/spec/services/course/assessment/marketplace/publish_service_spec.rb +++ b/spec/services/course/assessment/marketplace/publish_service_spec.rb @@ -32,6 +32,19 @@ def container end end + # The snapshot shares the source's duplication root so plagiarism comparison can still reach + # across everything descended from it, but carries no link rows: publishing crosses from the + # source instance into the preview instance, and `#initialize_duplicate` drops those. + it 'gives the snapshot the source root and no links' do + listing = described_class.publish(assessment, publisher) + snapshot = listing.current_version.assessment + ActsAsTenant.without_tenant do + expect(snapshot.linkable_tree_id).to eq(assessment.linkable_tree_id) + expect(snapshot.linked_assessments).to be_empty + expect(snapshot.reverse_linked_assessments).to be_empty + end + end + it 'creates exactly one version row' do expect { described_class.publish(assessment, publisher) }. to change { Course::Assessment::Marketplace::ListingVersion.count }.by(1) From 9b26a0d64cd2cef750a8638d5ab28868ff05a707 Mon Sep 17 00:00:00 2001 From: lws49 Date: Sun, 2 Aug 2026 03:41:22 +0800 Subject: [PATCH 22/30] fix(marketplace): re-point a losing listing inside the destroy transaction Deleting the source assessment of a versioned listing now clones its latest snapshot and re-points authoring_assessment inside the same transaction, instead of orphaning the listing and rebuilding it from an after_commit job. A destroy that fails afterwards unwinds the clone with it, so the listing is never observably orphaned and the rebuild no longer depends on the queue. The trade is deliberate: a failure inside the re-point now aborts the delete that triggered it, so a marketplace bug can stop somebody deleting their own course. That is preferred to a silent rescue, which would put back exactly the orphan state this removes. dependent: :nullify comes off has_one :marketplace_listing: Rails registers it as its own before_destroy, which would write NULL over the pointer the callback just set. The FK (fk_caml_authoring_assessment_id, ON DELETE SET NULL) remains the backstop for a listing with no version to clone from, and the callback is now the single Ruby writer of that column. RestoreAuthoringJob is deleted rather than kept as a manual retry. Nothing enqueued it once the after_commit hook went, no admin action ever reached it, and its guard Listing#restorable? requires orphaned? && current_version_id -- a pair the re-point makes unreachable, since a listing with a version no longer orphans and one without has nothing to restore from. Listing#restorable? goes with it. PurgeService now reclaims an authoring copy that lives in the container. That is the normal shape after a re-point, and once the listing is destroyed nothing references it, so it would leak into the container exactly as an unreclaimed snapshot would. An authoring copy in somebody's own course is still left alone. Also from review: - preview_instance matched host case-sensitively while the DB indexes lower(host) UNIQUE, and saves with validate: false, so a differently-cased row was invisible and then collided on insert. Now case-insensitive, and the loser of a concurrent insert re-reads instead of raising -- inside requires_new: true, because the re-point calls this from a before_destroy and a unique violation would otherwise abort the rescue's re-read with the transaction. - Noted why the listing show page still reads the authoring copy. --- .../marketplace/restore_authoring_job.rb | 56 ----- app/models/course/assessment.rb | 56 ++--- .../course/assessment/marketplace/listing.rb | 21 +- .../marketplace/preview_container_service.rb | 25 ++- .../assessment/marketplace/purge_service.rb | 19 +- .../marketplace/restore_authoring_job_spec.rb | 162 -------------- .../assessment/marketplace/listing_spec.rb | 89 ++++---- spec/models/course/assessment_spec.rb | 171 ++++++++++++--- .../preview_container_service_spec.rb | 20 ++ .../marketplace/purge_service_spec.rb | 204 ++++++++++-------- 10 files changed, 377 insertions(+), 446 deletions(-) delete mode 100644 app/jobs/course/assessment/marketplace/restore_authoring_job.rb delete mode 100644 spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb diff --git a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb deleted file mode 100644 index 63c1299df5..0000000000 --- a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true -# Un-orphans a listing: duplicates the listing's latest snapshot into the marketplace's -# own container course as a NEW, editable assessment and points `authoring_assessment` at it, so the -# listing returns to the marketplace and `PublishService.publish_new_version` works again. -# -# The copy is a NEW assessment sitting alongside the immutable snapshots — never one of them. Editing -# a snapshot in place would mutate a published version for every adopter with no version cut and make -# `adoptions.adopted_version` a lie. -# -# The container's content freeze does not obstruct this: `restrict_preview_course_content` is reached -# only via `define_non_admin_course_permissions`, guarded by `!user&.administrator?` -# (assessment_marketplace_ability_component.rb:21, :37), and every marketplace write is already -# admin-only. So an admin can edit the working copy and cut v(n+1) from it in place. -class Course::Assessment::Marketplace::RestoreAuthoringJob < ApplicationJob - include TrackableJob - include Rails.application.routes.url_helpers - - queue_as :duplication - - protected - - def perform_tracked(listing_id, options = {}) - current_user = options[:current_user] - # The container lives in the dedicated preview instance, never the caller's. - ActsAsTenant.without_tenant do - listing = Course::Assessment::Marketplace::Listing.find(listing_id) - # Re-checked here and not only in the controller: the listing can be republished (which - # restores an authoring copy on its own) between enqueue and perform, and this job is the only - # writer of `authoring_assessment`. It is a column read on a loaded record. - return unless listing.orphaned? - raise ArgumentError, 'listing is not restorable' unless listing.restorable? - - container = Course::Assessment::Marketplace::PreviewContainerService.container_course - copy = restore_authoring_copy(listing, container, current_user) - redirect_to course_assessment_url(container, copy, host: container.instance.host) - end - end - - private - - # @return [Course::Assessment] the fresh authoring copy - def restore_authoring_copy(listing, container, current_user) - # The SNAPSHOT, so the restored copy is exactly the content of the latest published version. - source = listing.current_version.assessment - User.with_stamper(current_user) do - copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( - source.course, container, source, current_user: current_user - ) - # `source_course` and `source_course_name` are deliberately left untouched: they record where - # the content originally came from, a historical fact. Restoring is a maintenance action on the - # listing, not a republish from a new origin, so rewriting provenance would falsify its history. - listing.update!(authoring_assessment: copy) - copy - end - end -end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 4b9ba173d4..d5d66f8892 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -19,9 +19,7 @@ class Course::Assessment < ApplicationRecord after_create :set_linkable_tree_id after_commit :grade_with_new_test_cases, on: :update before_save :save_tab - # See #rebuild_marketplace_listing_authoring for why the pair is split across the two callbacks. - before_destroy :remember_orphaned_marketplace_listing - after_commit :rebuild_marketplace_listing_authoring, on: :destroy + before_destroy :repoint_marketplace_listing_authoring enum :randomization, { prepared: 0 } @@ -85,12 +83,9 @@ class Course::Assessment < ApplicationRecord has_one :gradebook_assessment_contribution, class_name: 'Course::Gradebook::AssessmentContribution', dependent: :destroy, inverse_of: :assessment - # `dependent: :nullify`, NOT `:destroy`: deleting the source assessment must ORPHAN the listing, - # not destroy it along with its version chain and every adopter's adoption record. The model - # callback fires before the DB, so this and the FK's `on_delete: :nullify` must move together. has_one :marketplace_listing, class_name: 'Course::Assessment::Marketplace::Listing', foreign_key: :authoring_assessment_id, - inverse_of: :authoring_assessment, dependent: :nullify + inverse_of: :authoring_assessment has_many :live_feedbacks, class_name: 'Course::Assessment::LiveFeedback', inverse_of: :assessment, dependent: :destroy has_many :links, class_name: 'Course::Assessment::Link', inverse_of: :assessment, dependent: :destroy @@ -410,33 +405,28 @@ def marketplace_snapshot? private - # The listing this assessment authors, if losing it would orphan a listing that can be rebuilt. - def remember_orphaned_marketplace_listing - listing = marketplace_listing - @orphaned_marketplace_listing_id = listing&.current_version_id ? listing.id : nil - end - - # Rebuilds the listing's authoring copy in the marketplace container as soon as its source is gone. - # Every course-facing path reads that copy, so until it is back the listing is off the marketplace - # and cannot publish a new version — which an admin would otherwise learn only by visiting the - # listings table. Doing it automatically makes the manual "Rebuild source assessment" a retry only. - # - # This is the single choke point for both ways a listing loses its source: a course deletion - # cascades to its assessments through Ruby `dependent: :destroy` (course -> categories -> tabs -> - # assessments), so it arrives here too. + # Hands the listing a fresh authoring copy before this assessment goes, so it is never observably + # orphaned. Clones the listing's latest SNAPSHOT (never this assessment, which is about to be + # destroyed) into the marketplace container and re-points `authoring_assessment` at the clone. # - # `after_commit`, never `after_destroy`: a course deletion destroys every one of its assessments - # inside one transaction, so a job enqueued mid-destroy could be picked up before the deletion is - # durable — or after a sibling's failure rolled the whole thing back, rebuilding a listing that was - # never orphaned at all. - # - # `User.system` because there is no acting user in a cascade, and the rebuild has to be attributable - # to something: the job stamps the duplicated copy's creator and the listing's updater. - def rebuild_marketplace_listing_authoring - return if @orphaned_marketplace_listing_id.nil? - - Course::Assessment::Marketplace::RestoreAuthoringJob. - perform_later(@orphaned_marketplace_listing_id, current_user: User.system) + # Returns early, rather than raising, when there is no version to clone from: such a listing has + # nothing to rebuild from and is left to orphan through `fk_caml_authoring_assessment_id`. The early + # return also keeps an assessment that authors no listing from provisioning the preview instance and + # container course inside an ordinary delete. + def repoint_marketplace_listing_authoring + listing = marketplace_listing + return if listing.nil? || listing.current_version_id.nil? + + ActsAsTenant.without_tenant do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + source = listing.current_version.assessment + User.with_stamper(User.system) do + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( + source.course, container, source, current_user: User.system + ) + listing.update!(authoring_assessment: copy) + end + end end # Parents the assessment under its duplicated parent tab, if it exists. diff --git a/app/models/course/assessment/marketplace/listing.rb b/app/models/course/assessment/marketplace/listing.rb index 62740e8faa..85d181a1f2 100644 --- a/app/models/course/assessment/marketplace/listing.rb +++ b/app/models/course/assessment/marketplace/listing.rb @@ -51,13 +51,6 @@ def orphaned? authoring_assessment_id.nil? end - # Restorable = orphaned AND still holding a snapshot to duplicate a fresh authoring copy from. - # A listing that is not orphaned already has one; one without a version has nothing to copy. - # @return [Boolean] - def restorable? - orphaned? && current_version_id.present? - end - # An unlisted listing kept its authoring copy but was taken off the marketplace. Distinct from # orphaned, which is about the authoring copy rather than visibility — and an orphaned listing keeps # its snapshots and its `published` flag, so neither state collapses into the other. @@ -75,9 +68,9 @@ def purgeable? end # Whether the authoring copy lives in the marketplace's container course rather than in a course - # somebody owns — true for a listing rebuilt after orphaning, and for one authored in the - # container directly. It is the only thing on the record that says where the copy an admin would - # edit actually is: `RestoreAuthoringJob` leaves the provenance fields on the origin course. + # somebody owns — true for a listing re-pointed after its source was deleted, and for one authored + # in the container directly. It is the only thing on the record that says where the copy an admin + # would edit actually is: the re-point leaves the provenance fields on the origin course. # # `without_tenant` is load-bearing, not defensive. `Course` is `acts_as_tenant :instance` and the # container lives in the dedicated preview instance, so under every real admin request the tenant @@ -88,10 +81,10 @@ def marketplace_hosted? ActsAsTenant.without_tenant { authoring_assessment&.course&.preview? } || false end - # Whether the original source assessment is gone — either there is no authoring copy at all (never - # rebuilt after orphaning, or the rebuild failed), or there is one but it now lives in the - # marketplace container while the listing was published elsewhere. `RestoreAuthoringJob` produces - # the second case: it duplicates into the container but leaves provenance on the origin course. + # Whether the original source assessment is gone — either there is no authoring copy at all (no + # version to re-point from), or there is one but it now lives in the marketplace container while + # the listing was published elsewhere. The re-point produces the second case: it clones into the + # container but leaves provenance on the origin course. # # `source_course&.preview?` keeps this false for a listing authored in the container directly, # where the container legitimately is the source course and nothing was ever lost. diff --git a/app/services/course/assessment/marketplace/preview_container_service.rb b/app/services/course/assessment/marketplace/preview_container_service.rb index 2312331c1b..735f425209 100644 --- a/app/services/course/assessment/marketplace/preview_container_service.rb +++ b/app/services/course/assessment/marketplace/preview_container_service.rb @@ -20,10 +20,7 @@ class << self # column — so a `*.coursemology.org` host validated against a `localhost:PORT` dev/test default # always fails on the injected colon. `db/seeds.rb` works around it the same way. def preview_instance - Instance.find_by(host: PREVIEW_INSTANCE_HOST) || - Instance.new(host: PREVIEW_INSTANCE_HOST, name: PREVIEW_INSTANCE_NAME).tap do |instance| - instance.save!(validate: false) - end + find_preview_instance || create_preview_instance end # @return [Course] the single `preview: true` container course in the preview instance. @@ -36,6 +33,26 @@ def container_course private + def find_preview_instance + Instance.where('lower(host) = ?', PREVIEW_INSTANCE_HOST.downcase).first + end + + # `save!(validate: false)` skips the model's own uniqueness check as well as hostname validation + # (see the note on this class), so the DB index is the only thing standing between two concurrent + # callers. The loser re-reads rather than raising: provisioning is idempotent by contract. + # + # `requires_new: true` because a caller may already be in a transaction — the re-point runs in a + # `before_destroy` — where a unique violation would abort the rescue's re-read along with it. + def create_preview_instance + ApplicationRecord.transaction(requires_new: true) do + Instance.new(host: PREVIEW_INSTANCE_HOST, name: PREVIEW_INSTANCE_NAME).tap do |instance| + instance.save!(validate: false) + end + end + rescue ActiveRecord::RecordNotUnique + find_preview_instance + end + # `published/gamified/enrollable: false` keep the container out of every listing, level and # self-enrolment path: it holds the marketplace's snapshots, so it must never surface as a # course in its own right. Previewers are attached to it explicitly, one at a time. diff --git a/app/services/course/assessment/marketplace/purge_service.rb b/app/services/course/assessment/marketplace/purge_service.rb index 823435c666..838f5ea571 100644 --- a/app/services/course/assessment/marketplace/purge_service.rb +++ b/app/services/course/assessment/marketplace/purge_service.rb @@ -10,8 +10,9 @@ # destination course that assessment lives in. A purge must never touch another course's content. # # Purging an unlisted listing destroys the listing, its versions and their container snapshots, but -# NOT the authoring assessment those snapshots were copied from — so unlike the orphaned case the -# content survives and can be published afresh. +# NOT an authoring assessment that lives in somebody's course — so the content survives and can be +# published afresh. An authoring copy the marketplace itself owns (one the re-point put in the +# container) has no owner left once the listing is gone, so that one is reclaimed with the snapshots. class Course::Assessment::Marketplace::PurgeService # @param [Course::Assessment::Marketplace::Listing] listing # @raise [ArgumentError] if the listing is not purgeable @@ -34,8 +35,10 @@ def purge! ActsAsTenant.without_tenant do Course::Assessment::Marketplace::Listing.transaction do snapshot_ids = @listing.versions.pluck(:assessment_id) + # Read before the destroy: `marketplace_hosted?` needs the pointer this is about to remove. + container_copy_id = @listing.authoring_assessment_id if @listing.marketplace_hosted? @listing.destroy! - destroy_snapshots(snapshot_ids) + destroy_container_assessments(snapshot_ids + [container_copy_id].compact) end end nil @@ -46,11 +49,11 @@ def purge! # Ordering is load-bearing, and it is why the ids are collected before the listing is destroyed: # `course_assessment_marketplace_listing_versions.assessment_id` carries a plain FK with no # `on_delete`, so destroying a snapshot while its version row still references it raises - # PG::ForeignKeyViolation. Destroy the listing first (its `versions` cascade), then the snapshots. + # PG::ForeignKeyViolation. Destroy the listing first (its `versions` cascade), then the assessments. # - # Skipping this second step would leak the snapshots: nothing else references them, so the - # container course would grow forever with no reclaim path. - def destroy_snapshots(snapshot_ids) - Course::Assessment.where(id: snapshot_ids).each(&:destroy!) + # Skipping this second step would leak them: nothing else references a snapshot or a reclaimed + # authoring copy, so the container course would grow forever with no reclaim path. + def destroy_container_assessments(assessment_ids) + Course::Assessment.where(id: assessment_ids).each(&:destroy!) end end diff --git a/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb deleted file mode 100644 index 5931dd4467..0000000000 --- a/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb +++ /dev/null @@ -1,162 +0,0 @@ -# frozen_string_literal: true -require 'rails_helper' - -RSpec.describe Course::Assessment::Marketplace::RestoreAuthoringJob, type: :job do - let(:instance) { create(:instance) } - with_tenant(:instance) do - let(:source_course) { create(:course) } - let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } - # Published through the real service so the snapshot genuinely lives in the container course in - # the preview instance: restoring must duplicate ACROSS instances, exactly as adoption does. - let(:listing) { Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) } - let(:user) { create(:administrator) } - - def container - ActsAsTenant.without_tenant do - Course::Assessment::Marketplace::PreviewContainerService.container_course - end - end - - # Deleting the authoring assessment nullifies `authoring_assessment_id` (`dependent: :nullify`), - # which is what "orphaned" means. - def orphan! - listing - source_assessment.destroy! - listing.reload - end - - def run - described_class.perform_now(listing.id, current_user: user) - end - - context 'when the listing is orphaned with a version' do - before { orphan! } - - it 'duplicates the snapshot into the container course' do - expect { run }.to change { container.assessments.count }.by(1) - end - - # A NEW assessment beside the snapshots, never one of them. Editing a snapshot would mutate a - # published version for every adopter with no version cut. - it 'creates a new assessment rather than reusing the snapshot' do - snapshot = listing.current_version.assessment - - run - - copy = listing.reload.authoring_assessment - expect(copy.id).not_to eq(snapshot.id) - expect(snapshot.reload).to be_persisted - expect(listing.current_version.reload.assessment_id).to eq(snapshot.id) - end - - # Pinned deliberately: this holds only because ObjectDuplicationService's object-mode default is - # `unpublish_all: true` and no caller overrides it. A published working copy in the container - # would be visible to PR8 previewers, so this must fail loudly if that default ever changes. - it 'lands the working copy as a draft' do - run - - expect(listing.reload.authoring_assessment.published).to be(false) - end - - it 'points the listing at the new copy, un-orphaning it' do - run - - copy = listing.reload.authoring_assessment - expect(listing.reload.authoring_assessment).to eq(copy) - expect(listing).not_to be_orphaned - end - - it 'carries the snapshot content into the copy' do - snapshot_title = ActsAsTenant.without_tenant { listing.current_version.assessment.title } - - run - - copy = listing.reload.authoring_assessment - expect(copy.title).to eq(snapshot_title) - expect(copy.questions.count).to eq(1) - end - - # Restoring maintenance access is not a course adopting the content. - it 'records no adoption' do - expect { run }.not_to change(Course::Assessment::Marketplace::Adoption, :count) - end - - # Restoring duplicates the snapshot WITHIN the container course, so the instance filter in - # `#initialize_duplicate` keeps that one link. It is inert: a later adoption crosses from the - # preview instance into the adopter's, and is filtered there — proven by - # `publish_service_spec`'s "gives the snapshot the source root and no links". - it 'keeps the source root and links only to the snapshot it was restored from' do - run - - listing.reload - copy = listing.authoring_assessment - snapshot = listing.current_version.assessment - ActsAsTenant.without_tenant do - expect(copy.linkable_tree_id).to eq(snapshot.linkable_tree_id) - expect(copy.linked_assessments).to contain_exactly(snapshot) - end - end - - # Provenance describes where the content originally came from. A maintenance action must not - # rewrite that historical fact. - it 'leaves the provenance fields untouched' do - provenance = [:source_course_id, :source_course_name] - before_restore = listing.slice(*provenance) - - run - - expect(listing.reload.slice(*provenance)).to eq(before_restore) - expect(listing.source_course).to eq(source_course) - end - - # The end-to-end proof for `#marketplace_hosted?`: the copy lands in the real container, so the - # admin table can tell a rebuilt listing from one that still has its own source course. - it 'reports the listing as marketplace-hosted afterwards' do - expect { run }.to change { listing.reload.marketplace_hosted? }.from(false).to(true) - end - - it 'leaves the current version untouched — restoring is not a republish' do - expect { run }.not_to(change { listing.reload.current_version_id }) - end - - it 'lets the listing cut a new version again' do - run - - expect do - Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) - end.to(change { listing.reload.current_version_id }) - end - end - - describe 'guards' do - # `perform_now` cannot be asserted with `raise_error`: TrackableJob installs - # `rescue_from(StandardError)`, so a refusal surfaces as an errored Job record instead. - def run_and_capture(target = listing) - job = described_class.new(target.id, current_user: user) - job.perform_now - job.job - end - - # Completes rather than errors, and rebuilds nothing: the end state this job exists to reach is - # the one it found. That race is ordinary now that the rebuild is enqueued automatically when an - # assessment is deleted — a republish can restore the authoring copy while the job sits in the - # queue — so it must not surface as a failure to whoever is watching. - it 'leaves a listing that already has an authoring copy alone' do - listing - - expect { run_and_capture }.not_to(change { container.assessments.count }) - expect(run_and_capture.status).to eq('completed') - end - - it 'refuses an orphaned listing with no version to restore from' do - versionless = create(:course_assessment_marketplace_listing, course: source_course) - versionless.authoring_assessment.destroy! - versionless.reload - - expect { run_and_capture(versionless) }. - not_to(change { container.assessments.count }) - expect(run_and_capture(versionless).status).to eq('errored') - end - end - end -end diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb index 46aff30f53..5db3615ce3 100644 --- a/spec/models/course/assessment/marketplace/listing_spec.rb +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -123,8 +123,13 @@ describe 'maintenance predicates' do let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } + # Orphaning is CONSTRUCTED here rather than derived from a deletion. Deleting the authoring + # assessment of a versioned listing re-points it at a fresh container copy in the same + # transaction (Course::Assessment#repoint_marketplace_listing_authoring), so a deletion no longer + # produces this state: the orphans left are rows orphaned before that shipped, and listings with + # no version to rebuild from. def orphan!(target = listing) - target.authoring_assessment.destroy! + target.update!(authoring_assessment: nil) target.reload end @@ -133,26 +138,11 @@ def orphan!(target = listing) expect(listing).not_to be_orphaned end - it 'is true once the authoring assessment is deleted' do + it 'is true once the listing loses its authoring copy' do expect(orphan!).to be_orphaned end end - describe '#restorable?' do - it 'is true for an orphaned listing that still has a version' do - expect(orphan!).to be_restorable - end - - it 'is false while the listing still has an authoring copy' do - expect(listing).not_to be_restorable - end - - it 'is false for an orphaned listing with no version to restore from' do - unversioned = create(:course_assessment_marketplace_listing) - expect(orphan!(unversioned)).not_to be_restorable - end - end - describe '#unlisted?' do it 'is true once a listing with an authoring copy is taken off the marketplace' do listing.update!(published: false) @@ -262,14 +252,13 @@ def orphan!(target = listing) expect(listing).not_to be_source_assessment_deleted end - it 'is true once the authoring assessment is destroyed (no rebuild yet)' do + it 'is true once the authoring assessment is destroyed' do listing.authoring_assessment.destroy! expect(listing.reload).to be_source_assessment_deleted end - # `RestoreAuthoringJob` always duplicates into the container and leaves `source_course` - # pointing at the ORIGIN, so this is the rebuilt case: published and marketplace-hosted, but - # the original is still gone. + # The re-point clones into the container and leaves `source_course` pointing at the ORIGIN, so + # this is that case: published and marketplace-hosted, but the original is still gone. it 'is true once the authoring copy is rebuilt into the marketplace container' do container = Course::Assessment::Marketplace::PreviewContainerService.container_course rebuilt = ActsAsTenant.with_tenant(container.instance) { create(:assessment, course: container) } @@ -378,29 +367,51 @@ def orphan!(target = listing) end end - describe 'orphaning when the authoring assessment is deleted' do - let!(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } - let!(:adoption) { create(:course_assessment_marketplace_adoption, listing: listing) } + describe 'when the authoring assessment is deleted' do + # The deletion path enqueues nothing, but the env default is `:background_thread` — a real thread + # sharing this example's connection — and these assertions must answer for the callback alone. + with_active_job_queue_adapter(:test) do + let!(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + let!(:adoption) { create(:course_assessment_marketplace_adoption, listing: listing) } + + # The listing NEVER loses its authoring copy while it has a version to rebuild one from: the + # copy is replaced, in the same transaction, by one the marketplace owns. Its version chain and + # its adopters' records are untouched either way — a deleted source assessment must never take + # them with it. + it 'is re-pointed at a marketplace-owned copy, keeping its versions and adoptions' do + expect { listing.authoring_assessment.destroy! }. + not_to(change { described_class.where(id: listing.id).count }) + + expect(listing.reload.authoring_assessment_id).not_to be_nil + expect(listing).to be_marketplace_hosted + expect(listing.versions.count).to eq(1) + expect(listing.adoptions).to include(adoption) + end - it 'survives with a null authoring assessment, keeping its versions and adoptions' do - expect { listing.authoring_assessment.destroy! }. - not_to(change { described_class.where(id: listing.id).count }) + # No version, nothing to rebuild from — and no Ruby `dependent:` option on the association + # either, so this is `fk_caml_authoring_assessment_id`'s `on_delete: :nullify` doing the work. + it 'survives with a null authoring assessment when it has no version, keeping its adoptions' do + versionless = create(:course_assessment_marketplace_listing, published: true) + versionless_adoption = create(:course_assessment_marketplace_adoption, listing: versionless) - expect(listing.reload.authoring_assessment_id).to be_nil - expect(listing.versions.count).to eq(1) - expect(listing.adoptions).to include(adoption) - end + expect { versionless.authoring_assessment.destroy! }. + not_to(change { described_class.where(id: versionless.id).count }) - it 'permits a second orphaned listing to coexist' do - first = create(:course_assessment_marketplace_listing) - second = create(:course_assessment_marketplace_listing) - first.authoring_assessment.destroy! + expect(versionless.reload.authoring_assessment_id).to be_nil + expect(versionless.adoptions).to include(versionless_adoption) + end + + it 'permits a second orphaned listing to coexist' do + first = create(:course_assessment_marketplace_listing) + second = create(:course_assessment_marketplace_listing) + first.authoring_assessment.destroy! - expect { second.authoring_assessment.destroy! }. - not_to(change { described_class.where(id: [first.id, second.id]).count }) + expect { second.authoring_assessment.destroy! }. + not_to(change { described_class.where(id: [first.id, second.id]).count }) - expect(first.reload.authoring_assessment_id).to be_nil - expect(second.reload.authoring_assessment_id).to be_nil + expect(first.reload.authoring_assessment_id).to be_nil + expect(second.reload.authoring_assessment_id).to be_nil + end end end end diff --git a/spec/models/course/assessment_spec.rb b/spec/models/course/assessment_spec.rb index a6946e6720..8fa97e000d 100644 --- a/spec/models/course/assessment_spec.rb +++ b/spec/models/course/assessment_spec.rb @@ -547,59 +547,160 @@ end end - # Deleting the source assessment ORPHANS its listing rather than destroying it: the marketplace - # goes on serving the last snapshot, but nobody can publish a new version of it again. Rebuilding - # the authoring copy is what restores that, and it is automatic so an admin never has to notice - # the breakage first — the "Rebuild source assessment" action remains only as a manual retry. - describe 'automatic marketplace authoring rebuild' do + describe 'in-transaction marketplace authoring re-point' do + # The re-point enqueues nothing, but the env default is `:background_thread` — a real thread + # sharing this example's connection — and these examples assert on row counts in the container. with_active_job_queue_adapter(:test) do let(:listing) do create(:course_assessment_marketplace_listing, :versioned, course: course) end - let(:listing_without_version) do - create(:course_assessment_marketplace_listing, course: course) + + def container + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + def container_assessment_count + ActsAsTenant.without_tenant { container.assessments.count } + end + + # Gives +listing+ a snapshot in the CONTAINER, where a real published one lives. The + # `:versioned` factory's stand-in snapshot sits in the origin course instead, which the + # course-deletion examples cannot use: destroying the course would take the snapshot with it + # and trip the version row's foreign key — a collision the production layout makes impossible. + # + # @return [Course::Assessment] the snapshot + def snapshot_in_container(listing) + snapshot = ActsAsTenant.with_tenant(container.instance) do + create(:assessment, course: container) + end + version = create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: snapshot, + published_at: listing.first_published_at || Time.zone.now, + published_by: listing.publisher) + listing.update!(current_version: version) + snapshot + end + + it 'points the listing at a fresh draft copy in the marketplace container' do + listed_assessment = listing.authoring_assessment + + listed_assessment.destroy! + + copy = listing.reload.authoring_assessment + expect(copy).to be_present + expect(copy.id).not_to eq(listed_assessment.id) + expect(ActsAsTenant.without_tenant { copy.course }).to eq(container) + expect(listing).not_to be_orphaned + # The assessment the user deleted is gone. Re-pointing is not undeletion. + expect(Course::Assessment.where(id: listed_assessment.id)).to be_empty + # A published copy in the container would be visible to previewers. Holds because + # ObjectDuplicationService's object-mode default is `unpublish_all: true`. + expect(copy.published).to be(false) + end + + it 'clones the snapshot rather than the assessment being deleted' do + listed_assessment = listing.authoring_assessment + snapshot = ActsAsTenant.without_tenant { listing.current_version.assessment } + snapshot_title = snapshot.title + listed_assessment.update!(title: 'Drifted since publication') + + listed_assessment.destroy! + + copy = listing.reload.authoring_assessment + expect(copy).to be_present + expect(copy.title).to eq(snapshot_title) + expect(copy.id).not_to eq(snapshot.id) + expect(snapshot.reload).to be_persisted + # The clone inherits the snapshot's duplication root rather than starting a tree of its own, + # so everything descended from the original source stays comparable for plagiarism. The link + # rows are what must not cross an instance boundary, and `#initialize_duplicate` handles that. + expect(copy.linkable_tree_id).to eq(snapshot.linkable_tree_id) end - it 'enqueues a rebuild when the source assessment is deleted' do + it 'cuts no version and leaves the current version alone' do listed_assessment = listing.authoring_assessment + current_version_id = listing.current_version_id expect { listed_assessment.destroy! }. - to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). - with(listing.id, current_user: User.system) + not_to(change { Course::Assessment::Marketplace::ListingVersion.where(listing_id: listing.id).count }) + expect(listing.reload.current_version_id).to eq(current_version_id) end - # A course deletion cascades to its assessments through Ruby `dependent: :destroy`, so the one - # hook on the assessment covers both ways a listing can lose its source. - # - # The snapshot is placed OUTSIDE the origin course, which is where a real one lives (the - # marketplace container). The `:versioned` factory's same-course stand-in cannot be used here: - # deleting the course would try to delete the snapshot too and trip the version's foreign key, - # a collision the production layout makes impossible. - it 'enqueues a rebuild when the whole source course is deleted' do - version = create(:course_assessment_marketplace_listing_version, - listing: listing_without_version, - assessment: create(:assessment, course: create(:course)), - published_at: Time.zone.now, - published_by: listing_without_version.publisher) - listing_without_version.update!(current_version: version) + # The in-transaction proof. The re-point is visible mid-destroy — which an `after_commit` job + # could never manage — and a rollback takes the copy with it, leaving the listing pointing at + # the assessment that survived. + it 'unwinds the copy when the destroy that triggered it fails' do + listed_assessment = listing.authoring_assessment + original_id = listing.authoring_assessment_id + copies_before = container_assessment_count + + ActiveRecord::Base.transaction(requires_new: true) do + listed_assessment.destroy! + + expect(listing.reload.authoring_assessment).to be_present + expect(listing.reload.authoring_assessment_id).not_to eq(original_id) - expect { course.destroy! }. - to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). - with(listing_without_version.id, current_user: User.system) + raise ActiveRecord::Rollback + end + + expect(listing.reload.authoring_assessment_id).to eq(original_id) + expect(listed_assessment.reload).to be_persisted + expect(container_assessment_count).to eq(copies_before) end - # There is nothing to rebuild FROM: the rebuild duplicates the latest snapshot, and this - # listing has never published one. It stays orphaned, and the admin's only route is deletion. - it 'enqueues nothing for a listing that has never published a version' do + # A course deletion cascades to its assessments through Ruby `dependent: :destroy` + # (course -> categories -> tabs -> assessments), so the one hook on the assessment is the + # single choke point for both ways a listing loses its source. + it 'points the listing at a container copy when the whole origin course is deleted' do + listing_in_course = create(:course_assessment_marketplace_listing, course: course) + snapshot = snapshot_in_container(listing_in_course) + + course.destroy! + + copy = listing_in_course.reload.authoring_assessment + expect(copy).to be_present + expect(copy.id).not_to eq(snapshot.id) + expect(ActsAsTenant.without_tenant { copy.course }).to eq(container) + expect(listing_in_course).not_to be_orphaned + end + + # One transaction, two listings: whichever assessment is destroyed first must not leave the + # second's re-point unrun, and the two must not collide over the container. + it 'points every listing in a deleted course at its own container copy' do + first = create(:course_assessment_marketplace_listing, course: course) + second = create(:course_assessment_marketplace_listing, course: course) + snapshot_in_container(first) + snapshot_in_container(second) + + course.destroy! + + expect(first.reload).not_to be_orphaned + expect(second.reload).not_to be_orphaned + expect(first.authoring_assessment_id).not_to eq(second.authoring_assessment_id) + [first, second].each do |listing| + expect(ActsAsTenant.without_tenant { listing.authoring_assessment.course }).to eq(container) + end + end + + # There is nothing to clone FROM, so this listing is left orphaned — and it is the DB foreign + # key that nullifies the column, not a Ruby callback (see the association guard below). Its + # only route out is the admin's deletion. + it 'orphans a listing that has never published a version' do versionless = create(:course_assessment_marketplace_listing, course: course) + listed_assessment = versionless.authoring_assessment + + expect { listed_assessment.destroy! }.to change(Course::Assessment, :count).by(-1) - expect { versionless.authoring_assessment.destroy! }. - not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + expect(versionless.reload).to be_orphaned + expect(Course::Assessment::Marketplace::Listing.where(id: versionless.id)).to exist end - it 'enqueues nothing when the assessment authors no listing at all' do - expect { assessment.destroy! }. - not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + it 'destroys an assessment that authors no listing without cloning anything' do + assessment + + expect { assessment.destroy! }.to change(Course::Assessment, :count).by(-1) end end end diff --git a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb index dc5af62571..2457ebd4af 100644 --- a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb +++ b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb @@ -17,6 +17,26 @@ expect(first.read_attribute(:host)).to eq(described_class::PREVIEW_INSTANCE_HOST) expect(first.name).to eq(described_class::PREVIEW_INSTANCE_NAME) end + + it 'finds an existing preview instance whose host differs only by case' do + existing = described_class.preview_instance + original_host = existing.read_attribute(:host) + existing.update_column(:host, original_host.upcase) + + expect { described_class.preview_instance }.not_to change(Instance, :count) + expect(described_class.preview_instance).to eq(existing) + ensure + existing&.update_column(:host, original_host) if original_host + end + + # Two callers can both miss the lookup and both insert, and the loser must recover rather than + # raise. Exercised by calling the insert directly against an instance that already exists, which + # is exactly the loser's position: the insert collides and the rescue re-reads. + it 'recovers when another caller has already inserted the preview instance' do + existing = described_class.preview_instance + + expect(described_class.send(:create_preview_instance)).to eq(existing) + end end describe '.container_course' do diff --git a/spec/services/course/assessment/marketplace/purge_service_spec.rb b/spec/services/course/assessment/marketplace/purge_service_spec.rb index db821b4225..669c96519f 100644 --- a/spec/services/course/assessment/marketplace/purge_service_spec.rb +++ b/spec/services/course/assessment/marketplace/purge_service_spec.rb @@ -10,123 +10,137 @@ let(:listing) { create(:course_assessment_marketplace_listing, :versioned) } let(:snapshot) { listing.current_version.assessment } + # Constructed, not derived from a deletion: `Course::Assessment#repoint_marketplace_listing_authoring` + # re-points a versioned listing instead, so only a listing with no version can still orphan. def orphan! - listing.authoring_assessment.destroy! + listing.update!(authoring_assessment: nil) listing.reload end - # `orphan!` deletes the authoring assessment, which enqueues the automatic `RestoreAuthoringJob` - # rebuild. Under the test env's default :background_thread adapter that job would run CONCURRENTLY - # with the purge under test: it duplicates the snapshot — link-rowing it, so destroying the snapshot - # trips course_assessment_links' foreign key — and un-orphans the listing, so `purgeable?` may be - # false by the time the purge reads it. Enqueue without performing; the rebuild has its own spec. - with_active_job_queue_adapter(:test) do - describe '.purge!' do - context 'when the listing is orphaned with no adoptions' do - before { snapshot && orphan! } - - it 'deletes the listing' do - expect { described_class.purge!(listing) }. - to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1) - end - - it 'deletes its versions' do - expect { described_class.purge!(listing) }. - to change { Course::Assessment::Marketplace::ListingVersion.where(listing_id: listing.id).count }.by(-1) - end - - # Without this the container course would grow forever: nothing else references a snapshot - # once its version row is gone, so there would be no reclaim path. - it 'deletes the container snapshot assessments' do - expect { described_class.purge!(listing) }. - to change { Course::Assessment.where(id: snapshot.id).count }.by(-1) - end - - it 'deletes every snapshot, not only the current one' do - older = create(:assessment, course: snapshot.course) - older_published_at = listing.current_version.published_at - 1.day - create(:course_assessment_marketplace_listing_version, - listing: listing, assessment: older, published_at: older_published_at, - published_by: listing.publisher) - - expect { described_class.purge!(listing) }. - to change { Course::Assessment.where(id: [snapshot.id, older.id]).count }.by(-2) - end - - it 'leaves an unrelated listing and its snapshot alone' do - other = create(:course_assessment_marketplace_listing, :versioned) - other_snapshot = other.current_version.assessment - - described_class.purge!(listing) - - expect(other.reload).to be_persisted - expect(other_snapshot.reload).to be_persisted - end + describe '.purge!' do + context 'when the listing is orphaned with no adoptions' do + before { snapshot && orphan! } + + it 'deletes the listing' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1) end - # Unlisted rather than orphaned: the authoring copy is still there, so the source assessment - # outlives the purge and the listing can simply be published again. - context 'when the listing is unlisted with no adoptions' do - before do - snapshot - listing.update!(published: false) - end + it 'deletes its versions' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::ListingVersion.where(listing_id: listing.id).count }.by(-1) + end - it 'deletes the listing and its snapshots' do - expect { described_class.purge!(listing) }. - to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). - and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) - end + # Without this the container course would grow forever: nothing else references a snapshot + # once its version row is gone, so there would be no reclaim path. + it 'deletes the container snapshot assessments' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + end - it 'leaves the authoring assessment alone, so the listing can be published again' do - authoring = listing.authoring_assessment + it 'deletes every snapshot, not only the current one' do + older = create(:assessment, course: snapshot.course) + older_published_at = listing.current_version.published_at - 1.day + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: older, published_at: older_published_at, + published_by: listing.publisher) - described_class.purge!(listing) + expect { described_class.purge!(listing) }. + to change { Course::Assessment.where(id: [snapshot.id, older.id]).count }.by(-2) + end - expect(authoring.reload).to be_persisted - end + it 'leaves an unrelated listing and its snapshot alone' do + other = create(:course_assessment_marketplace_listing, :versioned) + other_snapshot = other.current_version.assessment + + described_class.purge!(listing) + + expect(other.reload).to be_persisted + expect(other_snapshot.reload).to be_persisted end + end - # Publishing is the state that has to be undone first; unlisting is reversible, purging is not. - context 'when the listing is still published' do - it 'raises and deletes nothing' do - snapshot - expect { described_class.purge!(listing) }.to raise_error(ArgumentError) - expect(listing.reload).to be_persisted - expect(snapshot.reload).to be_persisted - end + # Unlisted rather than orphaned: the authoring copy is still there, so the source assessment + # outlives the purge and the listing can simply be published again. + context 'when the listing is unlisted with no adoptions' do + before do + snapshot + listing.update!(published: false) end - context 'when the unlisted listing has adoptions' do - before { listing.update!(published: false) } + it 'deletes the listing and its snapshots' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + end + + it 'leaves the authoring assessment alone, so the listing can be published again' do + authoring = listing.authoring_assessment + + described_class.purge!(listing) - it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do - adoption = create(:course_assessment_marketplace_adoption, listing: listing) - duplicated_assessment = adoption.duplicated_assessment + expect(authoring.reload).to be_persisted + end + end - expect { described_class.purge!(listing) }. - to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). - and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + # A re-pointed listing's authoring copy belongs to the marketplace, not to anyone's course, and + # nothing references it once the listing is gone — so it is reclaimed like a snapshot. + context 'when the unlisted listing was re-pointed into the container' do + let(:container) { Course::Assessment::Marketplace::PreviewContainerService.container_course } + let!(:container_copy) do + ActsAsTenant.with_tenant(container.instance) { create(:assessment, course: container) } + end - # A purge must never reach into another course's content — the adopter's own copy is not the - # listing's or the container's to delete. - expect(duplicated_assessment.reload).to be_persisted - end + before do + snapshot + listing.update!(authoring_assessment: container_copy, published: false) end - context 'when the orphaned listing has adoptions' do - before { orphan! } + it 'reclaims the container-hosted authoring copy along with the snapshots' do + expect { described_class.purge!(listing) }. + to change { Course::Assessment.where(id: [snapshot.id, container_copy.id]).count }.by(-2) + end + end + + # Publishing is the state that has to be undone first; unlisting is reversible, purging is not. + context 'when the listing is still published' do + it 'raises and deletes nothing' do + snapshot + expect { described_class.purge!(listing) }.to raise_error(ArgumentError) + expect(listing.reload).to be_persisted + expect(snapshot.reload).to be_persisted + end + end + + context 'when the unlisted listing has adoptions' do + before { listing.update!(published: false) } + + it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + + # A purge must never reach into another course's content — the adopter's own copy is not the + # listing's or the container's to delete. + expect(duplicated_assessment.reload).to be_persisted + end + end + + context 'when the orphaned listing has adoptions' do + before { orphan! } - it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do - adoption = create(:course_assessment_marketplace_adoption, listing: listing) - duplicated_assessment = adoption.duplicated_assessment + it 'deletes the listing and its adoption rows, but not the adopters own duplicated assessments' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment - expect { described_class.purge!(listing) }. - to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). - and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + expect { described_class.purge!(listing) }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) - expect(duplicated_assessment.reload).to be_persisted - end + expect(duplicated_assessment.reload).to be_persisted end end end From 5491589a2458f5987889646de503859cb5b277d4 Mon Sep 17 00:00:00 2001 From: lws49 Date: Sun, 2 Aug 2026 10:52:02 +0800 Subject: [PATCH 23/30] feat(marketplace): make the preview container a per-instance singleton `courses.preview` marks exactly one thing -- the marketplace container course. Its only writer is PreviewContainerService, it is absent from every strong parameter list, and both readers (AssessmentMarketplaceAbilityComponent's content freeze, Listing#marketplace_hosted?) answer as though the flag is unique. Nothing made that true, so two concurrent first-publishes could leave two containers and a lookup would then pick one arbitrarily. A partial unique index on courses (instance_id) WHERE preview settles it. Per instance rather than globally: the lookup already runs inside ActsAsTenant.with_tenant, so the index matches the query exactly, and an instance-local sandbox stays possible later. This retires the title match in container_course. It was the workaround for a non-unique flag and was itself unenforced -- a renamed container would have been missed by the lookup and then provisioned a second time. PREVIEW_COURSE_ TITLE stays as the title that is written, just not as a key. create_container_course gains the rescue create_preview_instance already had, in both limbs: the model validation loses a genuine race, the index settles it. Both inside requires_new: true, since the re-point reaches here from a before_destroy and a unique violation would otherwise abort the rescue's own re-read along with the caller's transaction. Course gains a matching validation, scoped to :instance_id. The scope is load-bearing rather than decorative -- Rails builds the uniqueness query from `unscoped`, which strips acts_as_tenant's default scope, so a bare uniqueness check would be global and stricter than the index. Three specs created preview courses in the shared default instance, which this suite commits (use_transactional_fixtures is false). They now each bring their own instance. The example asserting that container_course ignores an unrelated preview course goes: that course can no longer exist. In its place, the invariant itself, through both the validation and the index. course_spec's "valid when preview is true" now validates inside an instance of its own. acts_as_tenant rewrites instance_id to the current tenant during validation, so building elsewhere is not enough, and without this the example would quietly depend on what the default instance held when it ran. Existing test databases need their duplicates demoted before migrating: UPDATE courses SET preview = false WHERE preview AND id NOT IN ( SELECT DISTINCT ON (instance_id) id FROM courses WHERE preview ORDER BY instance_id, id); --- app/models/course.rb | 3 ++ .../marketplace/preview_container_service.rb | 40 +++++++++++++------ ..._add_unique_preview_course_per_instance.rb | 9 +++++ db/schema.rb | 3 +- .../assessment/marketplace/listing_spec.rb | 17 +++++--- .../assessment_marketplace_ability_spec.rb | 7 +++- spec/models/course_spec.rb | 10 +++-- .../preview_container_service_spec.rb | 25 ++++++++++++ 8 files changed, 89 insertions(+), 25 deletions(-) create mode 100644 db/migrate/20260802000000_add_unique_preview_course_per_instance.rb diff --git a/app/models/course.rb b/app/models/course.rb index 491d26e4bd..e428ef5d99 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -26,6 +26,9 @@ class Course < ApplicationRecord # rubocop:disable Metrics/ClassLength validates :published, inclusion: { in: [true, false] } validates :enrollable, inclusion: { in: [true, false] } validates :preview, inclusion: { in: [true, false] } + # Mirrors `index_courses_on_instance_id_one_preview`. `scope:` is load-bearing: Rails builds the + # uniqueness query from `unscoped`, which strips the `acts_as_tenant` default scope. + validates :preview, uniqueness: { scope: :instance_id }, if: :preview? validates :time_zone, length: { maximum: 255 }, allow_nil: true validates :creator, presence: true validates :updater, presence: true diff --git a/app/services/course/assessment/marketplace/preview_container_service.rb b/app/services/course/assessment/marketplace/preview_container_service.rb index 735f425209..32a66a5206 100644 --- a/app/services/course/assessment/marketplace/preview_container_service.rb +++ b/app/services/course/assessment/marketplace/preview_container_service.rb @@ -23,7 +23,10 @@ def preview_instance find_preview_instance || create_preview_instance end - # @return [Course] the single `preview: true` container course in the preview instance. + # @return [Course] the container course in the preview instance. + # + # The flag alone is a unique key here: `index_courses_on_instance_id_one_preview` allows at most + # one preview course per instance, so there is no second candidate to disambiguate against. def container_course instance = preview_instance ActsAsTenant.with_tenant(instance) do @@ -56,20 +59,31 @@ def create_preview_instance # `published/gamified/enrollable: false` keep the container out of every listing, level and # self-enrolment path: it holds the marketplace's snapshots, so it must never surface as a # course in its own right. Previewers are attached to it explicitly, one at a time. + # + # Both rescues re-read for the reason `create_preview_instance` gives, and the savepoint for the + # same reason: the model validation loses the race, the index settles it. def create_container_course(instance) - User.with_stamper(User.system) do - Course.create!( - instance: instance, - title: PREVIEW_COURSE_TITLE, - description: 'System container for marketplace version snapshots and hands-on previews.', - preview: true, - published: false, - gamified: false, - enrollable: false, - creator: User.system, - updater: User.system - ) + ApplicationRecord.transaction(requires_new: true) do + User.with_stamper(User.system) do + Course.create!( + instance: instance, + title: PREVIEW_COURSE_TITLE, + description: 'System container for marketplace version snapshots and hands-on previews.', + preview: true, + published: false, + gamified: false, + enrollable: false, + creator: User.system, + updater: User.system + ) + end end + rescue ActiveRecord::RecordNotUnique + Course.find_by!(preview: true) + rescue ActiveRecord::RecordInvalid => e + raise e if e.record.errors[:preview].empty? + + Course.find_by!(preview: true) end end end diff --git a/db/migrate/20260802000000_add_unique_preview_course_per_instance.rb b/db/migrate/20260802000000_add_unique_preview_course_per_instance.rb new file mode 100644 index 0000000000..c9649071f2 --- /dev/null +++ b/db/migrate/20260802000000_add_unique_preview_course_per_instance.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true +# At most one `preview` container course per instance. PreviewContainerService already provisions it +# as a singleton and every reader keys off the flag alone; this makes that a database invariant. +class AddUniquePreviewCoursePerInstance < ActiveRecord::Migration[7.2] + def change + add_index :courses, :instance_id, unique: true, where: 'preview', + name: 'index_courses_on_instance_id_one_preview' + end +end diff --git a/db/schema.rb b/db/schema.rb index 2c376be91e..1b644778c8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_07_28_000000) do +ActiveRecord::Schema[7.2].define(version: 2026_08_02_000000) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" enable_extension "uuid-ossp" @@ -1748,6 +1748,7 @@ t.boolean "preview", default: false, null: false t.index ["creator_id"], name: "fk__courses_creator_id" t.index ["instance_id"], name: "fk__courses_instance_id" + t.index ["instance_id"], name: "index_courses_on_instance_id_one_preview", unique: true, where: "preview" t.index ["registration_key"], name: "index_courses_on_registration_key", unique: true t.index ["ssid_folder_id"], name: "index_courses_on_ssid_folder_id", unique: true t.index ["updater_id"], name: "fk__courses_updater_id" diff --git a/spec/models/course/assessment/marketplace/listing_spec.rb b/spec/models/course/assessment/marketplace/listing_spec.rb index 5db3615ce3..1c1adcd004 100644 --- a/spec/models/course/assessment/marketplace/listing_spec.rb +++ b/spec/models/course/assessment/marketplace/listing_spec.rb @@ -321,6 +321,14 @@ def orphan!(target = listing) # reports marketplace visibility. A rebuilt listing can go on to be unlisted, so neither answer # can be read off the other. describe '#marketplace_hosted?' do + # `index_courses_on_instance_id_one_preview` allows one preview course per instance, so each + # example brings its own — built inside it, since the factory reads through the tenant scope. + def hosted_listing + ActsAsTenant.with_tenant(create(:instance)) do + create(:course_assessment_marketplace_listing, course: create(:course, preview: true)) + end + end + it 'is false while the authoring copy lives in an ordinary course' do listing = create(:course_assessment_marketplace_listing) expect(listing).not_to be_marketplace_hosted @@ -329,14 +337,11 @@ def orphan!(target = listing) # Keyed off `Course#preview`, never off a specific instance id — the same rule # PreviewContainerService documents, so a container in any instance reports correctly. it 'is true once the authoring copy lives in a preview container course' do - container = create(:course, preview: true) - listing = create(:course_assessment_marketplace_listing, course: container) - - expect(listing).to be_marketplace_hosted + expect(hosted_listing).to be_marketplace_hosted end it 'stays true for a marketplace-hosted listing that is later unlisted' do - listing = create(:course_assessment_marketplace_listing, course: create(:course, preview: true)) + listing = hosted_listing listing.update!(published: false) expect(listing.admin_state).to eq('unlisted') @@ -360,7 +365,7 @@ def orphan!(target = listing) end it 'is false for an orphaned listing, which has no authoring copy at all' do - listing = create(:course_assessment_marketplace_listing, course: create(:course, preview: true)) + listing = hosted_listing listing.authoring_assessment.destroy! expect(listing.reload).not_to be_marketplace_hosted diff --git a/spec/models/course/assessment_marketplace_ability_spec.rb b/spec/models/course/assessment_marketplace_ability_spec.rb index 8b81eedb78..c1057398b3 100644 --- a/spec/models/course/assessment_marketplace_ability_spec.rb +++ b/spec/models/course/assessment_marketplace_ability_spec.rb @@ -127,8 +127,11 @@ end context 'when the course is a preview (content-frozen) sandbox' do - let(:course) { create(:course, preview: true) } - let(:assessment) { create(:assessment, course: course) } + # Its own instance: `index_courses_on_instance_id_one_preview` allows one preview course each, + # and the default instance is shared with every other example in this suite. + let(:preview_instance) { create(:instance) } + let(:course) { ActsAsTenant.with_tenant(preview_instance) { create(:course, preview: true) } } + let(:assessment) { ActsAsTenant.with_tenant(preview_instance) { create(:assessment, course: course) } } context 'and the user is the previewer (a course manager)' do let(:course_user) { create(:course_manager, course: course) } diff --git a/spec/models/course_spec.rb b/spec/models/course_spec.rb index 24fe667da2..d462fd14ae 100644 --- a/spec/models/course_spec.rb +++ b/spec/models/course_spec.rb @@ -362,10 +362,14 @@ expect(course.errors[:preview]).to be_present end + # Validated inside its own instance: `preview` is unique per instance, and acts_as_tenant + # rewrites `instance_id` to the current tenant on validation, so building there is not enough. it 'is valid when preview is true' do - course = build(:course) - course.preview = true - expect(course).to be_valid + ActsAsTenant.with_tenant(create(:instance)) do + course = build(:course) + course.preview = true + expect(course).to be_valid + end end end end diff --git a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb index 2457ebd4af..455202886c 100644 --- a/spec/services/course/assessment/marketplace/preview_container_service_spec.rb +++ b/spec/services/course/assessment/marketplace/preview_container_service_spec.rb @@ -60,6 +60,31 @@ end) end + # The invariant that lets the lookup key off the flag alone. Both limbs: the model validation, + # then `index_courses_on_instance_id_one_preview` underneath it once validations are skipped. + it 'is the only preview course its instance can hold' do + container = described_class.container_course + + ActsAsTenant.with_tenant(described_class.preview_instance) do + expect { create(:course, preview: true) }.to raise_error(ActiveRecord::RecordInvalid) + + duplicate = container.dup + duplicate.title = 'Another preview course' + expect { duplicate.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + + # The loser of a concurrent insert re-reads rather than raising, same contract as the instance. + it 'recovers when another caller has already created the container' do + container = described_class.container_course + + recovered = ActsAsTenant.with_tenant(described_class.preview_instance) do + described_class.send(:create_container_course, described_class.preview_instance) + end + + expect(recovered).to eq(container) + end + # The container holds every published version snapshot, so it must never surface as a course # in its own right — not in a listing, not via self-enrolment, not to any user but the system # one. Previewers are attached explicitly, one at a time. From d2afbfd9a5b868fa642a0caf070228c712a8f74e Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:05:48 +0800 Subject: [PATCH 24/30] fix(spec): make factory sequences unique per process Specs commit (use_transactional_fixtures is false), so a bare per-second timestamp collides whenever two rspec processes start within the same second, tripping unique constraints. Append a random suffix per process. --- spec/factories/instances.rb | 7 ++++--- spec/factories/user_emails.rb | 7 +++++-- spec/support/userstamp.rb | 23 ++++++++++++++++++++--- 3 files changed, 29 insertions(+), 8 deletions(-) diff --git a/spec/factories/instances.rb b/spec/factories/instances.rb index c28441455d..e4f4ce155a 100644 --- a/spec/factories/instances.rb +++ b/spec/factories/instances.rb @@ -1,12 +1,13 @@ # frozen_string_literal: true FactoryBot.define do - base_time = Time.zone.now.to_i + # Unique per process — see the note in user_emails.rb; host and name are both unique-constrained. + run_id = "#{Time.zone.now.to_i}-#{SecureRandom.hex(3)}" sequence :host do |n| - "local-#{base_time}-#{n}.lvh.me" + "local-#{run_id}-#{n}.lvh.me" end factory :instance do - sequence(:name) { |n| "Instance-#{base_time}-#{n}" } + sequence(:name) { |n| "Instance-#{run_id}-#{n}" } host trait :with_learning_map_component_enabled do diff --git a/spec/factories/user_emails.rb b/spec/factories/user_emails.rb index a29f8d66e5..6801d742fc 100644 --- a/spec/factories/user_emails.rb +++ b/spec/factories/user_emails.rb @@ -1,8 +1,11 @@ # frozen_string_literal: true FactoryBot.define do - base_time = Time.zone.now.to_i + # Unique per process. Specs commit (use_transactional_fixtures is false), so a bare timestamp + # collides whenever two rspec processes start within the same second, and the second process then + # fails User::Email's uniqueness validation. The timestamp is kept for tracing leaked rows. + run_id = "#{Time.zone.now.to_i}-#{SecureRandom.hex(3)}" sequence :email do |n| - "user_#{n}@domain-#{base_time}-name.com" + "user_#{n}@domain-#{run_id}-name.com" end factory :user_email, class: User::Email.name do diff --git a/spec/support/userstamp.rb b/spec/support/userstamp.rb index 114d9431c2..03ae1c9c54 100644 --- a/spec/support/userstamp.rb +++ b/spec/support/userstamp.rb @@ -1,5 +1,22 @@ # frozen_string_literal: true -ActsAsTenant.with_tenant(Instance.default) do - # Create a global stamper for this spec run - User.stamper = User.human_users.first +RSpec.configure do |config| + # Create a global stamper for this spec run. + # + # The stamper becomes the creator (and therefore the auto-built owner course_user) of courses + # created in specs, and mail-sending specs deliver to that owner — so the stamper MUST own a + # valid email. This suite commits without cleanup (use_transactional_fixtures is false, no + # DatabaseCleaner), so if any spec removes the seeded admin's email it stays removed; the next + # process's db:seed then recreates the admin as a *new* user, leaving the lowest-id human + # (User.human_users.first) permanently without an email. + # + # Resolve the stamper by the seeded admin email (matching db/seeds and seed.rake) so it always + # owns one, and do it in before(:suite) — this runs AFTER rails_helper's top-level db:seed, so + # the admin email is guaranteed present even after such a recreation. (Setting it at file-load + # time ran before db:seed and re-froze the stale, emailless user.) Fall back to the lowest-id + # human only if that email is somehow absent. + config.before(:suite) do + ActsAsTenant.with_tenant(Instance.default) do + User.stamper = User::Email.find_by_email('test@example.org')&.user || User.human_users.first + end + end end From 88ad1c6444ec1fcc89b50d502b69fe82e100ef3e Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:30:47 +0800 Subject: [PATCH 25/30] feat(marketplace): browse, preview and duplicate the served snapshot Browse, listing/question preview and duplication all read the current version's snapshot rather than the live source assessment, and a manager can cut a new version from the assessment page. --- .../marketplace/listings_controller.rb | 22 +- .../marketplace/questions_controller.rb | 8 +- .../marketplace_listings_controller.rb | 37 +- .../assessment/marketplace/duplication_job.rb | 105 +++++- app/models/course/assessment.rb | 6 + .../course/assessment/marketplace/listing.rb | 8 +- .../marketplace/listings/index.json.jbuilder | 2 +- .../marketplace/listings/show.json.jbuilder | 5 +- client/app/api/course/Marketplace.ts | 8 + .../AssessmentShow/AssessmentShowHeader.tsx | 17 +- .../__test__/AssessmentShowHeader.test.tsx | 64 +++- .../components/DuplicateConfirmation.tsx | 11 +- .../components/PublishToMarketplaceButton.tsx | 36 ++ .../__test__/DuplicationConfirmation.test.tsx | 47 ++- .../PublishToMarketplaceButton.test.tsx | 42 +++ .../ListingPreview/__test__/index.test.tsx | 44 ++- .../course/marketplace/translations.ts | 28 +- client/locales/en.json | 6 +- client/locales/ko.json | 2 +- client/locales/zh.json | 2 +- config/routes.rb | 4 +- .../marketplace/listings_controller_spec.rb | 54 ++- .../marketplace/questions_controller_spec.rb | 73 ++-- .../marketplace_listings_controller_spec.rb | 127 ++++++- .../marketplace/duplication_job_spec.rb | 333 +++++++++++++++++- 25 files changed, 943 insertions(+), 148 deletions(-) diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb index 3ef8152e88..85bea0e910 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 ccbba11729..22a6b21ffe 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_listings_controller.rb b/app/controllers/course/assessment/marketplace_listings_controller.rb index de9462916f..e7e9c7c0cd 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/duplication_job.rb b/app/jobs/course/assessment/marketplace/duplication_job.rb index 14bfdbc0ac..997863c563 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 d5d66f8892..4d3d1c4233 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/listing.rb b/app/models/course/assessment/marketplace/listing.rb index 85d181a1f2..21faa61fa9 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/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder index 5b67c87681..39c6462155 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 92d6b4f730..06cc7bddcc 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 344178c65d..41f402c8d0 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -22,6 +22,14 @@ export default class MarketplaceAPI extends BaseCourseAPI { ); } + publishNewVersion( + assessmentId: number, + ): Promise> { + return this.client.post( + `/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing/versions`, + ); + } + 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 0837787ab8..c81dcb970d 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/__test__/AssessmentShowHeader.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowHeader.test.tsx index 8b8f3c4217..e5ead14331 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/marketplace/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx index 5b6518dfdc..d04a0f1a2c 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 56aaaf19fe..dec05373d7 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 && ( + + )} + + ) : ( + + {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__/AssessmentShowPage.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx new file mode 100644 index 0000000000..31937d7c3b --- /dev/null +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx @@ -0,0 +1,83 @@ +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(); + }); + + 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(); + }); +}); 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 0000000000..936545a354 --- /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 0000000000..db97056d49 --- /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 0000000000..c24a25c2a6 --- /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/MarketplaceVersionChip.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/MarketplaceVersionChip.tsx new file mode 100644 index 0000000000..878aa37c1f --- /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__/MarketplaceVersionChip.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/MarketplaceVersionChip.test.tsx new file mode 100644 index 0000000000..b0e9c2722c --- /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/translations.ts b/client/app/bundles/course/assessment/translations.ts index eddd80ac53..c51cde451e 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,54 @@ 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.', + }, requirements: { id: 'course.assessment.show.requirements', defaultMessage: 'Requirements', diff --git a/client/app/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index f23d634b07..0e901c93d6 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -26,6 +26,30 @@ export interface AchievementBadgeData { title: string; } +/** + * Which marketplace listing a container-course assessment belongs to. Present only for a system + * admin viewing the marketplace's container course — every assessment there keeps its original title + * verbatim, so this is the only thing telling them apart. + */ +export interface MarketplaceVersionData { + listingId: number; + /** Null for the listing's editable working copy, which is not a version at all. */ + publishedAt: string | null; + /** Denormalised at publish; survives deletion of the origin course, but may never have been set. */ + source: string | null; + /** + * Whether `Listing#current_version` points at this snapshot — the newest cut, not necessarily one + * anybody can adopt. Always false for the working copy, which is not a version. + */ + latest: boolean; + /** + * Whether the listing is on the marketplace (`Listing#published`), carried on every one of its + * rows including the working copy. Combined with `latest` this is what distinguishes a version + * being served from merely the newest one. True for a published orphan, which still serves. + */ + listed: boolean; +} + export interface AssessmentListData extends AssessmentActionsData { id: number; title: string; @@ -40,6 +64,7 @@ export interface AssessmentListData extends AssessmentActionsData { timeLimit?: number; isStartTimeBegin: boolean; isKoditsuAssessmentEnabled?: boolean; + marketplaceVersion?: MarketplaceVersionData; baseExp?: number; timeBonusExp?: number; @@ -69,6 +94,8 @@ export interface AssessmentsListData { tabTitle: string; tabUrl: string; canManageMonitor: boolean; + /** True only in the marketplace's snapshot container, viewed by a system admin. */ + isMarketplaceContainer: boolean; category: { id: number; title: string; @@ -92,6 +119,27 @@ interface GenerateQuestionBuilderData { url: string; } +export interface MarketplaceUpdateData { + /** + * When the content this copy was made from was published — its vintage, not the copy date. A + * version IS its publication datetime; there is no ordinal anywhere in this payload. + */ + adoptedVersionAt: string; + /** When the version the marketplace currently serves was published. */ + latestVersionAt: string; + /** + * Whether this copy may be replaced in place. False as soon as any non-phantom student of the + * course has a submission on it, in which case the banner offers no action at all. Advisory: the + * endpoint re-checks before destroying anything. + */ + canUpdateInPlace: boolean; + /** + * Staff and phantom test runs on this copy. They do not block the update, but it deletes them, so + * the confirmation prompt names the number first. + */ + testSubmissionCount: number; +} + export interface AssessmentData extends AssessmentActionsData { id: number; title: string; @@ -110,6 +158,13 @@ export interface AssessmentData extends AssessmentActionsData { }; isPublishedToMarketplace: boolean; marketplaceListingUrl: string; + /** Null unless a newer version of the adopted marketplace listing is available. */ + marketplaceUpdate: MarketplaceUpdateData | null; + /** + * Present only for a system admin viewing an assessment the marketplace owns inside its container + * course — a published snapshot or a listing's working copy. Same shape as the index row's badge. + */ + marketplaceVersion?: MarketplaceVersionData; requirements: { title: string; satisfied?: boolean; diff --git a/client/locales/en.json b/client/locales/en.json index 57e731ccf6..aa30c20c10 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -1571,6 +1571,27 @@ "course.assessment.generation.untitledQuestion": { "defaultMessage": "Untitled Question" }, + "course.assessment.marketplaceUpdateCompleted": { + "defaultMessage": "Assessment updated to the latest version. Refresh to see the latest version." + }, + "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." + }, + "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." + }, + "course.assessment.marketplaceUpdateConfirmTitle": { + "defaultMessage": "Update this assessment?" + }, + "course.assessment.marketplaceUpdateFailed": { + "defaultMessage": "Could not update this assessment." + }, + "course.assessment.marketplaceUpdateInPlace": { + "defaultMessage": "Update this assessment" + }, + "course.assessment.marketplaceUpdateStarted": { + "defaultMessage": "Updating this assessment…" + }, "course.assessment.question.multipleResponses.showOptions": { "defaultMessage": "Show Options" }, @@ -4271,6 +4292,15 @@ "course.assessments.index.hasTodo": { "defaultMessage": "Has TODO" }, + "course.assessments.index.marketplaceAuthoring": { + "defaultMessage": "Source Assessment" + }, + "course.assessments.index.marketplaceAuthoringHint": { + "defaultMessage": "Listing ID {listingId} · editable working copy, not a published version" + }, + "course.assessments.index.marketplaceAuthoringHintWithSource": { + "defaultMessage": "Listing ID {listingId} · editable working copy, not a published version · from {source}" + }, "course.assessments.index.neededFor": { "defaultMessage": "Needed for" }, diff --git a/client/locales/ko.json b/client/locales/ko.json index 720ee5aab9..10e11ce6b7 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -1571,6 +1571,27 @@ "course.assessment.generation.untitledQuestion": { "defaultMessage": "제목 없는 문항" }, + "course.assessment.marketplaceUpdateCompleted": { + "defaultMessage": "평가가 최신 버전으로 업데이트되었습니다." + }, + "course.assessment.marketplaceUpdateConfirmBody": { + "defaultMessage": "이 작업은 이 평가의 문항과 자료를 {latest}에 게시된 버전으로 대체합니다. 코스 내 위치, 마감일, 게시 여부는 유지됩니다." + }, + "course.assessment.marketplaceUpdateConfirmDeletion": { + "defaultMessage": "이 평가의 {count, plural, one {#개의 테스트 제출} other {#개의 테스트 제출}}이 삭제됩니다. 학생 제출물은 없습니다." + }, + "course.assessment.marketplaceUpdateConfirmTitle": { + "defaultMessage": "이 평가를 업데이트하시겠습니까?" + }, + "course.assessment.marketplaceUpdateFailed": { + "defaultMessage": "이 평가를 업데이트할 수 없습니다." + }, + "course.assessment.marketplaceUpdateInPlace": { + "defaultMessage": "이 평가 업데이트" + }, + "course.assessment.marketplaceUpdateStarted": { + "defaultMessage": "이 평가를 업데이트하는 중…" + }, "course.assessment.question.multipleResponses.showOptions": { "defaultMessage": "옵션 보기" }, @@ -4253,6 +4274,15 @@ "course.assessments.index.hasTodo": { "defaultMessage": "할 일 있음" }, + "course.assessments.index.marketplaceAuthoring": { + "defaultMessage": "원본 평가" + }, + "course.assessments.index.marketplaceAuthoringHint": { + "defaultMessage": "등록 항목 ID {listingId} · 수정 가능한 작업 사본이며, 발행된 버전이 아닙니다" + }, + "course.assessments.index.marketplaceAuthoringHintWithSource": { + "defaultMessage": "등록 항목 ID {listingId} · 수정 가능한 작업 사본이며, 발행된 버전이 아닙니다 · 출처: {source}" + }, "course.assessments.index.neededFor": { "defaultMessage": "필요한 경우" }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 2fc6a90ea3..7649ff1bab 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -1562,6 +1562,27 @@ "course.assessment.generation.untitledQuestion": { "defaultMessage": "无标题题目" }, + "course.assessment.marketplaceUpdateCompleted": { + "defaultMessage": "评估已更新到最新版本。" + }, + "course.assessment.marketplaceUpdateConfirmBody": { + "defaultMessage": "这会将此评估的问题和资料替换为 {latest} 发布的版本。它会保留其在课程中的位置、截止日期以及发布状态。" + }, + "course.assessment.marketplaceUpdateConfirmDeletion": { + "defaultMessage": "此评估上的 {count, plural, one {# 个测试提交} other {# 个测试提交}} 将被删除。没有学生提交过作业。" + }, + "course.assessment.marketplaceUpdateConfirmTitle": { + "defaultMessage": "更新此评估?" + }, + "course.assessment.marketplaceUpdateFailed": { + "defaultMessage": "无法更新此评估。" + }, + "course.assessment.marketplaceUpdateInPlace": { + "defaultMessage": "更新此评估" + }, + "course.assessment.marketplaceUpdateStarted": { + "defaultMessage": "正在更新此评估…" + }, "course.assessment.question.multipleResponses.showOptions": { "defaultMessage": "显示选项" }, @@ -4247,6 +4268,15 @@ "course.assessments.index.hasTodo": { "defaultMessage": "显示待办事项" }, + "course.assessments.index.marketplaceAuthoring": { + "defaultMessage": "源评估" + }, + "course.assessments.index.marketplaceAuthoringHint": { + "defaultMessage": "市场条目 ID {listingId} · 可编辑的工作副本,非已发布版本" + }, + "course.assessments.index.marketplaceAuthoringHintWithSource": { + "defaultMessage": "市场条目 ID {listingId} · 可编辑的工作副本,非已发布版本 · 来自 {source}" + }, "course.assessments.index.neededFor": { "defaultMessage": "需要的" }, diff --git a/config/locales/en/course/assessment/assessments.yml b/config/locales/en/course/assessment/assessments.yml index 5af72a174a..14477293b3 100644 --- a/config/locales/en/course/assessment/assessments.yml +++ b/config/locales/en/course/assessment/assessments.yml @@ -1,6 +1,11 @@ en: course: assessment: + marketplace_adoptions: + apply_latest_version: + student_submissions_exist: >- + This assessment cannot be updated in place because students have already submitted + work for it. Import the latest version as a new assessment instead. assessments: invalid_questions_order: 'Invalid ordering for assessment questions' show: diff --git a/config/locales/ko/course/assessment/assessments.yml b/config/locales/ko/course/assessment/assessments.yml index aec245e5ec..ad99f423c4 100644 --- a/config/locales/ko/course/assessment/assessments.yml +++ b/config/locales/ko/course/assessment/assessments.yml @@ -1,6 +1,11 @@ ko: course: assessment: + marketplace_adoptions: + apply_latest_version: + student_submissions_exist: >- + 학생들이 이미 이 평가에 제출한 작업이 있으므로 이 평가를 제자리에서 업데이트할 수 없습니다. + 대신 최신 버전을 새 평가로 가져오세요. assessments: invalid_questions_order: '평가 질문의 순서가 잘못되었습니다' show: diff --git a/config/locales/zh/course/assessment/assessments.yml b/config/locales/zh/course/assessment/assessments.yml index de0b696d65..6ce7a20cf3 100644 --- a/config/locales/zh/course/assessment/assessments.yml +++ b/config/locales/zh/course/assessment/assessments.yml @@ -1,6 +1,11 @@ zh: course: assessment: + marketplace_adoptions: + apply_latest_version: + student_submissions_exist: >- + 由于学生已经提交了此评估的作业,无法就地更新此评估。 + 请改为将最新版本导入为新的评估。 assessments: invalid_questions_order: '测验问题的权重无效' show: diff --git a/config/routes.rb b/config/routes.rb index 4938115aeb..b43af99952 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -297,6 +297,9 @@ resource :marketplace_listing, only: [:create, :destroy] do post 'versions' => 'marketplace_listings#publish_version' end + resource :marketplace_adoption, only: [] do + post 'apply_latest_version' => 'marketplace_adoptions#apply_latest_version' + end namespace :question do resources :multiple_responses, only: [:new, :create, :edit, :update, :destroy] do diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index 98bc1d0c3d..36c4cae751 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -6,6 +6,12 @@ let!(:instance) { Instance.default } with_tenant(:instance) do + # The container is a per-instance singleton (`index_courses_on_instance_id_one_preview`), and this + # suite commits, so examples share one row instead of each minting a colliding preview course. + def preview_container + Course.find_by(preview: true) || create(:course, preview: true) + end + let(:course) { create(:course) } let(:assessment) { create(:assessment, course: course) } let(:admin) { create(:administrator) } @@ -29,6 +35,49 @@ end end + describe 'marketplaceUpdate' do + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + let(:copy) { create(:assessment, course: destination_course) } + let(:v1_at) { 10.days.ago.change(usec: 0) } + let(:v2_at) { 1.day.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, :versioned, + published: true, first_published_at: v1_at) + end + + before { controller_sign_in(controller, manager) } + + subject do + get :show, params: { course_id: destination_course.id, id: copy.id, format: :json } + end + + it 'is null for an assessment that was never adopted' do + subject + + expect(response.parsed_body['marketplaceUpdate']).to be_nil + end + + it 'carries the notice when a newer version exists' do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + v2 = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v2_at, published_by: listing.publisher) + listing.update!(current_version: v2) + + subject + + notice = response.parsed_body['marketplaceUpdate'] + expect(notice.keys).to contain_exactly('adoptedVersionAt', 'latestVersionAt', + 'canUpdateInPlace', 'testSubmissionCount') + expect(Time.zone.parse(notice['adoptedVersionAt'])).to be_within(1.second).of(v1_at) + expect(Time.zone.parse(notice['latestVersionAt'])).to be_within(1.second).of(v2_at) + end + end + context 'as a course manager (non-admin)' do let(:manager) { create(:course_manager, course: course).user } before { controller_sign_in(controller, manager) } @@ -39,5 +88,200 @@ end end end + + # Opening a container assessment must carry the identity its index row carries. Without it the + # snapshot, the listing's working copy and an ordinary draft are three indistinguishable pages — + # and the snapshot's lone marketplace control invites republishing immutable content as a listing + # of its own, whose source assessment would then be frozen inside the container. + describe 'GET #show — marketplace container context' do + let(:container) { preview_container } + let(:listing) do + create(:course_assessment_marketplace_listing, course: container, + source_course_name: 'MP Allowlist Source Course') + end + let(:working_copy) { listing.authoring_assessment } + let(:snapshot) { create(:assessment, course: container) } + let(:published_at) { 3.days.ago.change(usec: 0) } + let!(:version) do + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: snapshot, published_at: published_at, + published_by: listing.publisher).tap { |cut| listing.update!(current_version: cut) } + end + + def show_for(target_course, target_assessment) + get :show, as: :json, params: { course_id: target_course.id, id: target_assessment.id } + end + + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'dates a snapshot with the same fields as its index row' do + show_for(container, snapshot) + + label = response.parsed_body['marketplaceVersion'] + expect(label.keys).to contain_exactly('listingId', 'publishedAt', 'source', 'latest', + 'listed') + expect(label['listingId']).to eq(listing.id) + expect(label['source']).to eq('MP Allowlist Source Course') + expect(label['latest']).to be(true) + expect(label['listed']).to be(true) + expect(Time.zone.parse(label['publishedAt'])).to be_within(1.second).of(published_at) + end + + it 'reports the working copy as a non-version' do + show_for(container, working_copy) + + label = response.parsed_body['marketplaceVersion'] + expect(label['listingId']).to eq(listing.id) + expect(label['publishedAt']).to be_nil + expect(label['latest']).to be(false) + end + + it 'withholds publishing from a snapshot, which is already an existing listing content' do + show_for(container, snapshot) + + expect(response.parsed_body['permissions']).to include('canPublishToMarketplace' => false) + end + + it 'keeps publishing available on the working copy' do + show_for(container, working_copy) + + expect(response.parsed_body['permissions']).to include('canPublishToMarketplace' => true) + end + + # An assessment authored directly in the container is neither a snapshot nor a working copy. + # Publishing it is the supported way a marketplace-hosted listing comes to exist at all. + it 'keeps publishing available on an unlabelled container assessment' do + fresh = create(:assessment, course: container) + + show_for(container, fresh) + + expect(response.parsed_body).not_to have_key('marketplaceVersion') + expect(response.parsed_body['permissions']).to include('canPublishToMarketplace' => true) + end + + # The guard is the container's `preview` flag, mirroring the index: the same assessment + # outside the container must stay unlabelled. + it 'omits the context outside the container, even for a versioned assessment' do + in_normal_course = create(:assessment, course: course) + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: in_normal_course, published_at: 2.days.ago, + published_by: listing.publisher) + + show_for(course, in_normal_course) + + expect(response.parsed_body).not_to have_key('marketplaceVersion') + end + end + + # Previewers are enrolled into the container as managers. The context is admin-only navigation, + # exactly as on the index. + context 'as a non-admin manager of the container' do + before { controller_sign_in(controller, create(:course_manager, course: container).user) } + + it 'omits the context' do + show_for(container, snapshot) + + expect(response.parsed_body).not_to have_key('marketplaceVersion') + end + end + end + + describe 'version identity in the assessment payloads' do + render_views + + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + let(:copy) { create(:assessment, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + + before do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, published_by: listing.publisher) + listing.update!(current_version: version) + controller_sign_in(controller, manager) + end + + it 'dates both vintages on the update notice and carries no ordinal' do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + latest = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: 1.day.ago.change(usec: 0), published_by: listing.publisher) + listing.update!(current_version: latest) + + get :show, as: :json, params: { course_id: destination_course, id: copy } + + update = response.parsed_body['marketplaceUpdate'] + expect(update.keys).to contain_exactly('adoptedVersionAt', 'latestVersionAt', + 'canUpdateInPlace', 'testSubmissionCount') + expect(Time.zone.parse(update['adoptedVersionAt'])).to be_within(1.second).of(v1_at) + expect(Time.zone.parse(update['latestVersionAt'])). + to be_within(1.second).of(latest.published_at) + end + + it 'emits a null update notice when the copy is current' do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + + get :show, as: :json, params: { course_id: destination_course, id: copy } + + expect(response.parsed_body['marketplaceUpdate']).to be_nil + end + end + + describe 'the in-place update gate on the show payload' do + render_views + + let(:destination_course) { create(:course) } + let(:manager) { create(:course_manager, course: destination_course).user } + let(:copy) { create(:assessment, :with_mcq_question, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + + before do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, published_by: listing.publisher) + listing.update!(current_version: version) + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + newer = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: 1.day.ago.change(usec: 0), published_by: listing.publisher) + listing.update!(current_version: newer) + controller_sign_in(controller, manager) + end + + it 'offers the in-place update on an unattempted copy' do + get :show, as: :json, params: { course_id: destination_course, id: copy } + + update = response.parsed_body['marketplaceUpdate'] + expect(update['canUpdateInPlace']).to be(true) + expect(update['testSubmissionCount']).to eq(0) + end + + it 'withholds the in-place update once a real student has attempted the copy' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + + get :show, as: :json, params: { course_id: destination_course, id: copy } + + expect(response.parsed_body['marketplaceUpdate']['canUpdateInPlace']).to be(false) + end + end end end diff --git a/spec/controllers/course/assessment/marketplace_adoptions_controller_spec.rb b/spec/controllers/course/assessment/marketplace_adoptions_controller_spec.rb new file mode 100644 index 0000000000..8b97024469 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace_adoptions_controller_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::MarketplaceAdoptionsController, type: :controller do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:destination_course) { create(:course) } + let(:copy) { create(:assessment, :with_mcq_question, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + let!(:v1) do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, published_by: listing.publisher) + listing.update!(current_version: version) + version + end + let!(:adoption) do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: v1_at) + end + let(:manager) { create(:course_manager, course: destination_course).user } + + describe 'POST #apply_latest_version' do + render_views + + with_active_job_queue_adapter(:test) do + def apply + post :apply_latest_version, as: :json, + params: { course_id: destination_course.id, assessment_id: copy.id } + end + + context 'as a course manager' do + before { controller_sign_in(controller, manager) } + + it 'enqueues the update and answers with the job url' do + expect { apply }.to have_enqueued_job(Course::Assessment::Marketplace::ApplyVersionJob) + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['jobUrl']).to be_present + end + + # The client flag is advisory. A stale page must never be able to destroy student work. + it 'refuses when a real student has attempted the copy, whatever the client believed' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + + expect { apply }.not_to have_enqueued_job(Course::Assessment::Marketplace::ApplyVersionJob) + expect(response).to have_http_status(:unprocessable_content) + key = 'course.assessment.marketplace_adoptions.apply_latest_version.student_submissions_exist' + expect(I18n.t(key)).to eq(key) + expect(response.parsed_body['errors'].first).to eq(I18n.t(key)) + end + + it 'still allows the update when only staff have test submissions' do + create(:submission, :attempting, assessment: copy, creator: manager) + + expect { apply }.to have_enqueued_job(Course::Assessment::Marketplace::ApplyVersionJob) + end + + it 'responds 404 when the assessment was never adopted' do + other = create(:assessment, course: destination_course) + + post :apply_latest_version, as: :json, + params: { course_id: destination_course.id, assessment_id: other.id } + + expect(response).to have_http_status(:not_found) + end + end + + context 'as a course student' do + before { controller_sign_in(controller, create(:course_student, course: destination_course).user) } + + it 'is denied' do + expect { apply }.to raise_exception(CanCan::AccessDenied) + end + end + end + end + end +end diff --git a/spec/jobs/course/assessment/marketplace/apply_version_job_spec.rb b/spec/jobs/course/assessment/marketplace/apply_version_job_spec.rb new file mode 100644 index 0000000000..da9569b084 --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/apply_version_job_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ApplyVersionJob, type: :job do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:user) { create(:administrator) } + let(:source_course) { create(:course) } + let(:source_assessment) do + create(:assessment, :with_mcq_question, course: source_course, title: 'Marketplace Lab') + end + let(:destination_course) { create(:course) } + let!(:listing) do + Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) + end + let(:copy) do + create(:assessment, :with_mcq_question, course: destination_course, title: 'My Local Title') + end + let!(:adoption) do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, + adopted_version_at: listing.current_version.published_at) + end + + before do + source_assessment.update!(title: 'Marketplace Lab v2') + Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) + end + + def run_and_capture + job = described_class.new(copy, current_user: user) + job.perform_now + job.job + end + + it 'replaces the content and redirects back to the same assessment' do + job = run_and_capture + + expect(copy.reload.title).to eq('Marketplace Lab v2') + expect(job.redirect_to).to include("/courses/#{destination_course.id}/assessments/#{copy.id}") + end + + it 'reports the job as completed' do + job = run_and_capture + + expect(job.status).to eq('completed') + end + + it 'errors the job rather than raising when the listing serves nothing' do + listing.update!(current_version: nil) + + job = run_and_capture + + expect(job.status).to eq('errored') + end + + it 'errors instead of deleting work when a student attempt exists by execution time' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + + job = run_and_capture + + expect(job.status).to eq('errored') + expect(copy.reload.title).to eq('My Local Title') + expect(copy.questions).not_to be_empty + end + end +end diff --git a/spec/models/course/assessment/marketplace/adoption_spec.rb b/spec/models/course/assessment/marketplace/adoption_spec.rb index 7b0e9c3848..f0c525eceb 100644 --- a/spec/models/course/assessment/marketplace/adoption_spec.rb +++ b/spec/models/course/assessment/marketplace/adoption_spec.rb @@ -20,5 +20,171 @@ adoption.duplicated_assessment.destroy expect(described_class.exists?(adoption.id)).to be(false) end + + describe '.update_notice_for' do + let(:destination_course) { create(:course) } + let(:copy) { create(:assessment, :with_mcq_question, course: destination_course) } + let(:v1_at) { 30.days.ago.change(usec: 0) } + let(:listing) do + create(:course_assessment_marketplace_listing, published: true, first_published_at: v1_at) + end + let!(:v1) do + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: v1_at, + published_by: listing.publisher) + listing.update!(current_version: version) + version + end + + def cut_version(published_at) + version = create(:course_assessment_marketplace_listing_version, + listing: listing, + assessment: create(:assessment, course: listing.authoring_assessment.course), + published_at: published_at, + published_by: listing.publisher) + listing.update!(current_version: version) + version + end + + def adopt(adopted_version_at:) + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, adopted_version_at: adopted_version_at) + end + + it 'returns nil when the assessment was never adopted' do + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'returns nil when the adopted vintage is the current one' do + adopt(adopted_version_at: v1_at) + + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'returns the notice when a newer vintage exists' do + adopt(adopted_version_at: v1_at) + v2 = cut_version(2.days.ago.change(usec: 0)) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:adopted_version_at]).to be_within(1.second).of(v1_at) + expect(notice[:latest_version_at]).to be_within(1.second).of(v2.published_at) + end + + # The banner speaks in dates only — there is no ordinal anywhere in the payload. + it 'carries no version ordinal in the notice' do + adopt(adopted_version_at: v1_at) + cut_version(2.days.ago.change(usec: 0)) + + notice = described_class.update_notice_for(copy.id) + + expect(notice.keys).to contain_exactly(:adopted_version_at, :latest_version_at, + :can_update_in_place, :test_submission_count) + end + + it 'dates a mid-chain adopted vintage from the adoption row itself' do + v2 = cut_version(10.days.ago.change(usec: 0)) + adopt(adopted_version_at: v2.published_at) + cut_version(1.day.ago.change(usec: 0)) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:adopted_version_at]).to be_within(1.second).of(v2.published_at) + end + + # Fail toward silence: a false "an update is waiting" trains managers to ignore the banner. + it 'returns nil when the adopted vintage is unknown, rather than guessing' do + adopt(adopted_version_at: nil) + cut_version(2.days.ago.change(usec: 0)) + + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'returns nil when the listing has no current version at all' do + adoption = adopt(adopted_version_at: v1_at) + listing.update!(current_version: nil) + + expect(described_class.update_notice_for(adoption.duplicated_assessment_id)).to be_nil + end + + # An adopter whose copy is somehow NEWER than what the listing serves must not be told an + # update is waiting — the comparison is strictly greater-than, not merely different. + it 'returns nil when the adopted vintage is newer than the served one' do + adopt(adopted_version_at: 1.hour.ago.change(usec: 0)) + + expect(described_class.update_notice_for(copy.id)).to be_nil + end + + it 'resolves when the snapshot lives in another tenant, with no tenant escape' do + adopt(adopted_version_at: v1_at) + other_instance = create(:instance) + published = 1.day.ago.change(usec: 0) + ActsAsTenant.without_tenant do + snapshot = ActsAsTenant.with_tenant(other_instance) { create(:assessment) } + v2 = create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: snapshot, published_at: published, + published_by: listing.publisher) + listing.update!(current_version: v2) + end + + expect(described_class.update_notice_for(copy.id)[:latest_version_at]). + to be_within(1.second).of(published) + end + + describe 'the in-place update gate' do + before do + adopt(adopted_version_at: v1_at) + cut_version(2.days.ago.change(usec: 0)) + end + + it 'allows the in-place update when nobody has attempted the copy' do + notice = described_class.update_notice_for(copy.id) + + expect(notice[:can_update_in_place]).to be(true) + expect(notice[:test_submission_count]).to eq(0) + end + + it 'reports the test submissions the update would delete' do + manager = create(:course_manager, course: destination_course) + create(:submission, :attempting, assessment: copy, creator: manager.user) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:can_update_in_place]).to be(true) + expect(notice[:test_submission_count]).to eq(1) + end + + it 'refuses the in-place update once a real student has attempted the copy' do + student = create(:course_student, course: destination_course) + create(:submission, :attempting, assessment: copy, creator: student.user) + + notice = described_class.update_notice_for(copy.id) + + expect(notice[:can_update_in_place]).to be(false) + end + end + end + + describe '#latest_version_at' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + let(:adoption) do + create(:course_assessment_marketplace_adoption, listing: listing, + adopted_version_at: 1.day.ago) + end + + it 'reports the served version publish date' do + expect(adoption.latest_version_at). + to be_within(1.second).of(listing.current_version.published_at) + end + + it 'is nil for a listing with no current version' do + listing.update!(current_version: nil) + + expect(adoption.reload.latest_version_at).to be_nil + end + end end end diff --git a/spec/services/course/assessment/marketplace/apply_version_service_spec.rb b/spec/services/course/assessment/marketplace/apply_version_service_spec.rb new file mode 100644 index 0000000000..cdbba81360 --- /dev/null +++ b/spec/services/course/assessment/marketplace/apply_version_service_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::ApplyVersionService, type: :service do + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:user) { create(:administrator) } + let(:source_course) { create(:course) } + let(:source_assessment) do + create(:assessment, :with_mcq_question, course: source_course, title: 'Marketplace Lab') + end + let(:destination_course) { create(:course) } + let!(:listing) do + Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) + end + let(:copy) do + create(:assessment, :with_mcq_question, course: destination_course, title: 'My Local Title') + end + let!(:adoption) do + create(:course_assessment_marketplace_adoption, + listing: listing, destination_course: destination_course, + duplicated_assessment: copy, + adopted_version_at: listing.current_version.published_at) + end + + def cut_newer_version + source_assessment.update!(title: 'Marketplace Lab v2') + Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) + end + + describe '.apply' do + it 'keeps the same assessment row rather than making a new one' do + cut_newer_version + + expect { described_class.apply(copy, user) }. + not_to(change { Course::Assessment.where(id: copy.id).count }) + + expect(copy.reload).to be_present + end + + it 'destroys the throwaway copy it duplicated to' do + cut_newer_version + + expect { described_class.apply(copy, user) }. + to change { destination_course.assessments.count }.by(0) + end + + # Restamping the vintage is the ONLY thing that retires the update banner — there is no + # dismissal state alongside it to clear. + it 'advances the adoption to the served vintage' do + version = cut_newer_version + + described_class.apply(copy, user) + + expect(adoption.reload.adopted_version_at).to be_within(1.second).of(version.published_at) + expect(adoption.reload).not_to be_update_pending + end + + it 'takes the title from the new version' do + cut_newer_version + + described_class.apply(copy, user) + + expect(copy.reload.title).to eq('Marketplace Lab v2') + end + + # Slice 3's rule, minus self-collision: the copy's own old title must not count against it. + it 'renames when the new title is already taken in the destination course' do + version = cut_newer_version + create(:assessment, course: destination_course, title: 'Marketplace Lab v2') + + described_class.apply(copy, user) + + expect(copy.reload.title).to eq("Marketplace Lab v2 [#{version.published_at.strftime('%d %b %Y')}]") + end + + it 'does not rename when only its own old title would collide' do + cut_newer_version + + described_class.apply(copy, user) + + expect(copy.reload.title).to eq('Marketplace Lab v2') + end + + it 'keeps the tab position the manager chose' do + cut_newer_version + original_tab_id = copy.tab_id + + described_class.apply(copy, user) + + expect(copy.reload.tab_id).to eq(original_tab_id) + end + + # Replacing content must not silently expose or hide an assessment. + it 'keeps the published state' do + cut_newer_version + copy.update!(published: true) + + described_class.apply(copy, user) + + expect(copy.reload.published).to be(true) + end + + it 'replaces the questions with the new version questions' do + old_question_ids = copy.questions.map(&:id) + cut_newer_version + + described_class.apply(copy, user) + + expect(copy.reload.questions.map(&:id)).not_to match_array(old_question_ids) + expect(copy.questions).not_to be_empty + expect(Course::Assessment::Question.where(id: old_question_ids)).to be_empty + end + + # Staff test runs do not block the update, but their answers point at questions that no longer + # exist, so they go with them. + it 'destroys the submissions that were on the copy' do + manager = create(:course_manager, course: destination_course) + create(:submission, :attempting, assessment: copy, creator: manager.user) + cut_newer_version + + expect { described_class.apply(copy, user) }. + to change { Course::Assessment::Submission.where(assessment_id: copy.id).count }.to(0) + end + + it 'refuses once a real student has attempted by execution time' do + create(:submission, :attempting, assessment: copy, + creator: create(:course_student, course: destination_course).user) + old_question_ids = copy.questions.map(&:id) + cut_newer_version + + expect { described_class.apply(copy, user) }. + to raise_error(ArgumentError, /students have already submitted/) + + expect(copy.reload.title).to eq('My Local Title') + expect(copy.questions.map(&:id)).to match_array(old_question_ids) + expect(adoption.reload.adopted_version_at).to be_within(1.second).of(listing.first_published_at) + end + + # They were computed against a schedule that no longer exists. `find_or_create_personal_time_for` + # rebuilds them on demand from the new reference times, so this is not data loss. + it 'destroys personal times anchored to the replaced schedule' do + student = create(:course_student, course: destination_course) + copy.lesson_plan_item.find_or_create_personal_time_for(student).save! + cut_newer_version + + expect { described_class.apply(copy, user) }. + to change { Course::PersonalTime.where(lesson_plan_item_id: copy.lesson_plan_item.id).count }.to(0) + end + + it 'refuses an assessment that was never adopted' do + plain = create(:assessment, course: destination_course) + + expect { described_class.apply(plain, user) }.to raise_error(ArgumentError) + end + + it 'refuses a listing with no current version' do + listing.update!(current_version: nil) + + expect { described_class.apply(copy, user) }.to raise_error(ArgumentError) + end + + # The whole point of one transaction: a half-replaced assessment has no questions and no way back. + it 'leaves the copy untouched when the transplant fails' do + cut_newer_version + allow_any_instance_of(described_class).to receive(:copy_attributes!).and_raise('boom') + + expect { described_class.apply(copy, user) }.to raise_error('boom') + expect(copy.reload.questions).not_to be_empty + expect(copy.title).to eq('My Local Title') + end + end + end +end From 13d823d3cdd6e8382fdb11ebb7aaabd757eb4a62 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 17:31:21 +0800 Subject: [PATCH 27/30] feat(marketplace): badge container snapshots in the assessment index Inside the preview container every listing's snapshots share one title, so the index chip dates each one and links it to the listing it belongs to. --- .../assessments/index.json.jbuilder | 18 + .../AssessmentsIndex/AssessmentsTable.tsx | 128 +++++++ .../__test__/AssessmentsTable.test.tsx | 318 ++++++++++++++++++ .../__test__/StatusBadges.test.tsx | 41 +++ .../bundles/course/assessment/translations.ts | 28 ++ .../assessments_marketplace_spec.rb | 148 ++++++++ 6 files changed, 681 insertions(+) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx diff --git a/app/views/course/assessment/assessments/index.json.jbuilder b/app/views/course/assessment/assessments/index.json.jbuilder index a2fbca99c0..14a964d704 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/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/AssessmentsTable.tsx index 85602e2722..cb6ab8cbda 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/__test__/AssessmentsTable.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/AssessmentsTable.test.tsx new file mode 100644 index 0000000000..73ef86ed8b --- /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__/StatusBadges.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/StatusBadges.test.tsx new file mode 100644 index 0000000000..1f24bb7f0e --- /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 c51cde451e..35cb4e9bd4 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -249,6 +249,34 @@ const translations = defineMessages({ 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', + }, requirements: { id: 'course.assessment.show.requirements', defaultMessage: 'Requirements', diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index 36c4cae751..a679b2c827 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -89,6 +89,139 @@ def preview_container end end + # Snapshots keep their original title and share one tab of the container course, so the badge is + # the only thing distinguishing them. It must stay off every normal course's index (hot path) and + # away from the previewers who are enrolled into the container as managers. + describe 'GET #index — marketplace version badge' do + let(:container) { preview_container } + let(:snapshot) { create(:assessment, course: container) } + let(:listing) do + create(:course_assessment_marketplace_listing, source_course_name: 'MP Allowlist Source Course') + end + let(:published_at) { 3.days.ago.change(usec: 0) } + let(:outside_published_at) { 2.days.ago.change(usec: 0) } + let!(:version) do + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: snapshot, published_at: published_at, + published_by: listing.publisher) + end + + def index_for(target_course) + get :index, as: :json, params: { course_id: target_course.id } + end + + def payload_for(target_assessment) + response.parsed_body['assessments'].find { |json| json['id'] == target_assessment.id } + end + + context 'as a system admin' do + before { controller_sign_in(controller, admin) } + + it 'labels a container snapshot with its published date and provenance' do + index_for(container) + + label = payload_for(snapshot)['marketplaceVersion'] + expect(label.keys).to contain_exactly('listingId', 'publishedAt', 'source', 'latest', + 'listed') + expect(label['listingId']).to eq(listing.id) + expect(label['source']).to eq('MP Allowlist Source Course') + expect(Time.zone.parse(label['publishedAt'])).to be_within(1.second).of(published_at) + end + + # The guard is the container's `preview` flag, not the mere existence of a version row: the + # same assessment id outside the container must stay unlabelled. + it 'omits the badge outside the container, even for a versioned assessment' do + in_normal_course = create(:assessment, course: course) + create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: in_normal_course, published_at: outside_published_at, + published_by: listing.publisher) + + index_for(course) + + expect(payload_for(in_normal_course)).not_to have_key('marketplaceVersion') + end + + it 'does not query listing versions for a normal course' do + expect(Course::Assessment::Marketplace::ListingVersion).not_to receive(:labels_for_assessments) + + index_for(course) + end + + it 'marks the served snapshot as the latest' do + listing.update!(current_version: version) + + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['latest']).to be(true) + end + + it 'does not mark a superseded snapshot as the latest' do + pointed_at_snapshot = create(:assessment, course: container) + pointed_at = create(:course_assessment_marketplace_listing_version, + listing: listing, assessment: pointed_at_snapshot, published_at: 1.day.ago, + published_by: listing.publisher) + listing.update!(current_version: pointed_at) + + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['latest']).to be(false) + expect(payload_for(pointed_at_snapshot)['marketplaceVersion']['latest']).to be(true) + end + + it 'reports whether the listing is on the marketplace' do + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['listed']).to be(true) + end + + it 'reports an unlisted listing as not listed' do + listing.update!(published: false) + + index_for(container) + + expect(payload_for(snapshot)['marketplaceVersion']['listed']).to be(false) + end + + it 'flags the container so the client can show its own columns and toolbar' do + index_for(container) + + expect(response.parsed_body['display']).to include('isMarketplaceContainer' => true) + end + + # The flag drives a search toolbar and three extra columns. Leaking it into ordinary courses + # would change the assessments index for every course in the deployment. + it 'does not flag an ordinary course as the container' do + index_for(course) + + expect(response.parsed_body['display']).to include('isMarketplaceContainer' => false) + end + end + + context 'as a non-admin manager of the container' do + before { controller_sign_in(controller, create(:course_manager, course: container).user) } + + it 'omits the badge' do + index_for(container) + + expect(payload_for(snapshot)).not_to have_key('marketplaceVersion') + end + + it 'does not query listing versions' do + expect(Course::Assessment::Marketplace::ListingVersion).not_to receive(:labels_for_assessments) + + index_for(container) + end + + # Previewers are enrolled into the container as managers. They must see neither the badge nor + # the admin-only navigation the flag switches on. + it 'does not flag the container' do + index_for(container) + + expect(response.parsed_body['display']).to include('isMarketplaceContainer' => false) + end + end + end + # Opening a container assessment must carry the identity its index row carries. Without it the # snapshot, the listing's working copy and an ordinary draft are three indistinguishable pages — # and the snapshot's lone marketplace control invites republishing immutable content as a listing @@ -236,6 +369,21 @@ def show_for(target_course, target_assessment) expect(response.parsed_body['marketplaceUpdate']).to be_nil end + + it 'dates a container snapshot chip by publish date, with no ordinal' do + container_course = preview_container + snapshot = create(:assessment, course: container_course) + listing.current_version.update!(assessment: snapshot) + controller_sign_in(controller, admin) + + get :index, as: :json, params: { course_id: container_course } + + row = response.parsed_body['assessments'].find { |a| a['id'] == snapshot.id } + expect(row['marketplaceVersion']).to have_key('publishedAt') + expect(row['marketplaceVersion']).not_to have_key('version') + expect(Time.zone.parse(row['marketplaceVersion']['publishedAt'])). + to be_within(1.second).of(v1_at) + end end describe 'the in-place update gate on the show payload' do From ab79266579e1eafcd800586a3787d9dea8daa334 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 21:15:09 +0800 Subject: [PATCH 28/30] feat(marketplace): warn against editing a published snapshot A snapshot in the container course is an ordinary assessment with every management affordance live, and editing one is silently destructive: it changes what future adopters copy for a version that was never published, and stops the version's publication date describing its content. A soft guard only. Nothing is disabled, because the surface is admin-only and the escape hatch for repairing served content without minting a version is deliberate. The banner names the risk and links straight at the source assessment to edit instead, on that assessment's own host since it may live on another instance. --- .../assessment/assessments_controller.rb | 30 ++++++- app/models/instance.rb | 15 ++++ .../assessment/assessments/show.json.jbuilder | 3 + .../AssessmentShow/AssessmentShowPage.tsx | 3 + .../MarketplaceSnapshotBanner.tsx | 56 +++++++++++++ .../__test__/AssessmentShowPage.test.tsx | 28 +++++++ .../MarketplaceSnapshotBanner.test.tsx | 81 +++++++++++++++++++ .../bundles/course/assessment/translations.ts | 14 ++++ .../types/course/assessment/assessments.ts | 7 ++ .../assessments_marketplace_spec.rb | 76 ++++++++++++++++- spec/models/instance_spec.rb | 29 +++++++ 11 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 client/app/bundles/course/assessment/pages/AssessmentShow/MarketplaceSnapshotBanner.tsx create mode 100644 client/app/bundles/course/assessment/pages/AssessmentShow/__test__/MarketplaceSnapshotBanner.test.tsx diff --git a/app/controllers/course/assessment/assessments_controller.rb b/app/controllers/course/assessment/assessments_controller.rb index d7b81829b3..bc3d920781 100644 --- a/app/controllers/course/assessment/assessments_controller.rb +++ b/app/controllers/course/assessment/assessments_controller.rb @@ -275,9 +275,37 @@ def marketplace_version_labels # 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 - Course::Assessment::Marketplace::ListingVersion.labels_for_assessments([@assessment.id])[@assessment.id] + 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 diff --git a/app/models/instance.rb b/app/models/instance.rb index 38eb55e865..31761d1068 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/views/course/assessment/assessments/show.json.jbuilder b/app/views/course/assessment/assessments/show.json.jbuilder index 3281ea7d26..e735270156 100644 --- a/app/views/course/assessment/assessments/show.json.jbuilder +++ b/app/views/course/assessment/assessments/show.json.jbuilder @@ -92,6 +92,9 @@ if @marketplace_version 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 diff --git a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx index 3ccd8b428e..452456a512 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/AssessmentShowPage.tsx @@ -27,6 +27,7 @@ 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'; @@ -82,6 +83,8 @@ const AssessmentShowPage = (props: AssessmentShowPageProps): JSX.Element => { )} + + { + 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/__test__/AssessmentShowPage.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx index 31937d7c3b..dce462b8c9 100644 --- a/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx +++ b/client/app/bundles/course/assessment/pages/AssessmentShow/__test__/AssessmentShowPage.test.tsx @@ -71,6 +71,11 @@ describe('', () => { 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 () => { @@ -79,5 +84,28 @@ describe('', () => { 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 0000000000..7f26179802 --- /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/translations.ts b/client/app/bundles/course/assessment/translations.ts index 35cb4e9bd4..19ffbe54d0 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -277,6 +277,20 @@ const translations = defineMessages({ 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/types/course/assessment/assessments.ts b/client/app/types/course/assessment/assessments.ts index 0e901c93d6..669553a952 100644 --- a/client/app/types/course/assessment/assessments.ts +++ b/client/app/types/course/assessment/assessments.ts @@ -48,6 +48,13 @@ export interface MarketplaceVersionData { * being served from merely the newest one. True for a published orphan, which still serves. */ listed: boolean; + /** + * Where to edit the content this snapshot froze. Present only on the show page, and only for a + * snapshot — on the working copy the source assessment is the page you are already on. Null when + * the listing is orphaned and its rebuilt copy has not landed. Absolute, because the source may + * live on another instance. + */ + sourceAssessmentUrl?: string | null; } export interface AssessmentListData extends AssessmentActionsData { diff --git a/spec/controllers/course/assessment/assessments_marketplace_spec.rb b/spec/controllers/course/assessment/assessments_marketplace_spec.rb index a679b2c827..198502401b 100644 --- a/spec/controllers/course/assessment/assessments_marketplace_spec.rb +++ b/spec/controllers/course/assessment/assessments_marketplace_spec.rb @@ -253,7 +253,7 @@ def show_for(target_course, target_assessment) label = response.parsed_body['marketplaceVersion'] expect(label.keys).to contain_exactly('listingId', 'publishedAt', 'source', 'latest', - 'listed') + 'listed', 'sourceAssessmentUrl') expect(label['listingId']).to eq(listing.id) expect(label['source']).to eq('MP Allowlist Source Course') expect(label['latest']).to be(true) @@ -270,6 +270,14 @@ def show_for(target_course, target_assessment) expect(label['latest']).to be(false) end + # The source assessment IS this page, so there is nothing to link to. Key absent, not null, + # so the banner's trigger never has to special-case it. + it 'omits the source link on the working copy, which is the page itself' do + show_for(container, working_copy) + + expect(response.parsed_body['marketplaceVersion']).not_to have_key('sourceAssessmentUrl') + end + it 'withholds publishing from a snapshot, which is already an existing listing content' do show_for(container, snapshot) @@ -305,6 +313,72 @@ def show_for(target_course, target_assessment) expect(response.parsed_body).not_to have_key('marketplaceVersion') end + + # The one field the index badge never carries. A snapshot is frozen, so the only useful + # action is to go edit the assessment it was cut from. + it 'points a snapshot at the assessment it was published from' do + show_for(container, snapshot) + + expect(response.parsed_body['marketplaceVersion']['sourceAssessmentUrl']). + to eq("http://#{instance.host}/courses/#{working_copy.course_id}/" \ + "assessments/#{working_copy.id}") + end + + # `Instance#host_options` exists for this: a controller's `url_options` always supplies the port the + # request arrived on, which behind any proxy is not the port the app is served on. + it 'builds the link on the instance host, not the port the request arrived on' do + request.host = 'localhost:3999' + + show_for(container, snapshot) + + expect(response.parsed_body['marketplaceVersion']['sourceAssessmentUrl']). + to start_with("http://#{instance.host}/") + end + + # The regression `without_tenant` exists for. Viewing a container snapshot means the request + # is tenanted to the container's instance, so a tenant-scoped `Course` lookup on a source + # published from elsewhere returns nil rather than raising - dropping the link silently. + it 'resolves a source assessment published from another instance' do + origin_instance = create(:instance) + origin_course = ActsAsTenant.with_tenant(origin_instance) { create(:course) } + origin_assessment = ActsAsTenant.with_tenant(origin_instance) do + create(:assessment, course: origin_course) + end + cross_listing = create(:course_assessment_marketplace_listing, + authoring_assessment: origin_assessment, + publisher: create(:user)) + cross_snapshot = create(:assessment, course: container) + create(:course_assessment_marketplace_listing_version, + listing: cross_listing, assessment: cross_snapshot, + published_at: 2.days.ago.change(usec: 0), + published_by: cross_listing.publisher). + tap { |cut| cross_listing.update!(current_version: cut) } + + show_for(container, cross_snapshot) + + expect(response.parsed_body['marketplaceVersion']['sourceAssessmentUrl']). + to eq("http://#{origin_instance.host}/courses/#{origin_course.id}/" \ + "assessments/#{origin_assessment.id}") + end + + # An orphaned listing has nothing to link at until its rebuild lands. The key is still + # emitted so the client can tell "no source" from "not a snapshot". + it 'emits a null link for an orphaned listing, whose source was deleted' do + orphan_listing = create(:course_assessment_marketplace_listing) + orphan_snapshot = create(:assessment, course: container) + create(:course_assessment_marketplace_listing_version, + listing: orphan_listing, assessment: orphan_snapshot, + published_at: 4.days.ago.change(usec: 0), + published_by: orphan_listing.publisher). + tap { |cut| orphan_listing.update!(current_version: cut) } + orphan_listing.update!(authoring_assessment: nil) + + show_for(container, orphan_snapshot) + + label = response.parsed_body['marketplaceVersion'] + expect(label).to have_key('sourceAssessmentUrl') + expect(label['sourceAssessmentUrl']).to be_nil + end end # Previewers are enrolled into the container as managers. The context is admin-only navigation, diff --git a/spec/models/instance_spec.rb b/spec/models/instance_spec.rb index ad5f4724f5..b8f4de25a3 100644 --- a/spec/models/instance_spec.rb +++ b/spec/models/instance_spec.rb @@ -210,6 +210,35 @@ end end + describe '#host_options' do + around do |example| + orig_default_host = Application::Application.config.x.default_host + example.run + ensure + Application::Application.config.x.default_host = orig_default_host + end + + subject(:instance) { build(:instance, host: 'tenant.coursemology.org') } + + context 'when the host carries no port' do + before { Application::Application.config.x.default_host = 'coursemology.org' } + + it 'names no port, leaving the default for the protocol' do + expect(instance.host_options).to eq(host: 'tenant.coursemology.org', port: nil) + end + end + + # The development shape: the served port arrives through `default_host`, and `#host` rewrites it + # onto every tenant. + context 'when the host carries a port' do + before { Application::Application.config.x.default_host = 'lvh.me:8080' } + + it 'names the port separately from the host' do + expect(instance.host_options).to eq(host: 'tenant.lvh.me', port: '8080') + end + end + end + let(:instance) { create(:instance) } with_tenant(:instance) do describe '.active_course_count' do From 372207d865e6b862cd42f462aa09af6cc5d4940e Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 20:31:45 +0800 Subject: [PATCH 29/30] feat(marketplace): admin API for listing and version management Every listing across instances, with its version history, adoption list and source provenance, plus the actions only an admin has: unlist, re-list, rebuild the authoring copy, and permanently delete a listing that is off the marketplace. The whole read path runs tenant-free -- listings span instances while their snapshots live in the preview container -- and links to a source assessment are absolute, since a course id only resolves on its own instance's host. The rebuild is the failsafe half of the pair the in-transaction re-point introduced. Losing a source assessment re-points the listing inside the destroy transaction, so an orphan now means that callback was bypassed; this action is how an admin repairs one without a console. Its clone is extracted into RestoreAuthoringService, which Course::Assessment's before_destroy calls too, so a hand-repaired listing is indistinguishable from an automatically re-pointed one. RestoreAuthoringJob returns as a thin wrapper over that service, having been deleted along with the after_commit rebuild it used to back. A job rather than an inline call for the reason adoption's DuplicationJob is one: duplicating a large assessment can outlast a request. The re-point pays that cost inline only because a clone that must precede a destroy cannot be deferred. --- .../admin/marketplace_listings_controller.rb | 156 ++++ .../marketplace/restore_authoring_job.rb | 40 + app/models/course/assessment.rb | 14 +- .../marketplace/restore_authoring_service.rb | 34 + .../courses/_course_list_data.json.jbuilder | 1 + .../marketplace_listings/index.json.jbuilder | 21 + .../marketplace_listings/show.json.jbuilder | 31 + config/routes.rb | 3 + .../system/admin/courses_controller_spec.rb | 30 + .../marketplace_listings_controller_spec.rb | 697 ++++++++++++++++++ .../marketplace/restore_authoring_job_spec.rb | 83 +++ .../restore_authoring_service_spec.rb | 133 ++++ 12 files changed, 1233 insertions(+), 10 deletions(-) create mode 100644 app/controllers/system/admin/marketplace_listings_controller.rb create mode 100644 app/jobs/course/assessment/marketplace/restore_authoring_job.rb create mode 100644 app/services/course/assessment/marketplace/restore_authoring_service.rb create mode 100644 app/views/system/admin/marketplace_listings/index.json.jbuilder create mode 100644 app/views/system/admin/marketplace_listings/show.json.jbuilder create mode 100644 spec/controllers/system/admin/marketplace_listings_controller_spec.rb create mode 100644 spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb create mode 100644 spec/services/course/assessment/marketplace/restore_authoring_service_spec.rb diff --git a/app/controllers/system/admin/marketplace_listings_controller.rb b/app/controllers/system/admin/marketplace_listings_controller.rb new file mode 100644 index 0000000000..f19b4b0005 --- /dev/null +++ b/app/controllers/system/admin/marketplace_listings_controller.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true +# System-admin view of every marketplace listing — what version is served, how many courses adopted +# it, and whether its source still exists — plus the maintenance actions on a listing off the +# marketplace: restore a source assessment (orphaned only), or delete it permanently. +class System::Admin::MarketplaceListingsController < System::Admin::Controller + def index + @listings = Course::Assessment::Marketplace::Listing.for_admin_index + @adoption_counts = Course::Assessment::Marketplace::Adoption. + where(listing_id: @listings.map(&:id)).group(:listing_id). + distinct.count(:destination_course_id) + @authoring_urls = authoring_urls(@listings) + end + + # The per-listing provenance + history page. Read-only: every mutation stays on the index. + def show + @listing = find_listing + @versions = @listing.versions.ordered.includes(:assessment, :published_by).to_a + @adoptions = ActsAsTenant.without_tenant do + @listing.adoptions.includes(destination_course: :instance).order(:created_at).to_a + end + @snapshot_urls = snapshot_urls(@versions) + @authoring_url = authoring_urls([@listing])[@listing.id] + end + + # The manual repair for a listing that has no authoring copy: duplicates its latest snapshot into + # the marketplace's own container course and makes that the authoring copy, so "Publish new version" + # works again. + # + # A failsafe rather than the ordinary path: losing a source assessment re-points the listing inside + # the destroy transaction, so an orphan means that callback was bypassed. Asynchronous — assessment + # duplication is the same heavy path adopters go through — so the client polls the returned `jobUrl`. + def restore_authoring + listing = find_listing + error = restore_rejection(listing) + return render json: { errors: [error] }, status: :unprocessable_content if error + + job = Course::Assessment::Marketplace::RestoreAuthoringJob. + perform_later(listing.id, current_user: current_user).job + render partial: 'jobs/submitted', locals: { job: job } + end + + # Takes a listing off the marketplace, or puts it back. Reversible. Admin has to unlist a listing + # before `destroy` will accept said listing at all. + # + # Admin-side rather than the course-side unlist because that one hangs off the authoring assessment, + # which after a re-point lives in the container course on the preview instance — a course an admin + # cannot reach from their own host, and one an orphaned listing has no pointer to at all. Re-listing + # never cuts a version: it restores visibility over the version the listing already holds. + def update + listing = find_listing + error = list_rejection(listing, published_param) + return render json: { errors: [error] }, status: :unprocessable_content if error + + listing.update!(published: published_param) + head :ok + end + + # PERMANENT deletion, not unlisting. Restricted to listings that are off the marketplace. + def destroy + listing = find_listing + return render json: { errors: [purge_rejection(listing)] }, status: :unprocessable_content unless + listing.purgeable? + + Course::Assessment::Marketplace::PurgeService.purge!(listing) + head :ok + end + + # The single canonicalisation both the version rows and the adoption rows go through. + # @param [ActiveSupport::TimeWithZone, nil] published_at + # @return [String, nil] + def self.snapshot_key(published_at) + published_at&.utc&.iso8601(6) + end + + private + + # Listings span every instance while their snapshots live in the container's, so every lookup here + # is tenant-free — the same reason `.for_admin_index` is. + def find_listing + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::Listing.find(params[:id]) + end + end + + # `params[:published]` arrives as a JSON boolean from our own client and as a string from anything + # else, so it goes through the same cast the settings components use. + # @return [Boolean] + def published_param + ActiveRecord::Type::Boolean.new.cast(params[:published]) + end + + # Unlisting is always allowed — it is the reversible step, and it is what an admin reaches for when + # something is wrong with a listing. + # + # @return [String, nil] the reason the change is refused, or nil if it may proceed + def list_rejection(listing, published) + return nil unless published + return 'This listing has no published version to serve.' if listing.current_version_id.nil? + + nil + end + + # @return [String, nil] the reason the restore is refused, or nil if it may proceed + def restore_rejection(listing) + return 'Only an orphaned listing can have its source assessment rebuilt.' unless listing.orphaned? + return 'This listing has no published version to restore from.' if listing.current_version.nil? + + nil + end + + # @return [String] the reason the deletion is refused + def purge_rejection(_listing) + 'A published listing cannot be permanently deleted. Unlist it first.' + end + + # @return [Hash{String => String}] canonicalised publish date => absolute snapshot url + def snapshot_urls(versions) + return {} if versions.empty? + + ActsAsTenant.without_tenant do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + host = container.instance.host + + versions.each_with_object({}) do |version, urls| + next if version.assessment.nil? + + urls[self.class.snapshot_key(version.published_at)] = + course_assessment_url(version.assessment.course_id, version.assessment, **host_options(host)) + end + end + end + + # An absolute URL carrying the source course's own host, not a path: `Course` is + # `acts_as_tenant :instance`, so a course id only resolves on its instance's host and a relative + # path 404s for every listing published from another instance. + # + # @return [Hash{Integer => String}] listing id => absolute authoring assessment url + def authoring_urls(listings) + ActsAsTenant.without_tenant do + listings.each_with_object({}) do |listing, urls| + assessment = listing.authoring_assessment + next if assessment.nil? + + urls[listing.id] = course_assessment_url(assessment.course_id, assessment, + **host_options(assessment.course.instance.host)) + end + end + end + + # @param [String] host an instance host, optionally carrying a port + # @return [Hash] the `host:`/`port:` options for a url on that instance + def host_options(host) + name, port = host.split(':', 2) + { host: name, port: port } + end +end diff --git a/app/jobs/course/assessment/marketplace/restore_authoring_job.rb b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb new file mode 100644 index 0000000000..6d1dd8b5c6 --- /dev/null +++ b/app/jobs/course/assessment/marketplace/restore_authoring_job.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +# The system admin's manual repair for an orphaned listing: rebuilds its authoring copy from the +# latest snapshot, so the listing can cut versions again. +# +# This is the FAILSAFE, not the ordinary path. Losing a source assessment normally re-points the +# listing inside the destroy transaction (`Course::Assessment#repoint_marketplace_listing_authoring`), +# which is what keeps a listing from ever being observably orphaned. An orphan therefore means that +# callback was bypassed (a raw delete leaving `fk_caml_authoring_assessment_id` to null the column) +# and this is how an admin puts it right without a console. +# +# A job rather than an inline controller call for the reason adoption's `DuplicationJob` is one: +# duplicating a large assessment can outlast a request. The re-point pays that cost inline only +# because a clone that must precede a destroy cannot be deferred. +# +# The clone itself lives in `RestoreAuthoringService`, shared with the re-point, so a hand-repaired +# listing is indistinguishable from an automatically re-pointed one. +class Course::Assessment::Marketplace::RestoreAuthoringJob < ApplicationJob + include TrackableJob + include Rails.application.routes.url_helpers + + queue_as :duplication + + protected + + def perform_tracked(listing_id, options = {}) + current_user = options[:current_user] + ActsAsTenant.without_tenant do + listing = Course::Assessment::Marketplace::Listing.find(listing_id) + # Re-checked here rather than trusting the controller's guard: a republish restores an authoring + # copy on its own, and it can land between enqueue and perform. Completing rather than erroring + # is deliberate — the end state this job exists to reach is the one it found. + return unless listing.orphaned? + raise ArgumentError, 'listing has no version to restore from' if listing.current_version.nil? + + copy = Course::Assessment::Marketplace::RestoreAuthoringService. + restore!(listing, current_user: current_user) + redirect_to course_assessment_url(copy.course, copy, host: copy.course.instance.host) + end + end +end diff --git a/app/models/course/assessment.rb b/app/models/course/assessment.rb index 4d3d1c4233..97991da327 100644 --- a/app/models/course/assessment.rb +++ b/app/models/course/assessment.rb @@ -419,20 +419,14 @@ def marketplace_snapshot? # nothing to rebuild from and is left to orphan through `fk_caml_authoring_assessment_id`. The early # return also keeps an assessment that authors no listing from provisioning the preview instance and # container course inside an ordinary delete. + # + # The clone itself lives in `RestoreAuthoringService`, shared with the admin's repair action, so a + # listing rebuilt by hand is indistinguishable from one rebuilt here. def repoint_marketplace_listing_authoring listing = marketplace_listing return if listing.nil? || listing.current_version_id.nil? - ActsAsTenant.without_tenant do - container = Course::Assessment::Marketplace::PreviewContainerService.container_course - source = listing.current_version.assessment - User.with_stamper(User.system) do - copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( - source.course, container, source, current_user: User.system - ) - listing.update!(authoring_assessment: copy) - end - end + Course::Assessment::Marketplace::RestoreAuthoringService.restore!(listing) end # Parents the assessment under its duplicated parent tab, if it exists. diff --git a/app/services/course/assessment/marketplace/restore_authoring_service.rb b/app/services/course/assessment/marketplace/restore_authoring_service.rb new file mode 100644 index 0000000000..a94b953b41 --- /dev/null +++ b/app/services/course/assessment/marketplace/restore_authoring_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true +# Gives a listing a fresh authoring copy by cloning its latest SNAPSHOT into the marketplace +# container, and points `authoring_assessment` at the clone. +# +# The listing MUST have a `current_version`: there is nothing else to clone from. +class Course::Assessment::Marketplace::RestoreAuthoringService + # @param [Course::Assessment::Marketplace::Listing] listing + # @param [User] current_user whoever the copy is stamped to — the system user for the automatic + # re-point, the acting admin for the repair action + # @return [Course::Assessment] the new authoring copy + def self.restore!(listing, current_user: User.system) + new(listing, current_user).restore! + end + + def initialize(listing, current_user) + @listing = listing + @current_user = current_user + end + + # @return [Course::Assessment] + def restore! + ActsAsTenant.without_tenant do + container = Course::Assessment::Marketplace::PreviewContainerService.container_course + snapshot = @listing.current_version.assessment + User.with_stamper(@current_user) do + copy = Course::Duplication::ObjectDuplicationService.duplicate_objects( + snapshot.course, container, snapshot, current_user: @current_user + ) + @listing.update!(authoring_assessment: copy) + copy + end + end + end +end diff --git a/app/views/system/admin/courses/_course_list_data.json.jbuilder b/app/views/system/admin/courses/_course_list_data.json.jbuilder index 91cfaf3f2b..aef45a69da 100644 --- a/app/views/system/admin/courses/_course_list_data.json.jbuilder +++ b/app/views/system/admin/courses/_course_list_data.json.jbuilder @@ -4,6 +4,7 @@ json.title course.title json.createdAt course.created_at json.activeUserCount course.active_user_count json.userCount course.user_count +json.preview course.preview json.instance do json.id course.instance.id json.name course.instance.name diff --git a/app/views/system/admin/marketplace_listings/index.json.jbuilder b/app/views/system/admin/marketplace_listings/index.json.jbuilder new file mode 100644 index 0000000000..71df657b2d --- /dev/null +++ b/app/views/system/admin/marketplace_listings/index.json.jbuilder @@ -0,0 +1,21 @@ +# frozen_string_literal: true +json.listings @listings do |listing| + json.id listing.id + json.title listing.current_version&.assessment&.title + json.currentVersionPublishedAt listing.current_version&.published_at + json.lastPublishedAt listing.last_published_at + json.adoptions(@adoption_counts[listing.id] || 0) + json.sourceCourseId listing.source_course_id + json.sourceCourseName listing.source_course_name + json.sourceInstanceName listing.source_instance&.name + json.sourceInstanceHost listing.source_instance&.host + json.state listing.admin_state + # Orthogonal to `state`: WHERE the authoring copy lives, not whether the listing is on the + # marketplace. + json.marketplaceHosted listing.marketplace_hosted? + # Deletion facts, separate from `state`/visibility: a rebuilt listing can be published AND have a + # deleted origin at the same time. + json.sourceAssessmentDeleted listing.source_assessment_deleted? + json.sourceCourseDeleted listing.source_course_deleted? + json.authoringAssessmentUrl @authoring_urls[listing.id] +end diff --git a/app/views/system/admin/marketplace_listings/show.json.jbuilder b/app/views/system/admin/marketplace_listings/show.json.jbuilder new file mode 100644 index 0000000000..b9c09d627e --- /dev/null +++ b/app/views/system/admin/marketplace_listings/show.json.jbuilder @@ -0,0 +1,31 @@ +# frozen_string_literal: true +json.id @listing.id +json.title @listing.current_version&.assessment&.title +json.currentVersionPublishedAt @listing.current_version&.published_at +json.state @listing.admin_state +json.marketplaceHosted @listing.marketplace_hosted? +json.sourceAssessmentDeleted @listing.source_assessment_deleted? +json.sourceCourseDeleted @listing.source_course_deleted? +json.authoringAssessmentUrl @authoring_url +json.sourceCourseId @listing.source_course_id +json.sourceCourseName @listing.source_course_name +json.sourceInstanceName @listing.source_instance&.name +json.sourceInstanceHost @listing.source_instance&.host + +json.versions @versions do |version| + json.publishedAt version.published_at + json.publisherName version.published_by&.name + json.isCurrent version.id == @listing.current_version_id + json.snapshotUrl @snapshot_urls[System::Admin::MarketplaceListingsController.snapshot_key(version.published_at)] +end + +json.adoptions @adoptions do |adoption| + json.id adoption.id + json.destinationCourseId adoption.destination_course_id + json.destinationCourseName adoption.destination_course&.title + json.destinationCourseHost adoption.destination_course&.instance&.host + json.adoptedVersionAt adoption.adopted_version_at + json.adoptedAt adoption.created_at + snapshot_key = System::Admin::MarketplaceListingsController.snapshot_key(adoption.adopted_version_at) + json.snapshotUrl @snapshot_urls[snapshot_key] +end diff --git a/config/routes.rb b/config/routes.rb index b43af99952..e527cbc210 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -116,6 +116,9 @@ end get 'marketplace_access' => 'marketplace_access#index' resources :marketplace_access_blocks, only: [:create, :destroy] + resources :marketplace_listings, only: [:index, :show, :update, :destroy] do + post :restore_authoring, on: :member + end resources :instances, only: [:index, :create, :update, :destroy] resources :users, only: [:index, :update, :destroy] resources :courses, only: [:index, :destroy] diff --git a/spec/controllers/system/admin/courses_controller_spec.rb b/spec/controllers/system/admin/courses_controller_spec.rb index b1a84b17be..449cd0c8b4 100644 --- a/spec/controllers/system/admin/courses_controller_spec.rb +++ b/spec/controllers/system/admin/courses_controller_spec.rb @@ -31,6 +31,36 @@ end end + # The hidden marketplace preview container is a `preview: true` course and the system-admin index + # is cross-instance, so it appears here like any other course. Course pickers key off this flag to + # leave it out of their options (never off a host or instance id), so the payload must carry it. + describe '#index payload' do + render_views + + let!(:container) do + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + let!(:ordinary_course) { create(:course) } + + before { controller_sign_in(controller, admin) } + + def row_for(course) + response.parsed_body['courses'].find { |c| c['id'] == course.id } + end + + it 'flags the preview container and only the preview container' do + get :index, as: :json, params: { search: container.title } + + expect(row_for(container)['preview']).to be(true) + + get :index, as: :json, params: { search: ordinary_course.title } + + expect(row_for(ordinary_course)['preview']).to be(false) + end + end + describe '#destroy' do let!(:course_to_delete) { create(:course) } let!(:course_stub) do diff --git a/spec/controllers/system/admin/marketplace_listings_controller_spec.rb b/spec/controllers/system/admin/marketplace_listings_controller_spec.rb new file mode 100644 index 0000000000..fa0161fe56 --- /dev/null +++ b/spec/controllers/system/admin/marketplace_listings_controller_spec.rb @@ -0,0 +1,697 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe System::Admin::MarketplaceListingsController, type: :controller do + render_views + + let!(:instance) { Instance.default } + with_tenant(:instance) do + let(:course) { create(:course) } + let(:assessment) { create(:assessment, course: course) } + let(:admin) { create(:administrator) } + + # The real container, never `create(:course, preview: true)`: at most one preview course may exist + # per instance (`index_courses_on_instance_id_one_preview`), and these examples run in + # `Instance.default` — so a spec minting its own would commit it (specs are not transactional) and + # every later run would collide with the row the last one left behind. + def container_course + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + # @return [Course::Assessment] an assessment authored directly in the container course + def assessment_in_container + container = container_course + ActsAsTenant.with_tenant(container.instance) { create(:assessment, course: container) } + end + + # The only way a listing is orphaned now that `Course::Assessment` re-points inside the destroy + # transaction: the column is nulled underneath the model layer, as + # `fk_caml_authoring_assessment_id` does when a delete bypasses the callback. + def orphan!(listing) + listing.update_column(:authoring_assessment_id, nil) + listing.reload + end + + describe 'GET #index' do + subject { get :index, format: :json } + + before { controller_sign_in(controller, admin) } + + def row_for(listing) + response.parsed_body['listings'].find { |l| l['id'] == listing.id } + end + + it 'lists a published listing with its published vintage, provenance and adoption count' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + create(:course_assessment_marketplace_adoption, listing: listing) + + subject + + row = row_for(listing) + expect(row).to have_key('currentVersionPublishedAt') + expect(row).not_to have_key('currentVersion') + expect(Time.zone.parse(row['currentVersionPublishedAt'])). + to be_within(1.second).of(listing.current_version.published_at) + expect(row['adoptions']).to eq(1) + expect(row['sourceCourseName']).to eq(course.title) + expect(row['state']).to eq('published') + end + + it 'carries the source instance, so two same-named courses can be told apart' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + + subject + + row = row_for(listing) + expect(row['sourceInstanceName']).to eq(instance.name) + expect(row['sourceInstanceHost']).to eq(instance.host) + end + + # The marketplace is cross-instance while `Course` is `acts_as_tenant :instance`, so a course id + # resolves ONLY on its own instance's host. A path (or the admin's own host) 404s for every + # listing published from elsewhere, which is why the url is absolute and carries that host. + context 'when the source course lives in another instance' do + let(:other_instance) { create(:instance) } + let(:other_course) { ActsAsTenant.with_tenant(other_instance) { create(:course) } } + let(:other_assessment) do + ActsAsTenant.with_tenant(other_instance) { create(:assessment, course: other_course) } + end + + it 'builds the authoring url on that instance host' do + listing = Course::Assessment::Marketplace::PublishService.publish(other_assessment, admin) + + subject + + # Asserted as prefix + suffix rather than one literal: the test env's + # `default_url_options` injects a port that production does not have. + url = row_for(listing)['authoringAssessmentUrl'] + expect(url).to start_with("http://#{other_instance.host}") + expect(url). + to end_with("/courses/#{other_course.id}/assessments/#{other_assessment.id}") + end + + it 'reports that instance as the source, not the admin’s own' do + listing = Course::Assessment::Marketplace::PublishService.publish(other_assessment, admin) + + subject + + row = row_for(listing) + expect(row['sourceInstanceName']).to eq(other_instance.name) + expect(row['sourceInstanceHost']).to eq(other_instance.host) + expect(row['sourceInstanceHost']).not_to eq(instance.host) + end + end + + # `Instance#host` carries the port the app is publicly served on, which is not the port the + # request reached Rails on whenever a proxy sits in front — i.e. every development setup. 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 the request's port silently won. + # + # Reproduced by moving the request off the instance's port, which is what the proxy does. + it 'keeps the port carried by the instance host, not the one the request arrived on' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + request.host = 'localhost:3999' + + subject + + expect(row_for(listing)['authoringAssessmentUrl']).to start_with("http://#{instance.host}/") + end + + it 'reports no instance for a listing orphaned before the instance was ever recorded' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update_columns(authoring_assessment_id: nil, source_course_id: nil, + source_instance_id: nil) + + subject + + row = row_for(listing) + expect(row['sourceInstanceName']).to be_nil + expect(row['sourceInstanceHost']).to be_nil + end + + it 'serves the snapshot title, not the authoring title' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + assessment.update!(title: 'Renamed after publish') + + subject + + expect(row_for(listing)['title']).not_to eq('Renamed after publish') + end + + it 'flags an unlisted listing' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update!(published: false) + + subject + + expect(row_for(listing)['state']).to eq('unlisted') + end + + it 'flags a listing orphaned by an assessment deletion and offers no authoring url' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update!(authoring_assessment: nil) + + subject + + row = row_for(listing) + expect(row['state']).to eq('published') + expect(row['sourceAssessmentDeleted']).to be(true) + expect(row['sourceCourseDeleted']).to be(false) + expect(row['authoringAssessmentUrl']).to be_nil + end + + # Reported alongside `state` rather than folded into it: the two answer different questions, and + # the provenance columns keep naming the ORIGIN course even after a rebuild, so this is the only + # thing in the payload that says where the copy an admin would edit actually lives. + it 'marks a listing whose authoring copy lives in the container as marketplace-hosted' do + listing = Course::Assessment::Marketplace::PublishService. + publish(assessment_in_container, admin) + + subject + + expect(row_for(listing)['marketplaceHosted']).to be(true) + expect(row_for(listing)['state']).to eq('published') + end + + it 'does not mark a listing whose authoring copy is in an ordinary course' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + + subject + + expect(row_for(listing)['marketplaceHosted']).to be(false) + end + + # The whole origin course went, so the FK nullified `source_course_id` too. Identifiable + # provenance differs from a plain assessment deletion, which is why it is its own boolean. + it 'flags a listing orphaned by a course deletion' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + listing.update!(authoring_assessment: nil, source_course: nil) + + subject + + row = row_for(listing) + expect(row['state']).to eq('published') + expect(row['sourceAssessmentDeleted']).to be(true) + expect(row['sourceCourseDeleted']).to be(true) + expect(row['sourceCourseName']).to eq(course.title) + end + + it 'reports no deletion facts for a healthy listing with an intact authoring copy' do + listing = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + + subject + + row = row_for(listing) + expect(row['sourceAssessmentDeleted']).to be(false) + expect(row['sourceCourseDeleted']).to be(false) + end + + it 'only ever reports published or unlisted as the state' do + listed = Course::Assessment::Marketplace::PublishService.publish(assessment, admin) + unlisted = Course::Assessment::Marketplace::PublishService. + publish(create(:assessment, course: course), admin) + unlisted.update!(published: false) + + subject + + expect(row_for(listed)['state']).to eq('published') + expect(row_for(unlisted)['state']).to eq('unlisted') + expect(response.parsed_body['listings'].map { |l| l['state'] }.uniq.sort). + to eq(%w[published unlisted]) + end + end + + describe 'POST #restore_authoring' do + # `have_enqueued_job` requires the :test adapter; the test env defaults to :background_thread. + with_active_job_queue_adapter(:test) do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, course: course) } + + before { controller_sign_in(controller, admin) } + + def restore(overrides = {}) + post :restore_authoring, params: { id: listing.id, format: :json }.merge(overrides) + end + + context 'when the listing is orphaned and versioned' do + before { orphan!(listing) } + + it 'enqueues the restore job and returns a pollable jobUrl' do + expect { restore }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing.id, current_user: admin) + expect(response.parsed_body['jobUrl']).to be_present + end + + # No destination is accepted any more: the container is the only destination, so a stray + # param must not be able to redirect the copy into somebody's live course. + it 'ignores a destination_course_id param entirely' do + other_course = create(:course) + + expect { restore(destination_course_id: other_course.id) }. + to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob). + with(listing.id, current_user: admin) + end + end + + it 'rejects a listing that still has its authoring copy' do + expect { restore }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/Only an orphaned listing/) + end + + it 'rejects an orphaned listing with no version to restore from' do + listing = create(:course_assessment_marketplace_listing, course: course) + orphan!(listing) + + expect { restore(id: listing.id) }. + not_to have_enqueued_job(Course::Assessment::Marketplace::RestoreAuthoringJob) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/no published version/) + end + end + end + + describe 'DELETE #destroy (permanent purge)' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, course: course) } + + before { controller_sign_in(controller, admin) } + + def purge + delete :destroy, params: { id: listing.id, format: :json } + end + + it 'deletes an orphaned listing with no adoptions, along with its snapshots' do + snapshot = listing.current_version.assessment + orphan!(listing) + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + expect(response).to have_http_status(:ok) + end + + it 'deletes an unlisted listing with no adoptions, along with its snapshots' do + snapshot = listing.current_version.assessment + listing.update!(published: false) + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment.where(id: snapshot.id).count }.by(-1) + expect(response).to have_http_status(:ok) + end + + # Unlisting is the reversible step, so it is the one an admin has to take first. + it 'refuses a published listing and says to unlist it first' do + expect { purge }. + not_to(change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/Unlist it first/) + end + + it 'deletes an unlisted listing that has been adopted, along with its adoption rows' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + listing.update!(published: false) + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + expect(response).to have_http_status(:ok) + # A purge must never reach into another course's content. + expect(duplicated_assessment.reload).to be_persisted + end + + it 'deletes an orphaned listing that has been adopted, along with its adoption rows' do + adoption = create(:course_assessment_marketplace_adoption, listing: listing) + duplicated_assessment = adoption.duplicated_assessment + orphan!(listing) + + expect { purge }. + to change { Course::Assessment::Marketplace::Listing.where(id: listing.id).count }.by(-1). + and change { Course::Assessment::Marketplace::Adoption.where(id: adoption.id).count }.by(-1) + expect(response).to have_http_status(:ok) + expect(duplicated_assessment.reload).to be_persisted + end + end + + # The admin-side counterpart of the course-side unlist. It exists separately because that one + # resolves the listing through its AUTHORING assessment, so it cannot reach an orphaned listing at + # all — which is precisely the listing an admin most often has to take off the marketplace. + describe 'PATCH #update (list / unlist)' do + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, course: course) } + + before { controller_sign_in(controller, admin) } + + def set_published(value, id: listing.id) + patch :update, params: { id: id, published: value, format: :json } + end + + it 'unlists a published listing' do + expect { set_published(false) }.to change { listing.reload.published }.from(true).to(false) + expect(response).to have_http_status(:ok) + end + + it 'lists an unlisted listing again, serving the version it already had' do + version = listing.current_version + listing.update!(published: false) + + expect { set_published(true) }.to change { listing.reload.published }.from(false).to(true) + expect(response).to have_http_status(:ok) + # Re-listing restores VISIBILITY only. It must never cut a version, or an unlist/list round + # trip would mint a vintage nobody published — same rule PublishService follows. + expect(listing.current_version).to eq(version) + expect(listing.versions.count).to eq(1) + end + + # The case the course-side unlist cannot serve at all. + it 'unlists an orphaned listing' do + listing.authoring_assessment.destroy! + + expect { set_published(false) }.to change { listing.reload.published }.from(true).to(false) + expect(response).to have_http_status(:ok) + end + + # An orphan goes on serving its snapshot, so there is nothing incoherent about it being listed. + it 'lists an orphaned listing that still holds a version' do + listing.authoring_assessment.destroy! + listing.update!(published: false) + + expect { set_published(true) }.to change { listing.reload.published }.from(false).to(true) + expect(response).to have_http_status(:ok) + end + + it 'refuses to list a listing that has never published a version' do + versionless = create(:course_assessment_marketplace_listing, course: course, published: false) + + expect { set_published(true, id: versionless.id) }. + not_to(change { versionless.reload.published }) + expect(response).to have_http_status(:unprocessable_content) + expect(response.parsed_body['errors'].first).to match(/no published version/) + end + + # Nothing else on a listing is the admin's to edit here: provenance is historical fact and the + # version pointer belongs to the publish path. + it 'ignores every attribute other than published' do + expect do + patch :update, params: { id: listing.id, published: false, title: 'Renamed', + source_course_name: 'Elsewhere', format: :json } + end. + not_to(change { listing.reload.source_course_name }) + expect(response).to have_http_status(:ok) + end + end + + describe 'GET #show' do + let(:listing) do + create(:course_assessment_marketplace_listing, :versioned, course: course, + source_course: course, + source_instance: instance) + end + + before { controller_sign_in(controller, admin) } + + def show + get :show, params: { id: listing.id, format: :json } + end + + it 'reports provenance, the served vintage and the assessment title' do + show + + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body['id']).to eq(listing.id) + expect(body['title']).to eq(listing.current_version.assessment.title) + expect(body).to have_key('currentVersionPublishedAt') + expect(body).not_to have_key('currentVersion') + expect(Time.zone.parse(body['currentVersionPublishedAt'])). + to be_within(1.second).of(listing.current_version.published_at) + expect(body['state']).to eq('published') + expect(body['sourceInstanceName']).to eq(instance.name) + expect(body['marketplaceHosted']).to be(false) + end + + it 'reports a container-hosted authoring copy, matching the index' do + listing.update!(authoring_assessment: assessment_in_container) + + show + + expect(response.parsed_body['marketplaceHosted']).to be(true) + end + + it 'lists every version ascending, flagging the current one' do + v1_at = listing.current_version.published_at + v2_at = v1_at + 1.day + v3_at = v1_at + 2.days + create(:course_assessment_marketplace_listing_version, + listing: listing, published_at: v3_at, published_by: admin) + create(:course_assessment_marketplace_listing_version, + listing: listing, published_at: v2_at, published_by: admin) + + show + + versions = response.parsed_body['versions'] + published_times = versions.map { |version| Time.zone.parse(version['publishedAt']) } + expect(published_times).to eq(published_times.sort) + expect(published_times).to all(be_present) + expect(published_times[0]).to be_within(1.second).of(v1_at) + expect(published_times[1]).to be_within(1.second).of(v2_at) + expect(published_times[2]).to be_within(1.second).of(v3_at) + expect(versions).to all(satisfy { |version| !version.key?('version') }) + # v1 is the current version because the :versioned trait pointed the listing at it. + expect(versions.map { |v| v['isCurrent'] }).to eq([true, false, false]) + end + + it 'names who published each version' do + create(:course_assessment_marketplace_listing_version, + listing: listing, published_at: listing.current_version.published_at + 1.day, + published_by: admin) + + show + + expect(response.parsed_body['versions'].last['publisherName']).to eq(admin.name) + end + + it 'dates v1 from the version publish date' do + published = 4.months.ago.change(usec: 0) + listing.current_version.update!(published_at: published) + + show + + expect(Time.zone.parse(response.parsed_body['versions'].first['publishedAt'])). + to be_within(1.second).of(published) + end + + it 'links each version to its snapshot on the container host' do + show + + snapshot = listing.current_version.assessment + url = response.parsed_body['versions'].first['snapshotUrl'] + expect(url).to include("/assessments/#{snapshot.id}") + expect(url).to start_with('http') + end + + # Same trap as the authoring url on the index — see the note there. + it 'keeps the port carried by the container host, not the one the request arrived on' do + container_host = ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course.instance.host + end + request.host = 'localhost:3999' + + show + + expect(response.parsed_body['versions'].first['snapshotUrl']). + to start_with("http://#{container_host}/") + end + + it 'reports adoptions with the vintage each course holds' do + adopter = create(:course) + create(:course_assessment_marketplace_adoption, listing: listing, + destination_course: adopter, + adopted_version_at: listing.current_version.published_at) + + show + + adoptions = response.parsed_body['adoptions'] + expect(adoptions.size).to eq(1) + expect(adoptions.first['destinationCourseId']).to eq(adopter.id) + expect(adoptions.first['destinationCourseName']).to eq(adopter.title) + expect(adoptions.first).to have_key('adoptedVersionAt') + expect(adoptions.first).not_to have_key('adoptedVersion') + expect(Time.zone.parse(adoptions.first['adoptedVersionAt'])). + to be_within(1.second).of(listing.current_version.published_at) + expect(adoptions.first['adoptedAt']).to be_present + end + + it 'reports destination provenance for an adoption in another instance' do + other_instance = create(:instance) + other_course = ActsAsTenant.with_tenant(other_instance) { create(:course) } + ActsAsTenant.with_tenant(other_instance) do + create(:course_assessment_marketplace_adoption, listing: listing, + destination_course: other_course, + adopted_version_at: listing.current_version.published_at) + end + + show + + adoption = response.parsed_body['adoptions'].first + expect(adoption['destinationCourseName']).to eq(other_course.title) + expect(adoption['destinationCourseHost']).to eq(other_instance.host) + end + + # Unusual but valid, so it reports as an empty list rather than a missing key. + it 'reports an empty adoptions list when nobody has adopted it' do + show + + expect(response.parsed_body['adoptions']).to eq([]) + end + + it 'still serves the full history for an orphaned listing' do + listing.authoring_assessment.destroy! + + show + + body = response.parsed_body + expect(response).to have_http_status(:ok) + expect(body['state']).to eq('published') + expect(body['sourceAssessmentDeleted']).to be(true) + expect(body['versions'].size).to eq(1) + end + + # Cannot happen for a published listing, but the page must not 500 if it ever does. + it 'renders an empty history for a listing with no current version' do + unversioned = create(:course_assessment_marketplace_listing, course: course) + + get :show, params: { id: unversioned.id, format: :json } + + expect(response).to have_http_status(:ok) + expect(response.parsed_body['currentVersionPublishedAt']).to be_nil + expect(response.parsed_body).not_to have_key('currentVersion') + expect(response.parsed_body['versions']).to eq([]) + end + + # A course MANAGER, not a student: managers hold a blanket `can :manage, Course` over their own + # course, so they are the user who proves the `:manage, :all` gate is what stops this. + it 'denies a course manager' do + manager = create(:course_manager, course: course).user + controller_sign_in(controller, manager) + + expect { show }.to raise_exception(CanCan::AccessDenied) + end + end + + describe 'authorization' do + # A course MANAGER, not a student: managers hold a blanket `can :manage, Course` and + # `can :manage, Course::Assessment` over their own course, so they are the user who proves the + # `:manage, :all` gate is what stops these actions. + let(:manager) { create(:course_manager, course: course).user } + + it 'denies a non-administrator' do + controller_sign_in(controller, create(:course_manager, course: course).user) + + expect { get :index, format: :json }.to raise_exception(CanCan::AccessDenied) + end + + it 'denies a course manager the restore action' do + listing = create(:course_assessment_marketplace_listing, :versioned, course: course) + listing.authoring_assessment.destroy! + controller_sign_in(controller, manager) + + expect do + post :restore_authoring, params: { id: listing.id, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + it 'denies a course manager the unlist' do + listing = create(:course_assessment_marketplace_listing, :versioned, course: course) + controller_sign_in(controller, manager) + + expect do + patch :update, params: { id: listing.id, published: false, format: :json } + end.to raise_exception(CanCan::AccessDenied) + end + + it 'denies a course manager the permanent delete' do + listing = create(:course_assessment_marketplace_listing, :versioned, course: course) + listing.authoring_assessment.destroy! + controller_sign_in(controller, manager) + + expect { delete :destroy, params: { id: listing.id, format: :json } }. + to raise_exception(CanCan::AccessDenied) + end + end + + describe 'version identity in the admin payloads' do + render_views + + let(:admin) { create(:administrator) } + let(:listing) { create(:course_assessment_marketplace_listing, :versioned, published: true) } + + before { controller_sign_in(controller, admin) } + + it 'names the served vintage on the index, with no ordinal' do + listing + + get :index, as: :json + + row = response.parsed_body['listings'].find { |l| l['id'] == listing.id } + expect(row).to have_key('currentVersionPublishedAt') + expect(row).not_to have_key('currentVersion') + expect(Time.zone.parse(row['currentVersionPublishedAt'])). + to be_within(1.second).of(listing.current_version.published_at) + end + + it 'names each version by publish date on the show page, with no ordinal' do + get :show, as: :json, params: { id: listing.id } + + version = response.parsed_body['versions'].first + expect(version).to have_key('publishedAt') + expect(version).not_to have_key('version') + expect(version['isCurrent']).to be(true) + end + + it 'links a version snapshot through its publish date key' do + get :show, as: :json, params: { id: listing.id } + + expect(response.parsed_body['versions'].first['snapshotUrl']).to be_present + end + + it 'names the vintage an adopter holds, with no ordinal' do + adoption = create(:course_assessment_marketplace_adoption, + listing: listing, + adopted_version_at: listing.current_version.published_at) + + get :show, as: :json, params: { id: listing.id } + + row = response.parsed_body['adoptions'].find { |a| a['id'] == adoption.id } + expect(row).to have_key('adoptedVersionAt') + expect(row).not_to have_key('adoptedVersion') + expect(Time.zone.parse(row['adoptedVersionAt'])). + to be_within(1.second).of(listing.current_version.published_at) + end + + # The snapshot map is keyed by a canonicalised timestamp string. An adoption holding exactly + # the served vintage must therefore resolve to the same snapshot the version row links to. + it 'resolves the adopter snapshot link from the same key as the version row' do + create(:course_assessment_marketplace_adoption, + listing: listing, adopted_version_at: listing.current_version.published_at) + + get :show, as: :json, params: { id: listing.id } + + body = response.parsed_body + expect(body['adoptions'].first['snapshotUrl']).to eq(body['versions'].first['snapshotUrl']) + end + + it 'leaves the snapshot link null for an adoption with an unknown vintage' do + create(:course_assessment_marketplace_adoption, + listing: listing, adopted_version_at: nil) + + get :show, as: :json, params: { id: listing.id } + + expect(response.parsed_body['adoptions'].first['snapshotUrl']).to be_nil + end + end + end +end diff --git a/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb new file mode 100644 index 0000000000..24bf4c559c --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/restore_authoring_job_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true +require 'rails_helper' + +# The clone itself is `RestoreAuthoringService`'s contract and is covered there. What is job-level — +# and only testable here — is the guarding it does around a repair that may run long after it was +# asked for, and the url it hands back for the client to follow. +RSpec.describe Course::Assessment::Marketplace::RestoreAuthoringJob, type: :job do + let(:instance) { create(:instance) } + with_tenant(:instance) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + let(:user) { create(:administrator) } + # Published through the real service so the snapshot genuinely lives in the container course in + # the preview instance: restoring must duplicate ACROSS instances, exactly as adoption does. + let(:listing) { Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) } + + def container + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + def container_assessment_count + ActsAsTenant.without_tenant { container.assessments.count } + end + + # The only way a listing is orphaned now that `Course::Assessment` re-points inside the destroy + # transaction: the column is nulled underneath the model layer, as the foreign key does when a + # delete bypasses the callback. That is the state this job exists to repair. + def orphan!(target = listing) + target.update_column(:authoring_assessment_id, nil) + target.reload + end + + # `perform_now` cannot be asserted with `raise_error`: TrackableJob installs + # `rescue_from(StandardError)`, so a refusal surfaces as an errored Job record instead. + def run(target = listing) + job = described_class.new(target.id, current_user: user) + job.perform_now + job.job + end + + context 'when the listing is orphaned with a version' do + before { orphan! } + + it 'rebuilds the authoring copy in the container' do + expect { run }.to change { container_assessment_count }.by(1) + expect(listing.reload).not_to be_orphaned + end + + # The client follows this after polling. It carries the CONTAINER's own host: the copy lives in + # the preview instance, so a relative path would resolve on nobody's host but the admin's. + it 'completes with a redirect url on the container instance' do + job = run + + copy = ActsAsTenant.without_tenant { listing.reload.authoring_assessment } + expect(job.status).to eq('completed') + expect(job.redirect_to).to include(container.instance.host) + expect(job.redirect_to).to include("/assessments/#{copy.id}") + end + end + + describe 'guards' do + # Completes rather than errors, and rebuilds nothing: the end state this job exists to reach is + # the one it found. A republish restores an authoring copy on its own and can land between + # enqueue and perform, so that race must not surface as a failure to whoever is watching. + it 'leaves a listing that already has an authoring copy alone' do + listing + + expect { run }.not_to(change { container_assessment_count }) + expect(run.status).to eq('completed') + end + + it 'refuses an orphaned listing with no version to restore from' do + versionless = create(:course_assessment_marketplace_listing, course: source_course) + orphan!(versionless) + + expect { run(versionless) }.not_to(change { container_assessment_count }) + expect(run(versionless).status).to eq('errored') + end + end + end +end diff --git a/spec/services/course/assessment/marketplace/restore_authoring_service_spec.rb b/spec/services/course/assessment/marketplace/restore_authoring_service_spec.rb new file mode 100644 index 0000000000..9b29392f17 --- /dev/null +++ b/spec/services/course/assessment/marketplace/restore_authoring_service_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true +require 'rails_helper' + +RSpec.describe Course::Assessment::Marketplace::RestoreAuthoringService, type: :service do + let(:instance) { create(:instance) } + with_tenant(:instance) do + # The duplication itself enqueues nothing, but the env default is `:background_thread` — a real + # thread sharing this example's connection — and these examples assert on container row counts. + with_active_job_queue_adapter(:test) do + let(:source_course) { create(:course) } + let(:source_assessment) { create(:assessment, :with_mcq_question, course: source_course) } + let(:user) { create(:administrator) } + # Published through the real service so the snapshot genuinely lives in the container course in + # the preview instance: restoring must duplicate ACROSS instances, exactly as adoption does. + let(:listing) { Course::Assessment::Marketplace::PublishService.publish(source_assessment, user) } + + def container + ActsAsTenant.without_tenant do + Course::Assessment::Marketplace::PreviewContainerService.container_course + end + end + + def container_assessment_count + ActsAsTenant.without_tenant { container.assessments.count } + end + + # The only way a listing is orphaned now that `Course::Assessment` re-points inside the destroy + # transaction: the column is nulled underneath the model layer, as `fk_caml_authoring_assessment_id` + # does when a delete bypasses the callback. That is the state this service exists to repair. + def orphan!(target = listing) + target.update_column(:authoring_assessment_id, nil) + target.reload + end + + def restore(target = listing) + described_class.restore!(target, current_user: user) + end + + describe '.restore!' do + before { orphan! } + + it 'duplicates the snapshot into the container course' do + expect { restore }.to change { container_assessment_count }.by(1) + end + + # A NEW assessment beside the snapshots, never one of them. Editing a snapshot would mutate a + # published version for every adopter with no version cut. + it 'creates a new assessment rather than reusing the snapshot' do + snapshot = ActsAsTenant.without_tenant { listing.current_version.assessment } + + restore + + copy = listing.reload.authoring_assessment + expect(copy.id).not_to eq(snapshot.id) + expect(snapshot.reload).to be_persisted + expect(listing.current_version.reload.assessment_id).to eq(snapshot.id) + end + + # Pinned deliberately: this holds only because ObjectDuplicationService's object-mode default + # is `unpublish_all: true` and no caller overrides it. A published copy in the container would + # be visible to previewers, so this must fail loudly if that default ever changes. + it 'lands the copy as a draft' do + restore + + expect(listing.reload.authoring_assessment.published).to be(false) + end + + it 'points the listing at the new copy, un-orphaning it' do + restore + + expect(listing.reload.authoring_assessment).to be_present + expect(listing.reload).not_to be_orphaned + end + + it 'carries the snapshot content into the copy' do + snapshot_title = ActsAsTenant.without_tenant { listing.current_version.assessment.title } + + restore + + copy = listing.reload.authoring_assessment + expect(copy.title).to eq(snapshot_title) + expect(copy.questions.count).to eq(1) + end + + # Everything descended from the original source stays comparable for plagiarism, so the copy + # inherits the snapshot's duplication root rather than starting a tree of its own. Matches the + # re-point that runs inside `Course::Assessment#destroy`. + it 'inherits the snapshot link tree' do + snapshot = ActsAsTenant.without_tenant { listing.current_version.assessment } + + restore + + copy = listing.reload.authoring_assessment + expect(copy.linkable_tree_id).to eq(snapshot.linkable_tree_id) + end + + # Repairing maintenance access is not a course adopting the content. + it 'records no adoption' do + expect { restore }.not_to change(Course::Assessment::Marketplace::Adoption, :count) + end + + # Provenance describes where the content originally came from. A repair must not rewrite that + # historical fact — the row goes on naming the origin course after the copy moves. + it 'leaves the provenance fields untouched' do + provenance = [:source_course_id, :source_course_name, :source_instance_id] + before_restore = listing.slice(*provenance) + + restore + + expect(listing.reload.slice(*provenance)).to eq(before_restore) + end + + # The end-to-end proof for `#marketplace_hosted?`: the copy lands in the real container, so the + # admin table can tell a repaired listing from one that still has its own source course. + it 'reports the listing as marketplace-hosted afterwards' do + expect { restore }.to change { listing.reload.marketplace_hosted? }.from(false).to(true) + end + + it 'cuts no version — restoring is not a republish' do + expect { restore }.not_to(change { listing.reload.current_version_id }) + end + + it 'lets the listing cut a new version again' do + restore + + expect do + Course::Assessment::Marketplace::PublishService.publish_new_version(listing.reload, user) + end.to(change { listing.reload.current_version_id }) + end + end + end + end +end From 6eac0c47ae69847a0168853d3873481aba8c0f89 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 29 Jul 2026 20:31:45 +0800 Subject: [PATCH 30/30] feat(marketplace): admin UI for listing and version management The listings table, its per-listing page, and the two maintenance buttons. Actions sit in one fixed order so a given action always occupies the same slot, and delete is present on every row -- disabled until the listing is unlisted -- so its tooltip can state the rule rather than leaving the admin to infer it from a missing icon. A listing with no source assessment is chipped in the alarm colour on both surfaces, with a hint saying it should not happen and what to do about it. The re-point makes an orphan unreachable through the model layer, so one on screen is a fault worth naming rather than a state to be inferred from a missing link. The State filter carries it as a facet contributed only by the rows it describes, so the value appears just when there is something to look at -- unlike the marketplace-hosted pair, which every row completes. --- client/app/api/system/Admin.ts | 65 + .../system/admin/admin/AdminNavigator.tsx | 9 + .../MarketplaceListingVisibilityButton.tsx | 128 ++ .../MarketplaceRestoreAuthoringButton.tsx | 142 ++ .../tables/MarketplaceListingsTable.tsx | 512 +++++ .../admin/pages/MarketplaceListingShow.tsx | 460 +++++ .../admin/pages/MarketplaceListingsIndex.tsx | 102 + .../__test__/MarketplaceListingShow.test.tsx | 578 ++++++ .../MarketplaceListingsIndex.test.tsx | 1680 +++++++++++++++++ client/app/routers/courseless/systemAdmin.tsx | 22 + client/app/types/system/courses.ts | 1 + .../app/types/system/marketplaceListings.ts | 90 + 12 files changed, 3789 insertions(+) create mode 100644 client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx create mode 100644 client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx create mode 100644 client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx create mode 100644 client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx create mode 100644 client/app/types/system/marketplaceListings.ts diff --git a/client/app/api/system/Admin.ts b/client/app/api/system/Admin.ts index 13243af9a8..c34cd93258 100644 --- a/client/app/api/system/Admin.ts +++ b/client/app/api/system/Admin.ts @@ -13,6 +13,10 @@ import { AllowlistRuleData, AllowlistRuleFormData, } from 'types/system/marketplaceAllowlist'; +import { + MarketplaceListingAdminData, + MarketplaceListingDetailData, +} from 'types/system/marketplaceListings'; import { AdminStats, UserListData } from 'types/users'; import BaseSystemAPI from '../Base'; @@ -193,6 +197,67 @@ export default class AdminAPI extends BaseSystemAPI { ); } + /** + * Fetches every marketplace listing with its version chain and provenance. + */ + indexMarketplaceListings(): Promise< + AxiosResponse<{ listings: MarketplaceListingAdminData[] }> + > { + return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_listings`); + } + + /** + * Fetches one listing's provenance, full version history and adoptions. Read-only — every + * mutation stays on the index. + */ + fetchMarketplaceListing( + id: number, + ): Promise> { + return this.client.get(`${AdminAPI.#urlPrefix}/marketplace_listings/${id}`); + } + + /** + * Permanently deletes a marketplace listing, its versions and their container snapshots. + * Irreversible. + */ + deleteMarketplaceListing(id: number): Promise> { + return this.client.delete( + `${AdminAPI.#urlPrefix}/marketplace_listings/${id}`, + ); + } + + /** + * Takes a listing off the marketplace, or puts it back. Reversible. Admin must unlist before + * deleting. + * + * Admin-side rather than through the course-side unlist because that one resolves the listing + * through its authoring assessment — which, once the origin is deleted, lives in the container + * course on the preview instance, and which an orphaned listing has no pointer to at all. + * Re-listing never cuts a version: it restores visibility over the version already held. + */ + setMarketplaceListingPublished( + id: number, + published: boolean, + ): Promise> { + return this.client.patch( + `${AdminAPI.#urlPrefix}/marketplace_listings/${id}`, + { published }, + ); + } + + /** + * Duplicates an orphaned listing's latest snapshot into the marketplace's container course and + * makes it the new source assessment. There is no destination to choose. Asynchronous — the + * response carries a `jobUrl` for the client to poll. + */ + restoreMarketplaceListingAuthoring( + id: number, + ): Promise> { + return this.client.post( + `${AdminAPI.#urlPrefix}/marketplace_listings/${id}/restore_authoring`, + ); + } + /** * Creates a marketplace allow-list rule. */ diff --git a/client/app/bundles/system/admin/admin/AdminNavigator.tsx b/client/app/bundles/system/admin/admin/AdminNavigator.tsx index ddb5875f3c..7b74de715d 100644 --- a/client/app/bundles/system/admin/admin/AdminNavigator.tsx +++ b/client/app/bundles/system/admin/admin/AdminNavigator.tsx @@ -37,6 +37,10 @@ const translations = defineMessages({ id: 'system.admin.admin.AdminNavigator.marketplace', defaultMessage: 'Marketplace Access', }, + marketplaceListings: { + id: 'system.admin.admin.AdminNavigator.marketplaceListings', + defaultMessage: 'Marketplace Listings', + }, systemAdminPanel: { id: 'system.admin.admin.AdminNavigator.systemAdminPanel', defaultMessage: 'System Admin Panel', @@ -74,6 +78,11 @@ const AdminNavigator = (): JSX.Element => { title: t(translations.marketplace), path: '/admin/marketplace_allowlist_rules', }, + { + icon: , + title: t(translations.marketplaceListings), + path: '/admin/marketplace_listings', + }, { icon: , title: t(translations.getHelp), diff --git a/client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx new file mode 100644 index 0000000000..27d924e3c7 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceListingVisibilityButton.tsx @@ -0,0 +1,128 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button } from '@mui/material'; +import { AxiosError } from 'axios'; +import { MarketplaceListingAdminData } from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +interface Props { + listing: MarketplaceListingAdminData; + /** Called once the flip lands: `state` changes, and with it whether the row can be deleted. */ + onChanged: () => void; +} + +const translations = defineMessages({ + unlist: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlist', + defaultMessage: 'Unlist', + }, + list: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.list', + defaultMessage: 'List', + }, + unlistTitle: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlistTitle', + defaultMessage: 'Take this listing off the marketplace?', + }, + listTitle: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.listTitle', + defaultMessage: 'Put this listing back on the marketplace?', + }, + unlistExplanation: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlistExplanation', + defaultMessage: + 'It stops appearing in the marketplace and can no longer be copied or previewed. Nothing is deleted, and courses that already copied it are unaffected.', + }, + unlistReversible: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlistReversible', + defaultMessage: + 'This is reversible — you can list it again at any time. It is also what has to happen before a listing can be deleted permanently.', + }, + listExplanation: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.listExplanation', + defaultMessage: + 'It appears in the marketplace again, serving the version it already holds. No new version is published.', + }, + unlisted: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.unlisted', + defaultMessage: 'Listing taken off the marketplace.', + }, + listed: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.listed', + defaultMessage: 'Listing is back on the marketplace.', + }, + failed: { + id: 'system.admin.admin.MarketplaceListingVisibilityButton.failed', + defaultMessage: 'Could not change the listing’s visibility.', + }, +}); + +const MarketplaceListingVisibilityButton = ({ + listing, + onChanged, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const listed = listing.state === 'published'; + + const submit = async (): Promise => { + setSubmitting(true); + try { + await SystemAPI.admin.setMarketplaceListingPublished(listing.id, !listed); + toast.success(t(listed ? translations.unlisted : translations.listed)); + setOpen(false); + onChanged(); + } catch (error) { + const message = + error instanceof AxiosError + ? error.response?.data?.errors?.[0] + : undefined; + toast.error(message ?? t(translations.failed)); + } finally { + setSubmitting(false); + } + }; + + return ( + <> + + + setOpen(false)} + open={open} + primaryColor={listed ? 'error' : 'primary'} + primaryLabel={t(listed ? translations.unlist : translations.list)} + title={t(listed ? translations.unlistTitle : translations.listTitle)} + > + + {t( + listed + ? translations.unlistExplanation + : translations.listExplanation, + )} + + + {listed && {t(translations.unlistReversible)}} + + + ); +}; + +export default MarketplaceListingVisibilityButton; diff --git a/client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx new file mode 100644 index 0000000000..a777b2c2a6 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/buttons/MarketplaceRestoreAuthoringButton.tsx @@ -0,0 +1,142 @@ +import { useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Button } from '@mui/material'; +import { AxiosError } from 'axios'; +import { MarketplaceListingAdminData } from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; +import pollJob from 'lib/helpers/jobHelpers'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +const JOB_POLL_INTERVAL_MS = 2000; + +interface Props { + listing: MarketplaceListingAdminData; + /** + * Called once the rebuild job completes: `state`, `authoringAssessmentUrl` and `marketplaceHosted` + * all change. + */ + onRestored: () => void; +} + +const translations = defineMessages({ + restore: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.restore', + defaultMessage: 'Rebuild source assessment', + }, + confirm: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.confirm', + defaultMessage: 'Rebuild', + }, + explanation: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.explanation', + defaultMessage: + "The latest published version is copied into the marketplace's own container course as a new, editable source assessment, so that new versions can be published from it again.", + }, + intoContainer: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.intoContainer', + defaultMessage: + 'No live course is touched, and the published versions themselves are left unchanged.', + }, + completed: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.completed', + defaultMessage: 'Source assessment rebuilt in the marketplace container.', + }, + viewRestored: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.viewRestored', + defaultMessage: 'View assessment', + }, + failed: { + id: 'system.admin.admin.MarketplaceRestoreAuthoringButton.failed', + defaultMessage: 'Could not rebuild the source assessment.', + }, +}); + +const MarketplaceRestoreAuthoringButton = ({ + listing, + onRestored, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const close = (): void => { + setOpen(false); + }; + + const submit = async (): Promise => { + setSubmitting(true); + try { + const response = await SystemAPI.admin.restoreMarketplaceListingAuthoring( + listing.id, + ); + pollJob( + response.data.jobUrl, + // pollJob's *completion* callback — the duplication has finished by now, so the list can be + // refetched and `redirectUrl` points at the assessment that was just created. + (data) => { + toast.success( + <> + {t(translations.completed)} + {data.redirectUrl && ( + <> + {' '} + + {t(translations.viewRestored)} + + + )} + , + ); + setSubmitting(false); + close(); + onRestored(); + }, + () => { + toast.error(t(translations.failed)); + setSubmitting(false); + }, + JOB_POLL_INTERVAL_MS, + ); + } catch (error) { + const message = + error instanceof AxiosError + ? error.response?.data?.errors?.[0] + : undefined; + toast.error(message ?? t(translations.failed)); + setSubmitting(false); + } + }; + + return ( + <> + + + + {t(translations.explanation)} + + {t(translations.intoContainer)} + + + ); +}; + +export default MarketplaceRestoreAuthoringButton; diff --git a/client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx b/client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx new file mode 100644 index 0000000000..5241fa03c4 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/tables/MarketplaceListingsTable.tsx @@ -0,0 +1,512 @@ +import { defineMessages } from 'react-intl'; +import { StorefrontOutlined } from '@mui/icons-material'; +import { Chip, Tooltip, Typography } from '@mui/material'; +import { + MarketplaceListingAdminData, + MarketplaceListingState, +} from 'types/system/marketplaceListings'; + +import DeleteButton from 'lib/components/core/buttons/DeleteButton'; +import { PromptText } from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; +import Table, { ColumnTemplate } from 'lib/components/table'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDate, formatLongDateTime } from 'lib/moment'; + +import MarketplaceListingVisibilityButton from '../buttons/MarketplaceListingVisibilityButton'; +import MarketplaceRestoreAuthoringButton from '../buttons/MarketplaceRestoreAuthoringButton'; + +interface Props { + listings: MarketplaceListingAdminData[]; + /** Permanent purge, not unlisting. Resolves once the list has been refetched. */ + onDelete: (id: number) => Promise; + onRestored: () => void; + /** The reversible unlist/list flip, which changes `state` and with it what else the row offers. */ + onVisibilityChanged: () => void; +} + +/** + * Filter-menu bucket for a listing with no recorded source instance. Publishing always records one, + * so a row lands here only once that instance is DELETED — the FK nullifies both the instance and the + * source course, leaving the denormalised course name as the whole of the row's provenance. It is a + * real bucket rather than an omission so an admin hunting a problem still sees those rows. + */ +const NO_INSTANCE = ''; + +/** + * A filter facet, deliberately NOT a fifth `MarketplaceListingState`. Marketplace-hosted answers WHERE + * the authoring copy lives, while the state values answer whether the listing is on the marketplace — + * and the two cross, so a row contributes both to the State filter menu and matches either + * independently. The value cannot collide with a real state. + */ +const MARKETPLACE_HOSTED = 'marketplace_hosted'; + +/** + * The complement of `MARKETPLACE_HOSTED`, and a filter value only, not a chip, because it is the + * ordinary state of the marketplace listings. + */ +const NOT_MARKETPLACE_HOSTED = 'not_marketplace_hosted'; + +/** + * A filter facet for listings with no source assessment at all. Unlike the marketplace-hosted pair, + * it is contributed ONLY by the rows it describes, so it appears in the menu just when something is + * wrong: an orphan is not a state the system produces any more — losing a source assessment re-points + * the listing inside the same transaction — so a value matching nothing on a healthy deployment would + * advertise a fault as an ordinary way for a listing to be. It has no complement for the same reason. + */ +const ORPHANED = 'orphaned'; + +type StateFilterValue = + | MarketplaceListingState + | typeof MARKETPLACE_HOSTED + | typeof NOT_MARKETPLACE_HOSTED + | typeof ORPHANED; + +const translations = defineMessages({ + colId: { + id: 'system.admin.admin.MarketplaceListingsTable.colId', + defaultMessage: 'ID', + }, + colTitle: { + id: 'system.admin.admin.MarketplaceListingsTable.colTitle', + defaultMessage: 'Original assessment', + }, + colSource: { + id: 'system.admin.admin.MarketplaceListingsTable.colSource', + defaultMessage: 'Source course', + }, + colInstance: { + id: 'system.admin.admin.MarketplaceListingsTable.colInstance', + defaultMessage: 'Instance', + }, + colVersion: { + id: 'system.admin.admin.MarketplaceListingsTable.colVersion', + defaultMessage: 'Version', + }, + colAdoptions: { + id: 'system.admin.admin.MarketplaceListingsTable.colAdoptions', + defaultMessage: 'Adoptions', + }, + colState: { + id: 'system.admin.admin.MarketplaceListingsTable.colState', + defaultMessage: 'State', + }, + colActions: { + id: 'system.admin.admin.MarketplaceListingsTable.colActions', + defaultMessage: 'Actions', + }, + statePublished: { + id: 'system.admin.admin.MarketplaceListingsTable.statePublished', + defaultMessage: 'Published', + }, + stateUnlisted: { + id: 'system.admin.admin.MarketplaceListingsTable.stateUnlisted', + defaultMessage: 'Unlisted', + }, + deletedSuffix: { + id: 'system.admin.admin.MarketplaceListingsTable.deletedSuffix', + defaultMessage: '(deleted)', + }, + assessmentDeletedHint: { + id: 'system.admin.admin.MarketplaceListingsTable.assessmentDeletedHint', + defaultMessage: + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.', + }, + courseDeletedHint: { + id: 'system.admin.admin.MarketplaceListingsTable.courseDeletedHint', + defaultMessage: + 'The course this listing was published from has been deleted. Its name is kept as a record of where the content came from.', + }, + marketplaceHosted: { + id: 'system.admin.admin.MarketplaceListingsTable.marketplaceHosted', + defaultMessage: 'Marketplace-hosted', + }, + notMarketplaceHosted: { + id: 'system.admin.admin.MarketplaceListingsTable.notMarketplaceHosted', + defaultMessage: 'Not marketplace-hosted', + }, + marketplaceHostedHint: { + id: 'system.admin.admin.MarketplaceListingsTable.marketplaceHostedHint', + defaultMessage: + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from.", + }, + orphaned: { + id: 'system.admin.admin.MarketplaceListingsTable.orphaned', + defaultMessage: 'Orphaned', + }, + // Names the fault AND the remedy: a chip reading "Orphaned" beside a Published listing otherwise + // leaves an admin unable to tell whether the listing is serving, whether to act, or which action. + orphanedHint: { + id: 'system.admin.admin.MarketplaceListingsTable.orphanedHint', + defaultMessage: + 'This listing has no source assessment, which should not happen: one is rebuilt automatically whenever an assessment or the course holding it is deleted. Rebuild it from the latest published version, or delete the listing if there is no version left to rebuild from.', + }, + openSourceAssessment: { + id: 'system.admin.admin.MarketplaceListingsTable.openSourceAssessment', + defaultMessage: 'Open source assessment', + }, + filterNoInstance: { + id: 'system.admin.admin.MarketplaceListingsTable.filterNoInstance', + defaultMessage: 'Instance not recorded', + }, + searchText: { + id: 'system.admin.admin.MarketplaceListingsTable.searchText', + defaultMessage: 'Search listings by assessment title or source course', + }, + emptyTitle: { + id: 'system.admin.admin.MarketplaceListingsTable.emptyTitle', + defaultMessage: 'No assessments have been published yet.', + }, + unknown: { + id: 'system.admin.admin.MarketplaceListingsTable.unknown', + defaultMessage: '—', + }, + deleteTitle: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteTitle', + defaultMessage: 'Delete this listing permanently?', + }, + deleteConfirm: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteConfirm', + defaultMessage: + 'The listing, all of its versions and the snapshots those versions hold in the preview container will be deleted permanently. This cannot be undone. To merely take the listing off the marketplace, unlist it instead.', + }, + deleteConfirmUnlisted: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteConfirmUnlisted', + defaultMessage: + 'The listing, all of its versions and the snapshots those versions hold in the preview container will be deleted permanently. The source assessment is not affected and can be published again. This cannot be undone.', + }, + deleteTooltip: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteTooltip', + defaultMessage: 'Delete permanently', + }, + deleteBlockedTooltip: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteBlockedTooltip', + defaultMessage: + 'A published listing cannot be deleted. Unlist it first, so the reversible step comes before the irreversible one.', + }, + deleteAdoptionWarning: { + id: 'system.admin.admin.MarketplaceListingsTable.deleteAdoptionWarning', + defaultMessage: + '{count, plural, one {# course has} other {# courses have}} adopted this listing. Their existing copies will not be affected, but the adoption history will be destroyed.', + }, +}); + +const MarketplaceListingsTable = ({ + listings, + onDelete, + onRestored, + onVisibilityChanged, +}: Props): JSX.Element => { + const { t } = useTranslation(); + + const stateDisplay: Record = { + published: t(translations.statePublished), + unlisted: t(translations.stateUnlisted), + }; + + const stateColors = { + published: 'success', + unlisted: 'default', + } as const; + + // Mirrors `Listing#orphaned?`, which is about the AUTHORING copy and not about the origin: a + // rebuilt listing has a fresh copy in the container and is no longer orphaned, even though its + // original source assessment is gone for good. The url is null exactly when the copy is missing. + const isOrphaned = (listing: MarketplaceListingAdminData): boolean => + listing.authoringAssessmentUrl === null; + + // Mirrors `Listing#purgeable?`: a listing that is off the marketplace, orphaned or unlisted. A + // published listing has to be unlisted first, which keeps the reversible step ahead of the + // irreversible one. Adoption count plays no part — a deliberate deletion of an adopted listing is + // allowed; the confirm dialog is where that fact is surfaced, not a disabled button. + const isPurgeable = (listing: MarketplaceListingAdminData): boolean => + isOrphaned(listing) || listing.state === 'unlisted'; + + // A deleted origin is struck through AND suffixed: the strikethrough carries at a glance, the + // suffix carries for anyone who cannot see it, and the tooltip carries the part neither can — that + // the listing itself is fine. "Deleted" beside a live, serving listing reads as "broken" without it. + const deletedOrigin = (name: string, hint: string): JSX.Element => ( + + + {name}{' '} + {t(translations.deletedSuffix)} + + + ); + + const columns: ColumnTemplate[] = [ + { + of: 'id', + title: t(translations.colId), + sortable: true, + // The second entrance to the version history, and the only unconditional one. The Version cell + // beside it links only when a version exists, so a listing that has never published one had no + // route to its own page at all. Surfacing the id also gives the container's "Listing ID n" + // chips something to resolve against. + cell: (listing) => ( + + {listing.id} + + ), + }, + { + of: 'title', + title: t(translations.colTitle), + sortable: true, + searchable: true, + cell: (listing): JSX.Element | string => { + const title = listing.title ?? t(translations.unknown); + + if (listing.sourceAssessmentDeleted) + return deletedOrigin(title, t(translations.assessmentDeletedHint)); + + return listing.authoringAssessmentUrl ? ( + + {title} + + ) : ( + title + ); + }, + }, + { + id: 'source', + title: t(translations.colSource), + sortable: true, + searchable: true, + // Deliberately not filterable over courses. + accessorFn: (listing) => listing.sourceCourseName ?? '', + cell: (listing): JSX.Element | string => { + const name = listing.sourceCourseName ?? t(translations.unknown); + + if (listing.sourceCourseDeleted) + return deletedOrigin(name, t(translations.courseDeletedHint)); + + return listing.sourceCourseId && listing.sourceInstanceHost ? ( + + {listing.sourceCourseName ?? `#${listing.sourceCourseId}`} + + ) : ( + name + ); + }, + }, + { + id: 'instance', + title: t(translations.colInstance), + sortable: true, + filterable: true, + accessorFn: (listing) => listing.sourceInstanceName ?? '', + filterProps: { + getValue: (listing) => [listing.sourceInstanceName ?? NO_INSTANCE], + getLabel: (value: string) => + value === NO_INSTANCE ? t(translations.filterNoInstance) : value, + shouldInclude: (listing, filterValue?: string[]) => + !filterValue?.length || + filterValue.includes(listing.sourceInstanceName ?? NO_INSTANCE), + }, + cell: (listing) => + listing.sourceInstanceName && listing.sourceInstanceHost ? ( + + {listing.sourceInstanceName} + + ) : ( + listing.sourceInstanceName ?? t(translations.unknown) + ), + }, + { + id: 'version', + title: t(translations.colVersion), + // The entrance to the version history, which is the only index into the container course. + // + // Date, not date+time: this table shows ONE version per listing, so there is no sibling to + // disambiguate against and the time would be noise. It stays reachable on hover. + cell: (listing) => + listing.currentVersionPublishedAt ? ( + + + {formatLongDate(listing.currentVersionPublishedAt)} + + + ) : ( + t(translations.unknown) + ), + }, + { + of: 'adoptions', + title: t(translations.colAdoptions), + sortable: true, + sortProps: { sort: (a, b): number => a.adoptions - b.adoptions }, + cell: (listing) => + listing.adoptions > 0 ? ( + + {listing.adoptions} + + ) : ( + listing.adoptions.toString() + ), + }, + { + of: 'state', + title: t(translations.colState), + filterable: true, + filterProps: { + // Every row contributes exactly one hosting value alongside its state, so the pair is always + // both offered and complete — selecting neither is the same as selecting both. Orphaned is + // contributed only by the rows it describes; see the constant. + getValue: (listing) => [ + listing.state, + listing.marketplaceHosted + ? MARKETPLACE_HOSTED + : NOT_MARKETPLACE_HOSTED, + ...(isOrphaned(listing) ? [ORPHANED] : []), + ], + getLabel: (value: StateFilterValue): string => { + if (value === MARKETPLACE_HOSTED) + return t(translations.marketplaceHosted); + if (value === NOT_MARKETPLACE_HOSTED) + return t(translations.notMarketplaceHosted); + if (value === ORPHANED) return t(translations.orphaned); + + return stateDisplay[value]; + }, + shouldInclude: (listing, filterValue?: StateFilterValue[]) => + !filterValue?.length || + filterValue.includes(listing.state) || + filterValue.includes( + listing.marketplaceHosted + ? MARKETPLACE_HOSTED + : NOT_MARKETPLACE_HOSTED, + ) || + (isOrphaned(listing) && filterValue.includes(ORPHANED)), + }, + cell: (listing) => ( +
+ + {/* Filled and in the alarm colour, unlike the marketplace-hosted marker beside it: that one + is provenance, this one is a fault nothing but a bypassed callback can produce. The state + chip stays as it is — an orphan goes on serving its last published version. */} + {isOrphaned(listing) && ( + + + + )} + {listing.marketplaceHosted && ( + + + + )} +
+ ), + }, + { + id: 'actions', + title: t(translations.colActions), + cell: (listing) => ( +
+ {listing.authoringAssessmentUrl && ( + + {t(translations.openSourceAssessment)} + + )} + + + + {isOrphaned(listing) && + listing.currentVersionPublishedAt !== null && ( + + )} + + + + {isOrphaned(listing) + ? t(translations.deleteConfirm) + : t(translations.deleteConfirmUnlisted)} + + + {listing.adoptions > 0 && ( + + {t(translations.deleteAdoptionWarning, { + count: listing.adoptions, + })} + + )} + + } + disabled={!isPurgeable(listing)} + onClick={(): Promise => onDelete(listing.id)} + title={t(translations.deleteTitle)} + tooltip={ + isPurgeable(listing) + ? t(translations.deleteTooltip) + : t(translations.deleteBlockedTooltip) + } + /> +
+ ), + }, + ]; + + const emptyState = ( +
+ + + + {t(translations.emptyTitle)} + +
+ ); + + return ( +
listing.id.toString()} + renderEmpty={emptyState} + search={{ searchPlaceholder: t(translations.searchText) }} + toolbar={{ show: true }} + /> + ); +}; + +export default MarketplaceListingsTable; diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx new file mode 100644 index 0000000000..f971845a56 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceListingShow.tsx @@ -0,0 +1,460 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { useParams } from 'react-router-dom'; +import { + Chip, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@mui/material'; +import { + MarketplaceListingDetailData, + MarketplaceListingState, +} from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Page from 'lib/components/core/layouts/Page'; +import Link from 'lib/components/core/Link'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import useTranslation from 'lib/hooks/useTranslation'; +import { formatLongDateTime } from 'lib/moment'; + +const translations = defineMessages({ + header: { + id: 'system.admin.admin.MarketplaceListingShow.header', + defaultMessage: 'Marketplace Listing', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceListingShow.fetchFailure', + defaultMessage: 'Failed to load this marketplace listing.', + }, + sourceCourse: { + id: 'system.admin.admin.MarketplaceListingShow.sourceCourse', + defaultMessage: 'Source course', + }, + instance: { + id: 'system.admin.admin.MarketplaceListingShow.instance', + defaultMessage: 'Instance', + }, + versionHistory: { + id: 'system.admin.admin.MarketplaceListingShow.versionHistory', + defaultMessage: 'Version history', + }, + colVersion: { + id: 'system.admin.admin.MarketplaceListingShow.colVersion', + defaultMessage: 'Version', + }, + colPublisher: { + id: 'system.admin.admin.MarketplaceListingShow.colPublisher', + defaultMessage: 'Published by', + }, + colContent: { + id: 'system.admin.admin.MarketplaceListingShow.colContent', + defaultMessage: 'Content', + }, + viewVersion: { + id: 'system.admin.admin.MarketplaceListingShow.viewVersion', + defaultMessage: 'View {version} content', + }, + // "Latest", matching the container's chip and the `apply_latest_version` route. The message id is + // left alone: renaming it would orphan the key in every locale file for a copy change. + currentBadge: { + id: 'system.admin.admin.MarketplaceListingShow.currentBadge', + defaultMessage: 'Latest', + }, + noVersions: { + id: 'system.admin.admin.MarketplaceListingShow.noVersions', + defaultMessage: 'No versions have been published yet.', + }, + adoptions: { + id: 'system.admin.admin.MarketplaceListingShow.adoptions', + defaultMessage: 'Adoptions', + }, + colCourse: { + id: 'system.admin.admin.MarketplaceListingShow.colCourse', + defaultMessage: 'Course', + }, + colVersionHeld: { + id: 'system.admin.admin.MarketplaceListingShow.colVersionHeld', + defaultMessage: 'Version held', + }, + colAdoptedAt: { + id: 'system.admin.admin.MarketplaceListingShow.colAdoptedAt', + defaultMessage: 'Adopted', + }, + noAdoptions: { + id: 'system.admin.admin.MarketplaceListingShow.noAdoptions', + defaultMessage: 'No courses have adopted this listing yet.', + }, + statePublished: { + id: 'system.admin.admin.MarketplaceListingShow.statePublished', + defaultMessage: 'Published', + }, + stateUnlisted: { + id: 'system.admin.admin.MarketplaceListingShow.stateUnlisted', + defaultMessage: 'Unlisted', + }, + deletedSuffix: { + id: 'system.admin.admin.MarketplaceListingShow.deletedSuffix', + defaultMessage: '(deleted)', + }, + // Rendered ONLY when the original is gone. While it exists there is nothing to say: the heading + // names it and "Open source assessment" opens it, so a field repeating the heading's own text + // would be noise — and the label is what lets this state the deletion without repeating it either. + originalAssessment: { + id: 'system.admin.admin.MarketplaceListingShow.originalAssessment', + defaultMessage: 'Original assessment', + }, + originalDeleted: { + id: 'system.admin.admin.MarketplaceListingShow.originalDeleted', + defaultMessage: 'deleted', + }, + assessmentDeletedHint: { + id: 'system.admin.admin.MarketplaceListingShow.assessmentDeletedHint', + defaultMessage: + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.', + }, + courseDeletedHint: { + id: 'system.admin.admin.MarketplaceListingShow.courseDeletedHint', + defaultMessage: + 'The course this listing was published from has been deleted. Its name is kept as a record of where the content came from.', + }, + openSourceAssessment: { + id: 'system.admin.admin.MarketplaceListingShow.openSourceAssessment', + defaultMessage: 'Open source assessment', + }, + marketplaceHosted: { + id: 'system.admin.admin.MarketplaceListingShow.marketplaceHosted', + defaultMessage: 'Marketplace-hosted', + }, + marketplaceHostedHint: { + id: 'system.admin.admin.MarketplaceListingShow.marketplaceHostedHint', + defaultMessage: + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from.", + }, + orphaned: { + id: 'system.admin.admin.MarketplaceListingShow.orphaned', + defaultMessage: 'Orphaned', + }, + orphanedHint: { + id: 'system.admin.admin.MarketplaceListingShow.orphanedHint', + defaultMessage: + 'This listing has no source assessment, which should not happen: one is rebuilt automatically whenever an assessment or the course holding it is deleted. Rebuild it from the latest published version on the listings page, or delete the listing if there is no version left to rebuild from.', + }, + unknown: { + id: 'system.admin.admin.MarketplaceListingShow.unknown', + defaultMessage: '—', + }, +}); + +const STATE_COLORS = { + published: 'success', + unlisted: 'default', +} as const; + +interface LoadedListing { + listingId: string; + data: MarketplaceListingDetailData; +} + +const MarketplaceListingShow: FC = () => { + const { t } = useTranslation(); + const { listingId } = useParams(); + const [loadedListing, setLoadedListing] = useState(); + const [failed, setFailed] = useState(false); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!listingId) { + setLoadedListing(undefined); + setFailed(false); + setLoading(false); + return (): void => {}; + } + + let active = true; + setLoadedListing(undefined); + setFailed(false); + setLoading(true); + + SystemAPI.admin + .fetchMarketplaceListing(Number(listingId)) + .then((response) => { + if (active) setLoadedListing({ listingId, data: response.data }); + }) + .catch(() => { + if (active) setFailed(true); + }) + .finally(() => { + if (active) setLoading(false); + }); + + return () => { + active = false; + }; + }, [listingId]); + + // Marketplace visibility, and only that. Whether either origin still exists is reported on the + // provenance line below, because a listing can be published and have a deleted origin at once. + const stateLabel = (state: MarketplaceListingState): string => + state === 'published' + ? t(translations.statePublished) + : t(translations.stateUnlisted); + + const deletedOrigin = (name: string, hint: string): JSX.Element => ( + + + {name}{' '} + {t(translations.deletedSuffix)} + + + ); + + const date = (value: string | null): string => + value ? formatLongDateTime(value) : t(translations.unknown); + const listing = + loadedListing && loadedListing.listingId === listingId + ? loadedListing.data + : undefined; + + if (loading) return ; + + if (failed || !listing) { + return ( + + + {t(translations.fetchFailure)} + + + ); + } + + // Links to the instance's own landing page, matching the index table's Instance column. Plain text + // once the origin instance has been deleted, which takes the host with it. + const sourceInstanceName = (): JSX.Element | string => { + if (!listing.sourceInstanceName || !listing.sourceInstanceHost) + return listing.sourceInstanceName ?? t(translations.unknown); + + return ( + + {listing.sourceInstanceName} + + ); + }; + + const sourceCourseName = (): JSX.Element | string => { + const name = listing.sourceCourseName ?? t(translations.unknown); + + if (listing.sourceCourseDeleted) + return deletedOrigin(name, t(translations.courseDeletedHint)); + + return listing.sourceCourseId && listing.sourceInstanceHost ? ( + + {listing.sourceCourseName ?? `#${listing.sourceCourseId}`} + + ) : ( + name + ); + }; + + return ( + +
+
+ + {listing.title ?? t(translations.unknown)} + + + + + {/* The same fault marker the index carries, so an admin who followed a red chip here is not + met by a page that looks healthy. The remedy lives on the index, and the hint says so: + every mutation stays there. */} + {listing.authoringAssessmentUrl === null && ( + + + + )} + + {listing.marketplaceHosted && ( + + + + )} +
+ +
+ + {t(translations.sourceCourse)}: {sourceCourseName()} + + + + {t(translations.instance)}: {sourceInstanceName()} + + + {listing.sourceAssessmentDeleted && ( + + {t(translations.originalAssessment)}:{' '} + + + {t(translations.originalDeleted)} + + + + )} + + {listing.authoringAssessmentUrl && ( + + + {t(translations.openSourceAssessment)} + + + )} +
+
+ + + {t(translations.versionHistory)} + + + {listing.versions.length === 0 ? ( + + {t(translations.noVersions)} + + ) : ( +
+ + + {t(translations.colVersion)} + {t(translations.colPublisher)} + {t(translations.colContent)} + + + + + {listing.versions.map((version) => ( + + +
+ {date(version.publishedAt)} + {version.isCurrent && ( + + )} +
+
+ + {version.publisherName ?? t(translations.unknown)} + + + {version.snapshotUrl ? ( + + {t(translations.viewVersion, { + version: date(version.publishedAt), + })} + + ) : ( + t(translations.unknown) + )} + +
+ ))} +
+
+ )} + + + {t(translations.adoptions)} + + + {listing.adoptions.length === 0 ? ( + + {t(translations.noAdoptions)} + + ) : ( + + + + {t(translations.colCourse)} + {t(translations.colVersionHeld)} + {t(translations.colAdoptedAt)} + {t(translations.colContent)} + + + + + {listing.adoptions.map((adoption) => ( + + + {adoption.destinationCourseId && + adoption.destinationCourseHost ? ( + + {adoption.destinationCourseName ?? + `#${adoption.destinationCourseId}`} + + ) : ( + adoption.destinationCourseName ?? t(translations.unknown) + )} + + {date(adoption.adoptedVersionAt)} + {date(adoption.adoptedAt)} + + {adoption.snapshotUrl && adoption.adoptedVersionAt ? ( + + {t(translations.viewVersion, { + version: date(adoption.adoptedVersionAt), + })} + + ) : ( + t(translations.unknown) + )} + + + ))} + +
+ )} + + ); +}; + +export default MarketplaceListingShow; diff --git a/client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx b/client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx new file mode 100644 index 0000000000..56476e209f --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/MarketplaceListingsIndex.tsx @@ -0,0 +1,102 @@ +import { FC, useEffect, useState } from 'react'; +import { defineMessages } from 'react-intl'; +import { Typography } from '@mui/material'; +import { AxiosError } from 'axios'; +import { MarketplaceListingAdminData } from 'types/system/marketplaceListings'; + +import SystemAPI from 'api/system'; +import Page from 'lib/components/core/layouts/Page'; +import LoadingIndicator from 'lib/components/core/LoadingIndicator'; +import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; + +import MarketplaceListingsTable from '../components/tables/MarketplaceListingsTable'; + +const translations = defineMessages({ + header: { + id: 'system.admin.admin.MarketplaceListingsIndex.header', + defaultMessage: 'Marketplace Listings', + }, + subtitle: { + id: 'system.admin.admin.MarketplaceListingsIndex.subtitle', + defaultMessage: + 'Every published assessment, the version currently served, and how many courses have copied it. Publish a new version from the source assessment - if the original is deleted, a source assessment is saved in the marketplace’s preview course so new versions can still be published.', + }, + fetchFailure: { + id: 'system.admin.admin.MarketplaceListingsIndex.fetchFailure', + defaultMessage: 'Failed to load marketplace listings.', + }, + deleteSuccess: { + id: 'system.admin.admin.MarketplaceListingsIndex.deleteSuccess', + defaultMessage: 'Listing deleted permanently.', + }, + deleteFailure: { + id: 'system.admin.admin.MarketplaceListingsIndex.deleteFailure', + defaultMessage: 'Failed to delete the listing.', + }, +}); + +const MarketplaceListingsIndex: FC = () => { + const { t } = useTranslation(); + const [listings, setListings] = useState([]); + const [loading, setLoading] = useState(true); + // "We do not know what is listed", which an empty `listings` cannot say on its own: the table's + // empty state is a claim about the marketplace, and a failed fetch has no standing to make it. + // Set on refetch failures too — rows that predate a mutation are as unknown as no rows at all. + const [failed, setFailed] = useState(false); + + const fetchListings = (): Promise => + SystemAPI.admin + .indexMarketplaceListings() + .then((response) => { + setListings(response.data.listings); + setFailed(false); + }) + .catch(() => { + setFailed(true); + toast.error(t(translations.fetchFailure)); + }); + + useEffect(() => { + fetchListings().finally(() => setLoading(false)); + }, []); + + const handleDelete = async (id: number): Promise => { + try { + await SystemAPI.admin.deleteMarketplaceListing(id); + toast.success(t(translations.deleteSuccess)); + await fetchListings(); + } catch (error) { + const message = + error instanceof AxiosError + ? error.response?.data?.errors?.[0] + : undefined; + toast.error(message ?? t(translations.deleteFailure)); + } + }; + + if (loading) return ; + + return ( + + + {t(translations.subtitle)} + + + {failed ? ( + + {t(translations.fetchFailure)} + + ) : ( + + )} + + ); +}; + +export default MarketplaceListingsIndex; diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx new file mode 100644 index 0000000000..85dd5a49f3 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingShow.test.tsx @@ -0,0 +1,578 @@ +import { createMockAdapter } from 'mocks/axiosMock'; +import { act, render, waitFor, within } from 'test-utils'; +import TestApp from 'utilities/TestApp'; + +import SystemAPI from 'api/system'; + +import MarketplaceListingShow from '../MarketplaceListingShow'; + +const SHOW_URL = '/admin/marketplace_listings/1'; +const SECOND_SHOW_URL = '/admin/marketplace_listings/2'; +const CONTAINER_V1 = 'http://preview.example.org/courses/7/assessments/101'; +const CONTAINER_V2 = 'http://preview.example.org/courses/7/assessments/102'; +const LISTING_TITLE = 'Recursion Drill'; +const SECOND_LISTING_TITLE = 'Sorting Drill'; +const MARKETPLACE_HOSTED = 'Marketplace-hosted'; +const MARKETPLACE_HOSTED_HINT = + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from."; +const ORPHANED = 'Orphaned'; +const ORPHANED_HINT = + 'This listing has no source assessment, which should not happen: one is rebuilt automatically whenever an assessment or the course holding it is deleted. Rebuild it from the latest published version on the listings page, or delete the listing if there is no version left to rebuild from.'; +const AUTHORING_URL = 'http://main.example.org/courses/9/assessments/12'; +const CONTAINER_AUTHORING_URL = + 'http://preview.example.org/courses/7/assessments/200'; +const OPEN_ACTION = 'Open source assessment'; +const DELETED_SUFFIX = '(deleted)'; +const SOURCE_COURSE_NAME = 'Intro to Programming'; +const VERSION_HISTORY = 'Version history'; +const INSTANCE_NAME = 'Main Campus'; +const ASSESSMENT_DELETED_HINT = + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.'; +let mockListingId = '1'; + +// Under TZ=Asia/Singapore these render as '15 Jan 2026, 6:00pm' and '20 Jun 2026, 6:00pm'. +const V1_AT = '2026-01-15T10:00:00.000Z'; +const V1_LABEL = '15 Jan 2026, 6:00pm'; +const V2_AT = '2026-06-20T10:00:00.000Z'; +const V2_LABEL = '20 Jun 2026, 6:00pm'; + +// `TestApp` mounts the component directly inside a `MemoryRouter` with no matching +// ``, so `useParams()` would otherwise be empty and the page would never +// fetch. Mock it to supply the route param — the same idiom as +// `course/marketplace/pages/ListingPreview/__test__/index.test.tsx`. +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: (): { listingId: string } => ({ listingId: mockListingId }), +})); + +const mock = createMockAdapter(SystemAPI.admin.client); + +beforeEach(() => { + mock.reset(); + mockListingId = '1'; +}); + +const detail = (overrides = {}): unknown => ({ + id: 1, + title: LISTING_TITLE, + currentVersionPublishedAt: V2_AT, + state: 'published', + marketplaceHosted: false, + sourceAssessmentDeleted: false, + sourceCourseDeleted: false, + authoringAssessmentUrl: AUTHORING_URL, + sourceCourseId: 9, + sourceCourseName: SOURCE_COURSE_NAME, + sourceInstanceName: INSTANCE_NAME, + sourceInstanceHost: 'main.example.org', + versions: [ + { + publishedAt: V1_AT, + publisherName: 'Ada Admin', + isCurrent: false, + snapshotUrl: CONTAINER_V1, + }, + { + publishedAt: V2_AT, + publisherName: 'Bob Admin', + isCurrent: true, + snapshotUrl: CONTAINER_V2, + }, + ], + adoptions: [ + { + id: 5, + destinationCourseId: 77, + destinationCourseName: 'Adopting Course', + destinationCourseHost: 'other.example.org', + adoptedVersionAt: V1_AT, + adoptedAt: '2026-02-01T10:00:00.000Z', + snapshotUrl: CONTAINER_V1, + }, + ], + ...overrides, +}); + +const renderPage = (): ReturnType => + render(, { at: [SHOW_URL] }); + +it('names the listing and its provenance', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText(SOURCE_COURSE_NAME)).toBeInTheDocument(); + expect(page.getByText(new RegExp(INSTANCE_NAME))).toBeInTheDocument(); +}); + +// The heading is the LISTING's identity, so it is plain text in every state — never a link (whose +// typography would shrink it inside the h6) and never struck through (which would say the listing is +// dead while it serves normally). "Open source assessment" beside it is the entrance. +it('keeps the heading plain text and puts the entrance beside it', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: LISTING_TITLE }), + ).not.toBeInTheDocument(); + expect(page.getByRole('link', { name: OPEN_ACTION })).toHaveAttribute( + 'href', + AUTHORING_URL, + ); +}); + +it('links the source course on its own instance host', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect( + await page.findByRole('link', { name: SOURCE_COURSE_NAME }), + ).toHaveAttribute('href', '//main.example.org/courses/9/assessments'); +}); + +// Same rule as the index table's Instance column: the instance is only reachable on its own host. +it('links the instance to its own host', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect( + await page.findByRole('link', { name: INSTANCE_NAME }), + ).toHaveAttribute('href', '//main.example.org/'); +}); + +it('leaves the instance as plain text when none was recorded', async () => { + mock + .onGet(SHOW_URL) + .reply(200, detail({ sourceInstanceName: null, sourceInstanceHost: null })); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByRole('link', { name: INSTANCE_NAME }), + ).not.toBeInTheDocument(); +}); + +// Stated on the provenance line under its own label, NOT on the heading: a label carries the meaning +// without a strikethrough and without repeating the title, which the heading already shows. +it('reports a deleted original on the provenance line, leaving the heading intact', async () => { + mock.onGet(SHOW_URL).reply( + 200, + detail({ + sourceAssessmentDeleted: true, + marketplaceHosted: true, + authoringAssessmentUrl: CONTAINER_AUTHORING_URL, + }), + ); + + const page = renderPage(); + + const heading = await page.findByText(LISTING_TITLE); + expect(heading).not.toHaveClass('line-through'); + expect(page.getByText(/Original assessment/)).toBeInTheDocument(); + expect(page.getByLabelText(ASSESSMENT_DELETED_HINT)).toHaveTextContent( + 'deleted', + ); + // The entrance follows the source assessment to the preview course. + expect(page.getByRole('link', { name: OPEN_ACTION })).toHaveAttribute( + 'href', + CONTAINER_AUTHORING_URL, + ); +}); + +it('says nothing about the original while it still exists', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.queryByText(/Original assessment/)).not.toBeInTheDocument(); +}); + +// The course NAME survives its deletion and is worth keeping on screen, so unlike the assessment it +// is struck through in place rather than replaced by a bare "deleted". +it('strikes out and unlinks a deleted source course', async () => { + mock.onGet(SHOW_URL).reply( + 200, + detail({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + sourceCourseId: null, + authoringAssessmentUrl: null, + }), + ); + + const page = renderPage(); + + expect(await page.findByText(SOURCE_COURSE_NAME)).toHaveClass('line-through'); + expect( + page.queryByRole('link', { name: SOURCE_COURSE_NAME }), + ).not.toBeInTheDocument(); + // Nothing to open while the listing has no source assessment at all. + expect( + page.queryByRole('link', { name: OPEN_ACTION }), + ).not.toBeInTheDocument(); + expect(page.getByText(DELETED_SUFFIX, { exact: false })).toBeInTheDocument(); +}); + +// The Source course line keeps naming the ORIGIN course after a rebuild — that provenance is a +// historical fact the rebuild leaves alone — so the marker is what stops this page reading as though +// the source assessment were still sitting in that course. +it('marks a marketplace-hosted listing alongside its state, keeping the origin provenance', async () => { + mock.onGet(SHOW_URL).reply(200, detail({ marketplaceHosted: true })); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText('Published')).toBeInTheDocument(); + expect(page.getByLabelText(MARKETPLACE_HOSTED_HINT)).toHaveTextContent( + MARKETPLACE_HOSTED, + ); + expect(page.getByText(SOURCE_COURSE_NAME)).toBeInTheDocument(); +}); + +// An admin arrives here from the index's red chip, so the page has to carry the same fault marker — +// otherwise following the alarm lands on a page that reads as healthy. The remedy stays on the index, +// which is where every mutation lives. +it('marks an orphaned listing here too, and says where the remedy is', async () => { + mock.onGet(SHOW_URL).reply(200, detail({ authoringAssessmentUrl: null })); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByLabelText(ORPHANED_HINT)).toHaveTextContent(ORPHANED); + // Visibility is a separate axis: an orphan goes on serving its last published version. + expect(page.getByText('Published')).toBeInTheDocument(); +}); + +it('shows no orphan marker for a listing that has a source assessment', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.queryByText(ORPHANED)).not.toBeInTheDocument(); +}); + +it('shows no marketplace-hosted marker for a listing with its own source course', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.queryByText(MARKETPLACE_HOSTED)).not.toBeInTheDocument(); +}); + +// Every version is listed, including the current one — the point of the page is the whole chain. +it('lists every version with its publisher and marks the current one', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const history = page.getByRole('table', { name: VERSION_HISTORY }); + const rows = within(history).getAllByRole('row').slice(1); + + expect(rows).toHaveLength(2); + expect(within(rows[0]).getByText(V1_LABEL)).toBeInTheDocument(); + expect(within(rows[0]).getByText('Ada Admin')).toBeInTheDocument(); + expect(within(rows[1]).getByText(V2_LABEL)).toBeInTheDocument(); + expect(within(rows[1]).getByText('Bob Admin')).toBeInTheDocument(); + + // Only the served version carries the badge. "Latest", not "Current": the adoptions table below + // has a "Version held" column, so *current* invites the question "current to whom?" — and the + // container's chips and the `apply_latest_version` route already say latest. + expect(within(rows[1]).getByText('Latest')).toBeInTheDocument(); + expect(within(rows[0]).queryByText('Latest')).not.toBeInTheDocument(); + + // The Version column now carries the publish datetime, so a separate Published column would + // repeat it verbatim. + expect(within(rows[0]).getAllByRole('cell')).toHaveLength(3); +}); + +// The snapshot lives in the container course on the preview host, and the admin returns to this page +// afterwards — so the link must not replace it. +it('links each version to its container snapshot in a new tab', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const history = page.getByRole('table', { name: VERSION_HISTORY }); + const link = within(history).getByRole('link', { + name: `View ${V1_LABEL} content`, + }); + expect(link).toHaveAttribute('href', CONTAINER_V1); + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', 'noopener noreferrer'); +}); + +it('renders a version with no surviving snapshot as plain text, not a link', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + versions: [ + { + publishedAt: V1_AT, + publisherName: 'Ada Admin', + isCurrent: true, + snapshotUrl: null, + }, + ], + }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + within(page.getByRole('table', { name: VERSION_HISTORY })).queryByRole( + 'link', + { name: `View ${V1_LABEL} content` }, + ), + ).not.toBeInTheDocument(); +}); + +it('reports which version each adopting course holds', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const adoptions = page.getByRole('table', { name: 'Adoptions' }); + const rows = within(adoptions).getAllByRole('row').slice(1); + + expect(rows).toHaveLength(1); + expect(within(rows[0]).getByText('Adopting Course')).toBeInTheDocument(); + expect(within(rows[0]).getByText(V1_LABEL)).toBeInTheDocument(); +}); + +// Unusual but valid, so it reports rather than the section vanishing. +it('shows an inline empty state when nothing has adopted the listing', async () => { + mock.onGet(SHOW_URL).reply(200, { ...(detail() as object), adoptions: [] }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect( + page.getByText('No courses have adopted this listing yet.'), + ).toBeInTheDocument(); + expect( + page.queryByRole('table', { name: 'Adoptions' }), + ).not.toBeInTheDocument(); +}); + +// Losing the origin removes the authoring copy, not the history — and not the listing's place on the +// marketplace either, since the copy is rebuilt automatically. +it('still renders the history, and stays published, when the origin was deleted', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + sourceAssessmentDeleted: true, + marketplaceHosted: true, + authoringAssessmentUrl: CONTAINER_AUTHORING_URL, + }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText('Published')).toBeInTheDocument(); + expect( + within(page.getByRole('table', { name: VERSION_HISTORY })).getAllByRole( + 'row', + ), + ).toHaveLength(3); +}); + +it('reports an unlisted listing as unlisted', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + state: 'unlisted', + }); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + expect(page.getByText('Unlisted')).toBeInTheDocument(); +}); + +it('renders an empty history without crashing when there is no version', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + currentVersionPublishedAt: null, + versions: [], + }); + + const page = renderPage(); + + expect( + await page.findByText('No versions have been published yet.'), + ).toBeInTheDocument(); +}); + +it('uses an em dash for unknown values', async () => { + mock.onGet(SHOW_URL).reply(200, { + ...(detail() as object), + title: null, + sourceCourseName: null, + sourceInstanceName: null, + versions: [ + { + publishedAt: null, + publisherName: null, + isCurrent: true, + snapshotUrl: null, + }, + ], + adoptions: [ + { + id: 5, + destinationCourseId: null, + destinationCourseName: null, + destinationCourseHost: null, + adoptedVersionAt: null, + adoptedAt: null, + snapshotUrl: null, + }, + ], + }); + + const page = renderPage(); + + expect(await page.findByText('Marketplace Listing')).toBeInTheDocument(); + expect(page.getAllByText('—').length).toBeGreaterThan(1); +}); + +it('toasts and renders nothing when the fetch fails', async () => { + mock.onGet(SHOW_URL).reply(500); + + const page = renderPage(); + + expect( + await page.findByText('Failed to load this marketplace listing.'), + ).toBeInTheDocument(); +}); + +it('loads a new listing after the previous listing failed', async () => { + mock.onGet(SHOW_URL).reply(500); + mock.onGet(SECOND_SHOW_URL).reply( + 200, + detail({ + id: 2, + title: SECOND_LISTING_TITLE, + }), + ); + + const page = renderPage(); + await page.findByText('Failed to load this marketplace listing.'); + + mockListingId = '2'; + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + expect(await page.findByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByText('Failed to load this marketplace listing.'), + ).not.toBeInTheDocument(); +}); + +it('does not keep the previous listing visible while a new listing loads', async () => { + let resolveSecondRequest: (response: [number, unknown]) => void; + mock.onGet(SHOW_URL).reply(200, detail()); + mock.onGet(SECOND_SHOW_URL).reply( + () => + new Promise((resolve) => { + resolveSecondRequest = resolve; + }), + ); + + const page = renderPage(); + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + mockListingId = '2'; + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + expect(page.queryByText(LISTING_TITLE)).not.toBeInTheDocument(); + await waitFor(() => expect(resolveSecondRequest).toBeDefined()); + + await act(async () => { + resolveSecondRequest!([ + 200, + detail({ + id: 2, + title: SECOND_LISTING_TITLE, + }), + ]); + }); + + expect(await page.findByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); +}); + +it('keeps the new listing when a superseded request fails late', async () => { + let resolveFirstRequest: (response: [number]) => void; + mock.onGet(SHOW_URL).reply( + () => + new Promise((resolve) => { + resolveFirstRequest = resolve; + }), + ); + mock.onGet(SECOND_SHOW_URL).reply( + 200, + detail({ + id: 2, + title: SECOND_LISTING_TITLE, + }), + ); + + const page = renderPage(); + await waitFor(() => expect(resolveFirstRequest).toBeDefined()); + + mockListingId = '2'; + // rerender bypasses test-utils' TestApp wrapper, so re-wrap to keep providers. + page.rerender( + + + , + ); + + expect(await page.findByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); + + await act(async () => { + resolveFirstRequest!([500]); + }); + + expect(page.getByText(SECOND_LISTING_TITLE)).toBeInTheDocument(); + expect( + page.queryByText('Failed to load this marketplace listing.'), + ).not.toBeInTheDocument(); +}); + +// The history is where two cuts sit side by side, so the time is what tells a same-day pair apart — +// and no ordinal survives anywhere on the page. +it('names versions by datetime and carries no ordinal', async () => { + mock.onGet(SHOW_URL).reply(200, detail()); + + const page = renderPage(); + + expect(await page.findByText(LISTING_TITLE)).toBeInTheDocument(); + + const history = page.getByRole('table', { name: VERSION_HISTORY }); + expect(within(history).queryByText(/^v\d+$/)).not.toBeInTheDocument(); + + const adoptions = page.getByRole('table', { name: 'Adoptions' }); + expect(within(adoptions).queryByText(/^v\d+$/)).not.toBeInTheDocument(); +}); diff --git a/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx new file mode 100644 index 0000000000..2e6253dc01 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceListingsIndex.test.tsx @@ -0,0 +1,1680 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { fireEvent, render, waitFor, within } from 'test-utils'; + +import GlobalAPI from 'api'; +import SystemAPI from 'api/system'; +import toast from 'lib/hooks/toast'; + +import MarketplaceListingsIndex from '../MarketplaceListingsIndex'; + +// The restore completion toast is a ReactNode (it carries a link), so capture it and render it +// rather than mounting a ToastContainer. +jest.mock('lib/hooks/toast', () => ({ success: jest.fn(), error: jest.fn() })); + +const INDEX_URL = '/admin/marketplace_listings'; +const SEARCH_PLACEHOLDER = + 'Search listings by assessment title or source course'; +const RECURSION_DRILL = 'Recursion Drill'; +const ARRAYS_WARMUP = 'Arrays Warmup'; +const RETIRED_QUIZ = 'Retired Quiz'; +const RESTORE_ACTION = 'Rebuild source assessment'; +const OPEN_ACTION = 'Open source assessment'; +const MAIN_CAMPUS = 'Main Campus'; +const SATELLITE_CAMPUS = 'Satellite Campus'; +const ASSESSMENT_DELETED_HINT = + 'The assessment this listing was originally published from has been deleted. The listing is unaffected: it goes on serving its last published version, and a source assessment is saved in the marketplace’s preview course so new versions can still be published.'; +const COURSE_DELETED_HINT = + 'The course this listing was published from has been deleted. Its name is kept as a record of where the content came from.'; +const DELETED_SUFFIX = '(deleted)'; +const SOURCE_COURSE_NAME = 'Intro to Programming'; +const MARKETPLACE_HOSTED = 'Marketplace-hosted'; +const MARKETPLACE_HOSTED_HINT = + "This listing's source assessment lives in the marketplace's own preview course, not in the course it was originally published from - the original was deleted, so the marketplace saved one to keep publishing from."; +const ORPHANED = 'Orphaned'; +const ORPHANED_HINT = + 'This listing has no source assessment, which should not happen: one is rebuilt automatically whenever an assessment or the course holding it is deleted. Rebuild it from the latest published version, or delete the listing if there is no version left to rebuild from.'; +const ID_COLUMN = 0; +const TITLE_COLUMN = 1; +const SOURCE_COLUMN = 2; +const INSTANCE_COLUMN = 3; +const VERSION_COLUMN = 4; +const ADOPTIONS_COLUMN = 5; +const STATE_COLUMN = 6; +const AUTHORING_URL = 'http://main.coursemology.org/courses/9/assessments/12'; +const DELETE_BLOCKED_TOOLTIP = + 'A published listing cannot be deleted. Unlist it first, so the reversible step comes before the irreversible one.'; +// pollJob keeps its interval running after the component unmounts (see its own docstring), so a +// poller started by one test outlives that test. Every test therefore gets its OWN job url: a stray +// poller then finds no handler registered for it and cannot satisfy — or break — the next test's +// assertions by resolving against that test's job handler. +const UNWATCHED_JOB_URL = '/jobs/unwatched'; +const COMPLETED_JOB_URL = '/jobs/completed'; +const ERRORED_JOB_URL = '/jobs/errored'; +const RESTORED_URL = '/courses/77/assessments/321'; + +const mock = createMockAdapter(SystemAPI.admin.client); +// pollJob polls the *jobs* endpoint, which lives on a different axios client to the admin API. +const jobsMock = createMockAdapter(GlobalAPI.jobs.client); + +beforeEach(() => { + mock.reset(); + jobsMock.reset(); + jest.clearAllMocks(); +}); + +const listingAt = (overrides = {}): unknown => ({ + id: 1, + title: RECURSION_DRILL, + currentVersionPublishedAt: '2026-07-24T07:04:00.000Z', + lastPublishedAt: '2026-07-20T10:00:00.000Z', + adoptions: 4, + sourceCourseId: 9, + sourceCourseName: SOURCE_COURSE_NAME, + sourceInstanceName: MAIN_CAMPUS, + sourceInstanceHost: 'main.coursemology.org', + state: 'published', + marketplaceHosted: false, + sourceAssessmentDeleted: false, + sourceCourseDeleted: false, + authoringAssessmentUrl: AUTHORING_URL, + ...overrides, +}); + +/** + * How many times the listings index itself has been fetched. Counted by url rather than off + * `mock.history.get.length`, which also holds the adapter's `/csrf_token` handshakes. + */ +const indexFetchCount = (): number => + mock.history.get.filter((request) => request.url === INDEX_URL).length; + +/** Text of one column across every body row, in the order the rows are rendered. */ +const columnTexts = ( + page: ReturnType, + columnIndex: number, +): (string | null)[] => + page + .getAllByRole('row') + .slice(1) + .map((row) => within(row).getAllByRole('cell')[columnIndex].textContent); + +/** + * Open one column's filter menu, click one of its items, then close the menu again — an open MUI menu + * marks the rest of the page `aria-hidden`, so the table rows are unqueryable until it is. Scoped to + * the column's own header cell: the table now carries two filter menus, both tooltipped "Filter". + */ +const clickFilterItem = async ( + page: ReturnType, + columnIndex: number, + itemName: string, +): Promise => { + const header = page.getAllByRole('columnheader')[columnIndex]; + fireEvent.click(within(header).getByRole('button', { name: 'Filter' })); + fireEvent.click(await page.findByRole('menuitem', { name: itemName })); + await userEvent.keyboard('{Escape}'); + await waitFor(() => expect(page.queryByRole('menu')).not.toBeInTheDocument()); +}; + +const clickStateFilterItem = ( + page: ReturnType, + itemName: string, +): Promise => clickFilterItem(page, STATE_COLUMN, itemName); + +const clickInstanceFilterItem = ( + page: ReturnType, + itemName: string, +): Promise => clickFilterItem(page, INSTANCE_COLUMN, itemName); + +it('renders a listing row with its version, adoptions and provenance', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(page.getByText('24 Jul 2026')).toBeInTheDocument(); + expect(page.getByText('4')).toBeInTheDocument(); + expect(page.getByText(SOURCE_COURSE_NAME)).toBeInTheDocument(); +}); + +// The served vintage is the only entrance to the version history, which is in turn the only index +// into the container course. One version per row means nothing to disambiguate against, so the time +// would be pure noise in an already-crowded table — it stays reachable on hover instead. +it('links the served vintage to the listing version history', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + // Queried by its visible text rather than by accessible name: the cell's Tooltip puts the full + // timestamp on the anchor, and dom-accessibility-api prefers that over the name-from-content, so a + // `name` query here would assert the tooltip's wording (and its timezone) instead of the link. + const link = (await page.findByText('24 Jul 2026')).closest('a'); + expect(link).toHaveAttribute('href', '/admin/marketplace_listings/1'); +}); + +it('carries no version ordinal anywhere in the row', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, VERSION_COLUMN)).toEqual(['24 Jul 2026']); +}); + +it('renders the empty marker instead of a link when there is no version', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ currentVersionPublishedAt: null })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, VERSION_COLUMN)).toEqual(['—']); + expect( + page.queryByRole('link', { name: '24 Jul 2026' }), + ).not.toBeInTheDocument(); +}); + +// The link only navigates; the publishing itself happens on the assessment page, so the label says +// what the action does rather than what the destination page offers. +it('links Open source assessment to the authoring assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + // The server hands back an ABSOLUTE url carrying the source course's own instance host, because a + // course id resolves nowhere else — so this must be followed as a plain href, not a client route. + const link = await page.findByRole('link', { name: OPEN_ACTION }); + expect(link).toHaveAttribute( + 'href', + 'http://main.coursemology.org/courses/9/assessments/12', + ); +}); + +// The title names the assessment, so it is the shortest route to it. Same absolute cross-instance url +// as the Actions link, for the same reason: a course id resolves only on its origin instance's host. +it('links the assessment title to its source assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByRole('link', { name: RECURSION_DRILL }), + ).toHaveAttribute('href', AUTHORING_URL); +}); + +// The column describes the ORIGIN, so a deleted original leaves it struck through and unlinked. The +// suffix is what carries the fact to anyone who cannot see the strikethrough. +it('strikes out and unlinks a deleted source assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toHaveClass('line-through'); + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + `${RECURSION_DRILL} ${DELETED_SUFFIX}`, + ]); + expect( + page.queryByRole('link', { name: RECURSION_DRILL }), + ).not.toBeInTheDocument(); +}); + +// The case that makes the rule worth stating: a REBUILT listing still has an authoring copy, and the +// cell must not quietly fall through to it. That copy is a different assessment in the marketplace +// container, so linking a column headed "Source assessment" at it would claim the origin survived. +it('leaves a rebuilt listing’s source assessment unlinked, though a copy exists', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + marketplaceHosted: true, + authoringAssessmentUrl: + 'http://preview.coursemology.org/courses/7/assessments/53', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toHaveClass('line-through'); + expect( + page.queryByRole('link', { name: RECURSION_DRILL }), + ).not.toBeInTheDocument(); + // The copy keeps its own entrance, so nothing became unreachable. + expect(page.getByRole('link', { name: OPEN_ACTION })).toHaveAttribute( + 'href', + 'http://preview.coursemology.org/courses/7/assessments/53', + ); +}); + +// A deleted course takes its assessment with it, so both columns mark — each with its own reason. +it('strikes out and unlinks a deleted source course', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, SOURCE_COLUMN)).toEqual([ + `Intro to Programming ${DELETED_SUFFIX}`, + ]); + expect( + page.queryByRole('link', { name: SOURCE_COURSE_NAME }), + ).not.toBeInTheDocument(); +}); + +// "Deleted" beside a live, serving listing reads as "broken" on its own, so each mark carries the +// sentence that says otherwise — and the two reasons are different, so they are two sentences. +it('explains on each mark what the deletion did and did not affect', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(ASSESSMENT_DELETED_HINT)).toHaveTextContent( + RECURSION_DRILL, + ); + expect(page.getByLabelText(COURSE_DELETED_HINT)).toHaveTextContent( + SOURCE_COURSE_NAME, + ); +}); + +// Deleting the origin no longer changes whether the listing is on the marketplace: the authoring copy +// is rebuilt automatically, so the listing goes on serving and goes on saying so. +it('keeps a listing published when its origin was deleted', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + marketplaceHosted: true, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText('Published')).toBeInTheDocument(); + expect(page.queryByText('Orphaned')).not.toBeInTheDocument(); +}); + +// "4 adoptions" raises "which courses?", and the listing page is the only place that answers it. +it('links a non-zero adoption count to the listing page', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ id: 42, adoptions: 4 }), + listingAt({ id: 43, title: ARRAYS_WARMUP, adoptions: 0 }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByRole('link', { name: '4' })).toHaveAttribute( + 'href', + '/admin/marketplace_listings/42', + ); + + // Zero stays plain text — there is no adoption list to go and look at, and the id beside it is + // already the unconditional entrance to the same page. + expect(columnTexts(page, ADOPTIONS_COLUMN)).toEqual(['4', '0']); + expect(page.queryByRole('link', { name: '0' })).not.toBeInTheDocument(); +}); + +// `Course` is tenanted by instance, so a course id resolves ONLY on its own instance's host — a +// relative link 404s for every listing published from another instance. +it('links the source course on its own instance host', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ sourceInstanceHost: 'satellite.coursemology.org' })], + }); + + const page = render(, { at: [INDEX_URL] }); + + const link = await page.findByRole('link', { name: SOURCE_COURSE_NAME }); + expect(link).toHaveAttribute( + 'href', + '//satellite.coursemology.org/courses/9/assessments', + ); +}); + +// The exact column set, in order: the source course's teaching period was dropped from this table — +// an admin auditing listings never asked "which term?", and the column cost width the actions needed. +it('names the instance in its own column beside the source course', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + // Just "Instance" — adjacency to "Source course" carries whose instance it is. + expect( + page.getAllByRole('columnheader').map((header) => header.textContent), + ).toEqual([ + 'ID', + 'Original assessment', + 'Source course', + 'Instance', + 'Version', + 'Adoptions', + 'State', + 'Actions', + ]); + + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual([MAIN_CAMPUS]); +}); + +// The instance is only reachable on its own host, so the cell goes there rather than to a route on +// the admin's host that would resolve to the wrong deployment. +it('links the instance to its own host', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceInstanceName: SATELLITE_CAMPUS, + sourceInstanceHost: 'satellite.coursemology.org', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByRole('link', { name: SATELLITE_CAMPUS }), + ).toHaveAttribute('href', '//satellite.coursemology.org/'); +}); + +// Deleting an instance nullifies both the source course and the source instance, leaving nothing on +// the row that locates the origin — so the row says so rather than silently omitting it. +it('renders the empty marker for a listing with no recorded instance', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + sourceCourseName: 'Retired Course', + sourceInstanceName: null, + sourceInstanceHost: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + // The denormalised course name survives the course deletion; the instance was never recorded, so + // the Instance column says so instead of leaving the origin blank. Neither is a link — there is + // nothing left to navigate to. + expect(columnTexts(page, SOURCE_COLUMN)).toEqual([ + `Retired Course ${DELETED_SUFFIX}`, + ]); + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual(['—']); + expect( + page.queryByRole('link', { name: 'Retired Course' }), + ).not.toBeInTheDocument(); +}); + +// Not a disabled placeholder either: nothing in the Actions cell mentions opening a copy that does +// not exist, so the cell holds only actions that can actually be taken. +it('hides the open action entirely while a listing has no authoring copy', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [assessmentDeleted, courseDeleted] = page.getAllByRole('row').slice(1); + + [assessmentDeleted, courseDeleted].forEach((row) => { + expect(within(row).queryByText(OPEN_ACTION)).not.toBeInTheDocument(); + // The restore and delete actions are what remains — the cell is not simply empty. + expect( + within(row).getByRole('button', { name: RESTORE_ACTION }), + ).toBeInTheDocument(); + }); +}); + +it('narrows the listings to those matching the searched assessment title', async () => { + const user = userEvent.setup(); + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt(), listingAt({ id: 2, title: ARRAYS_WARMUP })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await user.type(page.getByPlaceholderText(SEARCH_PLACEHOLDER), 'Arrays'); + + await waitFor(() => + expect(page.queryByText(RECURSION_DRILL)).not.toBeInTheDocument(), + ); + expect(page.getByText(ARRAYS_WARMUP)).toBeInTheDocument(); +}); + +// Search deliberately spans TWO columns and no more: title and source course. Source course is +// searchable instead of filterable because courses number in the hundreds — "listings from CS1010" is +// a text query, not a set selection. This test previously pinned search as title-ONLY; it now pins +// the widened scope, and still fails if a stray `searchable: true` reaches a third column. +it('searches assessment titles and source courses, not the other columns', async () => { + const user = userEvent.setup(); + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceCourseName: 'Data Structures', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const search = page.getByPlaceholderText(SEARCH_PLACEHOLDER); + const bothRows = [RECURSION_DRILL, ARRAYS_WARMUP]; + + // The source course of the second row only. + await user.type(search, 'Data Struct'); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ARRAYS_WARMUP]), + ); + + await user.clear(search); + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual(bothRows), + ); + + // The instance, which both rows carry: a two-value, low-cardinality dimension belongs behind the + // Instance column's FILTER, so putting it in the free-text box would invite typing "Main Campus" + // instead of filtering. Search must not match it even though the column is right beside the one it + // does match. + await user.type(search, MAIN_CAMPUS); + + await waitFor(() => expect(columnTexts(page, TITLE_COLUMN)).toEqual([])); + + await user.clear(search); + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual(bothRows), + ); + + // The served vintage, which both rows also carry: an unsearchable column must match neither. + await user.type(search, '24 Jul'); + + await waitFor(() => expect(columnTexts(page, TITLE_COLUMN)).toEqual([])); +}); + +it('sorts the listings by assessment title in both directions', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt(), listingAt({ id: 2, title: ARRAYS_WARMUP })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]); + + fireEvent.click(page.getByRole('button', { name: 'Original assessment' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RECURSION_DRILL, + ]), + ); + + fireEvent.click(page.getByRole('button', { name: 'Original assessment' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]), + ); +}); + +// Sorting groups the table by origin, which is the other half of what a per-course filter would have +// given — without a menu that grows with the table. +it('sorts the listings by source course in both directions', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ sourceCourseName: SOURCE_COURSE_NAME }), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceCourseName: 'Data Structures', + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + fireEvent.click(page.getByRole('button', { name: 'Source course' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RECURSION_DRILL, + ]), + ); + + fireEvent.click(page.getByRole('button', { name: 'Source course' })); + + await waitFor(() => + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]), + ); +}); + +// Sorting by instance comes free with the column, and groups the table by deployment. +it('sorts the listings by instance in both directions', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceInstanceName: SATELLITE_CAMPUS, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + fireEvent.click(page.getByRole('button', { name: 'Instance' })); + + await waitFor(() => + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual([ + MAIN_CAMPUS, + SATELLITE_CAMPUS, + ]), + ); + + fireEvent.click(page.getByRole('button', { name: 'Instance' })); + + await waitFor(() => + expect(columnTexts(page, INSTANCE_COLUMN)).toEqual([ + SATELLITE_CAMPUS, + MAIN_CAMPUS, + ]), + ); +}); + +// A handful of instances, stable values, and the natural slice for an admin auditing one deployment's +// contributions. The "not recorded" bucket is real, not an omission: it is where a listing lands once +// the instance it was published from has been deleted. +it('filters the listings by the source instance, including the unrecorded bucket', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceInstanceName: SATELLITE_CAMPUS, + sourceInstanceHost: 'satellite.coursemology.org', + }), + listingAt({ + id: 3, + title: RETIRED_QUIZ, + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + sourceInstanceName: null, + sourceInstanceHost: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await clickInstanceFilterItem(page, SATELLITE_CAMPUS); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ARRAYS_WARMUP]); + + await clickInstanceFilterItem(page, 'Instance not recorded'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + `${RETIRED_QUIZ} ${DELETED_SUFFIX}`, + ]); + + await clickInstanceFilterItem(page, 'Clear filter'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + `${RETIRED_QUIZ} ${DELETED_SUFFIX}`, + ]); +}); + +// Two independent menus in two different header cells: filtering by instance must not disturb the +// state filter, and neither may hijack the other's selection. +it('keeps the state and source instance filters independent', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + state: 'unlisted', + sourceInstanceName: SATELLITE_CAMPUS, + }), + listingAt({ id: 3, title: RETIRED_QUIZ, state: 'unlisted' }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await clickStateFilterItem(page, 'Unlisted'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); + + await clickInstanceFilterItem(page, MAIN_CAMPUS); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([RETIRED_QUIZ]); +}); + +it('sorts adoptions numerically rather than lexicographically', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ id: 1, title: 'Nine Adopters', adoptions: 9 }), + listingAt({ id: 2, title: 'Twelve Adopters', adoptions: 12 }), + listingAt({ id: 3, title: 'Four Adopters', adoptions: 4 }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText('Nine Adopters')).toBeInTheDocument(); + + fireEvent.click(page.getByRole('button', { name: 'Adoptions' })); + + await waitFor(() => + expect(columnTexts(page, ADOPTIONS_COLUMN)).toEqual(['12', '9', '4']), + ); + + fireEvent.click(page.getByRole('button', { name: 'Adoptions' })); + + await waitFor(() => + expect(columnTexts(page, ADOPTIONS_COLUMN)).toEqual(['4', '9', '12']), + ); +}); + +it('filters the listings by the states selected in the State column, and restores them all when the filter is cleared', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, state: 'unlisted' }), + listingAt({ + id: 3, + title: 'Legacy Quiz', + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await clickStateFilterItem(page, 'Unlisted'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ARRAYS_WARMUP]); + + await clickStateFilterItem(page, 'Published'); + + // Both states selected is every row — including the one whose origin was deleted, which is + // published like any other. + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + `Legacy Quiz ${DELETED_SUFFIX}`, + ]); + + await clickStateFilterItem(page, 'Clear filter'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + `Legacy Quiz ${DELETED_SUFFIX}`, + ]); +}); + +// The State column answers marketplace visibility and nothing else, so a deleted origin leaves no +// mark on it at all — the two Source columns carry that, and only they do. +it('tells the two deletion cases apart in the Source columns, not the State column', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + marketplaceHosted: true, + }), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + marketplaceHosted: true, + sourceCourseId: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [assessmentDeleted, courseDeleted] = page.getAllByRole('row').slice(1); + + // Both mark their assessment; only the second also lost its course. + expect( + within(assessmentDeleted).getByLabelText(ASSESSMENT_DELETED_HINT), + ).toBeInTheDocument(); + expect( + within(assessmentDeleted).queryByLabelText(COURSE_DELETED_HINT), + ).not.toBeInTheDocument(); + expect( + within(courseDeleted).getByLabelText(COURSE_DELETED_HINT), + ).toBeInTheDocument(); + + // Both keep a healthy listing's state chip and neither is marked broken: the rebuild happened, so + // the only extra marker either carries says WHERE its copy now lives. + [assessmentDeleted, courseDeleted].forEach((row) => { + const state = within(row).getAllByRole('cell')[STATE_COLUMN]; + expect(within(state).getByText('Published')).toBeInTheDocument(); + expect(within(state).queryByText(ORPHANED)).not.toBeInTheDocument(); + }); +}); + +// The State column used to carry the whole "Orphaned — assessment deleted" phrase in one chip, which +// made it greedy enough to squeeze Actions to ~90px and wrap every label onto three lines. Actions now +// sit on ONE row and each label is unbreakable, so a row's height never depends on its action count. +it('keeps every action label on one line in a single row', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + const openLink = await page.findByRole('link', { name: OPEN_ACTION }); + expect(openLink).toHaveClass('whitespace-nowrap'); + + const restoreButton = page.getByRole('button', { name: RESTORE_ACTION }); + expect(restoreButton).toHaveClass('whitespace-nowrap'); + + [openLink, restoreButton].forEach((action) => { + const row = action.closest('div'); + expect(row).toHaveClass('flex'); + expect(row).not.toHaveClass('flex-wrap'); + }); + + // Fixed slot order — list/unlist, then restore, then delete — so no action moves between rows. + // Scoped to this row: every row carries the visibility and delete actions now, published included. + const row = restoreButton.closest('tr')!; + const actions = Array.from(restoreButton.parentElement!.children); + expect(actions[0]).toHaveTextContent('Unlist'); + expect(actions[1]).toBe(restoreButton); + expect(actions[2]).toContainElement( + within(row).getByTestId('DeleteIconButton'), + ); +}); + +// The reversible half of the maintenance pair. It is admin-side and keyed on the listing id, because +// the course-side unlist resolves the listing through its authoring assessment — which a listing +// whose source was deleted no longer has, so that path cannot reach exactly the rows that need it. +it('unlists a published listing and refetches', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + mock.onPatch(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: 'Unlist' })); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/stops appearing in the marketplace/), + ).toBeVisible(); + // Unlisting is what has to happen before a deletion, so the dialog says so. + expect(within(dialog).getByText(/reversible/)).toBeVisible(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Unlist' })); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(JSON.parse(mock.history.patch[0].data)).toEqual({ published: false }); + // The row's state changes server-side, and with it whether the row can be deleted at all. + await waitFor(() => expect(indexFetchCount()).toBe(2)); +}); + +it('lists an unlisted listing again', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted' })], + }); + mock.onPatch(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: 'List' })); + + const dialog = await page.findByRole('dialog'); + // Re-listing serves the version already held — it must never read as publishing a new one. + expect( + within(dialog).getByText(/serving the version it already holds/), + ).toBeVisible(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'List' })); + + await waitFor(() => expect(mock.history.patch).toHaveLength(1)); + expect(JSON.parse(mock.history.patch[0].data)).toEqual({ published: true }); +}); + +// One button, flipping with the state it reads: offering both at once would leave one permanently +// inert, and a listing is either on the marketplace or it is not. +it('offers only the opposite action on each row', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, state: 'unlisted' }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [published, unlisted] = page.getAllByRole('row').slice(1); + + expect( + within(published).getByRole('button', { name: 'Unlist' }), + ).toBeInTheDocument(); + expect( + within(published).queryByRole('button', { name: 'List' }), + ).not.toBeInTheDocument(); + expect( + within(unlisted).getByRole('button', { name: 'List' }), + ).toBeInTheDocument(); +}); + +it('surfaces the server’s reason when it refuses to list', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ state: 'unlisted', currentVersionPublishedAt: null }), + ], + }); + mock.onPatch(`${INDEX_URL}/1`).reply(422, { + errors: ['This listing has no published version to serve.'], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: 'List' })); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'List', + }), + ); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'This listing has no published version to serve.', + ), + ); +}); + +// Deletion follows `Listing#purgeable?`: enabled wherever the listing is off the marketplace — +// orphaned or unlisted — and never on a published one, which must be unlisted first. The button is +// on every row either way, so its tooltip can state that rule. Adoption count gates nothing, so +// every purgeable row's delete action is enabled regardless of how many courses adopted it. +it('offers permanent deletion off the marketplace only, regardless of adoption history', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 4, + }), + listingAt({ + id: 3, + title: RETIRED_QUIZ, + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + // Unlisted keeps its source assessment, so unlike the orphans it still carries an open action. + listingAt({ + id: 4, + title: 'Unlisted Quiz', + state: 'unlisted', + adoptions: 0, + }), + listingAt({ + id: 5, + title: 'Unlisted And Adopted', + state: 'unlisted', + adoptions: 2, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [published, adopted, unadopted, unlisted, unlistedAdopted] = page + .getAllByRole('row') + .slice(1); + + // A published listing is unlisted, never deleted — so the icon is present but inert, and says why + // on hover rather than leaving the admin to guess at a control that simply is not there. + expect(within(published).getByTestId('DeleteIconButton')).toBeDisabled(); + expect( + within(published).getByLabelText(DELETE_BLOCKED_TOOLTIP), + ).toBeInTheDocument(); + + [adopted, unadopted, unlisted, unlistedAdopted].forEach((row) => { + expect(within(row).getByTestId('DeleteIconButton')).toBeEnabled(); + expect( + within(row).getByLabelText('Delete permanently'), + ).toBeInTheDocument(); + }); +}); + +// A disabled MUI IconButton swallows pointer events, so the tooltip only fires because DeleteButton +// wraps it in a `span` — and the whole point of keeping the icon is that hovering it explains the +// rule. Clicking must still do nothing: no confirm dialog, no request. +it('opens no confirm dialog from the disabled delete on a published listing', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + expect(page.queryByRole('dialog')).not.toBeInTheDocument(); + expect(mock.history.delete).toHaveLength(0); +}); + +// Adoption count is decision-relevant even though it no longer disables anything: a deliberate +// deletion needs the facts at the moment of deciding, so the confirm dialog states how many courses +// adopted the listing and that their copies are unaffected — layered on top of whichever of the +// orphaned/unlisted messages applies, not replacing it. +it('warns in the confirm dialog how many courses adopted the listing, layered on the base message', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 3, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + // The base orphaned message is still present … + expect( + within(dialog).getByText(/all of its versions and the snapshots/), + ).toBeVisible(); + // … with the adoption warning layered on top, not swapped in for it. + expect( + within(dialog).getByText( + /3 courses have adopted this listing\. Their existing copies will not be affected, but the adoption history will be destroyed\./, + ), + ).toBeVisible(); +}); + +it('does not show an adoption warning in the confirm dialog when nothing adopted the listing', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted', adoptions: 0 })], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).queryByText(/adopted this listing/), + ).not.toBeInTheDocument(); +}); + +// The two cases destroy different things, so they cannot share one warning: an orphan has already +// lost its source, whereas an unlisted listing keeps it — and telling someone to unlist a listing +// that is already unlisted is no advice at all. +it('warns that an unlisted deletion spares the source assessment', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted', adoptions: 0 })], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/all of its versions and the snapshots/), + ).toBeVisible(); + expect( + within(dialog).getByText( + /source assessment is not affected and can be published again/, + ), + ).toBeVisible(); + expect( + within(dialog).queryByText(/unlist it instead/), + ).not.toBeInTheDocument(); +}); + +it('warns what a permanent deletion destroys, then deletes and refetches', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/all of its versions and the snapshots/), + ).toBeVisible(); + expect(within(dialog).getByText(/cannot be undone/)).toBeVisible(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Delete' })); + + await waitFor(() => expect(mock.history.delete).toHaveLength(1)); + expect(mock.history.delete[0].url).toBe(`${INDEX_URL}/1`); + // The row is gone only because the list refetched — the client never patches it locally. + await waitFor(() => expect(indexFetchCount()).toBe(2)); +}); + +it('surfaces the server’s reason when it refuses a permanent deletion', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock.onDelete(`${INDEX_URL}/1`).reply(422, { + errors: ['This listing has been adopted by other courses.'], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'Delete', + }), + ); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'This listing has been adopted by other courses.', + ), + ); +}); + +// There is no destination to choose: the copy always lands in the marketplace's own container, so +// the dialog is a plain confirm. +it('restores without asking for a destination course', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock + .onPost(`${INDEX_URL}/1/restore_authoring`) + .reply(200, { status: 'submitted', jobUrl: UNWATCHED_JOB_URL }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + + const dialog = await page.findByRole('dialog'); + expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument(); + + fireEvent.click(within(dialog).getByRole('button', { name: 'Rebuild' })); + + await waitFor(() => expect(mock.history.post).toHaveLength(1)); + // No destination is sent at all — the server owns the only correct destination. + expect(mock.history.post[0].data).toBeUndefined(); +}); + +it('tells the admin the copy lands in the marketplace container', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + sourceCourseDeleted: true, + authoringAssessmentUrl: null, + sourceCourseId: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + + const dialog = await page.findByRole('dialog'); + expect( + within(dialog).getByText(/marketplace's own container course/), + ).toBeVisible(); + // The picker is gone for BOTH orphan states — a deleted origin course no longer changes anything. + expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument(); +}); + +it('toasts a link to the restored assessment and refetches once the job completes', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock + .onPost(`${INDEX_URL}/1/restore_authoring`) + .reply(200, { status: 'submitted', jobUrl: COMPLETED_JOB_URL }); + jobsMock + .onGet(COMPLETED_JOB_URL) + .reply(200, { status: 'completed', redirectUrl: RESTORED_URL }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'Rebuild', + }), + ); + + // pollJob polls every 2s — longer than waitFor's 1s default. + await waitFor(() => expect(toast.success).toHaveBeenCalled(), { + timeout: 6000, + }); + + const message = (toast.success as unknown as jest.Mock).mock.calls[0][0]; + const toasted = render(
{message}
); + + expect( + await toasted.findByText( + /Source assessment rebuilt in the marketplace container\./, + ), + ).toBeInTheDocument(); + expect( + toasted.getByRole('link', { name: 'View assessment' }), + ).toHaveAttribute('href', RESTORED_URL); + + // The listing's state and authoring url both change server-side, so the list must refetch. + await waitFor(() => expect(indexFetchCount()).toBe(2)); +}, 10000); + +it('reports a failed restore job without claiming success', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock + .onPost(`${INDEX_URL}/1/restore_authoring`) + .reply(200, { status: 'submitted', jobUrl: ERRORED_JOB_URL }); + jobsMock.onGet(ERRORED_JOB_URL).reply(200, { status: 'errored' }); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByRole('button', { name: RESTORE_ACTION })); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'Rebuild', + }), + ); + + await waitFor(() => expect(toast.error).toHaveBeenCalled(), { + timeout: 6000, + }); + + expect(toast.error).toHaveBeenCalledWith( + 'Could not rebuild the source assessment.', + ); + expect(toast.success).not.toHaveBeenCalled(); +}, 10000); + +it('offers no restore for an orphan with no version to restore from', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + currentVersionPublishedAt: null, + adoptions: 0, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + // Deletion is still on offer — it is the version, not the orphan state, that restore needs. + expect(await page.findByTestId('DeleteIconButton')).toBeInTheDocument(); + expect( + page.queryByRole('button', { name: RESTORE_ACTION }), + ).not.toBeInTheDocument(); +}); + +// A rebuilt listing keeps naming its ORIGIN course in the Source course column — that provenance is a +// historical fact the rebuild deliberately leaves alone — so without this marker its row is +// indistinguishable from a listing whose source assessment really is still in that course. +it('marks a marketplace-hosted listing apart from one with its own source course', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + marketplaceHosted: true, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [ownSource, hosted] = page.getAllByRole('row').slice(1); + + // Both are on the marketplace, so both keep the same state chip: the marker is what separates them. + expect(within(ownSource).getByText('Published')).toBeInTheDocument(); + expect(within(hosted).getByText('Published')).toBeInTheDocument(); + + expect(within(hosted).getByText(MARKETPLACE_HOSTED)).toBeInTheDocument(); + expect( + within(ownSource).queryByText(MARKETPLACE_HOSTED), + ).not.toBeInTheDocument(); +}); + +it('explains on the marker what marketplace-hosted means', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ marketplaceHosted: true })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(MARKETPLACE_HOSTED_HINT)).toHaveTextContent( + MARKETPLACE_HOSTED, + ); +}); + +// Visibility and authoring location are independent axes, and the marker is a facet rather than a +// state value precisely so this holds: a marketplace-hosted listing that is later unlisted still +// reports both facts, and each is separately filterable. +it('keeps the state chip when a marketplace-hosted listing is unlisted', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ state: 'unlisted', marketplaceHosted: true })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText('Unlisted')).toBeInTheDocument(); + expect(page.getByText(MARKETPLACE_HOSTED)).toBeInTheDocument(); +}); + +it('filters on the marketplace-hosted facet independently of the state values', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, marketplaceHosted: true }), + listingAt({ + id: 3, + title: RETIRED_QUIZ, + state: 'unlisted', + marketplaceHosted: true, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + // Cuts across published and unlisted alike — which is the point of asking for it as its own facet. + await clickStateFilterItem(page, MARKETPLACE_HOSTED); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); + + await clickStateFilterItem(page, MARKETPLACE_HOSTED); + await clickStateFilterItem(page, 'Unlisted'); + + // The state values still filter on state alone: the hosted published row is excluded here. + expect(columnTexts(page, TITLE_COLUMN)).toEqual([RETIRED_QUIZ]); + + await clickStateFilterItem(page, 'Clear filter'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); +}); + +// The complement, which is the half an admin auditing who-owns-what actually needs: "which listings +// still depend on course staff". Labelled as a negation and NOT chipped on the rows — it is the +// ordinary state of the world, and a second noun beside Published/Unlisted would read as a state. +it('filters on the negation of the marketplace-hosted facet', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ id: 2, title: ARRAYS_WARMUP, marketplaceHosted: true }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + // Only the exception is marked on the row; the ordinary case carries no chip of its own. + expect(page.getAllByText(MARKETPLACE_HOSTED)).toHaveLength(1); + + await clickStateFilterItem(page, 'Not marketplace-hosted'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([RECURSION_DRILL]); + + await clickStateFilterItem(page, MARKETPLACE_HOSTED); + + // Both halves selected is every row — the pair is exhaustive. + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + ]); +}); + +// An orphan is no longer a state the system produces: deleting a source assessment re-points the +// listing inside the same transaction. One reaching the table therefore means the model layer was +// bypassed, so it is chipped in the alarm colour rather than left to be inferred from a missing link. +it('marks an orphaned listing apart from a healthy one', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const [healthy, orphaned] = page.getAllByRole('row').slice(1); + + expect(within(orphaned).getByText(ORPHANED)).toBeInTheDocument(); + expect(within(healthy).queryByText(ORPHANED)).not.toBeInTheDocument(); + // Visibility is a separate axis: an orphan goes on serving its last version, so its state chip is + // untouched and the marker sits beside it. + expect(within(orphaned).getByText('Published')).toBeInTheDocument(); +}); + +// The marker names a fault, so it has to carry what to do about it — a chip reading "Orphaned" beside +// a Published listing otherwise leaves an admin with no idea whether to act or which action to take. +it('explains on the orphan marker that it should not happen, and what to do', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ authoringAssessmentUrl: null })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByLabelText(ORPHANED_HINT)).toHaveTextContent(ORPHANED); +}); + +// The debugging affordance the chip exists for: "show me everything that is broken", answerable in +// one click on a table an admin arrives at with hundreds of healthy rows. +it('filters on the orphaned facet, cutting across the state values', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [ + listingAt(), + listingAt({ + id: 2, + title: ARRAYS_WARMUP, + authoringAssessmentUrl: null, + }), + listingAt({ + id: 3, + title: RETIRED_QUIZ, + state: 'unlisted', + authoringAssessmentUrl: null, + }), + ], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + await clickStateFilterItem(page, ORPHANED); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); + + await clickStateFilterItem(page, 'Clear filter'); + + expect(columnTexts(page, TITLE_COLUMN)).toEqual([ + RECURSION_DRILL, + ARRAYS_WARMUP, + RETIRED_QUIZ, + ]); +}); + +// Offered only when there is something to look at, unlike the marketplace-hosted pair: a filter value +// that matches nothing on a healthy deployment would advertise a fault state as ordinary, and the +// menu it shares is the one an admin uses for the routine published/unlisted split. +it('offers no orphaned filter value when nothing is orphaned', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt()] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + + const header = page.getAllByRole('columnheader')[STATE_COLUMN]; + fireEvent.click(within(header).getByRole('button', { name: 'Filter' })); + + expect( + await page.findByRole('menuitem', { name: 'Published' }), + ).toBeInTheDocument(); + expect( + page.queryByRole('menuitem', { name: ORPHANED }), + ).not.toBeInTheDocument(); + + await userEvent.keyboard('{Escape}'); +}); + +it('shows the empty state when there are no listings', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [] }); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByText('No assessments have been published yet.'), + ).toBeInTheDocument(); +}); + +// The empty state is a claim about the marketplace ("nothing has been published"), not about the +// request. A failed fetch also leaves the list empty, so rendering the table there would make the +// page assert something it does not know. The failure is reported in the table's place. +it('reports the failure instead of the empty state when the listings cannot be loaded', async () => { + mock.onGet(INDEX_URL).reply(500); + + const page = render(, { at: [INDEX_URL] }); + + expect( + await page.findByText('Failed to load marketplace listings.'), + ).toBeInTheDocument(); + expect( + page.queryByText('No assessments have been published yet.'), + ).not.toBeInTheDocument(); +}); + +// A refetch failure is the same unknown as a first-load failure: the rows on screen predate the +// mutation that just succeeded, so presenting them as current would be a lie about live state. +it('stops presenting stale rows once a refetch fails', async () => { + mock.onGet(INDEX_URL).replyOnce(200, { + listings: [ + listingAt({ + sourceAssessmentDeleted: true, + authoringAssessmentUrl: null, + adoptions: 0, + }), + ], + }); + mock.onDelete(`${INDEX_URL}/1`).reply(200); + mock.onGet(INDEX_URL).reply(500); + + const page = render(, { at: [INDEX_URL] }); + + fireEvent.click(await page.findByTestId('DeleteIconButton')); + fireEvent.click( + within(await page.findByRole('dialog')).getByRole('button', { + name: 'Delete', + }), + ); + + expect( + await page.findByText('Failed to load marketplace listings.'), + ).toBeInTheDocument(); + expect(page.queryByText(RECURSION_DRILL)).not.toBeInTheDocument(); +}); + +// The id is a primary key, not a position: deleting a listing renumbers nothing. It is surfaced +// because it is the only thing that separates two listings sharing a title and a source course, +// and because the container's version chips name listings by it. +it('shows each listing id', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt({ id: 42 })] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByText(RECURSION_DRILL)).toBeInTheDocument(); + expect(columnTexts(page, ID_COLUMN)).toEqual(['42']); +}); + +it('links the id to the listing history page', async () => { + mock.onGet(INDEX_URL).reply(200, { listings: [listingAt({ id: 42 })] }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByRole('link', { name: '42' })).toHaveAttribute( + 'href', + '/admin/marketplace_listings/42', + ); +}); + +// The Version cell is only a link when a version exists, so before this column a listing that had +// never published one had NO route to its own history page from anywhere in the application. +it('links the id even when the listing has never published a version', async () => { + mock.onGet(INDEX_URL).reply(200, { + listings: [listingAt({ id: 42, currentVersionPublishedAt: null })], + }); + + const page = render(, { at: [INDEX_URL] }); + + expect(await page.findByRole('link', { name: '42' })).toHaveAttribute( + 'href', + '/admin/marketplace_listings/42', + ); + expect(columnTexts(page, VERSION_COLUMN)).toEqual(['—']); +}); diff --git a/client/app/routers/courseless/systemAdmin.tsx b/client/app/routers/courseless/systemAdmin.tsx index 2e1bba29aa..ef4401cd85 100644 --- a/client/app/routers/courseless/systemAdmin.tsx +++ b/client/app/routers/courseless/systemAdmin.tsx @@ -78,6 +78,28 @@ const systemAdminRouter: Translated = (_) => ({ ).default, }), }, + { + path: 'marketplace_listings', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceListingsIndex' */ + 'bundles/system/admin/admin/pages/MarketplaceListingsIndex' + ) + ).default, + }), + }, + { + path: 'marketplace_listings/:listingId', + lazy: async (): Promise> => ({ + Component: ( + await import( + /* webpackChunkName: 'MarketplaceListingShow' */ + 'bundles/system/admin/admin/pages/MarketplaceListingShow' + ) + ).default, + }), + }, { path: 'get_help', lazy: async (): Promise> => ({ diff --git a/client/app/types/system/courses.ts b/client/app/types/system/courses.ts index f3d2e8017d..11d5420b3d 100644 --- a/client/app/types/system/courses.ts +++ b/client/app/types/system/courses.ts @@ -8,6 +8,7 @@ export interface CourseListData { createdAt: string; activeUserCount: number; userCount: number; + preview: boolean; instance: InstanceMiniEntity; owners: UserBasicMiniEntity[]; } diff --git a/client/app/types/system/marketplaceListings.ts b/client/app/types/system/marketplaceListings.ts new file mode 100644 index 0000000000..ed1d9b838d --- /dev/null +++ b/client/app/types/system/marketplaceListings.ts @@ -0,0 +1,90 @@ +/** + * Marketplace VISIBILITY, and nothing else. The origin's fate is reported by `sourceAssessmentDeleted` + * / `sourceCourseDeleted` instead of by an `orphaned` state value, because the two cross: a listing + * whose source assessment was deleted is rebuilt into the marketplace container and goes on being + * published, so one enum cannot carry both facts. + */ +export type MarketplaceListingState = 'published' | 'unlisted'; + +export interface MarketplaceListingAdminData { + id: number; + title: string | null; + currentVersionPublishedAt: string | null; + lastPublishedAt: string | null; + adoptions: number; + sourceCourseId: number | null; + sourceCourseName: string | null; + /** + * The instance the source course belonged to. Publishing always records it, so it is null only + * once that instance has been deleted — after which nothing on the row locates the origin. + */ + sourceInstanceName: string | null; + /** The origin instance's host: a course id only resolves there, never on the admin's own host. */ + sourceInstanceHost: string | null; + state: MarketplaceListingState; + /** + * Whether the authoring copy lives in the marketplace's own container course rather than in a + * course somebody owns — true after a rebuild, and for anything authored in the container + * directly. Separate from state, which reports whether the listing is listed or unlisted. + */ + marketplaceHosted: boolean; + /** + * Whether the assessment this listing was published FROM has been deleted. Outlives the repair: + * the authoring copy is rebuilt in the container, so this stays true while `state` reads + * `published` — which is why it cannot be a state value. + */ + sourceAssessmentDeleted: boolean; + /** Whether the origin course has been deleted. Its denormalised name survives it. */ + sourceCourseDeleted: boolean; + authoringAssessmentUrl: string | null; +} + +export interface MarketplaceListingVersionData { + /** + * When this version's CONTENT was published, not when anyone copied it. This IS the version's + * identity — there is no ordinal. + */ + publishedAt: string | null; + publisherName: string | null; + isCurrent: boolean; + /** + * Absolute URL into the container course on the preview instance. Null when the snapshot no + * longer resolves — a version row without a link rather than a broken one. + */ + snapshotUrl: string | null; +} + +export interface MarketplaceListingAdoptionData { + id: number; + destinationCourseId: number | null; + destinationCourseName: string | null; + /** Adopters span instances, and a course id only resolves on its own instance's host. */ + destinationCourseHost: string | null; + adoptedVersionAt: string | null; + adoptedAt: string | null; + /** The snapshot of the version this course holds, so an admin can inspect what it actually got. */ + snapshotUrl: string | null; +} + +/** Provenance, full version history and every adoption for one listing. Read-only. */ +export interface MarketplaceListingDetailData { + id: number; + title: string | null; + currentVersionPublishedAt: string | null; + state: MarketplaceListingState; + /** See `MarketplaceListingAdminData.marketplaceHosted`. */ + marketplaceHosted: boolean; + /** See `MarketplaceListingAdminData.sourceAssessmentDeleted`. */ + sourceAssessmentDeleted: boolean; + /** See `MarketplaceListingAdminData.sourceCourseDeleted`. */ + sourceCourseDeleted: boolean; + /** Absolute url of the copy an admin would edit, or null while the listing has none. */ + authoringAssessmentUrl: string | null; + sourceCourseId: number | null; + sourceCourseName: string | null; + sourceInstanceName: string | null; + sourceInstanceHost: string | null; + /** Ascending by publish date. Empty for a listing that has never been published. */ + versions: MarketplaceListingVersionData[]; + adoptions: MarketplaceListingAdoptionData[]; +}