Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions app/controllers/course/assessment/marketplace/controller.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions client/app/api/course/Marketplace.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { AxiosResponse } from 'axios';

import { MarketplaceListing } from 'course/marketplace/types';

import BaseCourseAPI from './Base';

export default class MarketplaceAPI extends BaseCourseAPI {
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<Link to={`../marketplace?from_tab=${tabId}`}>
<Button variant="outlined">{t(translations.importAssessments)}</Button>
</Link>
);
};

export default ImportAssessmentsButton;
Original file line number Diff line number Diff line change
@@ -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(<ImportAssessmentsButton canImport tabId={42} />);
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(<ImportAssessmentsButton canImport={false} tabId={42} />);
expect(
page.queryByRole('link', { name: 'Import Assessments' }),
).not.toBeInTheDocument();
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand All @@ -30,17 +31,23 @@ const AssessmentsIndex = (): JSX.Element => {
<Page
actions={
data.display.canCreateAssessments && (
<NewAssessmentFormButton
key={data.display.tabId}
// @ts-ignore: component is still written in JSX
canManageMonitor={data.display.canManageMonitor}
categoryId={data.display.category.id}
gamified={data.display.isGamified}
isKoditsuExamEnabled={data.display.isKoditsuExamEnabled}
monitoringEnabled={data.display.isMonitoringEnabled}
randomizationAllowed={data.display.allowRandomization}
tabId={data.display.tabId}
/>
<>
<ImportAssessmentsButton
canImport={data.display.canCreateAssessments}
tabId={data.display.tabId}
/>
<NewAssessmentFormButton
key={data.display.tabId}
// @ts-ignore: component is still written in JSX
canManageMonitor={data.display.canManageMonitor}
categoryId={data.display.category.id}
gamified={data.display.isGamified}
isKoditsuExamEnabled={data.display.isKoditsuExamEnabled}
monitoringEnabled={data.display.isMonitoringEnabled}
randomizationAllowed={data.display.allowRandomization}
tabId={data.display.tabId}
/>
</>
)
}
title={data.display.category.title}
Expand Down
4 changes: 4 additions & 0 deletions client/app/bundles/course/assessment/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
8 changes: 8 additions & 0 deletions client/app/bundles/course/marketplace/operations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import CourseAPI from 'api/course';

import { MarketplaceListing } from './types';

export const fetchListings = async (): Promise<MarketplaceListing[]> => {
const response = await CourseAPI.marketplace.index();
return response.data.listings as MarketplaceListing[];
};
Original file line number Diff line number Diff line change
@@ -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<SortMode>('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<MarketplaceListing>[] = [
{
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) => (
<>
<Link opensInNewTab to={l.previewUrl}>
{t(translations.preview)}
</Link>
{/* Row-level Duplicate button wired in Task 18 */}
</>
),
},
];

return (
<>
<TextField
label={t(translations.sortLabel)}
onChange={(e): void => setSortMode(e.target.value as SortMode)}
select
size="small"
value={sortMode}
>
<MenuItem value="adoptions">{t(translations.sortMostAdopted)}</MenuItem>
<MenuItem value="newest">{t(translations.sortNewest)}</MenuItem>
</TextField>
<Table
columns={columns}
data={sorted}
getRowId={(l): string => 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) => (
<button onClick={(): void => onDuplicate(rows)} type="button">
{t(translations.duplicateN, { n: rows.length })}
</button>
),
}}
/>
</>
);
};

export default MarketplaceTable;
Original file line number Diff line number Diff line change
@@ -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<void> => {
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(<MarketplaceIndex />, { 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(<MarketplaceIndex />, { 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(<MarketplaceIndex />, { 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();
});
Loading