From b34bd1de78492be5621a7296dada5125e82a6bcf Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 7 Jul 2026 17:11:11 +0800 Subject: [PATCH 01/15] 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 c61308e6fb..7eeadc353c 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_06_25_000000) 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 @@ -1945,6 +1978,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 c15d91134ef85b8aaba7d7d2ef40845ea976b394 Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 8 Jul 2026 09:36:20 +0800 Subject: [PATCH 02/15] 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 | 42 ++++++++++ .../assessment/assessments/show.json.jbuilder | 4 + client/app/api/course/Marketplace.ts | 21 +++++ client/app/api/course/index.js | 2 + .../AssessmentShow/AssessmentShowHeader.tsx | 24 +++++- .../__test__/AssessmentShowHeader.test.tsx | 51 ++++++++++++ .../components/PublishToMarketplaceButton.tsx | 83 +++++++++++++++++++ .../PublishToMarketplaceButton.test.tsx | 74 +++++++++++++++++ .../course/marketplace/translations.ts | 43 ++++++++++ .../types/course/assessment/assessments.ts | 3 + client/locales/en.json | 27 ++++++ client/locales/ko.json | 27 ++++++ client/locales/zh.json | 27 ++++++ config/routes.rb | 2 + .../assessments_marketplace_spec.rb | 43 ++++++++++ .../marketplace_listings_controller_spec.rb | 78 +++++++++++++++++ 16 files changed, 550 insertions(+), 1 deletion(-) 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..50e4a5e200 --- /dev/null +++ b/app/controllers/course/assessment/marketplace_listings_controller.rb @@ -0,0 +1,42 @@ +# 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 + 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..4a9f30ad51 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 => { @@ -72,7 +77,14 @@ const AssessmentShowHeader = ( onClick={handleDelete} title={t(translations.sureDeletingAssessment)} > - {t(translations.deletingThisAssessment)} + + {t(translations.deletingThisAssessment)} + {assessment.isPublishedToMarketplace && ( + + {t(marketplaceTranslations.deleteWarning)} + + )} + {assessment.title} {t(translations.deleteAssessmentWarning)} @@ -146,6 +158,16 @@ const AssessmentShowHeader = ( )} + {assessment.permissions.canPublishToMarketplace && ( + + )} + {assessment.actionButtonUrl && ( 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(); + }); +}); 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..bfde25a3be --- /dev/null +++ b/client/app/bundles/course/marketplace/components/PublishToMarketplaceButton.tsx @@ -0,0 +1,83 @@ +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; + + 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); + } 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..525bfec613 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/PublishToMarketplaceButton.test.tsx @@ -0,0 +1,74 @@ +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); +}); diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts new file mode 100644 index 0000000000..63a93be452 --- /dev/null +++ b/client/app/bundles/course/marketplace/translations.ts @@ -0,0 +1,43 @@ +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.', + }, + 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 93611b2df8..c47392eede 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6059,6 +6059,33 @@ "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.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 2fb4e77cae..00ccdddb5a 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6023,6 +6023,33 @@ "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.deleteWarning": { + "defaultMessage": "이 평가는 평가 마켓플레이스에 있습니다. 삭제하면 마켓플레이스에서 제거되고 채택 기록도 삭제됩니다. 다른 강좌의 기존 복사본은 영향을 받지 않습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index 97ace07154..5a26db59f6 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6017,6 +6017,33 @@ "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.deleteWarning": { + "defaultMessage": "此评估位于评估市场中。删除它会将其从市场移除,并删除其采用历史记录。其他课程中的现有副本不受影响。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, diff --git a/config/routes.rb b/config/routes.rb index e57841f9fb..4a0fcf6665 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -286,6 +286,8 @@ resources :mock_answers, on: :member, only: [:index, :create, :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..ed42190bd3 --- /dev/null +++ b/spec/controllers/course/assessment/marketplace_listings_controller_spec.rb @@ -0,0 +1,78 @@ +# 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 + 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 9169fa13d1e747b99d18355ec282f2e926b1ecfc Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 8 Jul 2026 11:58:29 +0800 Subject: [PATCH 03/15] 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 | 20 ++++ .../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/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 +++++++ 24 files changed, 683 insertions(+), 11 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..bb46e15513 --- /dev/null +++ b/app/controllers/components/course/assessment_marketplace_component.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true +class Course::AssessmentMarketplaceComponent < SimpleDelegator + include Course::ControllerComponentHost::Component + + def self.display_name + 'Assessment Marketplace' + end + + def sidebar_items + return [] unless can?(:access_marketplace, current_course) + + [ + key: :admin_marketplace, + icon: :duplication, + type: :admin, + weight: 6, + path: course_marketplace_path(current_course) + ] + end +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 65286db93a..8e225e47cb 100644 --- a/client/app/bundles/course/assessment/translations.ts +++ b/client/app/bundles/course/assessment/translations.ts @@ -2043,6 +2043,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 63a93be452..797706a13e 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -40,4 +40,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/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 c47392eede..9e91fb0437 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" }, @@ -4322,6 +4325,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "Announcements" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "Assessment Marketplace" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "Assessments" }, @@ -4580,6 +4586,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" }, @@ -6086,6 +6095,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 00ccdddb5a..1fdb271987 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": "드래프트로 생성" }, @@ -4304,6 +4307,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "공지 사항" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "평가 마켓플레이스" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "평가" }, @@ -4562,6 +4568,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "데이터 복제" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "평가 마켓플레이스" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "타임라인 디자이너" }, @@ -6050,6 +6059,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 5a26db59f6..59e18f4caf 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": "创建为草稿" }, @@ -4298,6 +4301,9 @@ "course.componentTitles.course_announcements_component": { "defaultMessage": "公告" }, + "course.componentTitles.course_assessment_marketplace_component": { + "defaultMessage": "评估市场" + }, "course.componentTitles.course_assessments_component": { "defaultMessage": "测验" }, @@ -4556,6 +4562,9 @@ "course.courses.SidebarItem.admin.duplication": { "defaultMessage": "复制数据" }, + "course.courses.SidebarItem.admin.marketplace": { + "defaultMessage": "평가 마켓플레이스" + }, "course.courses.SidebarItem.admin.multipleReferenceTimelines": { "defaultMessage": "时间线设计工具" }, @@ -6044,6 +6053,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 4a0fcf6665..8326537ffc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -613,6 +613,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 284c2bb2c5a5dfda3e3d2ca2c9d87aaa8c1abece Mon Sep 17 00:00:00 2001 From: lws49 Date: Wed, 8 Jul 2026 12:46:26 +0800 Subject: [PATCH 04/15] 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 | 24 +++++++ .../assessment/marketplace/duplication_job.rb | 54 ++++++++++++++ client/app/api/course/Marketplace.ts | 10 +++ .../components/DuplicateConfirmation.tsx | 60 ++++++++++++++++ .../__test__/DuplicationConfirmation.test.tsx | 47 ++++++++++++ .../bundles/course/marketplace/operations.ts | 19 +++++ .../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 ++++ .../marketplace/listings_controller_spec.rb | 50 +++++++++++++ .../marketplace/duplication_job_spec.rb | 71 +++++++++++++++++++ 14 files changed, 417 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..6008d74e18 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -19,9 +19,33 @@ def index end end + def duplicate + listings = authorized_listings + job = Course::Assessment::Marketplace::DuplicationJob.perform_later( + listings.map(&:id), current_course, duplicate_params[:destination_tab_id].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..0463ef6aca --- /dev/null +++ b/app/jobs/course/assessment/marketplace/duplication_job.rb @@ -0,0 +1,54 @@ +# 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) + listings.each do |listing| + copy = duplicate_listing(listing, destination_course, current_user) + reparent_into_tab(copy, destination_course, destination_tab_id) + record_adoption(listing, destination_course, copy, current_user) + end + redirect_to course_assessments_url(destination_course, + category: destination_course.assessment_categories.first.id, + tab: destination_tab_id, + host: destination_course.instance.host) + end + end + + private + + 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, destination_course, destination_tab_id) + target_tab = destination_course.assessment_categories. + flat_map(&:tabs).find { |tab| tab.id == destination_tab_id } + return unless target_tab && copy.tab_id != target_tab.id + + copy.tab = target_tab + copy.folder.parent = target_tab.category.folder + copy.save! + end + + def record_adoption(listing, destination_course, copy, current_user) + Course::Assessment::Marketplace::Adoption.create!( + listing: listing, + destination_course: destination_course, + duplicated_assessment: copy, + creator: current_user, + updater: current_user + ) + end +end diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts index 4feade4a09..54b174382b 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -26,4 +26,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..6273d5f906 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -0,0 +1,60 @@ +import { useState } from 'react'; +import { useIntl } from 'react-intl'; + +import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import toast from 'lib/hooks/toast'; + +import { duplicateListings } from '../operations'; +import translations from '../translations'; +import { MarketplaceListing } from '../types'; + +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 confirm = async (): Promise => { + setSubmitting(true); + await duplicateListings( + listings.map((l) => l.id), + destinationTabId, + () => { + toast.success(t(translations.duplicateStarted, { n: listings.length })); + setSubmitting(false); + onClose(); + }, + () => { + toast.error(t(translations.duplicateFailed)); + setSubmitting(false); + }, + ); + }; + + return ( + + + {t(translations.duplicateBody, { n: listings.length })} + + + ); +}; + +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..c688d32115 --- /dev/null +++ b/client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx @@ -0,0 +1,47 @@ +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' }] as never; +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 +}); diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts index 8023024d8c..957fea42df 100644 --- a/client/app/bundles/course/marketplace/operations.ts +++ b/client/app/bundles/course/marketplace/operations.ts @@ -1,4 +1,5 @@ import CourseAPI from 'api/course'; +import pollJob from 'lib/helpers/jobHelpers'; import { MarketplaceListing } from './types'; @@ -6,3 +7,21 @@ export const fetchListings = async (): Promise => { const response = await CourseAPI.marketplace.index(); return response.data.listings as MarketplaceListing[]; }; + +export const duplicateListings = async ( + listingIds: number[], + destinationTabId: number | null, + onSuccess: (redirectUrl?: string) => void, + onFailure: () => void, +): Promise => { + const response = await CourseAPI.marketplace.duplicate( + listingIds, + destinationTabId, + ); + pollJob( + response.data.jobUrl, + (data) => onSuccess(data.redirectUrl), + onFailure, + 2000, + ); +}; 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 797706a13e..c5e58c2dc0 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -76,4 +76,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 9e91fb0437..47c20caad7 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6128,6 +6128,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 {n} 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 1fdb271987..b96f01651e 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6092,6 +6092,21 @@ "course.marketplace.duplicateN": { "defaultMessage": "{n}개 평가 복제" }, + "course.marketplace.duplicateTitle": { + "defaultMessage": "평가 {n}개를 내 강좌로 복제하시겠습니까?" + }, + "course.marketplace.duplicateBody": { + "defaultMessage": "{n, plural, one {이 평가가 내 강좌로 복사됩니다.} other {이 평가 {n}개가 내 강좌로 복사됩니다.}}" + }, + "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 59e18f4caf..cb6f383f66 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6086,6 +6086,21 @@ "course.marketplace.duplicateN": { "defaultMessage": "复制 {n} 个评估" }, + "course.marketplace.duplicateTitle": { + "defaultMessage": "要将 {n} 个评估复制到你的课程吗?" + }, + "course.marketplace.duplicateBody": { + "defaultMessage": "{n, plural, one {此评估将被复制到你的课程。} other {这 {n} 个评估将被复制到你的课程。}}" + }, + "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/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 4caf08b581..6ffa932279 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,48 @@ 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 + + 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..1dfb450503 --- /dev/null +++ b/spec/jobs/course/assessment/marketplace/duplication_job_spec.rb @@ -0,0 +1,71 @@ +# 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: only this job writes adoption rows. A plain course-to-course + # duplication of an already-adopted copy should not create a 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 + end +end From 6b783b90f78b623e2a9bc146a2056737af48acc0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:31 +0800 Subject: [PATCH 05/15] 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_component.rb | 2 +- .../assessment/marketplace/controller.rb | 4 + .../marketplace/listings_controller.rb | 54 ++++-- .../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 + 18 files changed, 435 insertions(+), 13 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/components/course/assessment_marketplace_component.rb b/app/controllers/components/course/assessment_marketplace_component.rb index bb46e15513..e6d7550011 100644 --- a/app/controllers/components/course/assessment_marketplace_component.rb +++ b/app/controllers/components/course/assessment_marketplace_component.rb @@ -11,7 +11,7 @@ def sidebar_items [ key: :admin_marketplace, - icon: :duplication, + icon: :marketplace, type: :admin, weight: 6, path: course_marketplace_path(current_course) 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 6008d74e18..dcbbf7bbd6 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -4,18 +4,14 @@ 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. The source course is deliberately NOT preloaded: the MVP exposes no + # attribution, so nothing in the view reaches for it. + @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 @@ -28,12 +24,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 8326537ffc..026a51fdec 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -618,6 +618,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 6ffa932279..008b73b548 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)) @@ -97,6 +111,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 910c14026f9255e748d34f169cdf25c351a23228 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:38 +0800 Subject: [PATCH 06/15] 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 + client/app/lib/constants/icons.ts | 3 + 11 files changed, 253 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 { 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 }, From 71ac58be1ff73257a1753d2b44a5ab7ed5e5d893 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:38 +0800 Subject: [PATCH 07/15] 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 +- .../components/DuplicateConfirmation.tsx | 44 ++++- .../components/PublishToMarketplaceButton.tsx | 4 +- .../__test__/DuplicationConfirmation.test.tsx | 49 +++++ .../bundles/course/marketplace/operations.ts | 31 ++- .../PreviewAssessmentDetails.tsx | 51 +++++ .../ListingPreview/PreviewQuestionCard.tsx | 144 ++++++++++++++ .../ListingPreview/__test__/index.test.tsx | 181 +++++++++++++++++ .../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 | 8 + .../__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 | 120 ++++++++++++ client/locales/en.json | 28 ++- client/locales/ko.json | 26 ++- client/locales/zh.json | 26 ++- 35 files changed, 2064 insertions(+), 92 deletions(-) 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 54b174382b..8f1f4bf7ea 100644 --- a/client/app/api/course/Marketplace.ts +++ b/client/app/api/course/Marketplace.ts @@ -1,6 +1,6 @@ import { AxiosResponse } from 'axios'; -import { MarketplaceListing } from 'course/marketplace/types'; +import { DestinationTab, MarketplaceListing } from 'course/marketplace/types'; import BaseCourseAPI from './Base'; @@ -22,7 +22,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); } @@ -36,4 +40,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/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx index 6273d5f906..bbe8c77435 100644 --- a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -1,8 +1,11 @@ import { useState } from 'react'; -import { useIntl } from 'react-intl'; +import { Card, CardContent, ListSubheader } from '@mui/material'; -import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt'; +import DuplicationAssessmentTree from 'course/duplication/components/DuplicationAssessmentTree'; +import Link from 'lib/components/core/Link'; +import Prompt from 'lib/components/core/dialogs/Prompt'; import toast from 'lib/hooks/toast'; +import useTranslation from 'lib/hooks/useTranslation'; import { duplicateListings } from '../operations'; import translations from '../translations'; @@ -11,6 +14,9 @@ import { MarketplaceListing } from '../types'; 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; } @@ -18,10 +24,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 confirm = async (): Promise => { @@ -35,7 +44,7 @@ const DuplicateConfirmation = ({ onClose(); }, () => { - toast.error(t(translations.duplicateFailed)); + toast.error(t(translations.duplicateFailed, { n: listings.length })); setSubmitting(false); }, ); @@ -48,11 +57,30 @@ const DuplicateConfirmation = ({ onClose={onClose} open={open} primaryLabel={t(translations.duplicateConfirm)} - title={t(translations.duplicateTitle)} + title={t(translations.confirmationQuestion)} > - - {t(translations.duplicateBody, { n: listings.length })} - + + {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 bfde25a3be..f016ac1703 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 c688d32115..6ae21fb1c0 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' }] as never; 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( => { +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'], + }; }; export const duplicateListings = async ( @@ -25,3 +34,19 @@ export const duplicateListings = async ( 2000, ); }; + +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..e1ebd1fc02 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/__test__/index.test.tsx @@ -0,0 +1,181 @@ +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(); + // 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')); +}); + +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', + baseExp: 0, + bonusExp: 0, + 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', + baseExp: 0, + bonusExp: 0, + 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', + baseExp: 0, + bonusExp: 0, + 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..163fd4c664 --- /dev/null +++ b/client/app/bundles/course/marketplace/pages/QuestionPreview/renderers/VoiceResponse.tsx @@ -0,0 +1,8 @@ +import { RendererProps } from './types'; + +// Voice questions carry no type-specific setup — the prompt is the base description and the max +// grade are already rendered by the shell's "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 c5e58c2dc0..34452679c8 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', @@ -57,10 +57,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', @@ -76,15 +88,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', @@ -100,4 +114,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..d093a4fdbf 100644 --- a/client/app/bundles/course/marketplace/types.ts +++ b/client/app/bundles/course/marketplace/types.ts @@ -8,3 +8,123 @@ 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'; + baseExp: number | null; + bonusExp: number | null; + 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 47c20caad7..b4b3e1569a 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6078,7 +6078,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?" @@ -6110,6 +6110,12 @@ "course.marketplace.colActions": { "defaultMessage": "Actions" }, + "course.marketplace.colPublished": { + "defaultMessage": "Published at" + }, + "course.marketplace.colPublisher": { + "defaultMessage": "Publisher" + }, "course.marketplace.previewAction": { "defaultMessage": "Preview" }, @@ -6128,11 +6134,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 {n} assessments will be copied to your course.}}" + "course.marketplace.destinationCourse": { + "defaultMessage": "Destination Course" + }, + "course.marketplace.assessmentsHeading": { + "defaultMessage": "Assessments" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "Duplicate" @@ -6143,6 +6152,15 @@ "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.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 b96f01651e..a035bfb56b 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -6074,6 +6074,12 @@ "course.marketplace.colActions": { "defaultMessage": "작업" }, + "course.marketplace.colPublished": { + "defaultMessage": "게시 일시" + }, + "course.marketplace.colPublisher": { + "defaultMessage": "게시자" + }, "course.marketplace.previewAction": { "defaultMessage": "미리보기" }, @@ -6092,11 +6098,14 @@ "course.marketplace.duplicateN": { "defaultMessage": "{n}개 평가 복제" }, - "course.marketplace.duplicateTitle": { - "defaultMessage": "평가 {n}개를 내 강좌로 복제하시겠습니까?" + "course.marketplace.confirmationQuestion": { + "defaultMessage": "항목을 복제하시겠습니까?" }, - "course.marketplace.duplicateBody": { - "defaultMessage": "{n, plural, one {이 평가가 내 강좌로 복사됩니다.} other {이 평가 {n}개가 내 강좌로 복사됩니다.}}" + "course.marketplace.destinationCourse": { + "defaultMessage": "대상 강좌" + }, + "course.marketplace.assessmentsHeading": { + "defaultMessage": "평가" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "복제" @@ -6107,6 +6116,15 @@ "course.marketplace.duplicateFailed": { "defaultMessage": "{n, plural, one {평가 복제에} other {평가 복제에}} 실패했습니다." }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "복제하려면 선택" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "아직 마켓플레이스에 게시된 평가가 없습니다." + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "검색과 일치하는 평가가 없습니다." + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요." }, diff --git a/client/locales/zh.json b/client/locales/zh.json index cb6f383f66..62d7829973 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -6068,6 +6068,12 @@ "course.marketplace.colActions": { "defaultMessage": "操作" }, + "course.marketplace.colPublished": { + "defaultMessage": "发布时间" + }, + "course.marketplace.colPublisher": { + "defaultMessage": "发布者" + }, "course.marketplace.previewAction": { "defaultMessage": "预览" }, @@ -6086,11 +6092,14 @@ "course.marketplace.duplicateN": { "defaultMessage": "复制 {n} 个评估" }, - "course.marketplace.duplicateTitle": { - "defaultMessage": "要将 {n} 个评估复制到你的课程吗?" + "course.marketplace.confirmationQuestion": { + "defaultMessage": "复制项目?" }, - "course.marketplace.duplicateBody": { - "defaultMessage": "{n, plural, one {此评估将被复制到你的课程。} other {这 {n} 个评估将被复制到你的课程。}}" + "course.marketplace.destinationCourse": { + "defaultMessage": "目标课程" + }, + "course.marketplace.assessmentsHeading": { + "defaultMessage": "评估" }, "course.marketplace.duplicateConfirm": { "defaultMessage": "复制" @@ -6101,6 +6110,15 @@ "course.marketplace.duplicateFailed": { "defaultMessage": "{n, plural, one {评估复制} other {评估复制}}失败。" }, + "course.marketplace.selectToDuplicate": { + "defaultMessage": "选择评估复制" + }, + "course.marketplace.emptyNoListings": { + "defaultMessage": "尚未有评估发布到市场。" + }, + "course.marketplace.emptyNoMatch": { + "defaultMessage": "没有符合搜索条件的评估。" + }, "course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": { "defaultMessage": "下载失败。请稍后再试。" }, From a7086e3698940acf584ed3cd06f08850ca328011 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:38 +0800 Subject: [PATCH 08/15] 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. --- .../components/course/gradebook_component.rb | 22 +++--- .../marketplace/questions_controller.rb | 5 +- .../course/statistics/aggregate_controller.rb | 4 +- .../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 | 29 +++++++ .../marketplace/__test__/handles.test.ts | 77 +++++++++++++++++++ .../components/DuplicateConfirmation.tsx | 2 +- .../app/bundles/course/marketplace/fromTab.ts | 15 ++++ .../app/bundles/course/marketplace/handles.ts | 49 ++++++++++++ .../ListingPreview/PreviewQuestionCard.tsx | 7 +- .../__test__/MarketplaceTable.test.tsx | 16 ++-- .../renderers/RubricBasedResponse.tsx | 4 +- client/app/routers/course/marketplace.tsx | 30 ++++++++ .../marketplace/questions_controller_spec.rb | 9 ++- 24 files changed, 280 insertions(+), 59 deletions(-) create mode 100644 client/app/bundles/course/marketplace/__test__/fromTab.test.ts create mode 100644 client/app/bundles/course/marketplace/__test__/handles.test.ts create mode 100644 client/app/bundles/course/marketplace/fromTab.ts create mode 100644 client/app/bundles/course/marketplace/handles.ts diff --git a/app/controllers/components/course/gradebook_component.rb b/app/controllers/components/course/gradebook_component.rb index a54d4dae4f..3e282fd467 100644 --- a/app/controllers/components/course/gradebook_component.rb +++ b/app/controllers/components/course/gradebook_component.rb @@ -16,13 +16,11 @@ def main_sidebar_items return [] unless can?(:read_gradebook, current_course) [ - { - key: self.class.key, - icon: :gradebook, - type: :normal, - weight: 9, - path: course_gradebook_path(current_course) - } + key: self.class.key, + icon: :gradebook, + type: :normal, + weight: 9, + path: course_gradebook_path(current_course) ] end @@ -30,12 +28,10 @@ def settings_sidebar_items return [] unless can?(:manage_gradebook_settings, current_course) [ - { - key: self.class.key, - type: :settings, - weight: 14, - path: course_admin_gradebook_path(current_course) - } + key: self.class.key, + type: :settings, + weight: 14, + path: course_admin_gradebook_path(current_course) ] end end 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..f28b8d6d35 100644 --- a/app/controllers/course/statistics/aggregate_controller.rb +++ b/app/controllers/course/statistics/aggregate_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true # This is named aggregate controller as naming this as course controller leads to name conflict issues -class Course::Statistics::AggregateController < Course::Statistics::Controller +class Course::Statistics::AggregateController < Course::Statistics::Controller # rubocop:disable Metrics/ClassLength before_action :preload_levels, only: [:all_students, :course_performance] include Course::Statistics::TimesConcern include Course::Statistics::GradesConcern @@ -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 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/__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/components/DuplicateConfirmation.tsx b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx index bbe8c77435..abd1aee3f6 100644 --- a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -2,8 +2,8 @@ import { useState } from 'react'; import { Card, CardContent, ListSubheader } from '@mui/material'; import DuplicationAssessmentTree from 'course/duplication/components/DuplicationAssessmentTree'; -import Link from 'lib/components/core/Link'; import Prompt from 'lib/components/core/dialogs/Prompt'; +import Link from 'lib/components/core/Link'; import toast from 'lib/hooks/toast'; import useTranslation from 'lib/hooks/useTranslation'; diff --git a/client/app/bundles/course/marketplace/fromTab.ts b/client/app/bundles/course/marketplace/fromTab.ts new file mode 100644 index 0000000000..81d260005f --- /dev/null +++ b/client/app/bundles/course/marketplace/fromTab.ts @@ -0,0 +1,15 @@ +// `from_tab` is the assessment tab the user came from when they clicked "Import assessments". +// It rides along in the URL through the whole browse flow (index → listing → question preview and +// 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. +export const FROM_TAB_PARAM = 'from_tab'; + +export const readFromTab = (search: string): string | null => + 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/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..0d0f400a00 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/PreviewQuestionCard.tsx @@ -63,12 +63,7 @@ const PreviewQuestionCard = ({ {q.title}
- + {q.unautogradable && ( 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/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 450a87f3fef43ff293337fe965c615a7d9b95032 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 16 Jul 2026 20:58:31 +0800 Subject: [PATCH 09/15] 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 | 458 ++++++++++++++++-- .../ListingPreview/__test__/index.test.tsx | 4 + .../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 +- .../marketplace/listings_controller_spec.rb | 14 + 15 files changed, 808 insertions(+), 107 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 dcbbf7bbd6..eec9f66166 100644 --- a/app/controllers/course/assessment/marketplace/listings_controller.rb +++ b/app/controllers/course/assessment/marketplace/listings_controller.rb @@ -31,6 +31,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 abd1aee3f6..06ec2ea5c5 100644 --- a/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx +++ b/client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx @@ -1,7 +1,9 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import { Tooltip } from 'react-tooltip'; import { Card, CardContent, ListSubheader } from '@mui/material'; -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 toast from 'lib/hooks/toast'; @@ -9,37 +11,81 @@ 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'; 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 => { const { t } = useTranslation(); const [submitting, setSubmitting] = useState(false); + // The selected tab defaults to the `from_tab` the user launched from, but only when it names a + // real tab in this course; otherwise the course's first tab. So exactly one existing tab is + // always selected, and an unknown/absent from_tab yields null (backend then defaults) rather than + // a phantom id. + 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); await duplicateListings( listings.map((l) => l.id), - destinationTabId, - () => { - toast.success(t(translations.duplicateStarted, { n: listings.length })); + selectedTabId, + // This is pollJob's *completion* callback — the job has finished by now. `redirectUrl` points + // at the destination tab; it is optional on JobCompleted, so the link is conditional. + (redirectUrl) => { + toast.success( + <> + {t(translations.duplicateCompleted, { n: listings.length })} + {redirectUrl && ( + <> + {' '} + + {t(translations.viewDuplicatedAssessment)} + + + )} + , + ); setSubmitting(false); onClose(); }, @@ -52,10 +98,12 @@ const DuplicateConfirmation = ({ return ( @@ -71,16 +119,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 6ae21fb1c0..afbac3070a 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' }] as never; +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); 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 e1ebd1fc02..5c24c0e10b 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 @@ -46,6 +46,7 @@ it('renders the read-only assessment config', async () => { mock.onGet(url).reply(200, { id: 70, title: LISTING_TITLE, + destinationTabs: [], description: '

Awesome description 5

', gradingMode: 'manual', baseExp: 1000, @@ -101,6 +102,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', baseExp: 0, @@ -137,6 +139,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', baseExp: 0, @@ -162,6 +165,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', baseExp: 0, diff --git a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx index 8907bc2d43..5be3349e75 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -80,10 +80,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 4b1f2850fc..346fe0fb91 100644 --- a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx +++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx @@ -9,7 +9,7 @@ import Preload from 'lib/components/wrappers/Preload'; import DuplicateConfirmation from '../../components/DuplicateConfirmation'; import { fetchListings } from '../../operations'; import translations from '../../translations'; -import { DestinationTab, MarketplaceListing } from '../../types'; +import { MarketplaceListing } from '../../types'; import MarketplaceTable from './MarketplaceTable'; @@ -21,43 +21,25 @@ const MarketplaceIndex = (): JSX.Element => { 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, 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 34452679c8..01a8043088 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -96,23 +96,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 d093a4fdbf..c4ac6667af 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'; baseExp: number | null; bonusExp: number | null; 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 b4b3e1569a..deefc50432 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6140,17 +6140,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/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb index 008b73b548..9c02a8ac2d 100644 --- a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb +++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb @@ -139,6 +139,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 413df78b57698e528771fbd5349ed975e9028cd7 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 16 Jul 2026 21:00:50 +0800 Subject: [PATCH 10/15] 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 909976bb1e01d6a3d5174d00f9f5a80e2b993513 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 16 Jul 2026 21:00:57 +0800 Subject: [PATCH 11/15] 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 +++ 4 files changed, 42 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 5c24c0e10b..c687b47d63 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 @@ -183,3 +183,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 5be3349e75..5e6c9a04ab 100644 --- a/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx +++ b/client/app/bundles/course/marketplace/pages/ListingPreview/index.tsx @@ -51,7 +51,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 01a8043088..3818d2785c 100644 --- a/client/app/bundles/course/marketplace/translations.ts +++ b/client/app/bundles/course/marketplace/translations.ts @@ -65,6 +65,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 deefc50432..0073cd7ca7 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -6119,6 +6119,9 @@ "course.marketplace.previewAction": { "defaultMessage": "Preview" }, + "course.marketplace.previewBadge": { + "defaultMessage": "Preview" + }, "course.marketplace.searchPlaceholder": { "defaultMessage": "Search by title" }, From bb91fa7482150abb42e34d68b0b6eb39f1cc2498 Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:05:48 +0800 Subject: [PATCH 12/15] 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 394bfaaae3290b679136f0b9e55405d89fff5722 Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:05:48 +0800 Subject: [PATCH 13/15] 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 | 38 ++- .../assessment/marketplace/access_block.rb | 23 ++ .../marketplace/access_list_query.rb | 133 ++++++++ .../assessment/marketplace/allowlist_rule.rb | 77 +++++ .../marketplace/rule_match_query.rb | 50 +++ .../marketplace/rule_preview_query.rb | 90 ++++++ 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 | 110 +++++++ .../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 | 258 ++++++++++++++++ .../marketplace/allowlist_rule_spec.rb | 260 ++++++++++++++++ .../marketplace/rule_match_query_spec.rb | 123 ++++++++ .../assessment_marketplace_ability_spec.rb | 75 +++++ spec/models/user_spec.rb | 61 ++++ 35 files changed, 2187 insertions(+), 5 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..45dc1e7922 100644 --- a/app/models/components/course/assessment_marketplace_ability_component.rb +++ b/app/models/components/course/assessment_marketplace_ability_component.rb @@ -4,12 +4,46 @@ 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 @@ -23,4 +57,4 @@ def allow_managers_access_marketplace assessment.marketplace_listing&.published? || false end end -end +end \ No newline at end of file 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..d0480acc47 --- /dev/null +++ b/app/models/course/assessment/marketplace/access_list_query.rb @@ -0,0 +1,133 @@ +# 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 + end + + # @return [Array] + def rows + @rows ||= annotate(listed_users.to_a) + end + + # @return [Hash] + def summary + { + total_with_access: rows.count { |row| !row.blocked? }, + total_blocked: rows.count(&:blocked?), + 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..a5c3317f16 --- /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? + + 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? + + # Identical rules grant nothing extra and make the rules table unreadable. Each is paired with a + # partial unique index. `scope: :rule_type` matters on the unresolved-email path: a user rule + # whose email matched nobody keeps user_id NULL, and Rails checks that as `user_id IS NULL`, + # which matches every instance and email-domain rule — reporting a bogus duplicate on top of the + # real "No user with that email." Scoping confines the check to rules of the same type. + 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.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 + + # Matching is case-insensitive everywhere, so the stored form is normalized on write. This keeps + # the uniqueness index a plain column comparison instead of a functional LOWER() index. + 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 +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..6e73cc3a40 --- /dev/null +++ b/app/models/course/assessment/marketplace/rule_preview_query.rb @@ -0,0 +1,90 @@ +# 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 + # rule can match hundreds, and the people this rule does NOT newly reach are the only reason to + # read the list at all — buried on page 14 of an alphabetical list they may as well not be + # shown. 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..be7e37d679 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_many :marketplace_allowlist_rules, class_name: 'Course::Assessment::Marketplace::AllowlistRule', + inverse_of: false, dependent: :destroy + has_many :marketplace_access_blocks, 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..d6fae59011 --- /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 \ No newline at end of file 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 026a51fdec..e40eeb54d4 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 7eeadc353c..0d63cf08be 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 @@ -1978,11 +2003,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 9c02a8ac2d..bdf1398de0 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,117 @@ 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 } @@ -116,6 +223,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) @@ -175,6 +284,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..ddabc899bc --- /dev/null +++ b/spec/models/course/assessment/marketplace/access_list_query_spec.rb @@ -0,0 +1,258 @@ +# 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 '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..17b37957f7 --- /dev/null +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -0,0 +1,260 @@ +# 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 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 + + # 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 59b99e08ee28806a70bdadb98d179b03549390d0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:09:26 +0800 Subject: [PATCH 14/15] 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 +- client/app/api/system/Admin.ts | 75 +++ .../system/admin/admin/AdminNavigator.tsx | 10 + .../MarketplaceAllowlistModeBanner.tsx | 133 +++++ .../forms/MarketplaceAllowlistRuleForm.tsx | 467 +++++++++++++++ .../MarketplaceAllowlistRuleForm.test.tsx | 543 ++++++++++++++++++ .../tables/MarketplaceAllowlistTable.tsx | 179 ++++++ .../MarketplaceAllowlistTable.test.tsx | 99 ++++ .../admin/pages/MarketplaceAllowlistIndex.tsx | 174 ++++++ .../MarketplaceAllowlistIndex.test.tsx | 435 ++++++++++++++ client/app/routers/courseless/systemAdmin.tsx | 11 + client/app/types/system/marketplaceAccess.ts | 19 + .../app/types/system/marketplaceAllowlist.ts | 19 + client/locales/en.json | 174 ++++++ client/locales/ko.json | 171 ++++++ client/locales/zh.json | 171 ++++++ ...etplace_allowlist_rules_controller_spec.rb | 2 +- .../marketplace/allowlist_rule_spec.rb | 10 +- 18 files changed, 2689 insertions(+), 9 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 a5c3317f16..b164934db3 100644 --- a/app/models/course/assessment/marketplace/allowlist_rule.rb +++ b/app/models/course/assessment/marketplace/allowlist_rule.rb @@ -27,11 +27,11 @@ class Course::Assessment::Marketplace::AllowlistRule < ApplicationRecord # whose email matched nobody keeps user_id NULL, and Rails checks that as `user_id IS NULL`, # which matches every instance and email-domain rule — reporting a bogus duplicate on top of the # real "No user with that email." Scoping confines the check to rules of the same type. - 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/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/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..d2a218286e --- /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 staff: 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 staff: 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 staff 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..91881017ae --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/MarketplaceAllowlistRuleForm.tsx @@ -0,0 +1,467 @@ +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 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', + }, + 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); + }); + }, [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..90bddf501f --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/forms/__test__/MarketplaceAllowlistRuleForm.test.tsx @@ -0,0 +1,543 @@ +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 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('/admin/instances').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 === '/admin/instances'), + ).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 === '/admin/instances'), + ).toHaveLength(1); +}); + +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..93b4cf6b2c --- /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 staff 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..1c48b23adf --- /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 staff 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..587f631919 --- /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 course managers.', + }, + 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..88de93f4d7 --- /dev/null +++ b/client/app/bundles/system/admin/admin/pages/__test__/MarketplaceAllowlistIndex.test.tsx @@ -0,0 +1,435 @@ +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'; +// 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, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + 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 staff: 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 staff: 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, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + 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, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + + 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, { + matchedCount: 1, + newCount: 1, + openToEveryone: false, + users: [], + }); + 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 0073cd7ca7..0638b06dfe 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8780,6 +8780,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" }, @@ -8864,6 +8867,177 @@ "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 course managers." + }, + "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 staff: 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 staff: 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 staff 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.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 staff 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 a035bfb56b..8127015525 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8747,6 +8747,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "도움 받기" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "마켓플레이스 접근" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "공지사항을 가져올 수 없습니다." }, @@ -8831,6 +8834,174 @@ "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.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 62d7829973..0b5e231c02 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8741,6 +8741,9 @@ "system.admin.admin.AdminNavigator.getHelp": { "defaultMessage": "获取帮助" }, + "system.admin.admin.AdminNavigator.marketplace": { + "defaultMessage": "市场访问" + }, "system.admin.admin.AnnouncementsIndex.fetchAnnouncementsFailure": { "defaultMessage": "无法获取公告" }, @@ -8825,6 +8828,174 @@ "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.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..43272af08b 100644 --- a/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb +++ b/spec/controllers/system/admin/marketplace_allowlist_rules_controller_spec.rb @@ -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 17b37957f7..4de6e87d06 100644 --- a/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb +++ b/spec/models/course/assessment/marketplace/allowlist_rule_spec.rb @@ -172,7 +172,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 @@ -189,7 +189,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 @@ -199,7 +199,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 @@ -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 'normalizes the stored domain to stripped lowercase' do @@ -220,7 +220,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 ec36419766d3eacb315e64a64ab7a7249432acf0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Tue, 21 Jul 2026 02:09:44 +0800 Subject: [PATCH 15/15] 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 | 173 ++++ .../components/MarketplaceAccessSection.tsx | 639 ++++++++++++ .../MarketplaceAccessSection.test.tsx | 950 ++++++++++++++++++ .../admin/pages/MarketplaceAllowlistIndex.tsx | 29 + .../MarketplaceAllowlistIndex.test.tsx | 204 ++++ client/app/types/system/marketplaceAccess.ts | 34 + client/locales/en.json | 120 +++ client/locales/ko.json | 120 +++ client/locales/zh.json | 120 +++ 10 files changed, 2423 insertions(+), 1 deletion(-) 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..f450aa2ba7 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessFilter.tsx @@ -0,0 +1,173 @@ +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', + }, +}); + +/** + * A bespoke filter popover rather than the shared table's built-in per-column filtering + * (`filterable` + `filterProps`). The built-in machinery could in fact handle both the array-valued + * rules column (via `filterProps.getValue`/`shouldInclude`) and the synthetic System-admin option + * (`getValue` is arbitrary) — those two objections are false. The real reason is that built-in + * filtering is table-internal in the three respects this feature needs externalised: + * + * 1. The filtered result never leaves the table. `TableTemplate` exposes no callback for it, and + * the count feeds pagination internally — yet the section renders a + * "Filtered: N with access · M blocked" line that needs the filtered set outside the table. + * (Decisive.) + * 2. Render location. `MuiFilterMenu` renders inside a column header; the design is one filter + * icon in the toolbar spanning Status AND rules, with a single badge and one "Clear all" — + * built-in yields two header icons, two badges, two independent clears. + * 3. Checked-by-default is unreachable. In the built-in filter the selection array IS the filter, + * so a both-on Status default would need the selection inverted, putting checkmarks on exactly + * the wrong items. This component instead tracks EXCLUSIONS, so a newly added rule filters in + * by default with no state to resynchronise. + */ +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..284dc26a9f --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/MarketplaceAccessSection.tsx @@ -0,0 +1,639 @@ +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(() => 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..925bb04ce5 --- /dev/null +++ b/client/app/bundles/system/admin/admin/components/__test__/MarketplaceAccessSection.test.tsx @@ -0,0 +1,950 @@ +import userEvent from '@testing-library/user-event'; +import { createMockAdapter } from 'mocks/axiosMock'; +import { 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 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('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 587f631919..071d62650a 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 88de93f4d7..2fe80de10a 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'; @@ -31,6 +35,10 @@ const RULES = [ }, ]; const PREVIEW_URL = '/admin/marketplace_allowlist_rules/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); @@ -408,6 +416,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, { @@ -433,3 +516,124 @@ 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(); }); + +const ZERO_MATCH_WARNING = + 'No eligible staff currently match this rule, so it grants access to nobody.'; + +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 0638b06dfe..6137b773a0 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -8867,6 +8867,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 8127015525..4c02c3b2b5 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -8834,6 +8834,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 0b5e231c02..263dbc1ebf 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -8828,6 +8828,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": "添加访问规则" },