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} + > + {t(translations.sortMostAdopted)} + {t(translations.sortNewest)} + + l.id.toString()} + indexing={{ rowSelectable: true }} + search={{ + searchPlaceholder: t(translations.searchPlaceholder), + searchProps: { + shouldInclude: (l, filter): boolean => + !filter || l.title.toLowerCase().includes(filter.toLowerCase()), + }, + }} + toolbar={{ + show: true, + activeToolbar: (rows) => ( + + ), + }} + /> + + ); +}; + +export default MarketplaceTable; diff --git a/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx b/client/app/bundles/course/marketplace/pages/MarketplaceIndex/__test__/index.test.tsx new file mode 100644 index 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