Skip to content

Commit 0b85578

Browse files
committed
fix(marketplace): address review on the duplication flow
Polling: - drop `pollJob` (marked DO NOT USE -- it orphans pollers on navigation) in favour of a `pollJobRequest` interval owned by DuplicateConfirmation and torn down by its effect cleanup - handle a rejected duplicate request, which previously left the prompt disabled with no job to re-enable it Destination tab: - keep an omitted `destination_tab_id` nil instead of coercing it to 0, so the job falls back to the destination course's default tab - redirect to the tab the copies actually landed in, and to that tab's own category, rather than to `tab=0` or a tab from another course Adoptions: - record them before the duplicates' `after_duplicate_save` callbacks run, so a failure rolls the transaction back before that work rather than undoing it - move the per-copy rule onto `Course::Assessment#record_marketplace_adoption`, next to the association and `initialize_duplicate`; the service keeps only the sweep, which the `after_duplicate_save` hook cannot replace (it never runs during course duplication) Messages: - pass `n` to the pluralised `duplicateTitle` and `duplicateFailed`, which were formatted without it - give `duplicateBody`'s `other` branch the count its locale strings already had - drop an `as never` cast that disabled type-checking on the test's props
1 parent 8133678 commit 0b85578

15 files changed

Lines changed: 194 additions & 66 deletions

File tree

app/controllers/course/assessment/marketplace/listings_controller.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ def index
2222
def duplicate
2323
listings = authorized_listings
2424
job = Course::Assessment::Marketplace::DuplicationJob.perform_later(
25-
listings.map(&:id), current_course, duplicate_params[:destination_tab_id].to_i,
25+
# `presence` first: an omitted tab (the sidebar entry point) must stay nil so the job lets the
26+
# duplication fall back to the destination course's first tab, rather than looking for tab 0.
27+
listings.map(&:id), current_course, duplicate_params[:destination_tab_id].presence&.to_i,
2628
current_user: current_user
2729
).job
2830
render partial: 'jobs/submitted', locals: { job: job }

