diff --git a/app/controllers/components/course/assessment_marketplace_component.rb b/app/controllers/components/course/assessment_marketplace_component.rb
new file mode 100644
index 00000000000..bb46e155136
--- /dev/null
+++ b/app/controllers/components/course/assessment_marketplace_component.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+class Course::AssessmentMarketplaceComponent < SimpleDelegator
+ include Course::ControllerComponentHost::Component
+
+ def self.display_name
+ 'Assessment Marketplace'
+ end
+
+ def sidebar_items
+ return [] unless can?(:access_marketplace, current_course)
+
+ [
+ key: :admin_marketplace,
+ icon: :duplication,
+ type: :admin,
+ weight: 6,
+ path: course_marketplace_path(current_course)
+ ]
+ end
+end
diff --git a/app/controllers/course/assessment/marketplace/controller.rb b/app/controllers/course/assessment/marketplace/controller.rb
new file mode 100644
index 00000000000..b617d93833d
--- /dev/null
+++ b/app/controllers/course/assessment/marketplace/controller.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+class Course::Assessment::Marketplace::Controller < Course::ComponentController
+ private
+
+ def component
+ current_component_host[:course_assessment_marketplace_component]
+ end
+end
diff --git a/app/controllers/course/assessment/marketplace/listings_controller.rb b/app/controllers/course/assessment/marketplace/listings_controller.rb
new file mode 100644
index 00000000000..6f144d7b99f
--- /dev/null
+++ b/app/controllers/course/assessment/marketplace/listings_controller.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+class Course::Assessment::Marketplace::ListingsController < Course::Assessment::Marketplace::Controller
+ before_action :authorize_access!
+
+ def index
+ ActsAsTenant.without_tenant do
+ @listings = Course::Assessment::Marketplace::Listing.published.includes(:assessment).to_a
+ listing_ids = @listings.map(&:id)
+ assessment_ids = @listings.map(&:assessment_id)
+ @adoption_counts = Course::Assessment::Marketplace::Adoption.
+ where(listing_id: listing_ids).group(:listing_id).
+ distinct.count(:destination_course_id)
+ # reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it
+ # the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is
+ # neither grouped nor aggregated).
+ @question_counts = Course::QuestionAssessment.
+ where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id).
+ distinct.count(:question_id)
+ end
+ end
+
+ private
+
+ def authorize_access!
+ authorize!(:access_marketplace, current_course)
+ end
+end
diff --git a/app/views/course/assessment/marketplace/listings/index.json.jbuilder b/app/views/course/assessment/marketplace/listings/index.json.jbuilder
new file mode 100644
index 00000000000..33ea65ec1ed
--- /dev/null
+++ b/app/views/course/assessment/marketplace/listings/index.json.jbuilder
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+json.canAccess true
+json.listings @listings do |listing|
+ assessment = listing.assessment
+ json.id listing.id
+ json.assessmentId assessment.id
+ json.title assessment.title
+ json.questionCount(@question_counts[assessment.id] || 0)
+ json.adoptions(@adoption_counts[listing.id] || 0)
+ json.firstPublishedAt listing.first_published_at
+ json.previewUrl course_listing_path(current_course, listing)
+ json.duplicateUrl duplicate_course_listings_path(current_course)
+end
diff --git a/client/app/api/course/Marketplace.ts b/client/app/api/course/Marketplace.ts
index d06a224111e..4feade4a090 100644
--- a/client/app/api/course/Marketplace.ts
+++ b/client/app/api/course/Marketplace.ts
@@ -1,5 +1,7 @@
import { AxiosResponse } from 'axios';
+import { MarketplaceListing } from 'course/marketplace/types';
+
import BaseCourseAPI from './Base';
export default class MarketplaceAPI extends BaseCourseAPI {
@@ -18,4 +20,10 @@ export default class MarketplaceAPI extends BaseCourseAPI {
`/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`,
);
}
+
+ index(): Promise<
+ AxiosResponse<{ listings: MarketplaceListing[]; canAccess: boolean }>
+ > {
+ return this.client.get(this.#urlPrefix);
+ }
}
diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx
new file mode 100644
index 00000000000..a0a78741b2e
--- /dev/null
+++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/ImportAssessmentsButton.tsx
@@ -0,0 +1,27 @@
+import { Button } from '@mui/material';
+
+import Link from 'lib/components/core/Link';
+import useTranslation from 'lib/hooks/useTranslation';
+
+import translations from '../../translations';
+
+interface Props {
+ canImport: boolean;
+ tabId: number;
+}
+
+const ImportAssessmentsButton = ({
+ canImport,
+ tabId,
+}: Props): JSX.Element | null => {
+ const { t } = useTranslation();
+ if (!canImport) return null;
+
+ return (
+
+
+
+ );
+};
+
+export default ImportAssessmentsButton;
diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx
new file mode 100644
index 00000000000..413b2b75274
--- /dev/null
+++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/__test__/ImportAssessmentsButton.test.tsx
@@ -0,0 +1,19 @@
+import { render } from 'test-utils';
+
+import ImportAssessmentsButton from '../ImportAssessmentsButton';
+
+it('links to the marketplace with the given tab as from_tab when the user can import', async () => {
+ const page = render();
+ const link = await page.findByRole('link', { name: 'Import Assessments' });
+ expect(link).toHaveAttribute(
+ 'href',
+ expect.stringContaining('/marketplace?from_tab=42'),
+ );
+});
+
+it('renders nothing when the user cannot import', () => {
+ const page = render();
+ expect(
+ page.queryByRole('link', { name: 'Import Assessments' }),
+ ).not.toBeInTheDocument();
+});
diff --git a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx
index fae862370ab..82a28038ba7 100644
--- a/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx
+++ b/client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx
@@ -9,6 +9,7 @@ import Preload from 'lib/components/wrappers/Preload';
import { fetchAssessments } from '../../operations/assessments';
import AssessmentsTable from './AssessmentsTable';
+import ImportAssessmentsButton from './ImportAssessmentsButton';
import NewAssessmentFormButton from './NewAssessmentFormButton';
const AssessmentsIndex = (): JSX.Element => {
@@ -30,17 +31,23 @@ const AssessmentsIndex = (): JSX.Element => {
+ <>
+
+
+ >
)
}
title={data.display.category.title}
diff --git a/client/app/bundles/course/assessment/translations.ts b/client/app/bundles/course/assessment/translations.ts
index 3a82c2558e1..1219997818c 100644
--- a/client/app/bundles/course/assessment/translations.ts
+++ b/client/app/bundles/course/assessment/translations.ts
@@ -1962,6 +1962,10 @@ const translations = defineMessages({
id: 'course.assessment.question.programming.liveFeedbackNotSupported',
defaultMessage: 'Get Help is not supported for {languageName}.',
},
+ importAssessments: {
+ id: 'course.assessment.AssessmentsIndex.importAssessments',
+ defaultMessage: 'Import Assessments',
+ },
});
export default translations;
diff --git a/client/app/bundles/course/marketplace/operations.ts b/client/app/bundles/course/marketplace/operations.ts
new file mode 100644
index 00000000000..8023024d8cb
--- /dev/null
+++ b/client/app/bundles/course/marketplace/operations.ts
@@ -0,0 +1,8 @@
+import CourseAPI from 'api/course';
+
+import { MarketplaceListing } from './types';
+
+export const fetchListings = async (): Promise => {
+ const response = await CourseAPI.marketplace.index();
+ return response.data.listings as MarketplaceListing[];
+};
diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx
new file mode 100644
index 00000000000..5cabe2e1d99
--- /dev/null
+++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/MarketplaceTable.tsx
@@ -0,0 +1,102 @@
+import { useMemo, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { MenuItem, TextField } from '@mui/material';
+
+import Link from 'lib/components/core/Link';
+import Table, { ColumnTemplate } from 'lib/components/table';
+
+import translations from '../../translations';
+import { MarketplaceListing } from '../../types';
+
+type SortMode = 'adoptions' | 'newest';
+
+interface Props {
+ listings: MarketplaceListing[];
+ onDuplicate: (rows: MarketplaceListing[]) => void;
+}
+
+const MarketplaceTable = ({ listings, onDuplicate }: Props): JSX.Element => {
+ const { formatMessage: t } = useIntl();
+ const [sortMode, setSortMode] = useState('adoptions');
+
+ const sorted = useMemo(() => {
+ const copy = [...listings];
+ if (sortMode === 'newest') {
+ copy.sort((a, b) =>
+ (b.firstPublishedAt ?? '').localeCompare(a.firstPublishedAt ?? ''),
+ );
+ } else {
+ copy.sort((a, b) => b.adoptions - a.adoptions);
+ }
+ return copy;
+ }, [listings, sortMode]);
+
+ const columns: ColumnTemplate[] = [
+ {
+ of: 'title',
+ title: t(translations.colTitle),
+ searchable: true,
+ cell: (l) => l.title,
+ },
+ {
+ of: 'questionCount',
+ title: t(translations.colQuestions),
+ cell: (l) => l.questionCount,
+ },
+ {
+ of: 'adoptions',
+ title: t(translations.colAdoptions),
+ cell: (l) => l.adoptions,
+ },
+ {
+ id: 'actions',
+ title: t(translations.colActions),
+ cell: (l) => (
+ <>
+
+ {t(translations.preview)}
+
+ {/* Row-level Duplicate button wired in Task 18 */}
+ >
+ ),
+ },
+ ];
+
+ return (
+ <>
+ setSortMode(e.target.value as SortMode)}
+ select
+ size="small"
+ value={sortMode}
+ >
+
+
+
+ l.id.toString()}
+ indexing={{ rowSelectable: true }}
+ search={{
+ searchPlaceholder: t(translations.searchPlaceholder),
+ searchProps: {
+ shouldInclude: (l, filter): boolean =>
+ !filter || l.title.toLowerCase().includes(filter.toLowerCase()),
+ },
+ }}
+ toolbar={{
+ show: true,
+ activeToolbar: (rows) => (
+
+ ),
+ }}
+ />
+ >
+ );
+};
+
+export default MarketplaceTable;
diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx
new file mode 100644
index 00000000000..2d1940b9d9a
--- /dev/null
+++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx
@@ -0,0 +1,83 @@
+import userEvent from '@testing-library/user-event';
+import { createMockAdapter } from 'mocks/axiosMock';
+import { fireEvent, render, waitFor } from 'test-utils';
+
+import CourseAPI from 'api/course';
+
+import MarketplaceIndex from '../index';
+
+const mock = createMockAdapter(CourseAPI.marketplace.client);
+beforeEach(() => mock.reset());
+
+// Fixture chosen so the two sort keys DISAGREE: Graph Theory is most-adopted but oldest;
+// Recursion Drills is fewer adoptions but newest. This lets the sort tests prove the mode
+// actually changes order rather than passing on a coincidental tie.
+const LISTINGS = [
+ {
+ id: 1,
+ assessmentId: 10,
+ title: 'Recursion Drills',
+ questionCount: 8,
+ adoptions: 5,
+ firstPublishedAt: '2026-06-01T00:00:00Z',
+ previewUrl: '/p/1',
+ duplicateUrl: '/d',
+ },
+ {
+ id: 2,
+ assessmentId: 11,
+ title: 'Graph Theory',
+ questionCount: 3,
+ adoptions: 12,
+ firstPublishedAt: '2026-01-01T00:00:00Z',
+ previewUrl: '/p/2',
+ duplicateUrl: '/d',
+ },
+];
+
+const url = `/courses/${global.courseId}/marketplace`;
+const renderPage = async (page): Promise => {
+ await waitFor(() => expect(page.getByText('Graph Theory')).toBeVisible());
+};
+
+it('renders published listings sorted by most adopted by default', async () => {
+ mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true });
+ const page = render(, { at: [url] });
+ await renderPage(page);
+
+ const rows = page.getAllByRole('row');
+ // Graph Theory (12 adoptions) precedes Recursion Drills (5) by default.
+ expect(rows[1]).toHaveTextContent('Graph Theory');
+});
+
+it('re-sorts by newest when the sort mode changes', async () => {
+ mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true });
+ const page = render(, { at: [url] });
+ await renderPage(page);
+
+ // Open the MUI "Sort by" select and choose Newest.
+ // NOTE (executor): confirm the exact idiom for driving a MUI `select`-mode TextField
+ // against an existing table test (e.g. mouseDown the combobox, then click the option).
+ fireEvent.mouseDown(page.getByLabelText('Sort by'));
+ fireEvent.click(page.getByRole('option', { name: 'Newest' }));
+
+ await waitFor(() => {
+ const rows = page.getAllByRole('row');
+ // Recursion Drills (2026-06) is newest and must now lead.
+ expect(rows[1]).toHaveTextContent('Recursion Drills');
+ });
+});
+
+it('filters rows by the title search', async () => {
+ mock.onGet(url).reply(200, { listings: LISTINGS, canAccess: true });
+ const page = render(, { at: [url] });
+ await renderPage(page);
+
+ // Search field must be driven with userEvent (React 18 startTransition) — see client/CLAUDE-testing.md.
+ await userEvent.type(page.getByPlaceholderText('Search by title'), 'Graph');
+
+ await waitFor(() =>
+ expect(page.queryByText('Recursion Drills')).not.toBeInTheDocument(),
+ );
+ expect(page.getByText('Graph Theory')).toBeVisible();
+});
diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx
new file mode 100644
index 00000000000..8078f4f03f8
--- /dev/null
+++ b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/index.tsx
@@ -0,0 +1,28 @@
+import { useState } from 'react';
+import { useIntl } from 'react-intl';
+
+import Page from 'lib/components/core/layouts/Page';
+import Preload from 'lib/components/wrappers/Preload';
+
+import { fetchListings } from '../../operations';
+import translations from '../../translations';
+import { MarketplaceListing } from '../../types';
+
+import MarketplaceTable from './MarketplaceTable';
+
+const MarketplaceIndex = (): JSX.Element => {
+ const { formatMessage: t } = useIntl();
+ const [pending, setPending] = useState([]);
+
+ return (
+ } while={fetchListings}>
+ {(listings): JSX.Element => (
+
+
+
+ )}
+
+ );
+};
+
+export default MarketplaceIndex;
diff --git a/client/app/bundles/course/marketplace/translations.ts b/client/app/bundles/course/marketplace/translations.ts
index 63a93be452b..797706a13ef 100644
--- a/client/app/bundles/course/marketplace/translations.ts
+++ b/client/app/bundles/course/marketplace/translations.ts
@@ -40,4 +40,40 @@ export default defineMessages({
defaultMessage:
'This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected.',
},
+ pageTitle: {
+ id: 'course.marketplace.pageTitle',
+ defaultMessage: 'Assessment Marketplace',
+ },
+ colTitle: { id: 'course.marketplace.colTitle', defaultMessage: 'Title' },
+ colQuestions: {
+ id: 'course.marketplace.colQuestions',
+ defaultMessage: 'Questions',
+ },
+ colAdoptions: {
+ id: 'course.marketplace.colAdoptions',
+ defaultMessage: 'Adoptions',
+ },
+ colActions: {
+ id: 'course.marketplace.colActions',
+ defaultMessage: 'Actions',
+ },
+ preview: {
+ id: 'course.marketplace.previewAction',
+ defaultMessage: 'Preview',
+ },
+ searchPlaceholder: {
+ id: 'course.marketplace.searchPlaceholder',
+ defaultMessage: 'Search by title',
+ },
+ sortLabel: { id: 'course.marketplace.sortLabel', defaultMessage: 'Sort by' },
+ sortMostAdopted: {
+ id: 'course.marketplace.sortMostAdopted',
+ defaultMessage: 'Most adopted',
+ },
+ sortNewest: { id: 'course.marketplace.sortNewest', defaultMessage: 'Newest' },
+ duplicateN: {
+ id: 'course.marketplace.duplicateN',
+ defaultMessage:
+ '{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}',
+ },
});
diff --git a/client/app/bundles/course/marketplace/types.ts b/client/app/bundles/course/marketplace/types.ts
new file mode 100644
index 00000000000..58053dc1d82
--- /dev/null
+++ b/client/app/bundles/course/marketplace/types.ts
@@ -0,0 +1,10 @@
+export interface MarketplaceListing {
+ id: number;
+ assessmentId: number;
+ title: string;
+ questionCount: number;
+ adoptions: number;
+ firstPublishedAt: string | null;
+ previewUrl: string;
+ duplicateUrl: string;
+}
diff --git a/client/app/bundles/course/translations.ts b/client/app/bundles/course/translations.ts
index c52ce359071..f5af35441ce 100644
--- a/client/app/bundles/course/translations.ts
+++ b/client/app/bundles/course/translations.ts
@@ -51,6 +51,14 @@ const translations = defineMessages({
id: 'course.componentTitles.course_announcements_component',
defaultMessage: 'Announcements',
},
+ course_assessment_marketplace_component: {
+ id: 'course.componentTitles.course_assessment_marketplace_component',
+ defaultMessage: 'Assessment Marketplace',
+ },
+ admin_marketplace: {
+ id: 'course.courses.SidebarItem.admin.marketplace',
+ defaultMessage: 'Assessment Marketplace',
+ },
course_assessments_component: {
id: 'course.componentTitles.course_assessments_component',
defaultMessage: 'Assessments',
diff --git a/client/app/routers/course/index.tsx b/client/app/routers/course/index.tsx
index 13bc90b1aca..0abdd08ba4d 100644
--- a/client/app/routers/course/index.tsx
+++ b/client/app/routers/course/index.tsx
@@ -10,6 +10,7 @@ import forumsRouter from './forums';
import gradebookRouter from './gradebook';
import groupsRouter from './groups';
import lessonPlanRouter from './lessonPlan';
+import marketplaceRouter from './marketplace';
import materialsRouter from './materials';
import plagiarismRouter from './plagiarism';
import scholaisticRouter from './scholaistic';
@@ -45,6 +46,7 @@ const courseRouter: Translated = (t) => ({
gradebookRouter(t),
groupsRouter(t),
lessonPlanRouter(t),
+ marketplaceRouter(t),
materialsRouter(t),
plagiarismRouter(t),
statisticsRouter(t),
diff --git a/client/app/routers/course/marketplace.tsx b/client/app/routers/course/marketplace.tsx
new file mode 100644
index 00000000000..e8a42b7db09
--- /dev/null
+++ b/client/app/routers/course/marketplace.tsx
@@ -0,0 +1,18 @@
+import { RouteObject } from 'react-router-dom';
+
+import { Translated } from 'lib/hooks/useTranslation';
+
+const marketplaceRouter: Translated = () => ({
+ path: 'marketplace',
+ children: [
+ {
+ index: true,
+ lazy: async () => ({
+ Component: (await import('course/marketplace/pages/MarketplaceIndex'))
+ .default,
+ }),
+ },
+ ],
+});
+
+export default marketplaceRouter;
diff --git a/client/locales/en.json b/client/locales/en.json
index 0feb1d5b58d..66eccad169e 100644
--- a/client/locales/en.json
+++ b/client/locales/en.json
@@ -1493,6 +1493,9 @@
"course.assessment.assessments.sendReminderEmailSuccess": {
"defaultMessage": "Closing assessment reminder emails have been successfully dispatched."
},
+ "course.assessment.AssessmentsIndex.importAssessments": {
+ "defaultMessage": "Import Assessments"
+ },
"course.assessment.create.createAsDraft": {
"defaultMessage": "Create As Draft"
},
@@ -4322,6 +4325,9 @@
"course.componentTitles.course_announcements_component": {
"defaultMessage": "Announcements"
},
+ "course.componentTitles.course_assessment_marketplace_component": {
+ "defaultMessage": "Assessment Marketplace"
+ },
"course.componentTitles.course_assessments_component": {
"defaultMessage": "Assessments"
},
@@ -4580,6 +4586,9 @@
"course.courses.SidebarItem.admin.duplication": {
"defaultMessage": "Duplicate Data"
},
+ "course.courses.SidebarItem.admin.marketplace": {
+ "defaultMessage": "Assessment Marketplace"
+ },
"course.courses.SidebarItem.admin.multipleReferenceTimelines": {
"defaultMessage": "Timeline Designer"
},
@@ -6086,6 +6095,39 @@
"course.marketplace.deleteWarning": {
"defaultMessage": "This assessment is in the Assessment Marketplace. Deleting it removes it from the marketplace and deletes its adoption history. Existing copies in other courses are unaffected."
},
+ "course.marketplace.pageTitle": {
+ "defaultMessage": "Assessment Marketplace"
+ },
+ "course.marketplace.colTitle": {
+ "defaultMessage": "Title"
+ },
+ "course.marketplace.colQuestions": {
+ "defaultMessage": "Questions"
+ },
+ "course.marketplace.colAdoptions": {
+ "defaultMessage": "Adoptions"
+ },
+ "course.marketplace.colActions": {
+ "defaultMessage": "Actions"
+ },
+ "course.marketplace.previewAction": {
+ "defaultMessage": "Preview"
+ },
+ "course.marketplace.searchPlaceholder": {
+ "defaultMessage": "Search by title"
+ },
+ "course.marketplace.sortLabel": {
+ "defaultMessage": "Sort by"
+ },
+ "course.marketplace.sortMostAdopted": {
+ "defaultMessage": "Most adopted"
+ },
+ "course.marketplace.sortNewest": {
+ "defaultMessage": "Newest"
+ },
+ "course.marketplace.duplicateN": {
+ "defaultMessage": "{n, plural, one {Duplicate # assessment} other {Duplicate # assessments}}"
+ },
"course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": {
"defaultMessage": "Download has failed. Please try again later."
},
diff --git a/client/locales/ko.json b/client/locales/ko.json
index 5f4189d125b..3b863ed20a2 100644
--- a/client/locales/ko.json
+++ b/client/locales/ko.json
@@ -1493,6 +1493,9 @@
"course.assessment.assessments.sendReminderEmailSuccess": {
"defaultMessage": "평가 마감 알림 이메일이 성공적으로 발송되었습니다."
},
+ "course.assessment.AssessmentsIndex.importAssessments": {
+ "defaultMessage": "평가 가져오기"
+ },
"course.assessment.create.createAsDraft": {
"defaultMessage": "드래프트로 생성"
},
@@ -4304,6 +4307,9 @@
"course.componentTitles.course_announcements_component": {
"defaultMessage": "공지 사항"
},
+ "course.componentTitles.course_assessment_marketplace_component": {
+ "defaultMessage": "평가 마켓플레이스"
+ },
"course.componentTitles.course_assessments_component": {
"defaultMessage": "평가"
},
@@ -4562,6 +4568,9 @@
"course.courses.SidebarItem.admin.duplication": {
"defaultMessage": "데이터 복제"
},
+ "course.courses.SidebarItem.admin.marketplace": {
+ "defaultMessage": "평가 마켓플레이스"
+ },
"course.courses.SidebarItem.admin.multipleReferenceTimelines": {
"defaultMessage": "타임라인 디자이너"
},
@@ -6050,6 +6059,39 @@
"course.marketplace.deleteWarning": {
"defaultMessage": "이 평가는 평가 마켓플레이스에 있습니다. 삭제하면 마켓플레이스에서 제거되고 채택 기록도 삭제됩니다. 다른 강좌의 기존 복사본은 영향을 받지 않습니다."
},
+ "course.marketplace.pageTitle": {
+ "defaultMessage": "평가 마켓플레이스"
+ },
+ "course.marketplace.colTitle": {
+ "defaultMessage": "제목"
+ },
+ "course.marketplace.colQuestions": {
+ "defaultMessage": "문제"
+ },
+ "course.marketplace.colAdoptions": {
+ "defaultMessage": "채택"
+ },
+ "course.marketplace.colActions": {
+ "defaultMessage": "작업"
+ },
+ "course.marketplace.previewAction": {
+ "defaultMessage": "미리보기"
+ },
+ "course.marketplace.searchPlaceholder": {
+ "defaultMessage": "제목으로 검색"
+ },
+ "course.marketplace.sortLabel": {
+ "defaultMessage": "정렬 기준"
+ },
+ "course.marketplace.sortMostAdopted": {
+ "defaultMessage": "가장 많이 채택됨"
+ },
+ "course.marketplace.sortNewest": {
+ "defaultMessage": "최신순"
+ },
+ "course.marketplace.duplicateN": {
+ "defaultMessage": "{n}개 평가 복제"
+ },
"course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": {
"defaultMessage": "다운로드에 실패했습니다. 나중에 다시 시도하세요."
},
diff --git a/client/locales/zh.json b/client/locales/zh.json
index 9672fc8cdb5..415e109764f 100644
--- a/client/locales/zh.json
+++ b/client/locales/zh.json
@@ -1484,6 +1484,9 @@
"course.assessment.assessments.sendReminderEmailSuccess": {
"defaultMessage": "已成功发送结束测验的提醒邮件。"
},
+ "course.assessment.AssessmentsIndex.importAssessments": {
+ "defaultMessage": "导入评估"
+ },
"course.assessment.create.createAsDraft": {
"defaultMessage": "创建为草稿"
},
@@ -4298,6 +4301,9 @@
"course.componentTitles.course_announcements_component": {
"defaultMessage": "公告"
},
+ "course.componentTitles.course_assessment_marketplace_component": {
+ "defaultMessage": "评估市场"
+ },
"course.componentTitles.course_assessments_component": {
"defaultMessage": "测验"
},
@@ -4556,6 +4562,9 @@
"course.courses.SidebarItem.admin.duplication": {
"defaultMessage": "复制数据"
},
+ "course.courses.SidebarItem.admin.marketplace": {
+ "defaultMessage": "평가 마켓플레이스"
+ },
"course.courses.SidebarItem.admin.multipleReferenceTimelines": {
"defaultMessage": "时间线设计工具"
},
@@ -6044,6 +6053,39 @@
"course.marketplace.deleteWarning": {
"defaultMessage": "此评估位于评估市场中。删除它会将其从市场移除,并删除其采用历史记录。其他课程中的现有副本不受影响。"
},
+ "course.marketplace.pageTitle": {
+ "defaultMessage": "评估市场"
+ },
+ "course.marketplace.colTitle": {
+ "defaultMessage": "标题"
+ },
+ "course.marketplace.colQuestions": {
+ "defaultMessage": "问题"
+ },
+ "course.marketplace.colAdoptions": {
+ "defaultMessage": "采用次数"
+ },
+ "course.marketplace.colActions": {
+ "defaultMessage": "操作"
+ },
+ "course.marketplace.previewAction": {
+ "defaultMessage": "预览"
+ },
+ "course.marketplace.searchPlaceholder": {
+ "defaultMessage": "按标题搜索"
+ },
+ "course.marketplace.sortLabel": {
+ "defaultMessage": "排序方式"
+ },
+ "course.marketplace.sortMostAdopted": {
+ "defaultMessage": "采用最多"
+ },
+ "course.marketplace.sortNewest": {
+ "defaultMessage": "最新"
+ },
+ "course.marketplace.duplicateN": {
+ "defaultMessage": "复制 {n} 个评估"
+ },
"course.material.folders.DownloadFolderButton.downloadFolderErrorMessage": {
"defaultMessage": "下载失败。请稍后再试。"
},
diff --git a/config/routes.rb b/config/routes.rb
index 892aacd4b7c..732a4543391 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -612,6 +612,13 @@
get 'learn_settings', to: 'stories#learn_settings'
get 'mission_control', to: 'stories#mission_control'
end
+
+ scope module: 'assessment/marketplace' do
+ get 'marketplace' => 'listings#index', as: :marketplace
+ resources :listings, only: [:show], path: 'marketplace/listings' do
+ post 'duplicate', on: :collection
+ end
+ end
end
end
diff --git a/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb
new file mode 100644
index 00000000000..4caf08b581d
--- /dev/null
+++ b/spec/controllers/course/assessment/marketplace/listings_controller_spec.rb
@@ -0,0 +1,74 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe Course::Assessment::Marketplace::ListingsController, type: :controller do
+ render_views # index.json.jbuilder output is asserted below — controller specs don't render views otherwise
+
+ let(:instance) { create(:instance) }
+ with_tenant(:instance) do
+ let(:course) { create(:course) }
+ let(:manager) { create(:course_manager, course: course) }
+
+ before { controller_sign_in(controller, manager.user) }
+
+ describe 'GET #index' do
+ let!(:published) { create(:course_assessment_marketplace_listing, published: true) }
+ let!(:unpublished) { create(:course_assessment_marketplace_listing, published: false) }
+
+ it 'returns only published listings' do
+ get :index, params: { course_id: course, format: :json }
+ ids = response.parsed_body['listings'].map { |l| l['id'] }
+ expect(ids).to include(published.id)
+ expect(ids).not_to include(unpublished.id)
+ end
+
+ it 'includes title, question count and adoptions, and canAccess' do
+ get :index, params: { course_id: course, format: :json }
+ expect(response.parsed_body['canAccess']).to be(true)
+ row = response.parsed_body['listings'].find { |l| l['id'] == published.id }
+ expect(row).to include('title', 'questionCount', 'adoptions', 'previewUrl', 'duplicateUrl')
+ end
+
+ it 'reports the actual question count for a listing (not the 0 fallback)' do
+ assessment_with_questions = create(:assessment, :with_mcq_question, question_count: 3, course: course)
+ listing = create(:course_assessment_marketplace_listing, published: true, assessment: assessment_with_questions)
+ get :index, params: { course_id: course, format: :json }
+ row = response.parsed_body['listings'].find { |l| l['id'] == listing.id }
+ expect(row['questionCount']).to eq(3)
+ end
+
+ context 'as a student' do
+ let(:student) { create(:course_student, course: course).user }
+ before { controller_sign_in(controller, student) }
+ it 'is forbidden' do
+ expect do
+ get :index, params: { course_id: course, format: :json }
+ end.to raise_exception(CanCan::AccessDenied)
+ end
+ end
+ end
+ end
+
+ # Cross-instance: a listing published in another instance is visible.
+ describe 'cross-instance visibility' do
+ let(:other_instance) { create(:instance) }
+ let(:home_instance) { create(:instance) }
+
+ it 'lists listings from other instances' do
+ foreign = ActsAsTenant.with_tenant(other_instance) do
+ create(:course_assessment_marketplace_listing, published: true)
+ end
+ ActsAsTenant.with_tenant(home_instance) do
+ course = create(:course)
+ manager = create(:course_manager, course: course)
+ controller_sign_in(controller, manager.user)
+ # Point the request at the home instance's host so `deduce_tenant` resolves it (this
+ # describe is outside `with_tenant`, which would otherwise set the host header for us).
+ @request.headers['host'] = home_instance.host
+ get :index, params: { course_id: course, format: :json }
+ ids = response.parsed_body['listings'].map { |l| l['id'] }
+ expect(ids).to include(foreign.id)
+ end
+ end
+ end
+end
diff --git a/spec/controllers/course/assessment_marketplace_component_spec.rb b/spec/controllers/course/assessment_marketplace_component_spec.rb
new file mode 100644
index 00000000000..0ecb37ec07d
--- /dev/null
+++ b/spec/controllers/course/assessment_marketplace_component_spec.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+require 'rails_helper'
+
+RSpec.describe Course::AssessmentMarketplaceComponent do
+ controller(Course::Controller) {} # rubocop:disable Lint/EmptyBlock
+
+ let!(:instance) { Instance.default }
+ with_tenant(:instance) do
+ let(:course) { create(:course) }
+
+ subject do
+ controller.instance_variable_set(:@course, course)
+ described_class.new(controller)
+ end
+
+ context 'when the user can access the marketplace (course manager)' do
+ let(:user) { create(:course_manager, course: course).user }
+ before { controller_sign_in(controller, user) }
+
+ it 'exposes an admin sidebar item pointing at the marketplace' do
+ item = subject.sidebar_items.find { |i| i[:key] == :admin_marketplace }
+ expect(item).to be_present
+ expect(item[:type]).to eq(:admin)
+ expect(item[:path]).to eq(course_marketplace_path(course))
+ end
+ end
+
+ context 'when the user cannot access the marketplace (course student)' do
+ let(:user) { create(:course_student, course: course).user }
+ before { controller_sign_in(controller, user) }
+
+ it 'exposes no sidebar item' do
+ expect(subject.sidebar_items).to be_empty
+ end
+ end
+ end
+end