From 6b783b90f78b623e2a9bc146a2056737af48acc0 Mon Sep 17 00:00:00 2001 From: lws49 Date: Thu, 9 Jul 2026 13:22:31 +0800 Subject: [PATCH 1/4] 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 2/4] 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 3/4] 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 4/4] 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)