From ca3c06830e9582d1c8b3c22ca1051da504eab0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Wed, 22 Oct 2025 10:59:48 -0300 Subject: [PATCH 01/14] feat: add course import page --- .../LegacyMigrationHelpSidebar.tsx | 6 +- src/library-authoring/LibraryLayout.tsx | 7 +- src/library-authoring/data/api.mocks.ts | 61 ++++++++++++ src/library-authoring/data/api.ts | 25 +++++ src/library-authoring/data/apiHooks.ts | 14 +++ .../CourseImportHomePage.test.tsx | 53 ++++++++++ .../import-course/CourseImportHomePage.tsx | 96 +++++++++++++++++++ .../import-course/HelpSidebar.tsx | 44 +++++++++ .../import-course/MigratedCourseCard.test.tsx | 94 ++++++++++++++++++ .../import-course/MigratedCourseCard.tsx | 83 ++++++++++++++++ .../import-course/index.scss | 29 ++++++ src/library-authoring/import-course/index.ts | 1 + .../import-course/messages.ts | 68 +++++++++++++ src/library-authoring/index.scss | 1 + .../library-info/LibraryInfoHeader.test.tsx | 3 +- .../library-info/LibraryInfoHeader.tsx | 2 +- src/library-authoring/routes.ts | 2 + src/utils.tsx | 2 + 18 files changed, 584 insertions(+), 7 deletions(-) create mode 100644 src/library-authoring/import-course/CourseImportHomePage.test.tsx create mode 100644 src/library-authoring/import-course/CourseImportHomePage.tsx create mode 100644 src/library-authoring/import-course/HelpSidebar.tsx create mode 100644 src/library-authoring/import-course/MigratedCourseCard.test.tsx create mode 100644 src/library-authoring/import-course/MigratedCourseCard.tsx create mode 100644 src/library-authoring/import-course/index.scss create mode 100644 src/library-authoring/import-course/index.ts create mode 100644 src/library-authoring/import-course/messages.ts diff --git a/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx b/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx index 72880a3163..abe5f0ae36 100644 --- a/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx +++ b/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx @@ -1,12 +1,10 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Icon, Stack } from '@openedx/paragon'; import { Question } from '@openedx/paragon/icons'; +import { Div, Paragraph } from '@src/utils'; import messages from './messages'; -export const SingleLineBreak = (chunk: string[]) =>
{chunk}
; -export const Paragraph = (chunk: string[]) =>

{chunk}

