diff --git a/src/data/api.mocks.ts b/src/data/api.mocks.ts index 964e6f524d..2f51de41c5 100644 --- a/src/data/api.mocks.ts +++ b/src/data/api.mocks.ts @@ -10,6 +10,10 @@ export async function mockGetMigrationStatus(migrationId: string): Promise {}); + case mockGetMigrationStatus.migrationIdInProgress: + return mockGetMigrationStatus.migrationStatusInProgressData; default: /* istanbul ignore next */ throw new Error(`mockGetMigrationStatus: unknown migration ID "${migrationId}"`); @@ -29,6 +33,7 @@ mockGetMigrationStatus.migrationStatusData = { artifacts: [], parameters: [ { + id: 1, source: 'legacy-lib-1', target: 'lib', compositionLevel: 'component', @@ -37,6 +42,10 @@ mockGetMigrationStatus.migrationStatusData = { targetCollectionSlug: 'coll-1', forwardSourceToTarget: true, isFailed: false, + targetCollection: { + key: 'coll', + title: 'Test Collection', + }, }, ], } as api.MigrateTaskStatusData; @@ -53,6 +62,7 @@ mockGetMigrationStatus.migrationStatusFailedData = { artifacts: [], parameters: [ { + id: 1, source: 'legacy-lib-1', target: 'lib', compositionLevel: 'component', @@ -61,6 +71,7 @@ mockGetMigrationStatus.migrationStatusFailedData = { targetCollectionSlug: 'coll-1', forwardSourceToTarget: true, isFailed: true, + targetCollection: null, }, ], } as api.MigrateTaskStatusData; @@ -77,6 +88,7 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = { artifacts: [], parameters: [ { + id: 1, source: 'legacy-lib-1', target: 'lib', compositionLevel: 'component', @@ -85,8 +97,10 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = { targetCollectionSlug: 'coll-1', forwardSourceToTarget: true, isFailed: true, + targetCollection: null, }, { + id: 2, source: 'legacy-lib-2', target: 'lib', compositionLevel: 'component', @@ -95,6 +109,7 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = { targetCollectionSlug: 'coll-1', forwardSourceToTarget: true, isFailed: true, + targetCollection: null, }, ], } as api.MigrateTaskStatusData; @@ -111,6 +126,7 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = { artifacts: [], parameters: [ { + id: 1, source: 'legacy-lib-1', target: 'lib', compositionLevel: 'component', @@ -119,8 +135,10 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = { targetCollectionSlug: 'coll-1', forwardSourceToTarget: true, isFailed: true, + targetCollection: null, }, { + id: 2, source: 'legacy-lib-2', target: 'lib', compositionLevel: 'component', @@ -129,6 +147,34 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = { targetCollectionSlug: 'coll-1', forwardSourceToTarget: true, isFailed: false, + targetCollection: null, + }, + ], +} as api.MigrateTaskStatusData; +mockGetMigrationStatus.migrationIdLoading = '5'; +mockGetMigrationStatus.migrationIdInProgress = '6'; +mockGetMigrationStatus.migrationStatusInProgressData = { + uuid: mockGetMigrationStatus.migrationIdInProgress, + state: 'In Progress', + stateText: 'In Progress', + completedSteps: 3, + totalSteps: 9, + attempts: 1, + created: '', + modified: '', + artifacts: [], + parameters: [ + { + id: 1, + source: 'legacy-lib-1', + target: 'lib', + compositionLevel: 'component', + repeatHandlingStrategy: 'update', + preserveUrlSlugs: false, + targetCollectionSlug: 'coll-1', + forwardSourceToTarget: true, + isFailed: false, + targetCollection: null, }, ], } as api.MigrateTaskStatusData; diff --git a/src/data/api.ts b/src/data/api.ts index e9bbad7b0b..7f2502a922 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -83,7 +83,8 @@ export async function getWaffleFlags(courseId?: string): Promise { /** * Get the migration status */ -export const useModulestoreMigrationStatus = (migrationId: string | null) => ( +export const useModulestoreMigrationStatus = (migrationId: string | null, refetchInterval: number | false = 1000) => ( useQuery({ queryKey: migrationQueryKeys.migrationTask(migrationId), queryFn: migrationId ? () => getModulestoreMigrationStatus(migrationId!) : skipToken, - refetchInterval: 1000, // Refresh every second + refetchInterval, }) ); diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index cd97c26688..cba9192c69 100644 --- a/src/generic/key-utils.test.ts +++ b/src/generic/key-utils.test.ts @@ -2,6 +2,7 @@ import { buildCollectionUsageKey, ContainerType, getBlockType, + getBlockTypeBlockV1, getLibraryId, isLibraryKey, isLibraryV1Key, @@ -142,4 +143,25 @@ describe('component utils', () => { }); } }); + + describe('getBlockTypeBlockV1', () => { + for (const [input, expected] of [ + ['block-v1:org+type@html+block@1', 'html'], + ['block-v1:OpenCraftX+type@html+block@1571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'html'], + ['block-v1:Axim+type@problem+block@571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'problem'], + ['block-v1:org+type@unit+block@1', 'unit'], + ['block-v1:org+type@section+block@1', 'section'], + ['block-v1:org+type@subsection+block@1', 'subsection'], + ]) { + it(`returns '${expected}' for usage key '${input}'`, () => { + expect(getBlockTypeBlockV1(input)).toStrictEqual(expected); + }); + } + + for (const input of ['', undefined, null, 'not a key', 'block-v1:foo']) { + it(`throws an exception for usage key '${input}'`, () => { + expect(() => getBlockTypeBlockV1(input as any)).toThrow(`Invalid usageKey: ${input}`); + }); + } + }); }); diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 86f84752b9..5842dc170b 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -126,3 +126,18 @@ export function normalizeContainerType(containerType: ContainerType | string) { return containerType; } } + +/** + * Given a usage key of V1 block like `block-v1:org+type@html+block@1`, get the type (e.g. `html`) + * @param usageKey e.g. `block-v1:org+type@html+block@1` + * @returns The block type as a string + */ +export function getBlockTypeBlockV1(usageKey: string): string { + if (usageKey && usageKey.startsWith('block-v1:')) { + const blockType = usageKey.match(/type@([^+]+)/); + if (blockType) { + return blockType[1]; + } + } + throw new Error(`Invalid usageKey: ${usageKey}`); +} diff --git a/src/library-authoring/LibraryContent.tsx b/src/library-authoring/LibraryContent.tsx index 74fe31f3cf..c1f419e15b 100644 --- a/src/library-authoring/LibraryContent.tsx +++ b/src/library-authoring/LibraryContent.tsx @@ -56,6 +56,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps) libraryId, collectionId, true, + undefined, showPlaceholderBlocks, ); // Fetch unsupported blocks usage_key information from meilisearch index. diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 3d4f19683b..047269e429 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -21,6 +21,7 @@ import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections import { LibraryUnitPage } from './units'; import { LibraryTeamModal } from './library-team'; import { ImportStepperPage } from './import-course/stepper/ImportStepperPage'; +import { ImportDetailsPage } from './import-course/ImportDetailsPage'; const LibraryLayoutWrapper: React.FC = ({ children }) => { const { @@ -102,6 +103,10 @@ const LibraryLayout = () => ( path={ROUTES.IMPORT_COURSE} Component={ImportStepperPage} /> + ); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 35410e53ed..6337462cbe 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -34,6 +34,52 @@ export const mockGetContentLibraryV2List = { }), }; +export const mockGetModulestoreMigratedBlocksInfo = { + applyMockSuccess: () => jest.spyOn(api, 'getModulestoreMigrationBlocksInfo').mockResolvedValue( + [ + { + sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@chapter+block@1', + targetKey: '1', + unsupportedReason: undefined, + }, + { + sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@sequential+block@2', + targetKey: '2', + unsupportedReason: undefined, + }, + { + sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@vertical+block@2', + targetKey: '3', + unsupportedReason: undefined, + }, + { + sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@html+block@3', + targetKey: '4', + unsupportedReason: undefined, + }, + ], + ), + applyMockPartial: () => jest.spyOn(api, 'getModulestoreMigrationBlocksInfo').mockResolvedValue( + [ + { + sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@library_content+block@test_lib_content', + targetKey: null, + unsupportedReason: 'The "library_content" XBlock (ID: "test_lib_content") has children, so it not supported in content libraries. It has 2 children blocks.', + }, + { + sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@html+block@1', + targetKey: '1', + unsupportedReason: undefined, + }, + { + sourceKey: 'block-v1:UNIX+UX2+2025_T2+type@chapter+block@1', + targetKey: '2', + unsupportedReason: undefined, + }, + ], + ), +}; + /** * Mock for `getContentLibrary()` * @@ -1091,6 +1137,7 @@ export async function mockGetCourseImports(libraryId: string): ReturnType { const client = getAuthenticatedHttpClient(); @@ -860,6 +862,9 @@ export async function getModulestoreMigrationBlocksInfo( if (collectionId) { params.append('target_collection_key', collectionId); } + if (taskUuid) { + params.append('task_uuid', taskUuid); + } if (isFailed !== undefined) { params.append('is_failed', JSON.stringify(isFailed)); } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 9d4f5e711d..9f516f82a4 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -995,10 +995,16 @@ export const useMigrationBlocksInfo = ( libraryId: string, collectionId?: string, isFailed?: boolean, + taskUuid?: string, enabled = true, ) => ( useQuery({ queryKey: libraryAuthoringQueryKeys.migrationBlocksInfo(libraryId, collectionId, isFailed), - queryFn: enabled ? () => api.getModulestoreMigrationBlocksInfo(libraryId, collectionId, isFailed) : skipToken, + queryFn: enabled ? () => api.getModulestoreMigrationBlocksInfo( + libraryId, + collectionId, + isFailed, + taskUuid, + ) : skipToken, }) ); diff --git a/src/library-authoring/import-course/ImportDetailsPage.test.tsx b/src/library-authoring/import-course/ImportDetailsPage.test.tsx new file mode 100644 index 0000000000..89cdb4b3c2 --- /dev/null +++ b/src/library-authoring/import-course/ImportDetailsPage.test.tsx @@ -0,0 +1,171 @@ +import userEvent from '@testing-library/user-event'; +import { + initializeMocks, + render as testRender, + screen, + waitFor, +} from '@src/testUtils'; +import { mockGetMigrationStatus } from '@src/data/api.mocks'; +import { bulkModulestoreMigrateUrl } from '@src/data/api'; +import { useGetContentHits } from '@src/search-manager'; +import { ImportDetailsPage } from './ImportDetailsPage'; +import { LibraryProvider } from '../common/context/LibraryContext'; +import { mockContentLibrary, mockGetModulestoreMigratedBlocksInfo } from '../data/api.mocks'; +import { libraryComponentsMock } from '../__mocks__'; + +mockContentLibrary.applyMock(); +mockGetMigrationStatus.applyMock(); +const { libraryId } = mockContentLibrary; +const mockNavigate = jest.fn(); +const mockUseSearchContext = jest.fn(); +const mockFetchNextPage = jest.fn(); +let axiosMock; + +// Mock the useCourseDetails hook +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useCourseDetails: jest.fn().mockReturnValue({ isPending: false, data: { title: 'Test Course' } }), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('@src/search-manager', () => ({ + ...jest.requireActual('@src/search-manager'), + useSearchContext: () => mockUseSearchContext(), + useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }), +})); + +const render = (migrationTaskId: string) => ( + testRender( + , + { + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + path: '/libraries/:libraryId/import/:courseId/:migrationTaskId', + params: { + libraryId, + migrationTaskId, + courseId: '1', + }, + }, + ) +); + +describe('', () => { + beforeEach(() => { + const newMocks = initializeMocks(); + axiosMock = newMocks.axiosMock; + }); + + it('should render loading state', () => { + render(mockGetMigrationStatus.migrationIdLoading); + expect(screen.getByText(/loading\.\.\./i)).toBeInTheDocument(); + expect(screen.getByText(/help & support/i)).toBeInTheDocument(); + }); + + it('should render In Progress state', async () => { + render(mockGetMigrationStatus.migrationIdInProgress); + expect(await screen.findByText(/test course is being imported/i)); + expect(screen.getByRole('button', { + name: /view imported content/i, + })).toBeDisabled(); + }); + + it('should render Failed state', async () => { + const user = userEvent.setup(); + const url = bulkModulestoreMigrateUrl(); + axiosMock.onPost(url).reply(200); + render(mockGetMigrationStatus.migrationIdFailed); + expect(await screen.findByText(/test course was not imported into your Library/i)); + expect(screen.getByText(/import failed for the following reasons:/i)); + const retryImport = screen.getByRole('button', { + name: /re-try import/i, + }); + + await user.click(retryImport); + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + }); + + it('should render Succeeded state', async () => { + mockGetModulestoreMigratedBlocksInfo.applyMockSuccess(); + render(mockGetMigrationStatus.migrationId); + expect(await screen.findByText( + /test course has been imported to your library in a collection called test collection/i, + )); + expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument(); + expect(await screen.findByText('4')).toBeInTheDocument(); + + const viewImportedContentBtn = screen.getByRole('button', { + name: /view imported content/i, + }); + + await viewImportedContentBtn.click(); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/library/lib:Axim:TEST/collection/coll')); + }); + + it('should render Partial Succeeded state', async () => { + mockGetModulestoreMigratedBlocksInfo.applyMockPartial(); + (useGetContentHits as jest.Mock).mockReturnValue({ + isPending: false, + data: { + hits: [ + { + display_name: 'Randomized Content Block', + usage_key: 'block-v1:UNIX+UX2+2025_T2+type@library_content+block@test_lib_content', + block_type: 'library_content', + }, + ], + query: '', + processingTimeMs: 0, + limit: 1, + offset: 0, + estimatedTotalHits: 1, + }, + }); + mockUseSearchContext.mockReturnValue({ + totalContentAndCollectionHits: 0, + contentAndCollectionHits: [], + isFetchingNextPage: false, + hasNextPage: false, + fetchNextPage: mockFetchNextPage, + searchKeywords: '', + isFiltered: false, + isPending: false, + hits: libraryComponentsMock, + }); + + render(mockGetMigrationStatus.migrationId); + expect(await screen.findByText(/partial import successful/i)).toBeInTheDocument(); + + expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument(); + expect(await screen.findByText('2/5')).toBeInTheDocument(); + expect(await screen.findByText(/Components/i)).toBeInTheDocument(); + expect(await screen.findByText('1/4')).toBeInTheDocument(); + + expect(await screen.findByText( + /40% of course test course has been imported successfully/i, + )).toBeInTheDocument(); + + expect(await screen.findByRole('cell', { + name: /randomized content block/i, + })).toBeInTheDocument(); + expect(await screen.findByRole('cell', { + name: 'library_content', + })).toBeInTheDocument(); + expect(await screen.findByRole('cell', { + name: /has children, so it not supported in content libraries/i, + })).toBeInTheDocument(); + + const viewImportedContentBtn = screen.getByRole('button', { + name: /view imported content/i, + }); + + await viewImportedContentBtn.click(); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/library/lib:Axim:TEST/collection/coll')); + }); +}); diff --git a/src/library-authoring/import-course/ImportDetailsPage.tsx b/src/library-authoring/import-course/ImportDetailsPage.tsx new file mode 100644 index 0000000000..41f4ab14db --- /dev/null +++ b/src/library-authoring/import-course/ImportDetailsPage.tsx @@ -0,0 +1,459 @@ +import { useContext, useMemo, useState } from 'react'; +import { Helmet } from 'react-helmet'; +import { useNavigate, useParams } from 'react-router-dom'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Stack, Container, Alert, Layout, Button, + DataTable, +} from '@openedx/paragon'; + +import Header from '@src/header'; +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import { + ArrowForward, CheckCircle, Info, WarningFilled, +} from '@openedx/paragon/icons'; +import Loading from '@src/generic/Loading'; +import { ToastContext } from '@src/generic/toast-context'; +import { Paragraph } from '@src/utils'; +import { useBulkModulestoreMigrate, useModulestoreMigrationStatus } from '@src/data/apiHooks'; +import { useGetContentHits } from '@src/search-manager'; +import { ContainerType, getBlockTypeBlockV1 } from '@src/generic/key-utils'; + +import messages from './messages'; +import { SummaryCard } from './stepper/SummaryCard'; +import { HelpSidebar } from './HelpSidebar'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useMigrationBlocksInfo } from '../data/apiHooks'; + +export interface MigrationSummary { + totalBlocks: number; + sections: number; + subsections: number; + units: number; + components: number; + unsupported: number; +} + +export const ImportDetailsPage = () => { + const intl = useIntl(); + const navigate = useNavigate(); + const { libraryId, libraryData, readOnly } = useLibraryContext(); + const [enableRefeshState, setEnableRefreshState] = useState(true); + const { courseId, migrationTaskId } = useParams(); + const { showToast } = useContext(ToastContext); + const [disableReimport, setDisableReimport] = useState(false); + + // Using bulk migrate as it allows us to create collection automatically + // TODO: Modify single migration API to allow create collection + const migrate = useBulkModulestoreMigrate(); + + if (libraryId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Error: route is missing libraryId.'); + } + if (migrationTaskId === undefined) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Error: route is missing migrationId.'); + } + + const { + data: courseDetails, + isPending: isPendingCourseDetails, + } = useCourseDetails(courseId); + const { + data: migrationStatusData, + isPaused: isPendingMigrationStatusData, + } = useModulestoreMigrationStatus(migrationTaskId, enableRefeshState ? 1000 : false); + // Get the first migration, because the courses are imported one by one + const courseImportDetails = migrationStatusData?.parameters?.[0]; + + const { + data: migrationBlockInfo, + isPending: isPendingMigrationBlockInfo, + } = useMigrationBlocksInfo( + libraryId, + undefined, + undefined, + migrationTaskId, + migrationStatusData?.state !== 'Failed', + ); + + const isPending = isPendingCourseDetails || isPendingMigrationStatusData || isPendingMigrationBlockInfo; + + // Build migration summary using the mibration blocks info + const { + migrationSummary, + unsupportedBlockIds, + } = useMemo(() => { + const counts: MigrationSummary = { + totalBlocks: 0, + sections: 0, + subsections: 0, + units: 0, + components: 0, + unsupported: 0, + }; + const resultUnsupportedIds: string[] = []; + + if (!migrationBlockInfo) { + return { + migrationSummary: counts, + unsupportedBlockIds: resultUnsupportedIds, + }; + } + + for (const block of migrationBlockInfo) { + if (!block.targetKey) { + // The migrations of this block is failed + counts.unsupported += 1; + resultUnsupportedIds.push(`"${block.sourceKey}"`); + + if (block.unsupportedReason) { + // Verify if the unsupported block has children + const match = block.unsupportedReason.match(/It has (\d+) children/); + counts.unsupported += match ? Number(match[1]) : 0; + } + } else { + counts.totalBlocks += 1; + const blockType = getBlockTypeBlockV1(block.sourceKey); + switch (blockType) { + case ContainerType.Chapter: + counts.sections += 1; + break; + case ContainerType.Sequential: + counts.subsections += 1; + break; + case ContainerType.Vertical: + counts.units += 1; + break; + default: + counts.components += 1; + } + } + } + + return { + migrationSummary: counts, + unsupportedBlockIds: resultUnsupportedIds, + }; + }, [migrationBlockInfo]); + + // Calculate current migration status + let migrationStatus = 'In Progress'; + if (migrationStatusData?.state === 'Failed') { + // The entire task has failed + migrationStatus = 'Failed'; + } else if (migrationStatusData?.state === 'Succeeded') { + // Currently, bulk migrate is being used to migrate courses because + // it has the ability to create collections. + // In bulk migration, the task may succeed, but each migration may fail. + // This checks whether the course migration has failed. + // TODO: Update this code when using simple migration + if (courseImportDetails?.isFailed) { + migrationStatus = 'Failed'; + } else if (migrationSummary.unsupported !== 0) { + migrationStatus = 'Partial Succeeded'; + } else { + migrationStatus = 'Succeeded'; + } + } + + // Fetch unsupported blocks usage_key information from meilisearch index. + const { data: unssupportedBlocksData } = useGetContentHits( + [ + `usage_key IN [${unsupportedBlockIds.join(',')}]`, + ], + (unsupportedBlockIds.length || 0) > 0, + ['usage_key', 'block_type', 'display_name'], + unsupportedBlockIds.length, + true, + ); + + // Build the data for the reasons for failed imports + const unsupportedTableData = useMemo(() => { + if (!migrationBlockInfo || !unssupportedBlocksData) { + return []; + } + + const reasons = migrationBlockInfo.reduce((result, block) => ({ + ...result, + [block.sourceKey]: block.unsupportedReason || '', + }), {} as Record); + + return unssupportedBlocksData.hits.map(block => ({ + blockName: block.display_name, + blockType: block.block_type, + reason: reasons[block.usage_key], + })); + }, [migrationBlockInfo, unssupportedBlocksData]); + + // In any state other than "in progress", it is no longer necessary + // to keep refreshing the task status. + if (enableRefeshState && migrationStatus !== 'In Progress') { + setEnableRefreshState(false); + } + + const collectionLink = () => { + let libUrl = `/library/${libraryId}`; + if (courseImportDetails?.targetCollection?.key) { + libUrl += `/collection/${courseImportDetails.targetCollection.key}`; + } + return libUrl; + }; + + const handleImportCourse = async () => { + if (!courseId || !courseImportDetails || !courseDetails || !migrationStatusData) { + return; + } + + setDisableReimport(true); + + try { + const newMigrationTask = await migrate.mutateAsync({ + sources: [courseId!], + target: libraryId, + createCollections: true, + repeatHandlingStrategy: 'fork', + compositionLevel: 'section', + }); + navigate(`../import/${courseImportDetails.source}/${newMigrationTask.uuid}`); + setDisableReimport(false); + } catch (error) { + showToast(intl.formatMessage(messages.importCourseCompleteFailedToastMessage, { + courseName: courseDetails.title, + })); + setDisableReimport(false); + } + }; + + const renderBody = () => { + if (isPending || !courseImportDetails) { + return ; + } + + if (migrationStatus === 'Succeeded') { + return ( + + + + + +

+ +

+
+

+ +

+ +

+
+ +
+
+ ); + } if (migrationStatus === 'Failed') { + return ( + + + + + +

+ +

+
+

+

+ +

+
+ +
+
+ ); + } if (migrationStatus === 'Partial Succeeded') { + return ( + + + + + +

+ +

+
+

+ +
+ +
+ {!isPendingMigrationBlockInfo && unsupportedTableData && ( + + + + + )} + +
+ +
+
+ ); + } + + return ( + // In Progress + +

+

+ +

+

+ +
+ +
+
+ ); + }; + + return ( +
+
+ + {courseDetails?.title ?? ''} | {process.env.SITE_NAME} + +
+ +
+ +
+ + +
+ {renderBody()} +
+
+ + + +
+
+
+
+ ); +}; diff --git a/src/library-authoring/import-course/ImportedCourseCard.tsx b/src/library-authoring/import-course/ImportedCourseCard.tsx index 651395a3e4..719733425f 100644 --- a/src/library-authoring/import-course/ImportedCourseCard.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.tsx @@ -1,8 +1,10 @@ +import { Link, useNavigate } from 'react-router-dom'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { Button, Card, Icon, + IconButton, } from '@openedx/paragon'; import { ArrowForwardIos, @@ -13,7 +15,6 @@ import { Warning, } from '@openedx/paragon/icons'; import classNames from 'classnames'; -import { Link } from 'react-router-dom'; import { type CourseImport } from '../data/api'; import { useLibraryRoutes } from '../routes'; @@ -54,6 +55,7 @@ const StateIcon = ({ state }: { state: CourseImport['state'] }) => ( export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) => { const intl = useIntl(); + const navigate = useNavigate(); const { navigateTo } = useLibraryRoutes(); return ( @@ -86,13 +88,11 @@ export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) =>
- - - + navigate(`${courseImport.source.key}/${courseImport.taskUuid}`)} + />
diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts index f93ad713bb..72c5a5881c 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -209,16 +209,110 @@ const messages = defineMessages({ defaultMessage: '{courseName} has already been imported into the Library "{libraryName}". If this course is re-imported, all Sections, Subsections, Units and Content Blocks will be reimported again.', description: 'Body of the info card when course import analysis is complete and it was already imported before.', }, - importCourseCompleteToastMessage: { - id: 'library-authoring.import-course.complete-import.in-progress.toast.message', - defaultMessage: '{courseName} is being migrated.', - description: 'Toast message that indicates a course is being migrated', - }, importCourseCompleteFailedToastMessage: { id: 'library-authoring.import-course.complete-import.failed.toast.message', defaultMessage: '{courseName} migration failed.', description: 'Toast message that indicates course migration failed.', }, + importDetailsTitle: { + id: 'library-authoring.import-course.import-details.title', + defaultMessage: 'Import Details', + description: 'Title of the Import Details page, in the import course', + }, + importSuccessfulAlertTitle: { + id: 'library-authoring.import-course.import-details.import-successful.alert.title', + defaultMessage: 'Import Successful', + description: 'Title of the import successful alert in the import details page', + }, + importSuccessfulAlertBody: { + id: 'library-authoring.import-course.import-details.import-successful.alert.body', + defaultMessage: '{courseName} has been imported to your library in a collection called {collectionName}', + description: 'Body of the import successful alert in the import details page', + }, + importSuccessfulBody: { + id: 'library-authoring.import-course.import-details.import-successful.body', + defaultMessage: 'Course {courseName} has been imported successfully.' + + ' Imported Course content can be edited and remixed in your Library, and reused in Courses', + description: 'Body of the import successful card in the import details page', + }, + importSummaryTitle: { + id: 'library-authoring.import-course.import-details.import-summary.title', + defaultMessage: 'Import Summary', + description: 'Title of the import summary card in the import details page', + }, + viewImportedContentButton: { + id: 'library-authoring.import-course.import-details.view-imported-content.button', + defaultMessage: 'View Imported Content', + description: 'Label of the button to view imported conten of a imported course', + }, + importFailedAlertTitle: { + id: 'library-authoring.import-course.import-details.import-failed.title', + defaultMessage: 'Import Failed', + description: 'Title of the import failed card in the import details page.', + }, + importFailedAlertBody: { + id: 'library-authoring.import-course.import-details.import-failed.body', + defaultMessage: '{courseName} was not imported into your Library. See details bellow', + description: 'Body of the import failed card in the import details page.', + }, + importFailedDetailsSectionTitle: { + id: 'library-authoring.import-course.import-details.import-failed.details.title', + defaultMessage: 'Details', + description: 'Title of the details section in the import details for a failed import', + }, + importFailedDetailsSectionBody: { + id: 'library-authoring.import-course.import-details.import-failed.details.body', + defaultMessage: 'Import failed for the following reasons:', + description: 'Body of the details section in the import details for a failed import', + }, + importFailedRetryImportButton: { + id: 'library-authoring.import-course.import-details.import-failed.re-try-import', + defaultMessage: 'Re-try Import', + description: 'Label of the button to re-try a failed import.', + }, + importInProgressTitle: { + id: 'library-authoring.import-course.import-details.import-in-progress.title', + defaultMessage: 'Import in Progress', + description: 'Title of the import details when the migration is in progress', + }, + importInProgressBody: { + id: 'library-authoring.import-course.import-details.import-in-progress.body', + defaultMessage: 'Course {courseName} is being imported. This page will update when import is complete', + description: 'Body of the import details when the migration is in progress', + }, + importPartialAlertTitle: { + id: 'library-authoring.import-course.import-details.import-partial.alert.title', + defaultMessage: 'Partial Import Successful', + description: 'Title of the alert in the import details page when the migration is in partial import.', + }, + importPartialAlertBody: { + id: 'library-authoring.import-course.import-details.import-partial.alert.title', + defaultMessage: '{courseName} has been imported to your library in a collection called {collectionName}.' + + ' Some content was not added to your course. See details bellow.', + description: 'Body of the alert in the import details page when the migration is in partial import.', + }, + importPartialBody: { + id: 'library-authoring.import-course.import-details.import-partial.alert.title', + defaultMessage: '

{percentage}% of Course {courseName} has been imported successfully.' + + ' Imported Course content can be edited and remixed in your Library, and reused in Courses.

' + + '

Details of the import, including reasons some content was not abled to be imported are described below

', + description: 'Body of the import details page when the migration is in partial import.', + }, + importPartialReasonTableBlockName: { + id: 'library-authoring.import-course.import-details.reasons-table.block-name', + defaultMessage: 'Block Name', + description: 'Label for the Block Name field in the Reasons table in the import details', + }, + importPartialReasonTableBlockType: { + id: 'library-authoring.import-course.import-details.reasons-table.block-type', + defaultMessage: 'Block Type', + description: 'Label for the Block Type field in the Reasons table in the import details', + }, + importPartialReasonTableReason: { + id: 'library-authoring.import-course.import-details.reasons-table.reason', + defaultMessage: 'Reason For Failed import', + description: 'Label for the Reason For Failed import field in the Reasons table in the import details', + }, }); export default messages; diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx index 2a844b1757..e8e617acc1 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx @@ -95,11 +95,7 @@ export const ImportStepperPage = () => { repeatHandlingStrategy: 'fork', compositionLevel: 'section', }); - showToast(intl.formatMessage(messages.importCourseCompleteToastMessage, { - courseName: courseData?.title, - })); - // TODO: Update this URL to redirect user to import details page. - navigate(`/library/${libraryId}?migration_task=${migrationTask.uuid}`); + navigate(`../import/${selectedCourseId}/${migrationTask.uuid}`); } catch (error) { showToast(intl.formatMessage(messages.importCourseCompleteFailedToastMessage, { courseName: courseData?.title, diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index 99a8c72c34..cca3c9b262 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -51,6 +51,8 @@ export const ROUTES = { IMPORT: '/import', // ImportStepperPage route: IMPORT_COURSE: '/import/courses', + // ImportDetailsPage route: + IMPORT_COURSE_DETAILS: '/import/:courseId/:migrationTaskId', }; export enum ContentType {