Skip to content

Commit 9c0b545

Browse files
authored
feat: Connect bulk migration backend with frontend (#2493)
- Connects the `Confirm` button with the bulk migrate backend - Updates the library page to get the migration task status and refresh the component on success.
1 parent cd36407 commit 9c0b545

File tree

9 files changed

+391
-17
lines changed

9 files changed

+391
-17
lines changed

src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@ import { getContentLibraryV2CreateApiUrl } from '@src/library-authoring/create-l
1414
import { getStudioHomeApiUrl } from '@src/studio-home/data/api';
1515

1616
import { LegacyLibMigrationPage } from './LegacyLibMigrationPage';
17+
import { bulkMigrateLegacyLibrariesUrl } from './data/api';
1718

1819
const path = '/libraries-v1/migrate/*';
1920
let axiosMock: MockAdapter;
21+
let mockShowToast;
2022

2123
mockGetStudioHomeLibraries.applyMock();
2224
mockGetContentLibraryV2List.applyMock();
@@ -41,7 +43,9 @@ const renderPage = () => (
4143

4244
describe('<LegacyLibMigrationPage />', () => {
4345
beforeEach(() => {
44-
axiosMock = initializeMocks().axiosMock;
46+
const mocks = initializeMocks();
47+
axiosMock = mocks.axiosMock;
48+
mockShowToast = mocks.mockShowToast;
4549
});
4650

4751
it('should render legacy library migration page', async () => {
@@ -292,6 +296,7 @@ describe('<LegacyLibMigrationPage />', () => {
292296

293297
it('should confirm migration', async () => {
294298
const user = userEvent.setup();
299+
axiosMock.onPost(bulkMigrateLegacyLibrariesUrl()).reply(200);
295300
renderPage();
296301
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
297302
expect(await screen.findByText('MBA')).toBeInTheDocument();
@@ -334,6 +339,66 @@ describe('<LegacyLibMigrationPage />', () => {
334339
const confirmButton = screen.getByRole('button', { name: /confirm/i });
335340
confirmButton.click();
336341

337-
// TODO: expect call migrate API
342+
await waitFor(() => {
343+
expect(axiosMock.history.post.length).toBe(1);
344+
});
345+
expect(axiosMock.history.post[0].data).toBe(
346+
'{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}',
347+
);
348+
expect(mockShowToast).toHaveBeenCalledWith('3 legacy libraries are being migrated.');
349+
});
350+
351+
it('should show error when confirm migration', async () => {
352+
const user = userEvent.setup();
353+
axiosMock.onPost(bulkMigrateLegacyLibrariesUrl()).reply(400);
354+
renderPage();
355+
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
356+
expect(await screen.findByText('MBA')).toBeInTheDocument();
357+
358+
// The filter is 'unmigrated' by default.
359+
// Clear the filter to select all libraries
360+
const filterButton = screen.getByRole('button', { name: /unmigrated/i });
361+
await user.click(filterButton);
362+
const clearButton = await screen.findByRole('button', { name: /clear filter/i });
363+
await user.click(clearButton);
364+
365+
const legacyLibrary1 = screen.getByRole('checkbox', { name: 'MBA' });
366+
const legacyLibrary2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i });
367+
const legacyLibrary3 = screen.getByRole('checkbox', { name: 'MBA 1' });
368+
369+
legacyLibrary1.click();
370+
legacyLibrary2.click();
371+
legacyLibrary3.click();
372+
373+
const nextButton = screen.getByRole('button', { name: /next/i });
374+
nextButton.click();
375+
376+
// Should show alert of SelectDestinationView
377+
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
378+
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
379+
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
380+
radioButton.click();
381+
382+
nextButton.click();
383+
384+
// Should show alert of ConfirmationView
385+
expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
386+
expect(screen.getByText('MBA')).toBeInTheDocument();
387+
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
388+
expect(screen.getByText('MBA 1')).toBeInTheDocument();
389+
expect(screen.getByText(
390+
/Previously migrated library. Any problem bank links were already moved will be migrated to/i,
391+
)).toBeInTheDocument();
392+
393+
const confirmButton = screen.getByRole('button', { name: /confirm/i });
394+
confirmButton.click();
395+
396+
await waitFor(() => {
397+
expect(axiosMock.history.post.length).toBe(1);
398+
});
399+
expect(axiosMock.history.post[0].data).toBe(
400+
'{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}',
401+
);
402+
expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration failed.');
338403
});
339404
});

src/legacy-libraries-migration/LegacyLibMigrationPage.tsx

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { useCallback, useMemo, useState } from 'react';
1+
import {
2+
useCallback,
3+
useContext,
4+
useMemo,
5+
useState,
6+
} from 'react';
27
import { Helmet } from 'react-helmet';
38
import { useNavigate } from 'react-router-dom';
49

@@ -16,11 +21,13 @@ import Header from '@src/header';
1621
import SubHeader from '@src/generic/sub-header/SubHeader';
1722
import type { ContentLibrary } from '@src/library-authoring/data/api';
1823
import type { LibraryV1Data } from '@src/studio-home/data/api';
24+
import { ToastContext } from '@src/generic/toast-context';
1925
import { Filter, LibrariesList } from '@src/studio-home/tabs-section/libraries-tab';
2026

2127
import messages from './messages';
2228
import { SelectDestinationView } from './SelectDestinationView';
2329
import { ConfirmationView } from './ConfirmationView';
30+
import { useUpdateContainerCollections } from './data/apiHooks';
2431

2532
export type MigrationStep = 'select-libraries' | 'select-destination' | 'confirmation-view';
2633

@@ -66,11 +73,33 @@ const ExitModal = ({
6673

6774
export const LegacyLibMigrationPage = () => {
6875
const intl = useIntl();
76+
const navigate = useNavigate();
77+
const { showToast } = useContext(ToastContext);
6978
const [currentStep, setCurrentStep] = useState<MigrationStep>('select-libraries');
7079
const [isExitModalOpen, openExitModal, closeExitModal] = useToggle(false);
7180
const [legacyLibraries, setLegacyLibraries] = useState<LibraryV1Data[]>([]);
7281
const [destinationLibrary, setDestination] = useState<ContentLibrary>();
7382
const [confirmationButtonState, setConfirmationButtonState] = useState('default');
83+
const migrate = useUpdateContainerCollections();
84+
85+
const handleMigrate = useCallback(async () => {
86+
if (destinationLibrary) {
87+
try {
88+
const migrationTask = await migrate.mutateAsync({
89+
sources: legacyLibraries.map((lib) => lib.libraryKey),
90+
target: destinationLibrary.id,
91+
createCollections: true,
92+
repeatHandlingStrategy: 'fork',
93+
});
94+
showToast(intl.formatMessage(messages.migrationInProgress, {
95+
count: legacyLibraries.length,
96+
}));
97+
navigate(`/library/${destinationLibrary.id}?migration_task=${migrationTask.uuid}`);
98+
} catch (error) {
99+
showToast(intl.formatMessage(messages.migrationFailed));
100+
}
101+
}
102+
}, [migrate, legacyLibraries, destinationLibrary]);
74103

75104
const handleNext = useCallback(() => {
76105
switch (currentStep) {
@@ -82,13 +111,13 @@ export const LegacyLibMigrationPage = () => {
82111
break;
83112
case 'confirmation-view':
84113
setConfirmationButtonState('pending');
85-
// TODO Call migration API
114+
handleMigrate();
86115
break;
87116
default:
88117
/* istanbul ignore next */
89118
break;
90119
}
91-
}, [currentStep, setCurrentStep]);
120+
}, [currentStep, setCurrentStep, handleMigrate]);
92121

93122
const handleBack = useCallback(() => {
94123
switch (currentStep) {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import * as api from './api';
2+
3+
export async function mockGetMigrationStatus(migrationId: string): Promise<api.MigrateTaskStatusData> {
4+
switch (migrationId) {
5+
case mockGetMigrationStatus.migrationId:
6+
return mockGetMigrationStatus.migrationStatusData;
7+
case mockGetMigrationStatus.migrationIdFailed:
8+
return mockGetMigrationStatus.migrationStatusFailedData;
9+
default:
10+
/* istanbul ignore next */
11+
throw new Error(`mockGetMigrationStatus: unknown migration ID "${migrationId}"`);
12+
}
13+
}
14+
15+
mockGetMigrationStatus.migrationId = '1';
16+
mockGetMigrationStatus.migrationStatusData = {
17+
uuid: mockGetMigrationStatus.migrationId,
18+
state: 'Succeeded',
19+
stateText: 'Succeeded',
20+
completedSteps: 9,
21+
totalSteps: 9,
22+
attempts: 1,
23+
created: '',
24+
modified: '',
25+
artifacts: [],
26+
parameters: [
27+
{
28+
source: 'legacy-lib-1',
29+
target: 'lib',
30+
compositionLevel: 'component',
31+
repeatHandlingStrategy: 'update',
32+
preserveUrlSlugs: false,
33+
targetCollectionSlug: 'coll-1',
34+
forwardSourceToTarget: true,
35+
},
36+
],
37+
} as api.MigrateTaskStatusData;
38+
mockGetMigrationStatus.migrationIdFailed = '2';
39+
mockGetMigrationStatus.migrationStatusFailedData = {
40+
uuid: mockGetMigrationStatus.migrationId,
41+
state: 'Failed',
42+
stateText: 'Failed',
43+
completedSteps: 9,
44+
totalSteps: 9,
45+
attempts: 1,
46+
created: '',
47+
modified: '',
48+
artifacts: [],
49+
parameters: [
50+
{
51+
source: 'legacy-lib-1',
52+
target: 'lib',
53+
compositionLevel: 'component',
54+
repeatHandlingStrategy: 'update',
55+
preserveUrlSlugs: false,
56+
targetCollectionSlug: 'coll-1',
57+
forwardSourceToTarget: true,
58+
},
59+
],
60+
} as api.MigrateTaskStatusData;
61+
mockGetMigrationStatus.applyMock = () => jest.spyOn(api, 'getMigrationStatus').mockImplementation(mockGetMigrationStatus);
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { initializeMocks } from '../../testUtils';
2+
import * as api from './api';
3+
4+
let axiosMock;
5+
6+
describe('legacy libraries migration API', () => {
7+
beforeEach(() => {
8+
({ axiosMock } = initializeMocks());
9+
});
10+
11+
describe('getMigrationStatus', () => {
12+
it('should get migration status', async () => {
13+
const migrationId = '1';
14+
const url = api.getMigrationStatusUrl(migrationId);
15+
axiosMock.onGet(url).reply(200);
16+
await api.getMigrationStatus(migrationId);
17+
18+
expect(axiosMock.history.get[0].url).toEqual(url);
19+
});
20+
});
21+
22+
describe('bulkMigrateLegacyLibraries', () => {
23+
it('should call bulk migrate legacy libraries', async () => {
24+
const url = api.bulkMigrateLegacyLibrariesUrl();
25+
axiosMock.onPost(url).reply(200);
26+
await api.bulkMigrateLegacyLibraries({
27+
sources: [],
28+
target: '1',
29+
});
30+
31+
expect(axiosMock.history.post[0].url).toEqual(url);
32+
});
33+
});
34+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform';
2+
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
3+
4+
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
5+
6+
/**
7+
* Get the URL to check the migration task status
8+
*/
9+
export const getMigrationStatusUrl = (migrationId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/migrations/${migrationId}/`;
10+
11+
/**
12+
* Get the URL for bulk migrate legacy libraries
13+
*/
14+
export const bulkMigrateLegacyLibrariesUrl = () => `${getApiBaseUrl()}/api/modulestore_migrator/v1/bulk_migration/`;
15+
16+
export interface MigrateArtifacts {
17+
source: string;
18+
target: string;
19+
compositionLevel: string;
20+
repeatHandlingStrategy: 'update' | 'skip' | 'fork';
21+
preserveUrlSlugs: boolean;
22+
targetCollectionSlug: string;
23+
forwardSourceToTarget: boolean;
24+
}
25+
26+
export interface MigrateTaskStatusData {
27+
state: string;
28+
stateText: string;
29+
completedSteps: number;
30+
totalSteps: number;
31+
attempts: number;
32+
created: string;
33+
modified: string;
34+
artifacts: string[];
35+
uuid: string;
36+
parameters: MigrateArtifacts[];
37+
}
38+
39+
export interface BulkMigrateRequestData {
40+
sources: string[];
41+
target: string;
42+
targetCollectionSlugList?: string[];
43+
createCollections?: boolean;
44+
compositionLevel?: string;
45+
repeatHandlingStrategy?: string;
46+
preserveUrlSlugs?: boolean;
47+
forwardSourceToTarget?: boolean;
48+
}
49+
50+
/**
51+
* Get migration task status
52+
*/
53+
export async function getMigrationStatus(
54+
migrationId: string,
55+
): Promise<MigrateTaskStatusData> {
56+
const client = getAuthenticatedHttpClient();
57+
const { data } = await client.get(getMigrationStatusUrl(migrationId));
58+
return camelCaseObject(data);
59+
}
60+
61+
/**
62+
* Bulk migrate legacy libraries
63+
*/
64+
export async function bulkMigrateLegacyLibraries(
65+
requestData: BulkMigrateRequestData,
66+
): Promise<MigrateTaskStatusData> {
67+
const client = getAuthenticatedHttpClient();
68+
const { data } = await client.post(bulkMigrateLegacyLibrariesUrl(), snakeCaseObject(requestData));
69+
return camelCaseObject(data);
70+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
2+
3+
import * as api from './api';
4+
5+
export const legacyMigrationQueryKeys = {
6+
all: ['contentLibrary'],
7+
/**
8+
* Base key for data specific to a migration task
9+
*/
10+
migrationTask: (migrationId?: string | null) => [...legacyMigrationQueryKeys.all, migrationId],
11+
};
12+
13+
/**
14+
* Use this mutation to update container collections
15+
*/
16+
export const useUpdateContainerCollections = () => (
17+
useMutation({
18+
mutationFn: async (requestData: api.BulkMigrateRequestData) => api.bulkMigrateLegacyLibraries(requestData),
19+
})
20+
);
21+
22+
/**
23+
* Get the migration status
24+
*/
25+
export const useMigrationStatus = (migrationId: string | null) => (
26+
useQuery({
27+
queryKey: legacyMigrationQueryKeys.migrationTask(migrationId),
28+
queryFn: migrationId ? () => api.getMigrationStatus(migrationId!) : skipToken,
29+
refetchInterval: 1000, // Refresh every second
30+
})
31+
);

src/legacy-libraries-migration/messages.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,21 @@ const messages = defineMessages({
8282
+ ' moved will be migrated to <b>{libraryName}</b>',
8383
description: 'Alert text when the legacy library is already migrated.',
8484
},
85+
migrationInProgress: {
86+
id: 'legacy-libraries-migration.confirmation-step.toast.migration-in-progress',
87+
defaultMessage: '{count, plural, one {{count} legacy library is} other {{count} legacy libraries are}} being migrated.',
88+
description: 'Toast message that indicates the legacy libraries are being migrated',
89+
},
90+
migrationFailed: {
91+
id: 'legacy-libraries-migration.confirmation-step.toast.migration-failed',
92+
defaultMessage: 'Legacy libraries migration failed.',
93+
description: 'Toast message that indicates the migration of legacy libraries is failed',
94+
},
95+
migrationSuccess: {
96+
id: 'legacy-libraries-migration.confirmation-step.toast.migration-success',
97+
defaultMessage: 'The migration of legacy libraries has been completed successfully.',
98+
description: 'Toast message that indicates the migration of legacy libraries is finished',
99+
},
85100
});
86101

87102
export default messages;

0 commit comments

Comments
 (0)