feat: Import Course Details Page [FC-0112] (#2664)
Implements all the states for the Import Course Details
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -56,6 +56,7 @@ const LibraryContent = ({ contentType = ContentType.home }: LibraryContentProps)
|
||||
libraryId,
|
||||
collectionId,
|
||||
true,
|
||||
undefined,
|
||||
showPlaceholderBlocks,
|
||||
);
|
||||
// Fetch unsupported blocks usage_key information from meilisearch index.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
171
src/library-authoring/import-course/ImportDetailsPage.test.tsx
Normal file
171
src/library-authoring/import-course/ImportDetailsPage.test.tsx
Normal 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'));
|
||||
});
|
||||
});
|
||||
469
src/library-authoring/import-course/ImportDetailsPage.tsx
Normal file
469
src/library-authoring/import-course/ImportDetailsPage.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user