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.
This commit is contained in:
Chris Chávez
2025-10-13 19:04:36 -05:00
committed by GitHub
parent cd36407457
commit 9c0b545b2f
9 changed files with 390 additions and 16 deletions

View File

@@ -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('<LegacyLibMigrationPage />', () => {
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('<LegacyLibMigrationPage />', () => {
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('<LegacyLibMigrationPage />', () => {
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.');
});
});

View File

@@ -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<MigrationStep>('select-libraries');
const [isExitModalOpen, openExitModal, closeExitModal] = useToggle(false);
const [legacyLibraries, setLegacyLibraries] = useState<LibraryV1Data[]>([]);
const [destinationLibrary, setDestination] = useState<ContentLibrary>();
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) {

View File

@@ -0,0 +1,61 @@
import * as api from './api';
export async function mockGetMigrationStatus(migrationId: string): Promise<api.MigrateTaskStatusData> {
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);

View File

@@ -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);
});
});
});

View File

@@ -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<MigrateTaskStatusData> {
const client = getAuthenticatedHttpClient();
const { data } = await client.get(getMigrationStatusUrl(migrationId));
return camelCaseObject(data);
}
/**
* Bulk migrate legacy libraries
*/
export async function bulkMigrateLegacyLibraries(
requestData: BulkMigrateRequestData,
): Promise<MigrateTaskStatusData> {
const client = getAuthenticatedHttpClient();
const { data } = await client.post(bulkMigrateLegacyLibrariesUrl(), snakeCaseObject(requestData));
return camelCaseObject(data);
}

View File

@@ -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
})
);

View File

@@ -82,6 +82,21 @@ const messages = defineMessages({
+ ' moved will be migrated to <b>{libraryName}</b>',
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;

View File

@@ -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('<LibraryAuthoringPage />', () => {
'This page cannot be shown: Libraries v2 are disabled.',
);
});
it('Should show success in migration legacy libraries', async () => {
render(<LibraryLayout />, {
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(<LibraryLayout />, {
path,
routerProps: {
initialEntries: [
`/library/${mockContentLibrary.libraryId}?migration_task=${mockGetMigrationStatus.migrationIdFailed}`,
],
},
});
await waitFor(() => expect(mockShowToast).toHaveBeenCalledWith('Legacy libraries migration failed.'));
});
});

View File

@@ -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 <Loading />;
}