Skip to content

Commit 9169fa1

Browse files
committed
feat(marketplace): cross-instance browse page + entry points
- add cross-instance listings index (published only, live counts) - add browse page with title search, adoptions/newest sort, row select - add sidebar admin entry + /courses/:id/marketplace route - add "Import Assessments" button on assessments index (from_tab) - add FE api/operations/types, controller + component specs
1 parent c15d911 commit 9169fa1

24 files changed

Lines changed: 683 additions & 11 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# frozen_string_literal: true
2+
class Course::AssessmentMarketplaceComponent < SimpleDelegator
3+
include Course::ControllerComponentHost::Component
4+
5+
def self.display_name
6+
'Assessment Marketplace'
7+
end
8+
9+
def sidebar_items
10+
return [] unless can?(:access_marketplace, current_course)
11+
12+
[
13+
key: :admin_marketplace,
14+
icon: :duplication,
15+
type: :admin,
16+
weight: 6,
17+
path: course_marketplace_path(current_course)
18+
]
19+
end
20+
end
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# frozen_string_literal: true
2+
class Course::Assessment::Marketplace::Controller < Course::ComponentController
3+
private
4+
5+
def component
6+
current_component_host[:course_assessment_marketplace_component]
7+
end
8+
end
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# frozen_string_literal: true
2+
class Course::Assessment::Marketplace::ListingsController < Course::Assessment::Marketplace::Controller
3+
before_action :authorize_access!
4+
5+
def index
6+
ActsAsTenant.without_tenant do
7+
@listings = Course::Assessment::Marketplace::Listing.published.includes(:assessment).to_a
8+
listing_ids = @listings.map(&:id)
9+
assessment_ids = @listings.map(&:assessment_id)
10+
@adoption_counts = Course::Assessment::Marketplace::Adoption.
11+
where(listing_id: listing_ids).group(:listing_id).
12+
distinct.count(:destination_course_id)
13+
# reorder(nil) strips QuestionAssessment's `default_scope { order(weight: :asc) }`; without it
14+
# the injected `ORDER BY weight` breaks the grouped aggregate (PG::GroupingError — weight is
15+
# neither grouped nor aggregated).
16+
@question_counts = Course::QuestionAssessment.
17+
where(assessment_id: assessment_ids).reorder(nil).group(:assessment_id).
18+
distinct.count(:question_id)
19+
end
20+
end
21+
22+
private
23+
24+
def authorize_access!
25+
authorize!(:access_marketplace, current_course)
26+
end
27+
end
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# frozen_string_literal: true
2+
json.canAccess true
3+
json.listings @listings do |listing|
4+
assessment = listing.assessment
5+
json.id listing.id
6+
json.assessmentId assessment.id
7+
json.title assessment.title
8+
json.questionCount(@question_counts[assessment.id] || 0)
9+
json.adoptions(@adoption_counts[listing.id] || 0)
10+
json.firstPublishedAt listing.first_published_at
11+
json.previewUrl course_listing_path(current_course, listing)
12+
json.duplicateUrl duplicate_course_listings_path(current_course)
13+
end

client/app/api/course/Marketplace.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { AxiosResponse } from 'axios';
22

3+
import { MarketplaceListing } from 'course/marketplace/types';
4+
35
import BaseCourseAPI from './Base';
46