app/jobs/course/assessment/marketplace/duplication_job.rb

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,36 +11,54 @@ def perform_tracked(listing_ids, destination_course, destination_tab_id, options
1111
current_user = options[:current_user]
1212
ActsAsTenant.without_tenant do
1313
listings = Course::Assessment::Marketplace::Listing.published.where(id: listing_ids)
14+
target_tab = find_tab(destination_course, destination_tab_id)
15+
last_copy = nil
1416
listings.each do |listing|
1517
# The adoption row is written by the duplication service itself, which tracks every copy of a
1618
# listed assessment regardless of the path that produced it. See
1719
# `Course::Duplication::BaseService#record_marketplace_adoptions`.
18-
copy = duplicate_listing(listing, destination_course, current_user)
19-
reparent_into_tab(copy, destination_course, destination_tab_id)
20+
last_copy = duplicate_listing(listing, destination_course, current_user)
21+
reparent_into_tab(last_copy, target_tab)
2022
end
21-
redirect_to course_assessments_url(destination_course,
22-
category: destination_course.assessment_categories.first.id,
23-
tab: destination_tab_id,
24-
host: destination_course.instance.host)
23+
redirect_to assessments_url(destination_course, target_tab || last_copy&.tab)
2524
end
2625
end
2726

2827
private
2928

29+
# @return [Course::Assessment::Tab, nil] The requested tab, or nil when no tab was requested or
30+
# the requested one does not belong to the destination course.
31+
def find_tab(destination_course, destination_tab_id)
32+
return nil unless destination_tab_id
33+
34+
destination_course.assessment_categories.
35+
flat_map(&:tabs).find { |tab| tab.id == destination_tab_id }
36+
end
37+
3038
def duplicate_listing(listing, destination_course, current_user)
3139
source = listing.assessment
3240
Course::Duplication::ObjectDuplicationService.duplicate_objects(
3341
source.course, destination_course, source, current_user: current_user
3442
)
3543
end
3644

37-
def reparent_into_tab(copy, destination_course, destination_tab_id)
38-
target_tab = destination_course.assessment_categories.
39-
flat_map(&:tabs).find { |tab| tab.id == destination_tab_id }
45+
def reparent_into_tab(copy, target_tab)
4046
return unless target_tab && copy.tab_id != target_tab.id
4147

4248
copy.tab = target_tab
4349
copy.folder.parent = target_tab.category.folder
4450
copy.save!
4551
end
52+
53+
# Points at the tab the copies actually landed in. No tab is requested from the sidebar entry
54+
# point, and a requested tab may not belong to the destination course -- in both cases the
55+
# duplication picks the destination's default tab, and the redirect has to follow it there
56+
# instead of naming a tab (and its category) that the user cannot open.
57+
def assessments_url(destination_course, tab)
58+
redirect_category_id = tab&.category_id || destination_course.assessment_categories.first.id
59+
course_assessments_url(destination_course,
60+
category: redirect_category_id,
61+
tab: tab&.id,
62+
host: destination_course.instance.host)
63+
end
4664
end

app/models/course/assessment.rb

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,28 @@ def csv_downloadable?
250250
questions.any?(&:csv_downloadable?)
251251
end
252252

253+
# Records +duplicate+, a copy of this assessment, as an adoption of this assessment's marketplace
254+
# listing. Every copy of a listed assessment is an adoption, whichever duplication path produced
255+
# it, so this is called by the duplication services rather than by the marketplace's own job.
256+
#
257+
# The listing itself is never carried over -- +initialize_duplicate+ below does not duplicate the
258+
# +marketplace_listing+ association -- so a copy always starts out unlisted.
259+
#
260+
# @param [Course::Assessment] duplicate The saved copy of this assessment.
261+
# @param [Course] destination_course The course the copy was duplicated into.
262+
# @param [User] current_user The user who triggered the duplication.
263+
def record_marketplace_adoption(duplicate, destination_course, current_user)
264+
return unless marketplace_listing&.published?
265+
266+
Course::Assessment::Marketplace::Adoption.create!(
267+
listing: marketplace_listing,
268+
destination_course: destination_course,
269+
duplicated_assessment: duplicate,
270+
creator: current_user,
271+
updater: current_user
272+
)
273+
end
274+
253275
def initialize_duplicate(duplicator, other) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
254276
copy_attributes(other, duplicator)
255277
target_tab = initialize_duplicate_tab(duplicator, other)

app/services/course/duplication/base_service.rb

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,23 @@ def initialize_duplicator(*)
3030
raise NotImplementedError, 'To be implemented by specific duplication service.'
3131
end
3232

33-
# Records a marketplace adoption for every duplicated assessment whose source is published on the
34-
# marketplace. Copies made outside +Course::Assessment::Marketplace::DuplicationJob+ -- selected
35-
# object duplications and full course duplications that happen to carry a listed assessment along
36-
# -- are adoptions too, and the listing has to know about them to reach every course holding a
37-
# copy. The listing itself is never carried over: +Course::Assessment#initialize_duplicate+ does
38-
# not duplicate the +marketplace_listing+ association, so a copy always starts out unlisted.
33+
# Hands every duplicated assessment its own copy so it can record a marketplace adoption. Copies
34+
# made outside +Course::Assessment::Marketplace::DuplicationJob+ -- selected object duplications
35+
# and full course duplications that happen to carry a listed assessment along -- are adoptions
36+
# too, and the listing has to know about them to reach every course holding a copy.
37+
#
38+
# This sweep lives in the duplication service rather than in a model's +after_duplicate_save+
39+
# hook because that hook only runs for the top-level objects of an object duplication, and never
40+
# at all during a course duplication -- both of which are paths this has to cover. The per-copy
41+
# rule itself belongs to the assessment: see +Course::Assessment#record_marketplace_adoption+.
3942
#
4043
# Must be called inside the duplication transaction, after the duplicates have been saved.
4144
def record_marketplace_adoptions
4245
destination_course = @options[:destination_course] || duplicator.options[:destination_course]
4346
return unless destination_course
4447

4548
duplicated_assessment_pairs.each do |source, duplicate|
46-
listing = source.marketplace_listing
47-
record_marketplace_adoption(listing, destination_course, duplicate) if listing&.published?
49+
source.record_marketplace_adoption(duplicate, destination_course, @options[:current_user])
4850
end
4951
end
5052

@@ -54,15 +56,4 @@ def duplicated_assessment_pairs
5456
source.is_a?(Course::Assessment) && duplicate&.persisted?
5557
end
5658
end
57-
58-
def record_marketplace_adoption(listing, destination_course, duplicate)
59-
current_user = @options[:current_user]
60-
Course::Assessment::Marketplace::Adoption.create!(
61-
listing: listing,
62-
destination_course: destination_course,
63-
duplicated_assessment: duplicate,
64-
creator: current_user,
65-
updater: current_user
66-
)
67-
end
6859
end

app/services/course/duplication/object_duplication_service.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ def duplicate_objects(objects)
4545
duplicated = duplicator.duplicate(objects)
4646
before_save(objects, duplicated)
4747
save_success = duplicated.respond_to?(:save) ? duplicated.save : duplicated.all?(&:save)
48+
# Recorded before `after_save` so that a failure here rolls the transaction back before the
49+
# models' post-duplication callbacks have run, rather than undoing their work afterwards.
50+
record_marketplace_adoptions if save_success
4851
after_save_success = save_success && after_save(objects, duplicated)
4952
raise ActiveRecord::Rollback unless after_save_success
5053

51-
record_marketplace_adoptions
5254
duplicated
5355
end
5456
end

client/app/api/course/Marketplace.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AxiosResponse } from 'axios';
2+
import { JobSubmitted } from 'types/jobs';
23

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

