Skip to content
Merged
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,44 @@
# frozen_string_literal: true
class Course::Assessment::MarketplaceListingsController < Course::Assessment::Controller
before_action :authorize_publish_to_marketplace!

def create
listing = Course::Assessment::Marketplace::Listing.find_or_initialize_by(assessment: @assessment)
now = Time.zone.now
listing.published = true
listing.first_published_at ||= now
listing.last_published_at = now
# `publisher` is an audit userstamp for the *latest* publish (design D29), so it moves with
# `last_published_at`. `creator` already retains whoever first created the row.
listing.publisher = current_user
if listing.save
render json: { published: true }, status: :ok
else
render json: { errors: listing.errors.full_messages }, status: :unprocessable_content
end
end

def destroy
listing = @assessment.marketplace_listing
if listing&.update(published: false)
head :ok
else
head :unprocessable_content
end
end

private

# Publishing is admin-only. `authorize!(:publish_to_marketplace, @assessment)` alone is
# insufficient: teaching staff hold `can :manage, Course::Assessment` over their own course's
# assessments (assessment_ability.rb:189), and CanCan's `:manage` wildcard subsumes every
# custom action — including `:publish_to_marketplace`. Gate explicitly on administrator status.
def authorize_publish_to_marketplace!
authorize!(:publish_to_marketplace, @assessment)
raise CanCan::AccessDenied unless current_user&.administrator?
end

def component
current_component_host[:course_assessments_component]
end
end
4 changes: 4 additions & 0 deletions app/views/course/assessment/assessments/show.json.jbuilder
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,12 @@ json.permissions do
json.canManage can_manage
json.canObserve can_observe
json.canInviteToKoditsu can?(:invite_to_koditsu, assessment)
json.canPublishToMarketplace((can?(:publish_to_marketplace, @assessment) && current_user&.administrator?) || false)
end

json.isPublishedToMarketplace @assessment.marketplace_listing&.published? || false
json.marketplaceListingUrl course_assessment_marketplace_listing_path(current_course, @assessment)

unless can_attempt
not_started_for_user = assessment_not_started(assessment.time_for(current_course_user))
json.willStartAt assessment.time_for(current_course_user).start_at if not_started_for_user
Expand Down
21 changes: 21 additions & 0 deletions client/app/api/course/Marketplace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AxiosResponse } from 'axios';

import BaseCourseAPI from './Base';

export default class MarketplaceAPI extends BaseCourseAPI {
get #urlPrefix(): string {
return `/courses/${this.courseId}/marketplace`;
}

publishListing(assessmentId: number): Promise<AxiosResponse> {
return this.client.post(
`/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`,
);
}

