diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx
index 5a9a4a149..db1d24a27 100644
--- a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx
+++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx
@@ -14,9 +14,11 @@ import { getContentLibraryV2CreateApiUrl } from '@src/library-authoring/create-l
import { getStudioHomeApiUrl } from '@src/studio-home/data/api';
import { LegacyLibMigrationPage } from './LegacyLibMigrationPage';
+import { bulkMigrateLegacyLibrariesUrl } from './data/api';
const path = '/libraries-v1/migrate/*';
let axiosMock: MockAdapter;
+let mockShowToast;
mockGetStudioHomeLibraries.applyMock();
mockGetContentLibraryV2List.applyMock();
@@ -41,7 +43,9 @@ const renderPage = () => (
describe('', () => {
beforeEach(() => {
- axiosMock = initializeMocks().axiosMock;
+ const mocks = initializeMocks();
+ axiosMock = mocks.axiosMock;
+ mockShowToast = mocks.mockShowToast;
});
it('should render legacy library migration page', async () => {
@@ -292,6 +296,7 @@ describe('', () => {
it('should confirm migration', async () => {
const user = userEvent.setup();
+ axiosMock.onPost(bulkMigrateLegacyLibrariesUrl()).reply(200);
renderPage();
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
expect(await screen.findByText('MBA')).toBeInTheDocument();
@@ -334,6 +339,66 @@ describe('', () => {
const confirmButton = screen.getByRole('button', { name: /confirm/i });
confirmButton.click();
- // TODO: expect call migrate API
+ await waitFor(() => {
+ expect(axiosMock.history.post.length).toBe(1);
+ });
+ expect(axiosMock.history.post[0].data).toBe(
+ '{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}',
+ );
+ expect(mockShowToast).toHaveBeenCalledWith('3 legacy libraries are being migrated.');
+ });
+
+ it('should show error when confirm migration', async () => {
+ const user = userEvent.setup();
+ axiosMock.onPost(bulkMigrateLegacyLibrariesUrl()).reply(400);
+ renderPage();
+ expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
+ expect(await screen.findByText('MBA')).toBeInTheDocument();
+
+ // The filter is 'unmigrated' by default.
+ // Clear the filter to select all libraries
+ const filterButton = screen.getByRole('button', { name: /unmigrated/i });
+ await user.click(filterButton);
+ const clearButton = await screen.findByRole('button', { name: /clear filter/i });
+ await user.click(clearButton);
+
+ const legacyLibrary1 = screen.getByRole('checkbox', { name: 'MBA' });
+ const legacyLibrary2 = screen.getByRole('checkbox', { name: /legacy library 1 imported library/i });
+ const legacyLibrary3 = screen.getByRole('checkbox', { name: 'MBA 1' });
+
+ legacyLibrary1.click();
+ legacyLibrary2.click();
+ legacyLibrary3.click();
+
+ const nextButton = screen.getByRole('button', { name: /next/i });
+ nextButton.click();
+
+ // Should show alert of SelectDestinationView
+ expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
+ expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
+ const radioButton = screen.getByRole('radio', { name: /test library 1/i });
+ radioButton.click();
+
+ nextButton.click();
+
+ // Should show alert of ConfirmationView
+ expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
+ expect(screen.getByText('MBA')).toBeInTheDocument();
+ expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
+ expect(screen.getByText('MBA 1')).toBeInTheDocument();
+ expect(screen.getByText(
+ /Previously migrated library. Any problem bank links were already moved will be migrated to/i,
+ )).toBeInTheDocument();
+
+ const confirmButton = screen.getByRole('button', { name: /confirm/i });
+ confirmButton.click();
+
+ await waitFor(() => {
+ expect(axiosMock.history.post.length).toBe(1);
+ });
+ expect(axiosMock.history.post[0].data).toBe(
+ '{"sources":["library-v1:MBA+123","library-v1:UNIX+LG1","library-v1:MBA+1234"],"target":"lib:SampleTaxonomyOrg1:TL1","create_collections":true,"repeat_handling_strategy":"fork"}',
+ );
+ expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration failed.');
});
});
diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx
index 0d8a7a7a9..168c8673b 100644
--- a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx
+++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx
@@ -1,4 +1,9 @@
-import { useCallback, useMemo, useState } from 'react';
+import {
+ useCallback,
+ useContext,
+ useMemo,
+ useState,
+} from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
@@ -16,11 +21,13 @@ import Header from '@src/header';
import SubHeader from '@src/generic/sub-header/SubHeader';
import type { ContentLibrary } from '@src/library-authoring/data/api';
import type { LibraryV1Data } from '@src/studio-home/data/api';
+import { ToastContext } from '@src/generic/toast-context';
import { Filter, LibrariesList } from '@src/studio-home/tabs-section/libraries-tab';
import messages from './messages';
import { SelectDestinationView } from './SelectDestinationView';
import { ConfirmationView } from './ConfirmationView';
+import { useUpdateContainerCollections } from './data/apiHooks';
export type MigrationStep = 'select-libraries' | 'select-destination' | 'confirmation-view';
@@ -66,11 +73,33 @@ const ExitModal = ({
export const LegacyLibMigrationPage = () => {
const intl = useIntl();
+ const navigate = useNavigate();
+ const { showToast } = useContext(ToastContext);
const [currentStep, setCurrentStep] = useState('select-libraries');
const [isExitModalOpen, openExitModal, closeExitModal] = useToggle(false);
const [legacyLibraries, setLegacyLibraries] = useState([]);
const [destinationLibrary, setDestination] = useState();
const [confirmationButtonState, setConfirmationButtonState] = useState('default');
+ const migrate = useUpdateContainerCollections();
+
+ const handleMigrate = useCallback(async () => {
+ if (destinationLibrary) {
+ try {
+ const migrationTask = await migrate.mutateAsync({
+ sources: legacyLibraries.map((lib) => lib.libraryKey),
+ target: destinationLibrary.id,
+ createCollections: true,
+ repeatHandlingStrategy: 'fork',
+ });
+ showToast(intl.formatMessage(messages.migrationInProgress, {
+ count: legacyLibraries.length,
+ }));
+ navigate(`/library/${destinationLibrary.id}?migration_task=${migrationTask.uuid}`);
+ } catch (error) {
+ showToast(intl.formatMessage(messages.migrationFailed));
+ }
+ }
+ }, [migrate, legacyLibraries, destinationLibrary]);
const handleNext = useCallback(() => {
switch (currentStep) {
@@ -82,13 +111,13 @@ export const LegacyLibMigrationPage = () => {
break;
case 'confirmation-view':
setConfirmationButtonState('pending');
- // TODO Call migration API
+ handleMigrate();
break;
default:
/* istanbul ignore next */
break;
}
- }, [currentStep, setCurrentStep]);
+ }, [currentStep, setCurrentStep, handleMigrate]);
const handleBack = useCallback(() => {
switch (currentStep) {
diff --git a/src/legacy-libraries-migration/data/api.mocks.ts b/src/legacy-libraries-migration/data/api.mocks.ts
new file mode 100644
index 000000000..a4d2d583f
--- /dev/null
+++ b/src/legacy-libraries-migration/data/api.mocks.ts
@@ -0,0 +1,61 @@
+import * as api from './api';
+
+export async function mockGetMigrationStatus(migrationId: string): Promise {
+ switch (migrationId) {
+ case mockGetMigrationStatus.migrationId:
+ return mockGetMigrationStatus.migrationStatusData;
+ case mockGetMigrationStatus.migrationIdFailed:
+ return mockGetMigrationStatus.migrationStatusFailedData;
+ default:
+ /* istanbul ignore next */
+ throw new Error(`mockGetMigrationStatus: unknown migration ID "${migrationId}"`);
+ }
+}
+
+mockGetMigrationStatus.migrationId = '1';
+mockGetMigrationStatus.migrationStatusData = {
+ uuid: mockGetMigrationStatus.migrationId,
+ state: 'Succeeded',
+ stateText: 'Succeeded',
+ completedSteps: 9,
+ totalSteps: 9,
+ attempts: 1,
+ created: '',
+ modified: '',
+ artifacts: [],
+ parameters: [
+ {
+ source: 'legacy-lib-1',
+ target: 'lib',
+ compositionLevel: 'component',
+ repeatHandlingStrategy: 'update',
+ preserveUrlSlugs: false,
+ targetCollectionSlug: 'coll-1',
+ forwardSourceToTarget: true,
+ },
+ ],
+} as api.MigrateTaskStatusData;
+mockGetMigrationStatus.migrationIdFailed = '2';
+mockGetMigrationStatus.migrationStatusFailedData = {
+ uuid: mockGetMigrationStatus.migrationId,
+ state: 'Failed',
+ stateText: 'Failed',
+ completedSteps: 9,
+ totalSteps: 9,
+ attempts: 1,
+ created: '',
+ modified: '',
+ artifacts: [],
+ parameters: [
+ {
+ source: 'legacy-lib-1',
+ target: 'lib',
+ compositionLevel: 'component',
+ repeatHandlingStrategy: 'update',
+ preserveUrlSlugs: false,
+ targetCollectionSlug: 'coll-1',
+ forwardSourceToTarget: true,
+ },
+ ],
+} as api.MigrateTaskStatusData;
+mockGetMigrationStatus.applyMock = () => jest.spyOn(api, 'getMigrationStatus').mockImplementation(mockGetMigrationStatus);
diff --git a/src/legacy-libraries-migration/data/api.test.ts b/src/legacy-libraries-migration/data/api.test.ts
new file mode 100644
index 000000000..21afef6d4
--- /dev/null
+++ b/src/legacy-libraries-migration/data/api.test.ts
@@ -0,0 +1,34 @@
+import { initializeMocks } from '../../testUtils';
+import * as api from './api';
+
+let axiosMock;
+
+describe('legacy libraries migration API', () => {
+ beforeEach(() => {
+ ({ axiosMock } = initializeMocks());
+ });
+
+ describe('getMigrationStatus', () => {
+ it('should get migration status', async () => {
+ const migrationId = '1';
+ const url = api.getMigrationStatusUrl(migrationId);
+ axiosMock.onGet(url).reply(200);
+ await api.getMigrationStatus(migrationId);
+
+ expect(axiosMock.history.get[0].url).toEqual(url);
+ });
+ });
+
+ describe('bulkMigrateLegacyLibraries', () => {
+ it('should call bulk migrate legacy libraries', async () => {
+ const url = api.bulkMigrateLegacyLibrariesUrl();
+ axiosMock.onPost(url).reply(200);
+ await api.bulkMigrateLegacyLibraries({
+ sources: [],
+ target: '1',
+ });
+
+ expect(axiosMock.history.post[0].url).toEqual(url);
+ });
+ });
+});
diff --git a/src/legacy-libraries-migration/data/api.ts b/src/legacy-libraries-migration/data/api.ts
new file mode 100644
index 000000000..fd711ec79
--- /dev/null
+++ b/src/legacy-libraries-migration/data/api.ts
@@ -0,0 +1,70 @@
+import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+
+const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
+
+/**
+ * Get the URL to check the migration task status
+ */
+export const getMigrationStatusUrl = (migrationId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/migrations/${migrationId}/`;
+
+/**
+ * Get the URL for bulk migrate legacy libraries
+ */
+export const bulkMigrateLegacyLibrariesUrl = () => `${getApiBaseUrl()}/api/modulestore_migrator/v1/bulk_migration/`;
+
+export interface MigrateArtifacts {
+ source: string;
+ target: string;
+ compositionLevel: string;
+ repeatHandlingStrategy: 'update' | 'skip' | 'fork';
+ preserveUrlSlugs: boolean;
+ targetCollectionSlug: string;
+ forwardSourceToTarget: boolean;
+}
+
+export interface MigrateTaskStatusData {
+ state: string;
+ stateText: string;
+ completedSteps: number;
+ totalSteps: number;
+ attempts: number;
+ created: string;
+ modified: string;
+ artifacts: string[];
+ uuid: string;
+ parameters: MigrateArtifacts[];
+}
+
+export interface BulkMigrateRequestData {
+ sources: string[];
+ target: string;
+ targetCollectionSlugList?: string[];
+ createCollections?: boolean;
+ compositionLevel?: string;
+ repeatHandlingStrategy?: string;
+ preserveUrlSlugs?: boolean;
+ forwardSourceToTarget?: boolean;
+}
+
+/**
+ * Get migration task status
+ */
+export async function getMigrationStatus(
+ migrationId: string,
+): Promise {
+ const client = getAuthenticatedHttpClient();
+ const { data } = await client.get(getMigrationStatusUrl(migrationId));
+ return camelCaseObject(data);
+}
+
+/**
+ * Bulk migrate legacy libraries
+ */
+export async function bulkMigrateLegacyLibraries(
+ requestData: BulkMigrateRequestData,
+): Promise {
+ const client = getAuthenticatedHttpClient();
+ const { data } = await client.post(bulkMigrateLegacyLibrariesUrl(), snakeCaseObject(requestData));
+ return camelCaseObject(data);
+}
diff --git a/src/legacy-libraries-migration/data/apiHooks.ts b/src/legacy-libraries-migration/data/apiHooks.ts
new file mode 100644
index 000000000..e8b8ffa6e
--- /dev/null
+++ b/src/legacy-libraries-migration/data/apiHooks.ts
@@ -0,0 +1,31 @@
+import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
+
+import * as api from './api';
+
+export const legacyMigrationQueryKeys = {
+ all: ['contentLibrary'],
+ /**
+ * Base key for data specific to a migration task
+ */
+ migrationTask: (migrationId?: string | null) => [...legacyMigrationQueryKeys.all, migrationId],
+};
+
+/**
+ * Use this mutation to update container collections
+ */
+export const useUpdateContainerCollections = () => (
+ useMutation({
+ mutationFn: async (requestData: api.BulkMigrateRequestData) => api.bulkMigrateLegacyLibraries(requestData),
+ })
+);
+
+/**
+ * Get the migration status
+ */
+export const useMigrationStatus = (migrationId: string | null) => (
+ useQuery({
+ queryKey: legacyMigrationQueryKeys.migrationTask(migrationId),
+ queryFn: migrationId ? () => api.getMigrationStatus(migrationId!) : skipToken,
+ refetchInterval: 1000, // Refresh every second
+ })
+);
diff --git a/src/legacy-libraries-migration/messages.ts b/src/legacy-libraries-migration/messages.ts
index b0d5b4e8b..2da5b485d 100644
--- a/src/legacy-libraries-migration/messages.ts
+++ b/src/legacy-libraries-migration/messages.ts
@@ -82,6 +82,21 @@ const messages = defineMessages({
+ ' moved will be migrated to {libraryName}',
description: 'Alert text when the legacy library is already migrated.',
},
+ migrationInProgress: {
+ id: 'legacy-libraries-migration.confirmation-step.toast.migration-in-progress',
+ defaultMessage: '{count, plural, one {{count} legacy library is} other {{count} legacy libraries are}} being migrated.',
+ description: 'Toast message that indicates the legacy libraries are being migrated',
+ },
+ migrationFailed: {
+ id: 'legacy-libraries-migration.confirmation-step.toast.migration-failed',
+ defaultMessage: 'Legacy libraries migration failed.',
+ description: 'Toast message that indicates the migration of legacy libraries is failed',
+ },
+ migrationSuccess: {
+ id: 'legacy-libraries-migration.confirmation-step.toast.migration-success',
+ defaultMessage: 'The migration of legacy libraries has been completed successfully.',
+ description: 'Toast message that indicates the migration of legacy libraries is finished',
+ },
});
export default messages;
diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index d84ff75d7..d2cc65d58 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -10,8 +10,12 @@ import {
within,
} from '@src/testUtils';
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
+import { mockGetMigrationStatus } from '@src/legacy-libraries-migration/data/api.mocks';
+import mockEmptyResult from '@src/search-modal/__mocks__/empty-search-result.json';
+import { mockContentSearchConfig } from '@src/search-manager/data/api.mock';
+import { getStudioHomeApiUrl } from '@src/studio-home/data/api';
+
import mockResult from './__mocks__/library-search.json';
-import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json';
import {
mockContentLibrary,
mockGetCollectionMetadata,
@@ -19,8 +23,6 @@ import {
mockGetLibraryTeam,
mockXBlockFields,
} from './data/api.mocks';
-import { mockContentSearchConfig } from '../search-manager/data/api.mock';
-import { getStudioHomeApiUrl } from '../studio-home/data/api';
import { LibraryLayout } from '.';
import { getLibraryCollectionsApiUrl, getLibraryContainersApiUrl } from './data/api';
@@ -33,6 +35,7 @@ mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetLibraryTeam.applyMock();
mockXBlockFields.applyMock();
+mockGetMigrationStatus.applyMock();
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
@@ -1062,4 +1065,30 @@ describe('', () => {
'This page cannot be shown: Libraries v2 are disabled.',
);
});
+
+ it('Should show success in migration legacy libraries', async () => {
+ render(, {
+ path,
+ routerProps: {
+ initialEntries: [
+ `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationId}`,
+ ],
+ },
+ });
+
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('The migration of legacy libraries has been completed successfully.'));
+ });
+
+ it('Should show fail in migration legacy libraries', async () => {
+ render(, {
+ path,
+ routerProps: {
+ initialEntries: [
+ `/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdFailed}`,
+ ],
+ },
+ });
+
+ await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration failed.'));
+ });
});
diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx
index 4769bdb35..90ee0040e 100644
--- a/src/library-authoring/LibraryAuthoringPage.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.tsx
@@ -1,6 +1,7 @@
import {
type ReactNode,
useCallback,
+ useContext,
useEffect,
useState,
} from 'react';
@@ -20,13 +21,15 @@ import {
Tabs,
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
-import { Link, useLocation } from 'react-router-dom';
+import { Link, useLocation, useNavigate } from 'react-router-dom';
+import { useQueryClient } from '@tanstack/react-query';
-import Loading from '../generic/Loading';
-import SubHeader from '../generic/sub-header/SubHeader';
-import Header from '../header';
-import NotFoundAlert from '../generic/NotFoundAlert';
-import { useStudioHome } from '../studio-home/hooks';
+import { useMigrationStatus } from '@src/legacy-libraries-migration/data/apiHooks';
+import Loading from '@src/generic/Loading';
+import SubHeader from '@src/generic/sub-header/SubHeader';
+import Header from '@src/header';
+import NotFoundAlert from '@src/generic/NotFoundAlert';
+import { useStudioHome } from '@src/studio-home/hooks';
import {
ClearFiltersButton,
FilterByBlockType,
@@ -35,16 +38,19 @@ import {
SearchKeywordsField,
SearchSortWidget,
TypesFilterData,
-} from '../search-manager';
+} from '@src/search-manager';
+import { ToastContext } from '@src/generic/toast-context';
+import migrationMessages from '@src/legacy-libraries-migration/messages';
+
import LibraryContent from './LibraryContent';
import { LibrarySidebar } from './library-sidebar';
import { useComponentPickerContext } from './common/context/ComponentPickerContext';
import { useLibraryContext } from './common/context/LibraryContext';
import { SidebarBodyItemId, useSidebarContext } from './common/context/SidebarContext';
import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes';
-
import messages from './messages';
import LibraryFilterByPublished from './generic/filter-by-published';
+import { libraryQueryPredicate } from './data/apiHooks';
const HeaderActions = () => {
const intl = useIntl();
@@ -137,6 +143,16 @@ const LibraryAuthoringPage = ({
}: LibraryAuthoringPageProps) => {
const intl = useIntl();
const location = useLocation();
+ const navigate = useNavigate();
+ const params = new URLSearchParams(location.search);
+ const { showToast } = useContext(ToastContext);
+ const queryClient = useQueryClient();
+
+ // Get migration status every second if applicable
+ const migrationId = params.get('migration_task');
+ const {
+ data: migrationStatusData,
+ } = useMigrationStatus(migrationId);
const {
isLoadingPage: isLoadingStudioHome,
@@ -205,6 +221,30 @@ const LibraryAuthoringPage = ({
}
}, [navigateTo]);
+ // Verify the migration task status
+ if (migrationId) {
+ let deleteMigrationIdParam = false;
+ if (migrationStatusData?.state === 'Succeeded') {
+ showToast(intl.formatMessage(migrationMessages.migrationSuccess));
+ queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
+ deleteMigrationIdParam = true;
+ } else if (migrationStatusData?.state === 'Failed') {
+ showToast(intl.formatMessage(migrationMessages.migrationFailed));
+ deleteMigrationIdParam = true;
+ } else if (migrationStatusData?.state === 'Canceled') {
+ /* istanbul ignore next */
+ deleteMigrationIdParam = true;
+ }
+
+ if (deleteMigrationIdParam) {
+ params.delete('migration_task');
+ navigate({
+ pathname: location.pathname,
+ search: params.toString(),
+ }, { replace: true });
+ }
+ }
+
if (isLoadingLibraryData) {
return ;
}