feat: Import Course Details Page [FC-0112] (#2664)

Implements all the states for the Import Course Details
This commit is contained in:
Chris Chávez
2025-12-05 12:03:55 -05:00
committed by GitHub
parent 0796e897ae
commit 50f4f70671
16 changed files with 889 additions and 24 deletions

View File

@@ -10,6 +10,10 @@ export async function mockGetMigrationStatus(migrationId: string): Promise<api.M
return mockGetMigrationStatus.migrationStatusFailedMultipleData;
case mockGetMigrationStatus.migrationIdOneLibrary:
return mockGetMigrationStatus.migrationStatusFailedOneLibraryData;
case mockGetMigrationStatus.migrationIdLoading:
return new 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;

View File

@@ -83,7 +83,8 @@ export async function getWaffleFlags(courseId?: string): Promise<WaffleFlagsStat
return normalizeCourseDetail(data);
}
export interface MigrateArtifacts {
export interface MigrateParameters {
id: number;
source: string;
target: string;
compositionLevel: string;
@@ -92,6 +93,10 @@ export interface MigrateArtifacts {
targetCollectionSlug: string;
forwardSourceToTarget: boolean;
isFailed: boolean;
targetCollection: {
key: string;
title: string;
} | null;
}
export interface MigrateTaskStatusData {
@@ -104,7 +109,7 @@ export interface MigrateTaskStatusData {
modified: string;
artifacts: string[];
uuid: string;
parameters: MigrateArtifacts[];
parameters: MigrateParameters[];
}
export interface BulkMigrateRequestData {

View File

@@ -65,10 +65,10 @@ export const useBulkModulestoreMigrate = () => {
/**
* 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,
})
);

View File

@@ -18,13 +18,19 @@ describe('component utils', () => {
['lct:org:lib:unit:my-unit-9284e2', 'unit'],
['lct:org:lib:section:my-section-9284e2', 'section'],
['lct:org:lib:subsection:my-section-9284e2', 'subsection'],
['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(getBlockType(input)).toStrictEqual(expected);
});
}
for (const input of ['', undefined, null, 'not a key', 'lb:foo']) {
for (const input of ['', undefined, null, 'not a key', 'lb:foo', 'block-v1:foo']) {
it(`throws an exception for usage key '${input}'`, () => {
expect(() => getBlockType(input as any)).toThrow(`Invalid usageKey: ${input}`);
});

View File

@@ -1,13 +1,20 @@
/**
* Given a usage key like `lb:org:lib:html:id`, get the type (e.g. `html`)
* @param usageKey e.g. `lb:org:lib:html:id`
* Given a usage key like `lb:org:lib:html:id` or `block-v1:org+type@html+block@1`, get the type (e.g. `html`)
* @param usageKey e.g. `lb:org:lib:html:id`, `block-v1:org+type@html+block@1`
* @returns The block type as a string
*/
export function getBlockType(usageKey: string): string {
if (usageKey && (usageKey.startsWith('lb:') || usageKey.startsWith('lct:'))) {
const blockType = usageKey.split(':')[3];
if (blockType) {
return blockType;
if (usageKey) {
if (usageKey.startsWith('lb:') || usageKey.startsWith('lct:')) {
const blockType = usageKey.split(':')[3];
if (blockType) {
return blockType;
}
} else if (usageKey.startsWith('block-v1:')) {
const blockType = usageKey.match(/type@([^+]+)/);
if (blockType) {
return blockType[1];
}
}
}
throw new Error(`Invalid usageKey: ${usageKey}`);

View File

@@ -56,6 +56,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
libraryId,
collectionId,
true,
undefined,
showPlaceholderBlocks,
);
// Fetch unsupported blocks usage_key information from meilisearch index.

View File

@@ -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<React.PropsWithChildren> = ({ children }) => {
const {
@@ -102,6 +103,10 @@ const LibraryLayout = () => (
path={ROUTES.IMPORT_COURSE}
Component={ImportStepperPage}
/>
<Route
path={ROUTES.IMPORT_COURSE_DETAILS}
Component={ImportDetailsPage}
/>
</Route>
</Routes>
);

View File

@@ -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<typeof
mockGetCourseImports.libraryId = mockContentLibrary.libraryId;
mockGetCourseImports.emptyLibraryId = mockContentLibrary.libraryId2;
mockGetCourseImports.succeedImport = {
taskUuid: '2d35e36b-1234-1234-1234-123456789000',
source: {
key: 'course-v1:edX+DemoX+2025_T1',
displayName: 'DemoX 2025 T1',
@@ -1100,6 +1147,7 @@ mockGetCourseImports.succeedImport = {
progress: 1,
} satisfies api.CourseImport;
mockGetCourseImports.succeedImportWithCollection = {
taskUuid: '2',
source: {
key: 'course-v1:edX+DemoX+2025_T2',
displayName: 'DemoX 2025 T2',
@@ -1112,6 +1160,7 @@ mockGetCourseImports.succeedImportWithCollection = {
progress: 1,
} satisfies api.CourseImport;
mockGetCourseImports.failImport = {
taskUuid: '3',
source: {
key: 'course-v1:edX+DemoX+2025_T3',
displayName: 'DemoX 2025 T3',
@@ -1121,6 +1170,7 @@ mockGetCourseImports.failImport = {
progress: 0.30,
} satisfies api.CourseImport;
mockGetCourseImports.inProgressImport = {
taskUuid: '4',
source: {
key: 'course-v1:edX+DemoX+2025_T4',
displayName: 'DemoX 2025 T4',

View File

@@ -798,6 +798,7 @@ export async function publishContainer(containerId: string) {
}
export interface CourseImport {
taskUuid: string;
source: {
key: string;
displayName: string;
@@ -852,6 +853,7 @@ export async function getModulestoreMigrationBlocksInfo(
libraryId: string,
collectionId?: string,
isFailed?: boolean,
taskUuid?: string,
): Promise<BlockMigrationInfo[]> {
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));
}

View File

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

View File

@@ -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(
<ImportDetailsPage />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>
{children}
</LibraryProvider>
),
path: '/libraries/:libraryId/import/:courseId/:migrationTaskId',
params: {
libraryId,
migrationTaskId,
courseId: '1',
},
},
)
);
describe('<ImportDetailsPage />', () => {
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'));
});
});

View File

@@ -0,0 +1,469 @@
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, getBlockType } 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';
const ImportDetailsContent = () => {
const intl = useIntl();
const navigate = useNavigate();
const [enableRefeshState, setEnableRefreshState] = useState(true);
const { libraryId } = useLibraryContext();
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,
refetch: refetchMigrationBlockInfo,
} = useMigrationBlocksInfo(
libraryId,
undefined,
undefined,
migrationTaskId,
migrationStatusData?.state !== 'Failed',
);
const isPending = isPendingCourseDetails || isPendingMigrationStatusData || isPendingMigrationBlockInfo;
// Build migration summary using the migration 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 = getBlockType(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') {
// refetch migrationBlockInfo data once the import is complete
refetchMigrationBlockInfo();
// 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: unsupportedBlocksData } = useGetContentHits(
[
`usage_key IN [${unsupportedBlockIds.map(k => `"${k}"`).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 || !unsupportedBlocksData) {
return [];
}
const reasons = migrationBlockInfo.reduce((result, block) => ({
...result,
[block.sourceKey]: block.unsupportedReason || '',
}), {} as Record<string, string>);
return unsupportedBlocksData.hits.map(block => ({
blockName: block.display_name,
blockType: block.block_type,
reason: reasons[block.usage_key],
}));
}, [migrationBlockInfo, unsupportedBlocksData]);
// 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);
}
};
if (isPending || !courseImportDetails) {
return <Loading />;
}
if (migrationStatus === 'Succeeded') {
return (
<Stack gap={3}>
<Alert variant="success" icon={CheckCircle}>
<Alert.Heading>
<FormattedMessage {...messages.importSuccessfulAlertTitle} />
</Alert.Heading>
<p>
<FormattedMessage
{...messages.importSuccessfulAlertBody}
values={{
courseName: courseDetails?.title,
collectionName: courseImportDetails.targetCollection?.title,
}}
/>
</p>
</Alert>
<h4><FormattedMessage {...messages.importSummaryTitle} /></h4>
<SummaryCard
totalBlocks={migrationSummary.totalBlocks}
totalComponents={migrationSummary.components}
sections={migrationSummary.sections}
subsections={migrationSummary.subsections}
units={migrationSummary.units}
unsupportedBlocks={migrationSummary.unsupported}
isPending={isPendingMigrationBlockInfo}
/>
<p>
<FormattedMessage
{...messages.importSuccessfulBody}
values={{
courseName: courseDetails?.title,
}}
/>
</p>
<div className="w-100 d-flex justify-content-end">
<Button
variant="outline-primary"
iconAfter={ArrowForward}
onClick={() => navigate(collectionLink())}
>
<FormattedMessage {...messages.viewImportedContentButton} />
</Button>
</div>
</Stack>
);
} if (migrationStatus === 'Failed') {
return (
<Stack gap={3}>
<Alert variant="danger" icon={Info}>
<Alert.Heading>
<FormattedMessage {...messages.importFailedAlertTitle} />
</Alert.Heading>
<p>
<FormattedMessage
{...messages.importFailedAlertBody}
values={{
courseName: courseDetails?.title,
}}
/>
</p>
</Alert>
<h4><FormattedMessage {...messages.importFailedDetailsSectionTitle} /></h4>
<p>
<FormattedMessage {...messages.importFailedDetailsSectionBody} />
</p>
<div className="w-100 d-flex justify-content-end">
<Button
variant="outline-primary"
iconAfter={ArrowForward}
onClick={handleImportCourse}
disabled={disableReimport}
>
<FormattedMessage {...messages.importFailedRetryImportButton} />
</Button>
</div>
</Stack>
);
} if (migrationStatus === 'Partial Succeeded') {
return (
<Stack gap={3}>
<Alert variant="warning" icon={WarningFilled}>
<Alert.Heading>
<FormattedMessage {...messages.importPartialAlertTitle} />
</Alert.Heading>
<p>
<FormattedMessage
{...messages.importPartialAlertBody}
values={{
courseName: courseDetails?.title,
collectionName: courseImportDetails.targetCollection?.title,
}}
/>
</p>
</Alert>
<h4><FormattedMessage {...messages.importSummaryTitle} /></h4>
<SummaryCard
totalBlocks={migrationSummary.totalBlocks}
totalComponents={migrationSummary.components}
sections={migrationSummary.sections}
subsections={migrationSummary.subsections}
units={migrationSummary.units}
unsupportedBlocks={migrationSummary.unsupported}
isPending={isPendingMigrationBlockInfo}
/>
<div>
<FormattedMessage
{...messages.importPartialBody}
values={{
percentage: Math.floor(
(migrationSummary.totalBlocks * 100) / (migrationSummary.totalBlocks + migrationSummary.unsupported),
),
courseName: courseDetails?.title,
p: Paragraph,
}}
/>
</div>
{!isPendingMigrationBlockInfo && unsupportedTableData && (
<DataTable
isPaginated
initialState={{
pageSize: 10,
}}
itemCount={unsupportedTableData.length}
columns={[
{
Header: intl.formatMessage(messages.importPartialReasonTableBlockName),
accessor: 'blockName',
},
{
Header: intl.formatMessage(messages.importPartialReasonTableBlockType),
accessor: 'blockType',
},
{
Header: intl.formatMessage(messages.importPartialReasonTableReason),
accessor: 'reason',
},
]}
data={unsupportedTableData}
>
<DataTable.Table />
<DataTable.TableFooter />
</DataTable>
)}
<div className="w-100 d-flex justify-content-end">
<Button
variant="outline-primary"
iconAfter={ArrowForward}
onClick={() => navigate(collectionLink())}
>
<FormattedMessage {...messages.viewImportedContentButton} />
</Button>
</div>
</Stack>
);
}
return (
// In Progress
<Stack gap={3}>
<h4><FormattedMessage {...messages.importInProgressTitle} /></h4>
<p>
<FormattedMessage
{...messages.importInProgressBody}
values={{
courseName: courseDetails?.title,
}}
/>
</p>
<h4><FormattedMessage {...messages.importSummaryTitle} /></h4>
<SummaryCard isPending />
<div className="w-100 d-flex justify-content-end">
<Button
variant="outline-primary"
iconAfter={ArrowForward}
disabled
>
<FormattedMessage {...messages.viewImportedContentButton} />
</Button>
</div>
</Stack>
);
};
export interface MigrationSummary {
totalBlocks: number;
sections: number;
subsections: number;
units: number;
components: number;
unsupported: number;
}
export const ImportDetailsPage = () => {
const intl = useIntl();
const { libraryId, libraryData, readOnly } = useLibraryContext();
const { courseId } = useParams();
const {
data: courseDetails,
} = useCourseDetails(courseId);
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet>
<title>{courseDetails?.title ?? ''} | {process.env.SITE_NAME}</title>
</Helmet>
<Header
number={libraryData?.slug}
title={libraryData?.title}
org={libraryData?.org}
contextId={libraryId}
isLibrary
readOnly={readOnly}
containerProps={{
size: undefined,
}}
/>
<Container className="mt-4 mb-5">
<div className="px-4 bg-light-200 border-bottom">
<SubHeader
title={intl.formatMessage(messages.importDetailsTitle)}
hideBorder
/>
</div>
<Layout xs={[{ span: 9 }, { span: 3 }]}>
<Layout.Element>
<div className="mt-4 px-4">
<ImportDetailsContent />
</div>
</Layout.Element>
<Layout.Element>
<HelpSidebar />
</Layout.Element>
</Layout>
</Container>
</div>
</div>
);
};

View File

@@ -1,3 +1,4 @@
import { Link } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Button,
@@ -13,7 +14,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';
@@ -87,7 +87,9 @@ export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) =>
</div>
<div className="d-flex align-items-center ml-auto">
<Link
to={`/course/${courseImport.source.key}`}
// window.location.href is required due to browser mistaking course id and taskUuid
// combination as an external link to be opened with an external application.
to={`${window.location.href}/${courseImport.source.key}/${courseImport.taskUuid}`}
aria-label={intl.formatMessage(messages.courseImportNavigateAlt)}
className="text-primary-500"
>

View File

@@ -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: '<p>{percentage}% of Course {courseName} has been imported successfully.'
+ ' Imported Course content can be edited and remixed in your Library, and reused in Courses.</p>'
+ '<p>Details of the import, including reasons some content was not abled to be imported are described below</p>',
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;

View File

@@ -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,

View File

@@ -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 {