@@ -30,7 +31,7 @@ export default class MarketplaceAPI extends BaseCourseAPI {
3031
duplicate(
3132
listingIds: number[],
3233
destinationTabId: number | null,
33-
): Promise<AxiosResponse> {
34+
): Promise<AxiosResponse<JobSubmitted>> {
3435
return this.client.post(`${this.#urlPrefix}/listings/duplicate`, {
3536
listing_ids: listingIds,
3637
...(destinationTabId ? { destination_tab_id: destinationTabId } : {}),

client/app/bundles/course/marketplace/components/DuplicateConfirmation.tsx

Lines changed: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1-
import { useState } from 'react';
1+
import { useEffect, useRef, useState } from 'react';
22
import { useIntl } from 'react-intl';
3+
import { JobStatus } from 'types/jobs';
34

45
import Prompt, { PromptText } from 'lib/components/core/dialogs/Prompt';
6+
import { pollJobRequest } from 'lib/helpers/jobHelpers';
57
import toast from 'lib/hooks/toast';
68

79
import { duplicateListings } from '../operations';
810
import translations from '../translations';
911
import { MarketplaceListing } from '../types';
1012

13+
const JOB_POLL_INTERVAL_MS = 2000;
14+
1115
interface Props {
1216
listings: Pick<MarketplaceListing, 'id' | 'title'>[];
1317
destinationTabId: number | null;
@@ -23,36 +27,70 @@ const DuplicateConfirmation = ({
2327
}: Props): JSX.Element => {
2428
const { formatMessage: t } = useIntl();
2529
const [submitting, setSubmitting] = useState(false);
30+
const [jobUrl, setJobUrl] = useState<string | null>(null);
31+
const pollingRef = useRef(false);
32+
33+
const n = listings.length;
2634

2735
const confirm = async (): Promise<void> => {
2836
setSubmitting(true);
29-
await duplicateListings(
30-
listings.map((l) => l.id),
31-
destinationTabId,
32-
() => {
33-
toast.success(t(translations.duplicateStarted, { n: listings.length }));
34-
setSubmitting(false);
35-
onClose();
36-
},
37-
() => {
38-
toast.error(t(translations.duplicateFailed));
39-
setSubmitting(false);
40-
},
41-
);
37+
try {
38+
const url = await duplicateListings(
39+
listings.map((l) => l.id),
40+
destinationTabId,
41+
);
42+
setJobUrl(url);
43+
} catch {
44+
// The request never reached the queue, so there is no job to poll. Releasing `submitting`
45+
// here is what keeps the prompt usable for a retry instead of disabled for good.
46+
toast.error(t(translations.duplicateFailed, { n }));
47+
setSubmitting(false);
48+
}
4249
};
4350

51+
// The poller lives with the component that started the job, so unmounting or navigating away
52+
// tears it down. `pollingRef` stops a slow response from stacking up overlapping requests.
53+
useEffect(() => {
54+
if (!jobUrl) return undefined;
55+
56+
const finish = (succeeded: boolean): void => {
57+
setJobUrl(null);
58+
setSubmitting(false);
59+
if (succeeded) {
60+
toast.success(t(translations.duplicateStarted, { n }));
61+
onClose();
62+
} else {
63+
toast.error(t(translations.duplicateFailed, { n }));
64+
}
65+
};
66+
67+
const interval = setInterval(() => {
68+
if (pollingRef.current) return;
69+
pollingRef.current = true;
70+
pollJobRequest(jobUrl)
71+
.then((response) => {
72+
if (response.status === JobStatus.completed) finish(true);
73+
else if (response.status === JobStatus.errored) finish(false);
74+
})
75+
.catch(() => finish(false))
76+
.finally(() => {
77+
pollingRef.current = false;
78+
});
79+
}, JOB_POLL_INTERVAL_MS);
80+
81+
return () => clearInterval(interval);
82+
}, [jobUrl, n]);
83+
4484
return (
4585
<Prompt
4686
disabled={submitting}
4787
onClickPrimary={confirm}
4888
onClose={onClose}
4989
open={open}
5090
primaryLabel={t(translations.duplicateConfirm)}
51-
title={t(translations.duplicateTitle)}
91+
title={t(translations.duplicateTitle, { n })}
5292
>
53-
<PromptText>
54-
{t(translations.duplicateBody, { n: listings.length })}
55-
</PromptText>
93+
<PromptText>{t(translations.duplicateBody, { n })}</PromptText>
5694
</Prompt>
5795
);
5896
};

client/app/bundles/course/marketplace/components/__test__/DuplicationConfirmation.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import DuplicateConfirmation from '../DuplicateConfirmation';
88
const mock = createMockAdapter(CourseAPI.marketplace.client);
99
beforeEach(() => mock.reset());
1010

11-
const listings = [{ id: 1, title: 'Recursion Drills' }] as never;
11+
const listings = [{ id: 1, title: 'Recursion Drills' }];
1212
const url = `/courses/${global.courseId}/marketplace/listings/duplicate`;
1313

1414
it('posts a duplication request with the destination tab on confirm', async () => {
@@ -45,3 +45,21 @@ it('omits destination_tab_id when entered without a tab (sidebar entry)', async
4545
expect(body).toMatchObject({ listing_ids: [1] });
4646
expect(body).not.toHaveProperty('destination_tab_id'); // backend then defaults to the first tab
4747
});
48+
49+
// A request that never reaches the queue leaves no job to poll, so nothing else can re-enable the
50+
// prompt: the confirm button has to come back by itself for the user to be able to retry.
51+
it('re-enables the prompt when the request itself fails', async () => {
52+
mock.onPost(url).reply(500);
53+
const page = render(
54+
<DuplicateConfirmation
55+
destinationTabId={42}
56+
listings={listings}
57+
onClose={jest.fn()}
58+
open
59+
/>,
60+
);
61+
const button = await page.findByRole('button', { name: /Duplicate/ });
62+
fireEvent.click(button);
63+
await waitFor(() => expect(mock.history.post).toHaveLength(1));
64+
await waitFor(() => expect(button).not.toBeDisabled());
65+
});
Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import CourseAPI from 'api/course';
2-
import pollJob from 'lib/helpers/jobHelpers';
32

43
import { MarketplaceListing } from './types';
54

@@ -8,20 +7,16 @@ export const fetchListings = async (): Promise<MarketplaceListing[]> => {
87
return response.data.listings as MarketplaceListing[];
98
};
109

10+
// Returns the URL of the duplication job to poll. Polling is deliberately left to the caller: it
11+
// has to be started and torn down by the component that owns the flow, so that navigating away
12+
// cannot leave an orphaned poller behind.
1113
export const duplicateListings = async (
1214
listingIds: number[],
1315
destinationTabId: number | null,
14-
onSuccess: (redirectUrl?: string) => void,
15-
onFailure: () => void,
16-
): Promise<void> => {
16+
): Promise<string> => {
1717
const response = await CourseAPI.marketplace.duplicate(
1818
listingIds,
1919
destinationTabId,
2020
);
21-
pollJob(
22-
response.data.jobUrl,
23-
(data) => onSuccess(data.redirectUrl),
24-
onFailure,
25-
2000,
26-
);
21+
return response.data.jobUrl;
2722
};

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export default defineMessages({
9292
duplicateBody: {
9393
id: 'course.marketplace.duplicateBody',
9494
defaultMessage:
95-
'{n, plural, one {This assessment will be copied to your course.} other {These assessments will be copied to your course.}}',
95+
'{n, plural, one {This assessment will be copied to your course.} other {These # assessments will be copied to your course.}}',
9696
},
9797
duplicateConfirm: {
9898
id: 'course.marketplace.duplicateConfirm',

0 commit comments

Comments
 (0)