; - export const LegacyMigrationHelpSidebar = () => (
@@ -42,7 +40,7 @@ export const LegacyMigrationHelpSidebar = () => ( diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index d51a25c0a8..7575c19127 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -6,8 +6,8 @@ import { useParams, } from 'react-router-dom'; -import { LibraryBackupPage } from '@src/library-authoring/backup-restore'; import LibraryAuthoringPage from './LibraryAuthoringPage'; +import { LibraryBackupPage } from './backup-restore'; import LibraryCollectionPage from './collections/LibraryCollectionPage'; import { LibraryProvider } from './common/context/LibraryContext'; import { SidebarProvider } from './common/context/SidebarContext'; @@ -15,6 +15,7 @@ import { ComponentPicker } from './component-picker'; import { ComponentEditorModal } from './components/ComponentEditorModal'; import { CreateCollectionModal } from './create-collection'; import { CreateContainerModal } from './create-container'; +import { CourseImportHomePage } from './import-course'; import { ROUTES } from './routes'; import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections'; import { LibraryUnitPage } from './units'; @@ -92,6 +93,10 @@ const LibraryLayout = () => ( path={ROUTES.BACKUP} Component={LibraryBackupPage} /> + ); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 760642bb55..4203c82650 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1072,3 +1072,64 @@ mockGetEntityLinks.applyMock = () => jest.spyOn( courseLibApi, 'getEntityLinks', ).mockImplementation(mockGetEntityLinks); + +export async function mockGetCourseMigrations(libraryId: string): ReturnType { + switch (libraryId) { + case mockContentLibrary.libraryId: + return [ + mockGetCourseMigrations.succeedMigration, + mockGetCourseMigrations.succeedMigrationWithCollection, + mockGetCourseMigrations.failMigration, + mockGetCourseMigrations.inProgressMigration, + ]; + case mockGetCourseMigrations.emptyLibraryId: + return []; + default: + throw new Error(`mockGetCourseMigrations doesn't know how to mock ${JSON.stringify(libraryId)}`); + } +} +mockGetCourseMigrations.libraryId = mockContentLibrary.libraryId; +mockGetCourseMigrations.emptyLibraryId = mockContentLibrary.libraryId2; +mockGetCourseMigrations.succeedMigration = { + source: { + key: 'course-v1:edX+DemoX+2025_T1', + displayName: 'DemoX 2025 T1', + }, + targetCollection: null, + state: 'Succeeded', + progress: 1, +} satisfies api.CourseMigration; +mockGetCourseMigrations.succeedMigrationWithCollection = { + source: { + key: 'course-v1:edX+DemoX+2025_T2', + displayName: 'DemoX 2025 T2', + }, + targetCollection: { + key: 'sample-collection', + title: 'DemoX 2025 T1 (2)', + }, + state: 'Succeeded', + progress: 1, +} satisfies api.CourseMigration; +mockGetCourseMigrations.failMigration = { + source: { + key: 'course-v1:edX+DemoX+2025_T3', + displayName: 'DemoX 2025 T3', + }, + targetCollection: null, + state: 'Failed', + progress: 0.30, +} satisfies api.CourseMigration; +mockGetCourseMigrations.inProgressMigration = { + source: { + key: 'course-v1:edX+DemoX+2025_T4', + displayName: 'DemoX 2025 T4', + }, + targetCollection: null, + state: 'InProgress', + progress: 0.5012, +} satisfies api.CourseMigration; +mockGetCourseMigrations.applyMock = () => jest.spyOn( + api, + 'getCourseMigrations', +).mockImplementation(mockGetCourseMigrations); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 211b1614b2..b05bf3c4ce 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -157,6 +157,10 @@ export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUr * Get the URL for the API endpoint to copy a single container. */ export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`; +/** + * Get the url for the API endpoint to list library course migrations. + */ +export const getCourseMigrationsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`; export interface ContentLibrary { id: string; @@ -784,3 +788,24 @@ export async function getLibraryContainerHierarchy( export async function publishContainer(containerId: string) { await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId)); } + +export interface CourseMigration { + source: { + key: string; + displayName: string; + }; + targetCollection: { + key: string; + title: string; + } | null; + state: 'Succeeded' | 'Failed' | 'InProgress'; + progress: number; +} + +/** + * Returns the course migrations which had this library as destination. + */ +export async function getCourseMigrations(libraryId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getCourseMigrationsApiUrl(libraryId)); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 5646d2f6b7..328cbe3810 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -89,6 +89,10 @@ export const libraryAuthoringQueryKeys = { } return ['hierarchy']; }, + migrations: (libraryId: string) => [ + ...libraryAuthoringQueryKeys.contentLibrary(libraryId), + 'migrations', + ], }; export const xblockQueryKeys = { @@ -951,3 +955,13 @@ export const useContentFromSearchIndex = (contentIds: string[]) => { skipBlockTypeFetch: true, }); }; + +/** + * Returns the course migrations which had this library as destination. + */ +export const useCourseMigrations = (libraryId: string) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.migrations(libraryId), + queryFn: () => api.getCourseMigrations(libraryId), + }) +); diff --git a/src/library-authoring/import-course/CourseImportHomePage.test.tsx b/src/library-authoring/import-course/CourseImportHomePage.test.tsx new file mode 100644 index 0000000000..24d8acd661 --- /dev/null +++ b/src/library-authoring/import-course/CourseImportHomePage.test.tsx @@ -0,0 +1,53 @@ +import { + initializeMocks, + render as testRender, + screen, +} from '@src/testUtils'; + +import { LibraryProvider } from '../common/context/LibraryContext'; +import { + mockContentLibrary, + mockGetCourseMigrations, +} from '../data/api.mocks'; +import { CourseImportHomePage } from './CourseImportHomePage'; + +initializeMocks(); +mockContentLibrary.applyMock(); +mockGetCourseMigrations.applyMock(); + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const render = (libraryId: string) => ( + testRender( + , + { + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + path: '/libraries/:libraryId/import-course', + params: { libraryId }, + }, + ) +); + +describe('', () => { + it('should render the library course import home page', async () => { + render(mockGetCourseMigrations.libraryId); + expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header + expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument(); + expect(screen.queryAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4); + }); + + it('should render the empty state', async () => { + render(mockGetCourseMigrations.emptyLibraryId); + expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header + expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument(); + expect(screen.queryByText('You have not imported any courses into this library.')).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx new file mode 100644 index 0000000000..df818cfd33 --- /dev/null +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -0,0 +1,96 @@ +import { + Button, + Card, + Container, + Layout, + Stack, +} from '@openedx/paragon'; +import { Add } from '@openedx/paragon/icons'; +import { Helmet } from 'react-helmet'; + +import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; +import NotFoundAlert from '@src/generic/NotFoundAlert'; +import Loading from '@src/generic/Loading'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import Header from '@src/header'; + +import { useLibraryContext } from '../common/context/LibraryContext'; +import { useContentLibrary, useCourseMigrations } from '../data/apiHooks'; +import { HelpSidebar } from './HelpSidebar'; +import { MigratedCourseCard } from './MigratedCourseCard'; +import messages from './messages'; + +const EmptyState = () => ( + + + + + + + + +); + +export const CourseImportHomePage = () => { + const intl = useIntl(); + const { libraryId } = useLibraryContext(); + const { data: libraryData } = useContentLibrary(libraryId); + const { data: courseMigrations } = useCourseMigrations(libraryId); + + if (!courseMigrations) { + return ; + } + + if (!libraryData) { + return ; + } + + return ( +
+
+ + {libraryData.title} | {process.env.SITE_NAME} + +
+ +
+ +
+ + + {courseMigrations.length ? ( + +

Previous Imports

+ {courseMigrations.map((courseMigration) => ( + + ))} +
+ ) : ()} +
+ + + +
+
+
+
+ ); +}; diff --git a/src/library-authoring/import-course/HelpSidebar.tsx b/src/library-authoring/import-course/HelpSidebar.tsx new file mode 100644 index 0000000000..6e831d44df --- /dev/null +++ b/src/library-authoring/import-course/HelpSidebar.tsx @@ -0,0 +1,44 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Icon, Stack } from '@openedx/paragon'; +import { Question } from '@openedx/paragon/icons'; +import { Paragraph } from '@src/utils'; + +import messages from './messages'; + +export const HelpSidebar = () => ( +
+ + + + + + +
+ + + + + + + + + +
+ + + + + + + + +
+
+
+); diff --git a/src/library-authoring/import-course/MigratedCourseCard.test.tsx b/src/library-authoring/import-course/MigratedCourseCard.test.tsx new file mode 100644 index 0000000000..65f9ae628c --- /dev/null +++ b/src/library-authoring/import-course/MigratedCourseCard.test.tsx @@ -0,0 +1,94 @@ +import userEvent from '@testing-library/user-event'; + +import { + initializeMocks, + render as testRender, + screen, + waitFor, +} from '@src/testUtils'; + +import { LibraryProvider } from '../common/context/LibraryContext'; +import { + mockContentLibrary, + mockGetCourseMigrations, +} from '../data/api.mocks'; +import { type CourseMigration } from '../data/api'; +import { MigratedCourseCard } from './MigratedCourseCard'; + +initializeMocks(); +mockContentLibrary.applyMock(); +const { libraryId } = mockContentLibrary; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const render = (courseMigration: CourseMigration) => ( + testRender( + , + { + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + path: '/libraries/:libraryId/import-course', + params: { libraryId }, + }, + ) +); + +describe('', () => { + it('should render a card for a successful migration', () => { + const { succeedMigration } = mockGetCourseMigrations; + render(succeedMigration); + expect(screen.getByText(succeedMigration.source.displayName)).toBeInTheDocument(); + expect(screen.getByText(/100% Imported/)).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: succeedMigration.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${succeedMigration.source.key}`); + }); + + it('should render a card for a successful migration with a collection', async () => { + const { succeedMigrationWithCollection } = mockGetCourseMigrations; + render(succeedMigrationWithCollection); + expect(screen.getByText(succeedMigrationWithCollection.source.displayName)).toBeInTheDocument(); + expect(screen.getByText(/100% Imported/)).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: succeedMigrationWithCollection.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${succeedMigrationWithCollection.source.key}`); + + const collectionLink = await screen.findByText(succeedMigrationWithCollection.targetCollection.title); + userEvent.click(collectionLink); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + { + pathname: `/library/${libraryId}/collection/${succeedMigrationWithCollection.targetCollection.key}`, + search: '', + }, + ); + }); + }); + + it('should render a card for a failed migration', () => { + const { failMigration } = mockGetCourseMigrations; + render(failMigration); + expect(screen.getByText(failMigration.source.displayName)).toBeInTheDocument(); + expect(screen.getByText('Import Failed')).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: failMigration.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${failMigration.source.key}`); + }); + + it('should render a card for an in-progress migration', () => { + const { inProgressMigration } = mockGetCourseMigrations; + render(inProgressMigration); + expect(screen.getByText(inProgressMigration.source.displayName)).toBeInTheDocument(); + expect(screen.getByText(/50% Imported/)).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: inProgressMigration.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${inProgressMigration.source.key}`); + }); +}); diff --git a/src/library-authoring/import-course/MigratedCourseCard.tsx b/src/library-authoring/import-course/MigratedCourseCard.tsx new file mode 100644 index 0000000000..830cf8063f --- /dev/null +++ b/src/library-authoring/import-course/MigratedCourseCard.tsx @@ -0,0 +1,83 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Button, Card, Icon } from '@openedx/paragon'; +import { + Check, + Error, + Folder, + IncompleteCircle, + Warning, +} from '@openedx/paragon/icons'; +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; + +import { type CourseMigration } from '../data/api'; +import { useLibraryRoutes } from '../routes'; +import messages from './messages'; + +interface MigratedCourseCardProps { + courseMigration: CourseMigration; +} + +const BORDER_CLASS = { + Succeeded: 'status-border-imported', + Failed: 'status-border-failed', + Partial: 'status-border-partial', + InProgress: 'status-border-in-progress', +}; + +const STATE_ICON = { + Succeeded: Check, + Failed: Error, + Partial: Warning, + InProgress: IncompleteCircle, +}; + +const STATE_ICON_COLOR_CLASS = { + Succeeded: undefined, + Failed: 'text-danger-500', + Partial: 'text-warning-500', + InProgress: undefined, +}; + +const StateIcon = ({ state }: { state: CourseMigration['state'] }) => ( + +); + +export const MigratedCourseCard = ({ courseMigration }: MigratedCourseCardProps) => { + const { navigateTo } = useLibraryRoutes(); + + return ( + + + +

{courseMigration.source.displayName}

+ +
+ + {courseMigration.state === 'Failed' ? ( + + ) : ( + <> + {Math.round(courseMigration.progress * 100)} + + + )} + {courseMigration.targetCollection && ( + + )} +
+
+
+ ); +}; diff --git a/src/library-authoring/import-course/index.scss b/src/library-authoring/import-course/index.scss new file mode 100644 index 0000000000..caedca34e4 --- /dev/null +++ b/src/library-authoring/import-course/index.scss @@ -0,0 +1,29 @@ +.course-migration-help { + z-index: 1000; // same as header + flex: 350px 0 0; + position: sticky; + top: 0; + right: 0; + height: 100vh; + overflow-y: auto; + + hr { + width: 100%; + } +} + +.status-border-imported { + border-left: 8px solid #5690BB; +} + +.status-border-failed { + border-left: 8px solid var(--pgn-color-danger-500); +} + +.status-border-partial { + border-left: 8px solid var(--pgn-color-warning-500); +} + +.status-border-in-progress { + border-left: 8px solid #F4B57B; +} diff --git a/src/library-authoring/import-course/index.ts b/src/library-authoring/import-course/index.ts new file mode 100644 index 0000000000..23de1b775f --- /dev/null +++ b/src/library-authoring/import-course/index.ts @@ -0,0 +1 @@ +export { CourseImportHomePage } from './CourseImportHomePage'; diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts new file mode 100644 index 0000000000..d17bbd8d92 --- /dev/null +++ b/src/library-authoring/import-course/messages.ts @@ -0,0 +1,68 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + pageTitle: { + id: 'course-authoring.library-authoring.import-course.title', + defaultMessage: 'Import', + description: 'Title for the library import course', + }, + pageSubtitle: { + id: 'course-authoring.library-authoring.import-course.subtitle', + defaultMessage: 'Tools', + description: 'Subtitle for the library import course', + }, + emptyStateText: { + id: 'course-authoring.library-authoring.import-course.empty-state.text', + defaultMessage: 'You have not imported any courses into this library.', + description: 'Text for the empty state of the library import course', + }, + emptyStateButtonText: { + id: 'course-authoring.library-authoring.import-course.empty-state.button.text', + defaultMessage: 'Import Course', + description: 'Text for the button to import a course into the library', + }, + courseImportTextProgress: { + id: 'course-authoring.library-authoring.import-course.course.text', + defaultMessage: '% Imported', + description: 'Text for the course import state', + }, + courseImportTextFailed: { + id: 'course-authoring.library-authoring.import-course.course.text-failed', + defaultMessage: 'Import Failed', + description: 'Text for the course import failed state', + }, + helpAndSupportTitle: { + id: 'course-authoring.library-authoring.import-course.help-and-support.title', + defaultMessage: 'Help & Support', + description: 'Title of the Help & Support sidebar', + }, + helpAndSupportFirstQuestionTitle: { + id: 'course-authoring.library-authoring.import-course.help-and-support.q1.title', + defaultMessage: 'Why import a course?', + description: 'Title of the first question in the Help & Support sidebar', + }, + helpAndSupportFirstQuestionBody: { + id: 'course-authoring.library-authoring.import-course.help-and-support.q1.body', + defaultMessage: '

You can import existing courses into a library in order to reference ' + + 'course content across courses.

' + + '

Courses with content you or others may want to reuse or reference in the future are ' + + 'excellent candidates for import.

', + description: 'Body of the first question in the Help & Support sidebar', + }, + helpAndSupportSecondQuestionTitle: { + id: 'course-authoring.library-authoring.import-course.help-and-support.q2.title', + defaultMessage: 'What content is imported?', + description: 'Title of the second question in the Help & Support sidebar', + }, + helpAndSupportSecondQuestionBody: { + id: 'course-authoring.library-authoring.import-course.help-and-support.q2.body', + defaultMessage: '

You can select a course to import and decide whether to import all sections, ' + + 'subsections, units, or blocks from this course.

' + + '

Not all courses content types can be imported, but this page will convey the status of imports ' + + 'and share any import errors found while importing your course.

' + + '

For additional details you can review the Library Import documentation.

', + description: 'Body of the second question in the Help & Support sidebar', + }, +}); + +export default messages; diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 1404cb68ff..43390d63d4 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -6,6 +6,7 @@ @import "./section-subsections"; @import "./containers"; @import "./hierarchy"; +@import "./import-course"; .library-cards-grid { display: grid; diff --git a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx index 33630af5b6..f939756437 100644 --- a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx +++ b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx @@ -6,7 +6,8 @@ import { screen, waitFor, initializeMocks, -} from '../../testUtils'; +} from '@src/testUtils'; + import { mockContentLibrary } from '../data/api.mocks'; import { getContentLibraryApiUrl } from '../data/api'; import { LibraryProvider } from '../common/context/LibraryContext'; diff --git a/src/library-authoring/library-info/LibraryInfoHeader.tsx b/src/library-authoring/library-info/LibraryInfoHeader.tsx index cafeccf268..00d5b304ad 100644 --- a/src/library-authoring/library-info/LibraryInfoHeader.tsx +++ b/src/library-authoring/library-info/LibraryInfoHeader.tsx @@ -8,7 +8,7 @@ import { import { Edit } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; -import { ToastContext } from '../../generic/toast-context'; +import { ToastContext } from '@src/generic/toast-context'; import { useLibraryContext } from '../common/context/LibraryContext'; import { useUpdateLibraryMetadata } from '../data/apiHooks'; import messages from './messages'; diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index 124264016b..e052c6fc4a 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -47,6 +47,8 @@ export const ROUTES = { UNIT: '/unit/:containerId/:selectedItemId?/:index?', // LibraryBackupPage route: BACKUP: '/backup', + // LibraryImportPage route: + IMPORT: '/import', }; export enum ContentType { diff --git a/src/utils.tsx b/src/utils.tsx index 1432a2ce19..f57d434a0c 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -349,3 +349,5 @@ export const skipIfUnwantedTarget = ( }; export const BoldText = (chunk: string[]) => {chunk}; +export const Div = (chunk: string[]) =>
{chunk}
; +export const Paragraph = (chunk: string[]) =>

{chunk}

; From 868084dffd0c730e274db6445ee1328487dbe8f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 10:40:49 -0300 Subject: [PATCH 02/14] fix: change loading placeholder --- .../import-course/CourseImportHomePage.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index df818cfd33..d268c99701 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -9,13 +9,12 @@ import { Add } from '@openedx/paragon/icons'; import { Helmet } from 'react-helmet'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; -import NotFoundAlert from '@src/generic/NotFoundAlert'; import Loading from '@src/generic/Loading'; import SubHeader from '@src/generic/sub-header/SubHeader'; import Header from '@src/header'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { useContentLibrary, useCourseMigrations } from '../data/apiHooks'; +import { useCourseMigrations } from '../data/apiHooks'; import { HelpSidebar } from './HelpSidebar'; import { MigratedCourseCard } from './MigratedCourseCard'; import messages from './messages'; @@ -35,18 +34,13 @@ const EmptyState = () => ( export const CourseImportHomePage = () => { const intl = useIntl(); - const { libraryId } = useLibraryContext(); - const { data: libraryData } = useContentLibrary(libraryId); + const { libraryId, libraryData } = useLibraryContext(); const { data: courseMigrations } = useCourseMigrations(libraryId); - if (!courseMigrations) { + if (!courseMigrations || !libraryData) { return ; } - if (!libraryData) { - return ; - } - return (
From 9a97f250b3e07782e9b4b0be24f9c3d5e8bf4672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 10:52:58 -0300 Subject: [PATCH 03/14] refactor: renaming migration > import --- src/library-authoring/data/api.mocks.ts | 40 ++++---- src/library-authoring/data/api.ts | 12 +-- src/library-authoring/data/apiHooks.ts | 12 +-- .../CourseImportHomePage.test.tsx | 8 +- .../import-course/CourseImportHomePage.tsx | 18 ++-- .../import-course/ImportedCourseCard.test.tsx | 94 +++++++++++++++++++ ...dCourseCard.tsx => ImportedCourseCard.tsx} | 28 +++--- .../import-course/MigratedCourseCard.test.tsx | 94 ------------------- 8 files changed, 153 insertions(+), 153 deletions(-) create mode 100644 src/library-authoring/import-course/ImportedCourseCard.test.tsx rename src/library-authoring/import-course/{MigratedCourseCard.tsx => ImportedCourseCard.tsx} (65%) delete mode 100644 src/library-authoring/import-course/MigratedCourseCard.test.tsx diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 4203c82650..9b3b272858 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1073,24 +1073,24 @@ mockGetEntityLinks.applyMock = () => jest.spyOn( 'getEntityLinks', ).mockImplementation(mockGetEntityLinks); -export async function mockGetCourseMigrations(libraryId: string): ReturnType { +export async function mockGetCourseImports(libraryId: string): ReturnType { switch (libraryId) { case mockContentLibrary.libraryId: return [ - mockGetCourseMigrations.succeedMigration, - mockGetCourseMigrations.succeedMigrationWithCollection, - mockGetCourseMigrations.failMigration, - mockGetCourseMigrations.inProgressMigration, + mockGetCourseImports.succeedImport, + mockGetCourseImports.succeedImportWithCollection, + mockGetCourseImports.failImport, + mockGetCourseImports.inProgressImport, ]; - case mockGetCourseMigrations.emptyLibraryId: + case mockGetCourseImports.emptyLibraryId: return []; default: - throw new Error(`mockGetCourseMigrations doesn't know how to mock ${JSON.stringify(libraryId)}`); + throw new Error(`mockGetCourseImports doesn't know how to mock ${JSON.stringify(libraryId)}`); } } -mockGetCourseMigrations.libraryId = mockContentLibrary.libraryId; -mockGetCourseMigrations.emptyLibraryId = mockContentLibrary.libraryId2; -mockGetCourseMigrations.succeedMigration = { +mockGetCourseImports.libraryId = mockContentLibrary.libraryId; +mockGetCourseImports.emptyLibraryId = mockContentLibrary.libraryId2; +mockGetCourseImports.succeedImport = { source: { key: 'course-v1:edX+DemoX+2025_T1', displayName: 'DemoX 2025 T1', @@ -1098,8 +1098,8 @@ mockGetCourseMigrations.succeedMigration = { targetCollection: null, state: 'Succeeded', progress: 1, -} satisfies api.CourseMigration; -mockGetCourseMigrations.succeedMigrationWithCollection = { +} satisfies api.CourseImport; +mockGetCourseImports.succeedImportWithCollection = { source: { key: 'course-v1:edX+DemoX+2025_T2', displayName: 'DemoX 2025 T2', @@ -1110,8 +1110,8 @@ mockGetCourseMigrations.succeedMigrationWithCollection = { }, state: 'Succeeded', progress: 1, -} satisfies api.CourseMigration; -mockGetCourseMigrations.failMigration = { +} satisfies api.CourseImport; +mockGetCourseImports.failImport = { source: { key: 'course-v1:edX+DemoX+2025_T3', displayName: 'DemoX 2025 T3', @@ -1119,8 +1119,8 @@ mockGetCourseMigrations.failMigration = { targetCollection: null, state: 'Failed', progress: 0.30, -} satisfies api.CourseMigration; -mockGetCourseMigrations.inProgressMigration = { +} satisfies api.CourseImport; +mockGetCourseImports.inProgressImport = { source: { key: 'course-v1:edX+DemoX+2025_T4', displayName: 'DemoX 2025 T4', @@ -1128,8 +1128,8 @@ mockGetCourseMigrations.inProgressMigration = { targetCollection: null, state: 'InProgress', progress: 0.5012, -} satisfies api.CourseMigration; -mockGetCourseMigrations.applyMock = () => jest.spyOn( +} satisfies api.CourseImport; +mockGetCourseImports.applyMock = () => jest.spyOn( api, - 'getCourseMigrations', -).mockImplementation(mockGetCourseMigrations); + 'getCourseImports', +).mockImplementation(mockGetCourseImports); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index b05bf3c4ce..d87458dfef 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -158,9 +158,9 @@ export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUr */ export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`; /** - * Get the url for the API endpoint to list library course migrations. + * Get the url for the API endpoint to list library course imports. */ -export const getCourseMigrationsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`; +export const getCourseImportsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`; export interface ContentLibrary { id: string; @@ -789,7 +789,7 @@ export async function publishContainer(containerId: string) { await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId)); } -export interface CourseMigration { +export interface CourseImport { source: { key: string; displayName: string; @@ -803,9 +803,9 @@ export interface CourseMigration { } /** - * Returns the course migrations which had this library as destination. + * Returns the course imports which had this library as destination. */ -export async function getCourseMigrations(libraryId: string): Promise { - const { data } = await getAuthenticatedHttpClient().get(getCourseMigrationsApiUrl(libraryId)); +export async function getCourseImports(libraryId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getCourseImportsApiUrl(libraryId)); return camelCaseObject(data); } diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 328cbe3810..e72ac1cf41 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -89,9 +89,9 @@ export const libraryAuthoringQueryKeys = { } return ['hierarchy']; }, - migrations: (libraryId: string) => [ + courseImports: (libraryId: string) => [ ...libraryAuthoringQueryKeys.contentLibrary(libraryId), - 'migrations', + 'courseImports', ], }; @@ -957,11 +957,11 @@ export const useContentFromSearchIndex = (contentIds: string[]) => { }; /** - * Returns the course migrations which had this library as destination. + * Returns the course imports which had this library as destination. */ -export const useCourseMigrations = (libraryId: string) => ( +export const useCourseImports = (libraryId: string) => ( useQuery({ - queryKey: libraryAuthoringQueryKeys.migrations(libraryId), - queryFn: () => api.getCourseMigrations(libraryId), + queryKey: libraryAuthoringQueryKeys.courseImports(libraryId), + queryFn: () => api.getCourseImports(libraryId), }) ); diff --git a/src/library-authoring/import-course/CourseImportHomePage.test.tsx b/src/library-authoring/import-course/CourseImportHomePage.test.tsx index 24d8acd661..e1e7e0105d 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.test.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.test.tsx @@ -7,13 +7,13 @@ import { import { LibraryProvider } from '../common/context/LibraryContext'; import { mockContentLibrary, - mockGetCourseMigrations, + mockGetCourseImports, } from '../data/api.mocks'; import { CourseImportHomePage } from './CourseImportHomePage'; initializeMocks(); mockContentLibrary.applyMock(); -mockGetCourseMigrations.applyMock(); +mockGetCourseImports.applyMock(); const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -38,14 +38,14 @@ const render = (libraryId: string) => ( describe('', () => { it('should render the library course import home page', async () => { - render(mockGetCourseMigrations.libraryId); + render(mockGetCourseImports.libraryId); expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument(); expect(screen.queryAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4); }); it('should render the empty state', async () => { - render(mockGetCourseMigrations.emptyLibraryId); + render(mockGetCourseImports.emptyLibraryId); expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument(); expect(screen.queryByText('You have not imported any courses into this library.')).toBeInTheDocument(); diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index d268c99701..83ad4d105e 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -14,9 +14,9 @@ import SubHeader from '@src/generic/sub-header/SubHeader'; import Header from '@src/header'; import { useLibraryContext } from '../common/context/LibraryContext'; -import { useCourseMigrations } from '../data/apiHooks'; +import { useCourseImports } from '../data/apiHooks'; import { HelpSidebar } from './HelpSidebar'; -import { MigratedCourseCard } from './MigratedCourseCard'; +import { ImportedCourseCard } from './ImportedCourseCard'; import messages from './messages'; const EmptyState = () => ( @@ -35,9 +35,9 @@ const EmptyState = () => ( export const CourseImportHomePage = () => { const intl = useIntl(); const { libraryId, libraryData } = useLibraryContext(); - const { data: courseMigrations } = useCourseMigrations(libraryId); + const { data: courseImports } = useCourseImports(libraryId); - if (!courseMigrations || !libraryData) { + if (!courseImports || !libraryData) { return ; } @@ -67,13 +67,13 @@ export const CourseImportHomePage = () => {
- {courseMigrations.length ? ( + {courseImports.length ? (

Previous Imports

- {courseMigrations.map((courseMigration) => ( - ( + ))}
diff --git a/src/library-authoring/import-course/ImportedCourseCard.test.tsx b/src/library-authoring/import-course/ImportedCourseCard.test.tsx new file mode 100644 index 0000000000..74664dc6f1 --- /dev/null +++ b/src/library-authoring/import-course/ImportedCourseCard.test.tsx @@ -0,0 +1,94 @@ +import userEvent from '@testing-library/user-event'; + +import { + initializeMocks, + render as testRender, + screen, + waitFor, +} from '@src/testUtils'; + +import { LibraryProvider } from '../common/context/LibraryContext'; +import { + mockContentLibrary, + mockGetCourseImports, +} from '../data/api.mocks'; +import { type CourseImport } from '../data/api'; +import { ImportedCourseCard } from './ImportedCourseCard'; + +initializeMocks(); +mockContentLibrary.applyMock(); +const { libraryId } = mockContentLibrary; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +const render = (courseImport: CourseImport) => ( + testRender( + , + { + extraWrapper: ({ children }: { children: React.ReactNode }) => ( + + {children} + + ), + path: '/libraries/:libraryId/import-course', + params: { libraryId }, + }, + ) +); + +describe('', () => { + it('should render a card for a successful import', () => { + const { succeedImport } = mockGetCourseImports; + render(succeedImport); + expect(screen.getByText(succeedImport.source.displayName)).toBeInTheDocument(); + expect(screen.getByText(/100% Imported/)).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: succeedImport.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${succeedImport.source.key}`); + }); + + it('should render a card for a successful import with a collection', async () => { + const { succeedImportWithCollection } = mockGetCourseImports; + render(succeedImportWithCollection); + expect(screen.getByText(succeedImportWithCollection.source.displayName)).toBeInTheDocument(); + expect(screen.getByText(/100% Imported/)).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: succeedImportWithCollection.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${succeedImportWithCollection.source.key}`); + + const collectionLink = await screen.findByText(succeedImportWithCollection.targetCollection.title); + userEvent.click(collectionLink); + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith( + { + pathname: `/library/${libraryId}/collection/${succeedImportWithCollection.targetCollection.key}`, + search: '', + }, + ); + }); + }); + + it('should render a card for a failed import', () => { + const { failImport } = mockGetCourseImports; + render(failImport); + expect(screen.getByText(failImport.source.displayName)).toBeInTheDocument(); + expect(screen.getByText('Import Failed')).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: failImport.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${failImport.source.key}`); + }); + + it('should render a card for an in-progress import', () => { + const { inProgressImport } = mockGetCourseImports; + render(inProgressImport); + expect(screen.getByText(inProgressImport.source.displayName)).toBeInTheDocument(); + expect(screen.getByText(/50% Imported/)).toBeInTheDocument(); + + const courseLink = screen.getByRole('link', { name: inProgressImport.source.displayName }); + expect(courseLink).toHaveAttribute('href', `/course/${inProgressImport.source.key}`); + }); +}); diff --git a/src/library-authoring/import-course/MigratedCourseCard.tsx b/src/library-authoring/import-course/ImportedCourseCard.tsx similarity index 65% rename from src/library-authoring/import-course/MigratedCourseCard.tsx rename to src/library-authoring/import-course/ImportedCourseCard.tsx index 830cf8063f..373b972367 100644 --- a/src/library-authoring/import-course/MigratedCourseCard.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.tsx @@ -10,12 +10,12 @@ import { import classNames from 'classnames'; import { Link } from 'react-router-dom'; -import { type CourseMigration } from '../data/api'; +import { type CourseImport } from '../data/api'; import { useLibraryRoutes } from '../routes'; import messages from './messages'; -interface MigratedCourseCardProps { - courseMigration: CourseMigration; +interface ImportedCourseCardProps { + courseImport: CourseImport; } const BORDER_CLASS = { @@ -39,7 +39,7 @@ const STATE_ICON_COLOR_CLASS = { InProgress: undefined, }; -const StateIcon = ({ state }: { state: CourseMigration['state'] }) => ( +const StateIcon = ({ state }: { state: CourseImport['state'] }) => ( ( /> ); -export const MigratedCourseCard = ({ courseMigration }: MigratedCourseCardProps) => { +export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) => { const { navigateTo } = useLibraryRoutes(); return ( - + - -

{courseMigration.source.displayName}

+ +

{courseImport.source.displayName}

- - {courseMigration.state === 'Failed' ? ( + + {courseImport.state === 'Failed' ? ( ) : ( <> - {Math.round(courseMigration.progress * 100)} + {Math.round(courseImport.progress * 100)} )} - {courseMigration.targetCollection && ( + {courseImport.targetCollection && ( )}
diff --git a/src/library-authoring/import-course/MigratedCourseCard.test.tsx b/src/library-authoring/import-course/MigratedCourseCard.test.tsx deleted file mode 100644 index 65f9ae628c..0000000000 --- a/src/library-authoring/import-course/MigratedCourseCard.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import userEvent from '@testing-library/user-event'; - -import { - initializeMocks, - render as testRender, - screen, - waitFor, -} from '@src/testUtils'; - -import { LibraryProvider } from '../common/context/LibraryContext'; -import { - mockContentLibrary, - mockGetCourseMigrations, -} from '../data/api.mocks'; -import { type CourseMigration } from '../data/api'; -import { MigratedCourseCard } from './MigratedCourseCard'; - -initializeMocks(); -mockContentLibrary.applyMock(); -const { libraryId } = mockContentLibrary; - -const mockNavigate = jest.fn(); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useNavigate: () => mockNavigate, -})); - -const render = (courseMigration: CourseMigration) => ( - testRender( - , - { - extraWrapper: ({ children }: { children: React.ReactNode }) => ( - - {children} - - ), - path: '/libraries/:libraryId/import-course', - params: { libraryId }, - }, - ) -); - -describe('', () => { - it('should render a card for a successful migration', () => { - const { succeedMigration } = mockGetCourseMigrations; - render(succeedMigration); - expect(screen.getByText(succeedMigration.source.displayName)).toBeInTheDocument(); - expect(screen.getByText(/100% Imported/)).toBeInTheDocument(); - - const courseLink = screen.getByRole('link', { name: succeedMigration.source.displayName }); - expect(courseLink).toHaveAttribute('href', `/course/${succeedMigration.source.key}`); - }); - - it('should render a card for a successful migration with a collection', async () => { - const { succeedMigrationWithCollection } = mockGetCourseMigrations; - render(succeedMigrationWithCollection); - expect(screen.getByText(succeedMigrationWithCollection.source.displayName)).toBeInTheDocument(); - expect(screen.getByText(/100% Imported/)).toBeInTheDocument(); - - const courseLink = screen.getByRole('link', { name: succeedMigrationWithCollection.source.displayName }); - expect(courseLink).toHaveAttribute('href', `/course/${succeedMigrationWithCollection.source.key}`); - - const collectionLink = await screen.findByText(succeedMigrationWithCollection.targetCollection.title); - userEvent.click(collectionLink); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - { - pathname: `/library/${libraryId}/collection/${succeedMigrationWithCollection.targetCollection.key}`, - search: '', - }, - ); - }); - }); - - it('should render a card for a failed migration', () => { - const { failMigration } = mockGetCourseMigrations; - render(failMigration); - expect(screen.getByText(failMigration.source.displayName)).toBeInTheDocument(); - expect(screen.getByText('Import Failed')).toBeInTheDocument(); - - const courseLink = screen.getByRole('link', { name: failMigration.source.displayName }); - expect(courseLink).toHaveAttribute('href', `/course/${failMigration.source.key}`); - }); - - it('should render a card for an in-progress migration', () => { - const { inProgressMigration } = mockGetCourseMigrations; - render(inProgressMigration); - expect(screen.getByText(inProgressMigration.source.displayName)).toBeInTheDocument(); - expect(screen.getByText(/50% Imported/)).toBeInTheDocument(); - - const courseLink = screen.getByRole('link', { name: inProgressMigration.source.displayName }); - expect(courseLink).toHaveAttribute('href', `/course/${inProgressMigration.source.key}`); - }); -}); From 262754cf31be96a11f468e3b3f69a1adfd2fe72a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 11:24:56 -0300 Subject: [PATCH 04/14] test: move `initializeMocks` to `beforeEach` --- .../import-course/CourseImportHomePage.test.tsx | 5 ++++- .../import-course/ImportedCourseCard.test.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/import-course/CourseImportHomePage.test.tsx b/src/library-authoring/import-course/CourseImportHomePage.test.tsx index e1e7e0105d..8900ddd8e7 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.test.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.test.tsx @@ -11,7 +11,6 @@ import { } from '../data/api.mocks'; import { CourseImportHomePage } from './CourseImportHomePage'; -initializeMocks(); mockContentLibrary.applyMock(); mockGetCourseImports.applyMock(); @@ -37,6 +36,10 @@ const render = (libraryId: string) => ( ); describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + it('should render the library course import home page', async () => { render(mockGetCourseImports.libraryId); expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header diff --git a/src/library-authoring/import-course/ImportedCourseCard.test.tsx b/src/library-authoring/import-course/ImportedCourseCard.test.tsx index 74664dc6f1..1bf0113b3d 100644 --- a/src/library-authoring/import-course/ImportedCourseCard.test.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.test.tsx @@ -15,7 +15,6 @@ import { import { type CourseImport } from '../data/api'; import { ImportedCourseCard } from './ImportedCourseCard'; -initializeMocks(); mockContentLibrary.applyMock(); const { libraryId } = mockContentLibrary; @@ -41,6 +40,10 @@ const render = (courseImport: CourseImport) => ( ); describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + it('should render a card for a successful import', () => { const { succeedImport } = mockGetCourseImports; render(succeedImport); From 42a31b071ce9d15e7789c5bda34a7822974db915 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 11:29:04 -0300 Subject: [PATCH 05/14] test: replace `query..` by `get..` --- .../import-course/CourseImportHomePage.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/import-course/CourseImportHomePage.test.tsx b/src/library-authoring/import-course/CourseImportHomePage.test.tsx index 8900ddd8e7..0c9dbc4ef3 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.test.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.test.tsx @@ -44,13 +44,13 @@ describe('', () => { render(mockGetCourseImports.libraryId); expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument(); - expect(screen.queryAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4); + expect(screen.getAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4); }); it('should render the empty state', async () => { render(mockGetCourseImports.emptyLibraryId); expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument(); - expect(screen.queryByText('You have not imported any courses into this library.')).toBeInTheDocument(); + expect(screen.getByText('You have not imported any courses into this library.')).toBeInTheDocument(); }); }); From 85834d130eaa415bd8c4d7de7cc560b6d0227d8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 11:31:10 -0300 Subject: [PATCH 06/14] test: add `await` to `userEvent.click` and remove `waitFor` --- .../import-course/ImportedCourseCard.test.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/library-authoring/import-course/ImportedCourseCard.test.tsx b/src/library-authoring/import-course/ImportedCourseCard.test.tsx index 1bf0113b3d..a9ed0e4487 100644 --- a/src/library-authoring/import-course/ImportedCourseCard.test.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.test.tsx @@ -64,15 +64,13 @@ describe('', () => { expect(courseLink).toHaveAttribute('href', `/course/${succeedImportWithCollection.source.key}`); const collectionLink = await screen.findByText(succeedImportWithCollection.targetCollection.title); - userEvent.click(collectionLink); - await waitFor(() => { - expect(mockNavigate).toHaveBeenCalledWith( - { - pathname: `/library/${libraryId}/collection/${succeedImportWithCollection.targetCollection.key}`, - search: '', - }, - ); - }); + await userEvent.click(collectionLink); + expect(mockNavigate).toHaveBeenCalledWith( + { + pathname: `/library/${libraryId}/collection/${succeedImportWithCollection.targetCollection.key}`, + search: '', + }, + ); }); it('should render a card for a failed import', () => { From f17820be3e4097672884e48697b984285f029510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 11:33:20 -0300 Subject: [PATCH 07/14] fix: pass `readOnly` flag to `Header` --- src/library-authoring/import-course/CourseImportHomePage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index 83ad4d105e..df142b4db8 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -34,7 +34,7 @@ const EmptyState = () => ( export const CourseImportHomePage = () => { const intl = useIntl(); - const { libraryId, libraryData } = useLibraryContext(); + const { libraryId, libraryData, readOnly } = useLibraryContext(); const { data: courseImports } = useCourseImports(libraryId); if (!courseImports || !libraryData) { @@ -53,6 +53,7 @@ export const CourseImportHomePage = () => { org={libraryData.org} contextId={libraryId} isLibrary + readOnly={readOnly} containerProps={{ size: undefined, }} From b843763940a0c1b7d309cc4ab8778714c2b46efe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 11:35:29 -0300 Subject: [PATCH 08/14] fix: add intl to `Previous Imports` title --- src/library-authoring/import-course/CourseImportHomePage.tsx | 4 +++- src/library-authoring/import-course/messages.ts | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index df142b4db8..bbd516bb6c 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -70,7 +70,9 @@ export const CourseImportHomePage = () => { {courseImports.length ? ( -

Previous Imports

+

+ +

{courseImports.map((courseImport) => ( Date: Thu, 6 Nov 2025 13:39:58 -0300 Subject: [PATCH 09/14] test: removing unused import --- src/library-authoring/import-course/ImportedCourseCard.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/library-authoring/import-course/ImportedCourseCard.test.tsx b/src/library-authoring/import-course/ImportedCourseCard.test.tsx index a9ed0e4487..e5fa0e4075 100644 --- a/src/library-authoring/import-course/ImportedCourseCard.test.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.test.tsx @@ -4,7 +4,6 @@ import { initializeMocks, render as testRender, screen, - waitFor, } from '@src/testUtils'; import { LibraryProvider } from '../common/context/LibraryContext'; From cab000ae1cf5002c4696d952b332089156d7a391 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 13:43:48 -0300 Subject: [PATCH 10/14] fix: remove custom css for help sidebar --- .../import-course/HelpSidebar.tsx | 4 ++-- src/library-authoring/import-course/index.scss | 14 -------------- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/src/library-authoring/import-course/HelpSidebar.tsx b/src/library-authoring/import-course/HelpSidebar.tsx index 6e831d44df..3365f3fac3 100644 --- a/src/library-authoring/import-course/HelpSidebar.tsx +++ b/src/library-authoring/import-course/HelpSidebar.tsx @@ -6,7 +6,7 @@ import { Paragraph } from '@src/utils'; import messages from './messages'; export const HelpSidebar = () => ( -
+
@@ -38,7 +38,7 @@ export const HelpSidebar = () => ( /> -
+
); diff --git a/src/library-authoring/import-course/index.scss b/src/library-authoring/import-course/index.scss index caedca34e4..28ee60f38e 100644 --- a/src/library-authoring/import-course/index.scss +++ b/src/library-authoring/import-course/index.scss @@ -1,17 +1,3 @@ -.course-migration-help { - z-index: 1000; // same as header - flex: 350px 0 0; - position: sticky; - top: 0; - right: 0; - height: 100vh; - overflow-y: auto; - - hr { - width: 100%; - } -} - .status-border-imported { border-left: 8px solid #5690BB; } From ab24e7ccf334e7e550301cc448755031cd7c0e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 13:52:31 -0300 Subject: [PATCH 11/14] fix: remove unused class and padding --- src/library-authoring/import-course/CourseImportHomePage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/library-authoring/import-course/CourseImportHomePage.tsx b/src/library-authoring/import-course/CourseImportHomePage.tsx index bbd516bb6c..041da431d8 100644 --- a/src/library-authoring/import-course/CourseImportHomePage.tsx +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -58,7 +58,7 @@ export const CourseImportHomePage = () => { size: undefined, }} /> - +
Date: Thu, 6 Nov 2025 14:21:45 -0300 Subject: [PATCH 12/14] fix: style on `ImportedCourseCard` --- .../import-course/ImportedCourseCard.tsx | 69 ++++++++++++------- .../import-course/index.scss | 4 ++ .../import-course/messages.ts | 5 ++ 3 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/library-authoring/import-course/ImportedCourseCard.tsx b/src/library-authoring/import-course/ImportedCourseCard.tsx index 373b972367..80d5695749 100644 --- a/src/library-authoring/import-course/ImportedCourseCard.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.tsx @@ -1,6 +1,11 @@ -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Button, Card, Icon } from '@openedx/paragon'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { + Button, + Card, + Icon, +} from '@openedx/paragon'; +import { + ArrowForwardIos, Check, Error, Folder, @@ -48,34 +53,46 @@ const StateIcon = ({ state }: { state: CourseImport['state'] }) => ( ); export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) => { + const intl = useIntl(); const { navigateTo } = useLibraryRoutes(); return ( - - -

{courseImport.source.displayName}

- -
- - {courseImport.state === 'Failed' ? ( - - ) : ( - <> - {Math.round(courseImport.progress * 100)} - - - )} - {courseImport.targetCollection && ( - - )} + +
+ +

{courseImport.source.displayName}

+ +
+ + {courseImport.state === 'Failed' ? ( + + ) : ( + <> + {Math.round(courseImport.progress * 100)} + + + )} + {courseImport.targetCollection && ( + + )} +
+
+
+ + +
diff --git a/src/library-authoring/import-course/index.scss b/src/library-authoring/import-course/index.scss index 28ee60f38e..74012aea17 100644 --- a/src/library-authoring/import-course/index.scss +++ b/src/library-authoring/import-course/index.scss @@ -13,3 +13,7 @@ .status-border-in-progress { border-left: 8px solid #F4B57B; } + +.text-decoration-underline { + text-decoration: underline; +} diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts index 3df7a25173..e6a85b951c 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -36,6 +36,11 @@ const messages = defineMessages({ defaultMessage: 'Import Failed', description: 'Text for the course import failed state', }, + courseImportNavigateAlt: { + id: 'course-authoring.library-authoring.import-course.course.navigate-alt', + defaultMessage: 'Navigate to course', + description: 'Alt text for the course import navigate button', + }, helpAndSupportTitle: { id: 'course-authoring.library-authoring.import-course.help-and-support.title', defaultMessage: 'Help & Support', From fc79cb1b7da80be60302dc2b91cbb13c5daf9448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Thu, 6 Nov 2025 17:41:32 -0300 Subject: [PATCH 13/14] fix: task_status string for `In Progress` --- src/library-authoring/data/api.mocks.ts | 2 +- src/library-authoring/data/api.ts | 2 +- src/library-authoring/import-course/ImportedCourseCard.tsx | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 9b3b272858..a7739b191a 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -1126,7 +1126,7 @@ mockGetCourseImports.inProgressImport = { displayName: 'DemoX 2025 T4', }, targetCollection: null, - state: 'InProgress', + state: 'In Progress', progress: 0.5012, } satisfies api.CourseImport; mockGetCourseImports.applyMock = () => jest.spyOn( diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index d87458dfef..f1883a9c25 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -798,7 +798,7 @@ export interface CourseImport { key: string; title: string; } | null; - state: 'Succeeded' | 'Failed' | 'InProgress'; + state: 'Succeeded' | 'Failed' | 'In Progress'; progress: number; } diff --git a/src/library-authoring/import-course/ImportedCourseCard.tsx b/src/library-authoring/import-course/ImportedCourseCard.tsx index 80d5695749..9f06df1cba 100644 --- a/src/library-authoring/import-course/ImportedCourseCard.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.tsx @@ -27,21 +27,21 @@ const BORDER_CLASS = { Succeeded: 'status-border-imported', Failed: 'status-border-failed', Partial: 'status-border-partial', - InProgress: 'status-border-in-progress', + 'In Progress': 'status-border-in-progress', }; const STATE_ICON = { Succeeded: Check, Failed: Error, Partial: Warning, - InProgress: IncompleteCircle, + 'In Progress': IncompleteCircle, }; const STATE_ICON_COLOR_CLASS = { Succeeded: undefined, Failed: 'text-danger-500', Partial: 'text-warning-500', - InProgress: undefined, + 'In Progress': undefined, }; const StateIcon = ({ state }: { state: CourseImport['state'] }) => ( From 6b3827b13c5f8772a8134c9e4145f473df3f31dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 7 Nov 2025 09:43:03 -0300 Subject: [PATCH 14/14] fix: open course in new page Co-authored-by: Navin Karkera --- src/library-authoring/import-course/ImportedCourseCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/library-authoring/import-course/ImportedCourseCard.tsx b/src/library-authoring/import-course/ImportedCourseCard.tsx index 9f06df1cba..651395a3e4 100644 --- a/src/library-authoring/import-course/ImportedCourseCard.tsx +++ b/src/library-authoring/import-course/ImportedCourseCard.tsx @@ -60,7 +60,7 @@ export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) =>
- +

{courseImport.source.displayName}