57
export default class MarketplaceAPI extends BaseCourseAPI {
@@ -18,4 +20,10 @@ export default class MarketplaceAPI extends BaseCourseAPI {
1820
`/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`,
1921
);
2022
}
23+
24+
index(): Promise<
25+
AxiosResponse<{ listings: MarketplaceListing[]; canAccess: boolean }>
26+
> {
27+
return this.client.get(this.#urlPrefix);
28+
}
2129
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Button } from '@mui/material';
2+
3+
import Link from 'lib/components/core/Link';
4+
import useTranslation from 'lib/hooks/useTranslation';
5+
6+
import translations from '../../translations';
7+
8+
interface Props {
9+
canImport: boolean;
10+
tabId: number;
11+
}
12+
13+
const ImportAssessmentsButton = ({
14+
canImport,
15+
tabId,
16+
}: Props): JSX.Element | null => {
17+
const { t } = useTranslation();
18+
if (!canImport) return null;
19+
20+
return (
21+
<Link to={`../marketplace?from_tab=${tabId}`}>
22+
<Button variant="outlined">{t(translations.importAssessments)}</Button>
23+
</Link>
24+
);
25+
};
26+
27+
export default ImportAssessmentsButton;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { render } from 'test-utils';
2+
3+
import ImportAssessmentsButton from '../ImportAssessmentsButton';
4+
5+
it('links to the marketplace with the given tab as from_tab when the user can import', async () => {
6+
const page = render(<ImportAssessmentsButton canImport tabId={42} />);
7+
const link = await page.findByRole('link', { name: 'Import Assessments' });
8+
expect(link).toHaveAttribute(
9+
'href',
10+
expect.stringContaining('/marketplace?from_tab=42'),
11+
);
12+
});
13+
14+
it('renders nothing when the user cannot import', () => {
15+
const page = render(<ImportAssessmentsButton canImport={false} tabId={42} />);
16+
expect(
17+
page.queryByRole('link', { name: 'Import Assessments' }),
18+
).not.toBeInTheDocument();
19+
});

client/app/bundles/course/assessment/pages/AssessmentsIndex/index.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Preload from 'lib/components/wrappers/Preload';
99
import { fetchAssessments } from '../../operations/assessments';
1010

1111
import AssessmentsTable from './AssessmentsTable';
12+
import ImportAssessmentsButton from './ImportAssessmentsButton';
1213
import NewAssessmentFormButton from './NewAssessmentFormButton';
1314

1415
const AssessmentsIndex = (): JSX.Element => {
@@ -30,17 +31,23 @@ const AssessmentsIndex = (): JSX.Element => {
3031
<Page
3132
actions={
3233
data.display.canCreateAssessments && (
33-
<NewAssessmentFormButton
34-
key={data.display.tabId}
35-
// @ts-ignore: component is still written in JSX
36-
canManageMonitor={data.display.canManageMonitor}
37-
categoryId={data.display.category.id}
38-
gamified={data.display.isGamified}
39-
isKoditsuExamEnabled={data.display.isKoditsuExamEnabled}
40-
monitoringEnabled={data.display.isMonitoringEnabled}
41-
randomizationAllowed={data.display.allowRandomization}
42-
tabId={data.display.tabId}
43-
/>
34+
<>
35+
<ImportAssessmentsButton
36+
canImport={data.display.canCreateAssessments}
37+
tabId={data.display.tabId}
38+
/>
39+
<NewAssessmentFormButton
40+
key={data.display.tabId}
41+
// @ts-ignore: component is still written in JSX
42+
canManageMonitor={data.display.canManageMonitor}
43+
categoryId={data.display.category.id}
44+
gamified={data.display.isGamified}
45+
isKoditsuExamEnabled={data.display.isKoditsuExamEnabled}
46+
monitoringEnabled={data.display.isMonitoringEnabled}
47+
randomizationAllowed={data.display.allowRandomization}
48+
tabId={data.display.tabId}
49+
/>
50+
</>
4451
)
4552
}
4653
title={data.display.category.title}

client/app/bundles/course/assessment/translations.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2043,6 +2043,10 @@ const translations = defineMessages({
20432043
id: 'course.assessment.question.programming.liveFeedbackNotSupported',
20442044
defaultMessage: 'Get Help is not supported for {languageName}.',
20452045
},
2046+
importAssessments: {
2047+
id: 'course.assessment.AssessmentsIndex.importAssessments',
2048+
defaultMessage: 'Import Assessments',
2049+
},
20462050
});
20472051

20482052
export default translations;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import CourseAPI from 'api/course';
2+
3+
import { MarketplaceListing } from './types';
4+
5+
export const fetchListings = async (): Promise<MarketplaceListing[]> => {
6+
const response = await CourseAPI.marketplace.index();
7+
return response.data.listings as MarketplaceListing[];
8+
};

0 commit comments

Comments
 (0)