removeListing(assessmentId: number): Promise<AxiosResponse> {
return this.client.delete(
`/courses/${this.courseId}/assessments/${assessmentId}/marketplace_listing`,
);
}
}
Comment thread
adi-herwana-nus marked this conversation as resolved.
2 changes: 2 additions & 0 deletions client/app/api/course/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import LeaderboardAPI from './Leaderboard';
import LearningMapAPI from './LearningMap';
import LessonPlanAPI from './LessonPlan';
import LevelAPI from './Level';
import MarketplaceAPI from './Marketplace';
import MaterialFoldersAPI from './MaterialFolders';
import MaterialsAPI from './Materials';
import PersonalTimesAPI from './PersonalTimes';
Expand Down Expand Up @@ -55,6 +56,7 @@ const CourseAPI = {
learningMap: new LearningMapAPI(),
lessonPlan: new LessonPlanAPI(),
level: new LevelAPI(),
marketplace: new MarketplaceAPI(),
materials: new MaterialsAPI(),
materialFolders: new MaterialFoldersAPI(),
personalTimes: new PersonalTimesAPI(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
AssessmentDeleteResult,
} from 'types/course/assessment/assessments';

import PublishToMarketplaceButton from 'course/marketplace/components/PublishToMarketplaceButton';
import marketplaceTranslations from 'course/marketplace/translations';
import DeleteButton from 'lib/components/core/buttons/DeleteButton';
import { PromptText } from 'lib/components/core/dialogs/Prompt';
import Link from 'lib/components/core/Link';
Expand All @@ -37,6 +39,9 @@ const AssessmentShowHeader = (
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [inviting, setInviting] = useState(false);
const [publishedToMarketplace, setPublishedToMarketplace] = useState(
assessment.isPublishedToMarketplace,
);
const navigate = useNavigate();

const handleDelete = (): Promise<void> => {
Expand Down Expand Up @@ -75,6 +80,9 @@ const AssessmentShowHeader = (
<PromptText>{t(translations.deletingThisAssessment)}</PromptText>
<PromptText className="italic">{assessment.title}</PromptText>
<PromptText>{t(translations.deleteAssessmentWarning)}</PromptText>
{publishedToMarketplace && (
<PromptText>{t(marketplaceTranslations.deleteWarning)}</PromptText>
)}
</DeleteButton>
)}

Expand Down Expand Up @@ -146,6 +154,16 @@ const AssessmentShowHeader = (
</Tooltip>
)}

{assessment.permissions.canPublishToMarketplace && (
<PublishToMarketplaceButton
assessment={{
...assessment,
isPublishedToMarketplace: publishedToMarketplace,
}}
onChange={setPublishedToMarketplace}
/>
)}

{assessment.actionButtonUrl && (
<Link
opensInNewTab={assessment.isKoditsuAssessmentEnabled}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { createMockAdapter } from 'mocks/axiosMock';
import { fireEvent, render, waitFor, within } from 'test-utils';

import CourseAPI from 'api/course';

import AssessmentShowHeader from '../AssessmentShowHeader';

const mock = createMockAdapter(CourseAPI.marketplace.client);
beforeEach(() => mock.reset());

// Minimal AssessmentData: only `deleteUrl` + `title` are needed for the delete
// Prompt to render (see AssessmentShowHeader.tsx:71 / DeleteButton.tsx). All other
// action buttons stay hidden by leaving their URLs undefined, and the publish
// button stays hidden via `canPublishToMarketplace: false`.
const baseAssessment = {
id: 1,
title: 'Sample Assessment',
deleteUrl: '/courses/1/assessments/1',
status: 'open',
permissions: {
canAttempt: false,
canManage: true,
canObserve: true,
canInviteToKoditsu: false,
canPublishToMarketplace: false,
},
isPublishedToMarketplace: false,
};

// Test the conditional <PromptText> in the delete Prompt whose
// message contains this phrase, rendered only when `isPublishedToMarketplace`.
const MARKETPLACE_WARNING = /removes it from the marketplace/i;

describe('<AssessmentShowHeader />', () => {
it('warns that deletion removes the marketplace listing when the assessment is listed', async () => {
const page = render(
<AssessmentShowHeader
with={{ ...baseAssessment, isPublishedToMarketplace: true } as never}
/>,
);

// First query awaits the i18n LoadingIndicator; subsequent getBy* are sync.
fireEvent.click(await page.findByLabelText('Delete Assessment')); // opens the delete Prompt
expect(page.getByText(MARKETPLACE_WARNING)).toBeVisible();
});

it('shows no marketplace warning when the assessment is not listed', async () => {
const page = render(
<AssessmentShowHeader
with={{ ...baseAssessment, isPublishedToMarketplace: false } as never}
/>,
);

fireEvent.click(await page.findByLabelText('Delete Assessment')); // delete Prompt still opens
expect(page.queryByText(MARKETPLACE_WARNING)).not.toBeInTheDocument();
});

it('warns after the assessment is published in the same session', async () => {
mock
.onPost(`/courses/${global.courseId}/assessments/1/marketplace_listing`)
.reply(200, { published: true });

const page = render(
<AssessmentShowHeader
with={
{
...baseAssessment,
permissions: {
...baseAssessment.permissions,
canPublishToMarketplace: true,
},
} as never
}
/>,
);

fireEvent.click(await page.findByText('Publish to Marketplace')); // trigger button
const publishPrompt = await page.findByRole('dialog');
fireEvent.click(
within(publishPrompt).getByRole('button', {
name: /Publish to Marketplace/,
}),
);
await waitFor(() => expect(mock.history.post).toHaveLength(1));

// The warning reads the live state, not the initial `isPublishedToMarketplace` prop.
fireEvent.click(page.getByLabelText('Delete Assessment'));
expect(await page.findByText(MARKETPLACE_WARNING)).toBeVisible();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 translations from '../translations';

interface Props {
assessment: Pick<
AssessmentData,
'id' | 'isPublishedToMarketplace' | 'permissions'
>;
onChange: (published: boolean) => void;
}

const PublishToMarketplaceButton = ({
assessment,
onChange,
}: Props): JSX.Element | null => {
const { formatMessage: t } = useIntl();
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const listed = assessment.isPublishedToMarketplace;

if (!assessment.permissions.canPublishToMarketplace) return null;

// `Prompt`'s primary button does not await this handler, so every rejection must be caught
// here: an uncaught one surfaces nothing to the user and becomes an unhandled rejection.
const confirm = async (): Promise<void> => {
setSubmitting(true);
try {
if (listed) {
await CourseAPI.marketplace.removeListing(assessment.id);
toast.success(t(translations.removed));
onChange(false);
} else {
await CourseAPI.marketplace.publishListing(assessment.id);
toast.success(t(translations.published));
onChange(true);
}
setOpen(false);
} catch {
// Dialog stays open so the user can retry.
toast.error(
t(listed ? translations.removeFailed : translations.publishFailed),
);
} finally {
setSubmitting(false);
}
};

return (
<>
<Button
color={listed ? 'error' : 'primary'}
onClick={(): void => setOpen(true)}
variant="outlined"
>
{t(listed ? translations.remove : translations.publish)}
</Button>
<Prompt
disabled={submitting}
onClickPrimary={confirm}
onClose={(): void => setOpen(false)}
open={open}
primaryColor={listed ? 'error' : 'primary'}
primaryLabel={t(listed ? translations.remove : translations.publish)}
title={t(
listed
? translations.removeConfirmTitle
: translations.publishConfirmTitle,
)}
>
<PromptText>
{t(
listed
? translations.removeConfirmBody
: translations.publishConfirmBody,
)}
</PromptText>
</Prompt>
</>
);
};

export default PublishToMarketplaceButton;
Loading