From 60e1e60f4a9666b9d0e6fc1b0a86e5545ab730a2 Mon Sep 17 00:00:00 2001
From: XnpioChV
Date: Tue, 18 Nov 2025 19:41:31 -0500
Subject: [PATCH 01/15] feat: Basics for Success/Failed import details page
---
src/data/api.mocks.ts | 12 +
src/data/api.ts | 9 +-
src/library-authoring/LibraryLayout.tsx | 5 +
src/library-authoring/data/api.mocks.ts | 4 +
src/library-authoring/data/api.ts | 1 +
.../import-course/ImportDetailsPage.tsx | 207 ++++++++++++++++++
.../import-course/ImportedCourseCard.tsx | 16 +-
.../import-course/messages.ts | 56 +++++
.../stepper/ImportStepperPage.tsx | 6 +-
src/library-authoring/routes.ts | 2 +
10 files changed, 303 insertions(+), 15 deletions(-)
create mode 100644 src/library-authoring/import-course/ImportDetailsPage.tsx
diff --git a/src/data/api.mocks.ts b/src/data/api.mocks.ts
index 964e6f524d..55029fac3d 100644
--- a/src/data/api.mocks.ts
+++ b/src/data/api.mocks.ts
@@ -29,6 +29,7 @@ mockGetMigrationStatus.migrationStatusData = {
artifacts: [],
parameters: [
{
+ id: 1,
source: 'legacy-lib-1',
target: 'lib',
compositionLevel: 'component',
@@ -37,6 +38,7 @@ mockGetMigrationStatus.migrationStatusData = {
targetCollectionSlug: 'coll-1',
forwardSourceToTarget: true,
isFailed: false,
+ targetCollection: null,
},
],
} as api.MigrateTaskStatusData;
@@ -53,6 +55,7 @@ mockGetMigrationStatus.migrationStatusFailedData = {
artifacts: [],
parameters: [
{
+ id: 1,
source: 'legacy-lib-1',
target: 'lib',
compositionLevel: 'component',
@@ -61,6 +64,7 @@ mockGetMigrationStatus.migrationStatusFailedData = {
targetCollectionSlug: 'coll-1',
forwardSourceToTarget: true,
isFailed: true,
+ targetCollection: null,
},
],
} as api.MigrateTaskStatusData;
@@ -77,6 +81,7 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = {
artifacts: [],
parameters: [
{
+ id: 1,
source: 'legacy-lib-1',
target: 'lib',
compositionLevel: 'component',
@@ -85,8 +90,10 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = {
targetCollectionSlug: 'coll-1',
forwardSourceToTarget: true,
isFailed: true,
+ targetCollection: null,
},
{
+ id: 2,
source: 'legacy-lib-2',
target: 'lib',
compositionLevel: 'component',
@@ -95,6 +102,7 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = {
targetCollectionSlug: 'coll-1',
forwardSourceToTarget: true,
isFailed: true,
+ targetCollection: null,
},
],
} as api.MigrateTaskStatusData;
@@ -111,6 +119,7 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = {
artifacts: [],
parameters: [
{
+ id: 1,
source: 'legacy-lib-1',
target: 'lib',
compositionLevel: 'component',
@@ -119,8 +128,10 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = {
targetCollectionSlug: 'coll-1',
forwardSourceToTarget: true,
isFailed: true,
+ targetCollection: null,
},
{
+ id: 2,
source: 'legacy-lib-2',
target: 'lib',
compositionLevel: 'component',
@@ -129,6 +140,7 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = {
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 = ({ 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..b94ae68774 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -1091,6 +1091,7 @@ export async function mockGetCourseImports(libraryId: string): ReturnType {
+ const intl = useIntl();
+ const navigate = useNavigate();
+ const { libraryId, libraryData, readOnly } = useLibraryContext();
+ const { courseId, migrationTaskId } = useParams();
+ const { showToast } = useContext(ToastContext);
+ // 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);
+ // Get the first migration, because the courses are imported one by one
+ const courseImportDetails = migrationStatusData?.parameters?.[0];
+
+ const isPending = isPendingCourseDetails || isPendingMigrationStatusData;
+
+ 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;
+ }
+
+ try {
+ await migrate.mutateAsync({
+ sources: [courseId!],
+ target: libraryId,
+ createCollections: true,
+ repeatHandlingStrategy: 'fork',
+ compositionLevel: 'section',
+ });
+ showToast(intl.formatMessage(messages.importCourseCompleteToastMessage, {
+ courseName: courseDetails.title,
+ }));
+ navigate(`${courseImportDetails.source}/${migrationStatusData.uuid}`);
+ } catch (error) {
+ showToast(intl.formatMessage(messages.importCourseCompleteFailedToastMessage, {
+ courseName: courseDetails.title,
+ }));
+ }
+ };
+
+ const renderBody = () => {
+ if (isPending) {
+ return ;
+ }
+
+ if (migrationStatusData?.state === 'Succeeded') {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ } if (migrationStatusData?.state === 'Failed') {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
+ return (
+ // In Progress
+
+ 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..71dcb8f535 100644
--- a/src/library-authoring/import-course/messages.ts
+++ b/src/library-authoring/import-course/messages.ts
@@ -219,6 +219,62 @@ const messages = defineMessages({
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.',
+ },
});
export default messages;
diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx
index 8b182531ff..c38d641ca8 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 {
From 566774456e471afa51a92da254f8d1b6c7a193c5 Mon Sep 17 00:00:00 2001
From: XnpioChV
Date: Wed, 19 Nov 2025 14:33:07 -0500
Subject: [PATCH 02/15] feat: In-progress status for Import details
---
.../import-course/ImportDetailsPage.tsx | 55 +++++++++++++++----
.../import-course/messages.ts | 15 +++--
2 files changed, 55 insertions(+), 15 deletions(-)
diff --git a/src/library-authoring/import-course/ImportDetailsPage.tsx b/src/library-authoring/import-course/ImportDetailsPage.tsx
index a4de6d459e..b431f54fd6 100644
--- a/src/library-authoring/import-course/ImportDetailsPage.tsx
+++ b/src/library-authoring/import-course/ImportDetailsPage.tsx
@@ -1,4 +1,4 @@
-import { useContext } from 'react';
+import { useContext, useState } from 'react';
import { Helmet } from 'react-helmet';
import { useNavigate, useParams } from 'react-router';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
@@ -25,6 +25,8 @@ export const ImportDetailsPage = () => {
const { libraryId, libraryData, readOnly } = 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();
@@ -51,6 +53,24 @@ export const ImportDetailsPage = () => {
const isPending = isPendingCourseDetails || isPendingMigrationStatusData;
+ // 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 {
+ migrationStatus = 'Succeeded';
+ }
+ }
+
const collectionLink = () => {
let libUrl = `/library/${libraryId}`;
if (courseImportDetails?.targetCollection?.key) {
@@ -64,22 +84,23 @@ export const ImportDetailsPage = () => {
return;
}
+ setDisableReimport(true);
+
try {
- await migrate.mutateAsync({
+ const newMigrationTask = await migrate.mutateAsync({
sources: [courseId!],
target: libraryId,
createCollections: true,
repeatHandlingStrategy: 'fork',
compositionLevel: 'section',
});
- showToast(intl.formatMessage(messages.importCourseCompleteToastMessage, {
- courseName: courseDetails.title,
- }));
- navigate(`${courseImportDetails.source}/${migrationStatusData.uuid}`);
+ navigate(`../import/${courseImportDetails.source}/${newMigrationTask.uuid}`);
+ setDisableReimport(false);
} catch (error) {
showToast(intl.formatMessage(messages.importCourseCompleteFailedToastMessage, {
courseName: courseDetails.title,
}));
+ setDisableReimport(false);
}
};
@@ -88,7 +109,7 @@ export const ImportDetailsPage = () => {
return ;
}
- if (migrationStatusData?.state === 'Succeeded') {
+ if (migrationStatus === 'Succeeded') {
return (
@@ -126,7 +147,7 @@ export const ImportDetailsPage = () => {
);
- } if (migrationStatusData?.state === 'Failed') {
+ } if (migrationStatus === 'Failed') {
return (
@@ -151,6 +172,7 @@ export const ImportDetailsPage = () => {
variant="outline-primary"
iconAfter={ArrowForward}
onClick={handleImportCourse}
+ disabled={disableReimport}
>
@@ -160,8 +182,21 @@ export const ImportDetailsPage = () => {
}
return (
// In Progress
-
- In progress
+
+
+
+
+
+
+
+
+
);
};
diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts
index 71dcb8f535..88ad24f5af 100644
--- a/src/library-authoring/import-course/messages.ts
+++ b/src/library-authoring/import-course/messages.ts
@@ -209,11 +209,6 @@ 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.',
@@ -275,6 +270,16 @@ const messages = defineMessages({
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',
+ },
});
export default messages;
From 63ab799c7cc8222021eaa07021d8afdc93f7abca Mon Sep 17 00:00:00 2001
From: XnpioChV
Date: Wed, 19 Nov 2025 21:07:42 -0500
Subject: [PATCH 03/15] feat: Add counts to summary in success
---
src/data/api.mocks.ts | 48 +++++++++++++++++++
src/data/api.ts | 8 ++++
.../import-course/ImportDetailsPage.tsx | 15 ++++--
3 files changed, 68 insertions(+), 3 deletions(-)
diff --git a/src/data/api.mocks.ts b/src/data/api.mocks.ts
index 55029fac3d..4c6c4d7afb 100644
--- a/src/data/api.mocks.ts
+++ b/src/data/api.mocks.ts
@@ -39,6 +39,14 @@ mockGetMigrationStatus.migrationStatusData = {
forwardSourceToTarget: true,
isFailed: false,
targetCollection: null,
+ migrationSummary: {
+ totalBlocks: 0,
+ sections: 0,
+ subsections: 0,
+ units: 0,
+ components: 0,
+ unsupported: 0,
+ },
},
],
} as api.MigrateTaskStatusData;
@@ -65,6 +73,14 @@ mockGetMigrationStatus.migrationStatusFailedData = {
forwardSourceToTarget: true,
isFailed: true,
targetCollection: null,
+ migrationSummary: {
+ totalBlocks: 0,
+ sections: 0,
+ subsections: 0,
+ units: 0,
+ components: 0,
+ unsupported: 0,
+ },
},
],
} as api.MigrateTaskStatusData;
@@ -91,6 +107,14 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = {
forwardSourceToTarget: true,
isFailed: true,
targetCollection: null,
+ migrationSummary: {
+ totalBlocks: 0,
+ sections: 0,
+ subsections: 0,
+ units: 0,
+ components: 0,
+ unsupported: 0,
+ },
},
{
id: 2,
@@ -103,6 +127,14 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = {
forwardSourceToTarget: true,
isFailed: true,
targetCollection: null,
+ migrationSummary: {
+ totalBlocks: 0,
+ sections: 0,
+ subsections: 0,
+ units: 0,
+ components: 0,
+ unsupported: 0,
+ },
},
],
} as api.MigrateTaskStatusData;
@@ -129,6 +161,14 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = {
forwardSourceToTarget: true,
isFailed: true,
targetCollection: null,
+ migrationSummary: {
+ totalBlocks: 0,
+ sections: 0,
+ subsections: 0,
+ units: 0,
+ components: 0,
+ unsupported: 0,
+ },
},
{
id: 2,
@@ -141,6 +181,14 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = {
forwardSourceToTarget: true,
isFailed: false,
targetCollection: null,
+ migrationSummary: {
+ totalBlocks: 0,
+ sections: 0,
+ subsections: 0,
+ units: 0,
+ components: 0,
+ unsupported: 0,
+ },
},
],
} as api.MigrateTaskStatusData;
diff --git a/src/data/api.ts b/src/data/api.ts
index 7f2502a922..357b55d272 100644
--- a/src/data/api.ts
+++ b/src/data/api.ts
@@ -97,6 +97,14 @@ export interface MigrateParameters {
key: string;
title: string;
} | null;
+ migrationSummary: {
+ totalBlocks: number;
+ sections: number;
+ subsections: number;
+ units: number;
+ components: number;
+ unsupported: number;
+ }
}
export interface MigrateTaskStatusData {
diff --git a/src/library-authoring/import-course/ImportDetailsPage.tsx b/src/library-authoring/import-course/ImportDetailsPage.tsx
index b431f54fd6..ffea29037d 100644
--- a/src/library-authoring/import-course/ImportDetailsPage.tsx
+++ b/src/library-authoring/import-course/ImportDetailsPage.tsx
@@ -105,7 +105,7 @@ export const ImportDetailsPage = () => {
};
const renderBody = () => {
- if (isPending) {
+ if (isPending || !courseImportDetails) {
return ;
}
@@ -121,13 +121,22 @@ export const ImportDetailsPage = () => {
{...messages.importSuccessfulAlertBody}
values={{
courseName: courseDetails?.title,
- collectionName: courseImportDetails?.targetCollection?.title,
+ collectionName: courseImportDetails.targetCollection?.title,
}}
/>
-
+
Date: Thu, 20 Nov 2025 17:47:57 -0500
Subject: [PATCH 04/15] feat: Partial import for Import details page
---
.env | 2 +-
.env.development | 2 +-
.env.test | 2 +-
src/data/api.mocks.ts | 6 ++
src/data/api.ts | 5 +
.../import-course/ImportDetailsPage.tsx | 92 ++++++++++++++++++-
.../import-course/messages.ts | 33 +++++++
7 files changed, 134 insertions(+), 8 deletions(-)
diff --git a/.env b/.env
index 23fa3de594..3237da4c31 100644
--- a/.env
+++ b/.env
@@ -45,7 +45,7 @@ INVITE_STUDENTS_EMAIL_TO=''
ENABLE_CHECKLIST_QUALITY=''
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
-LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
+LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
diff --git a/.env.development b/.env.development
index c243685943..970902cff7 100644
--- a/.env.development
+++ b/.env.development
@@ -48,7 +48,7 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
-LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content"
+LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
diff --git a/.env.test b/.env.test
index e78d32b327..0e1e83d0cd 100644
--- a/.env.test
+++ b/.env.test
@@ -40,6 +40,6 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries
-LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
+LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content,itembank"
PARAGON_THEME_URLS=
COURSE_TEAM_SUPPORT_EMAIL='support@example.com'
diff --git a/src/data/api.mocks.ts b/src/data/api.mocks.ts
index 4c6c4d7afb..a5a102aecf 100644
--- a/src/data/api.mocks.ts
+++ b/src/data/api.mocks.ts
@@ -47,6 +47,7 @@ mockGetMigrationStatus.migrationStatusData = {
components: 0,
unsupported: 0,
},
+ unsupportedReasons: [],
},
],
} as api.MigrateTaskStatusData;
@@ -81,6 +82,7 @@ mockGetMigrationStatus.migrationStatusFailedData = {
components: 0,
unsupported: 0,
},
+ unsupportedReasons: [],
},
],
} as api.MigrateTaskStatusData;
@@ -115,6 +117,7 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = {
components: 0,
unsupported: 0,
},
+ unsupportedReasons: [],
},
{
id: 2,
@@ -135,6 +138,7 @@ mockGetMigrationStatus.migrationStatusFailedMultipleData = {
components: 0,
unsupported: 0,
},
+ unsupportedReasons: [],
},
],
} as api.MigrateTaskStatusData;
@@ -169,6 +173,7 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = {
components: 0,
unsupported: 0,
},
+ unsupportedReasons: [],
},
{
id: 2,
@@ -189,6 +194,7 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = {
components: 0,
unsupported: 0,
},
+ unsupportedReasons: [],
},
],
} as api.MigrateTaskStatusData;
diff --git a/src/data/api.ts b/src/data/api.ts
index 357b55d272..c0d9ce7d4a 100644
--- a/src/data/api.ts
+++ b/src/data/api.ts
@@ -105,6 +105,11 @@ export interface MigrateParameters {
components: number;
unsupported: number;
}
+ unsupportedReasons: {
+ block_name: string;
+ block_type: string;
+ reason: string;
+ }[];
}
export interface MigrateTaskStatusData {
diff --git a/src/library-authoring/import-course/ImportDetailsPage.tsx b/src/library-authoring/import-course/ImportDetailsPage.tsx
index ffea29037d..161f10316f 100644
--- a/src/library-authoring/import-course/ImportDetailsPage.tsx
+++ b/src/library-authoring/import-course/ImportDetailsPage.tsx
@@ -4,14 +4,18 @@ import { useNavigate, useParams } from 'react-router';
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 } from '@openedx/paragon/icons';
+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 messages from './messages';
@@ -66,6 +70,8 @@ export const ImportDetailsPage = () => {
// TODO: Update this code when using simple migration
if (courseImportDetails?.isFailed) {
migrationStatus = 'Failed';
+ } else if (courseImportDetails?.migrationSummary.unsupported !== 0) {
+ migrationStatus = 'Partial Succeeded';
} else {
migrationStatus = 'Succeeded';
}
@@ -128,10 +134,10 @@ export const ImportDetailsPage = () => {
{
);
+ } if (migrationStatus === 'Partial Succeeded') {
+ const importedSuccessfullyCount = courseImportDetails.migrationSummary.totalBlocks
+ - courseImportDetails.migrationSummary.unsupported;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
+
return (
- // In Progress
+ // In Progress
+
{
data={courseImportDetails.unsupportedReasons}
>
+
-
+
+
+
+ )}
- },
- {
- Header: intl.formatMessage(messages.importPartialReasonTableBlockType),
- accessor: 'blockType',
- },
- {
- Header: intl.formatMessage(messages.importPartialReasonTableReason),
- accessor: 'reason',
- },
- ]}
- data={courseImportDetails.unsupportedReasons}
- >
-
-
-
- navigate(`${courseImport.source.key}/${courseImport.taskUuid}`)}
- />
+
+
+
From 58acdb2a105fc7ed6ac0f1095e2a8e0ad5a1329f Mon Sep 17 00:00:00 2001
From: Navin Karkera
Date: Wed, 3 Dec 2025 15:39:01 +0530
Subject: [PATCH 14/15] fix: refetch migration block info when the import is
completed
After the import process is completed, it is required to refetch
migration block info else the page shows 0 components.
---
src/library-authoring/import-course/ImportDetailsPage.tsx | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/library-authoring/import-course/ImportDetailsPage.tsx b/src/library-authoring/import-course/ImportDetailsPage.tsx
index 41f4ab14db..181adfface 100644
--- a/src/library-authoring/import-course/ImportDetailsPage.tsx
+++ b/src/library-authoring/import-course/ImportDetailsPage.tsx
@@ -71,6 +71,7 @@ export const ImportDetailsPage = () => {
const {
data: migrationBlockInfo,
isPending: isPendingMigrationBlockInfo,
+ refetch: refetchMigrationBlockInfo,
} = useMigrationBlocksInfo(
libraryId,
undefined,
@@ -81,7 +82,7 @@ export const ImportDetailsPage = () => {
const isPending = isPendingCourseDetails || isPendingMigrationStatusData || isPendingMigrationBlockInfo;
- // Build migration summary using the mibration blocks info
+ // Build migration summary using the migration blocks info
const {
migrationSummary,
unsupportedBlockIds,
@@ -145,6 +146,8 @@ export const ImportDetailsPage = () => {
// 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.
From 0804a8439057fcd94b83acc7b8cedf45d2eeed07 Mon Sep 17 00:00:00 2001
From: XnpioChV
Date: Thu, 4 Dec 2025 17:04:29 -0500
Subject: [PATCH 15/15] style: Nits in the code
---
src/generic/key-utils.test.ts | 30 +-
src/generic/key-utils.ts | 34 +-
src/library-authoring/data/api.mocks.ts | 2 +-
.../import-course/ImportDetailsPage.test.tsx | 2 +-
.../import-course/ImportDetailsPage.tsx | 365 +++++++++---------
5 files changed, 208 insertions(+), 225 deletions(-)
diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts
index cba9192c69..aa14604faf 100644
--- a/src/generic/key-utils.test.ts
+++ b/src/generic/key-utils.test.ts
@@ -2,7 +2,6 @@ import {
buildCollectionUsageKey,
ContainerType,
getBlockType,
- getBlockTypeBlockV1,
getLibraryId,
isLibraryKey,
isLibraryV1Key,
@@ -19,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}`);
});
@@ -143,25 +148,4 @@ 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 5842dc170b..f943e7c82b 100644
--- a/src/generic/key-utils.ts
+++ b/src/generic/key-utils.ts
@@ -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}`);
@@ -126,18 +133,3 @@ 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/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index 6337462cbe..9dc3c929e4 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -1137,7 +1137,7 @@ export async function mockGetCourseImports(libraryId: string): ReturnType (
)
);
-describe('', () => {
+describe('', () => {
beforeEach(() => {
const newMocks = initializeMocks();
axiosMock = newMocks.axiosMock;
diff --git a/src/library-authoring/import-course/ImportDetailsPage.tsx b/src/library-authoring/import-course/ImportDetailsPage.tsx
index 181adfface..460371d5dd 100644
--- a/src/library-authoring/import-course/ImportDetailsPage.tsx
+++ b/src/library-authoring/import-course/ImportDetailsPage.tsx
@@ -18,7 +18,7 @@ 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 { ContainerType, getBlockType } from '@src/generic/key-utils';
import messages from './messages';
import { SummaryCard } from './stepper/SummaryCard';
@@ -26,20 +26,11 @@ 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 ImportDetailsContent = () => {
const intl = useIntl();
const navigate = useNavigate();
- const { libraryId, libraryData, readOnly } = useLibraryContext();
const [enableRefeshState, setEnableRefreshState] = useState(true);
+ const { libraryId } = useLibraryContext();
const { courseId, migrationTaskId } = useParams();
const { showToast } = useContext(ToastContext);
const [disableReimport, setDisableReimport] = useState(false);
@@ -108,7 +99,7 @@ export const ImportDetailsPage = () => {
if (!block.targetKey) {
// The migrations of this block is failed
counts.unsupported += 1;
- resultUnsupportedIds.push(`"${block.sourceKey}"`);
+ resultUnsupportedIds.push(block.sourceKey);
if (block.unsupportedReason) {
// Verify if the unsupported block has children
@@ -117,7 +108,7 @@ export const ImportDetailsPage = () => {
}
} else {
counts.totalBlocks += 1;
- const blockType = getBlockTypeBlockV1(block.sourceKey);
+ const blockType = getBlockType(block.sourceKey);
switch (blockType) {
case ContainerType.Chapter:
counts.sections += 1;
@@ -163,9 +154,9 @@ export const ImportDetailsPage = () => {
}
// Fetch unsupported blocks usage_key information from meilisearch index.
- const { data: unssupportedBlocksData } = useGetContentHits(
+ const { data: unsupportedBlocksData } = useGetContentHits(
[
- `usage_key IN [${unsupportedBlockIds.join(',')}]`,
+ `usage_key IN [${unsupportedBlockIds.map(k => `"${k}"`).join(',')}]`,
],
(unsupportedBlockIds.length || 0) > 0,
['usage_key', 'block_type', 'display_name'],
@@ -175,7 +166,7 @@ export const ImportDetailsPage = () => {
// Build the data for the reasons for failed imports
const unsupportedTableData = useMemo(() => {
- if (!migrationBlockInfo || !unssupportedBlocksData) {
+ if (!migrationBlockInfo || !unsupportedBlocksData) {
return [];
}
@@ -184,12 +175,12 @@ export const ImportDetailsPage = () => {
[block.sourceKey]: block.unsupportedReason || '',
}), {} as Record);
- return unssupportedBlocksData.hits.map(block => ({
+ return unsupportedBlocksData.hits.map(block => ({
blockName: block.display_name,
blockType: block.block_type,
reason: reasons[block.usage_key],
}));
- }, [migrationBlockInfo, unssupportedBlocksData]);
+ }, [migrationBlockInfo, unsupportedBlocksData]);
// In any state other than "in progress", it is no longer necessary
// to keep refreshing the task status.
@@ -230,196 +221,212 @@ export const ImportDetailsPage = () => {
}
};
- const renderBody = () => {
- if (isPending || !courseImportDetails) {
- return ;
- }
+ if (isPending || !courseImportDetails) {
+ return ;
+ }
- if (migrationStatus === 'Succeeded') {
- return (
-
-
-
-
-
-
-
-
-
-
-
+ if (migrationStatus === 'Succeeded') {
+ return (
+
+
+
+
+
-
-
-
-
- );
- } if (migrationStatus === 'Failed') {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- } if (migrationStatus === 'Partial Succeeded') {
- return (
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
-
+
+
+
+
+
+ );
+ } if (migrationStatus === 'Failed') {
+ return (
+
+
+
+
+
+
-
- {!isPendingMigrationBlockInfo && unsupportedTableData && (
-
-
-
-
- )}
-
-
-
-
-
- );
- }
-
+
+
+
+
+
+
+
+
+
+
+ );
+ } if (migrationStatus === 'Partial Succeeded') {
return (
- // In Progress
-
-
+
+
+
+
+
+
+
+
+
+
+
-
-
-
+
+ {!isPendingMigrationBlockInfo && unsupportedTableData && (
+
+
+
+
+ )}
+
);
- };
+ }
+
+ return (
+ // In Progress
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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 (
@@ -448,7 +455,7 @@ export const ImportDetailsPage = () => {
- {renderBody()}
+