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()} +