diff --git a/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx b/src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx index 72880a316..abe5f0ae3 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 d51a25c0a..7575c1912 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 760642bb5..a7739b191 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 mockGetCourseImports(libraryId: string): ReturnType { + switch (libraryId) { + case mockContentLibrary.libraryId: + return [ + mockGetCourseImports.succeedImport, + mockGetCourseImports.succeedImportWithCollection, + mockGetCourseImports.failImport, + mockGetCourseImports.inProgressImport, + ]; + case mockGetCourseImports.emptyLibraryId: + return []; + default: + throw new Error(`mockGetCourseImports doesn't know how to mock ${JSON.stringify(libraryId)}`); + } +} +mockGetCourseImports.libraryId = mockContentLibrary.libraryId; +mockGetCourseImports.emptyLibraryId = mockContentLibrary.libraryId2; +mockGetCourseImports.succeedImport = { + source: { + key: 'course-v1:edX+DemoX+2025_T1', + displayName: 'DemoX 2025 T1', + }, + targetCollection: null, + state: 'Succeeded', + progress: 1, +} satisfies api.CourseImport; +mockGetCourseImports.succeedImportWithCollection = { + 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.CourseImport; +mockGetCourseImports.failImport = { + source: { + key: 'course-v1:edX+DemoX+2025_T3', + displayName: 'DemoX 2025 T3', + }, + targetCollection: null, + state: 'Failed', + progress: 0.30, +} satisfies api.CourseImport; +mockGetCourseImports.inProgressImport = { + source: { + key: 'course-v1:edX+DemoX+2025_T4', + displayName: 'DemoX 2025 T4', + }, + targetCollection: null, + state: 'In Progress', + progress: 0.5012, +} satisfies api.CourseImport; +mockGetCourseImports.applyMock = () => jest.spyOn( + api, + 'getCourseImports', +).mockImplementation(mockGetCourseImports); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 211b1614b..f1883a9c2 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 imports. + */ +export const getCourseImportsApiUrl = (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 CourseImport { + source: { + key: string; + displayName: string; + }; + targetCollection: { + key: string; + title: string; + } | null; + state: 'Succeeded' | 'Failed' | 'In Progress'; + progress: number; +} + +/** + * Returns the course imports which had this library as destination. + */ +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 5646d2f6b..e72ac1cf4 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -89,6 +89,10 @@ export const libraryAuthoringQueryKeys = { } return ['hierarchy']; }, + courseImports: (libraryId: string) => [ + ...libraryAuthoringQueryKeys.contentLibrary(libraryId), + 'courseImports', + ], }; export const xblockQueryKeys = { @@ -951,3 +955,13 @@ export const useContentFromSearchIndex = (contentIds: string[]) => { skipBlockTypeFetch: true, }); }; + +/** + * Returns the course imports which had this library as destination. + */ +export const useCourseImports = (libraryId: string) => ( + useQuery({ + 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 new file mode 100644 index 000000000..0c9dbc4ef --- /dev/null +++ b/src/library-authoring/import-course/CourseImportHomePage.test.tsx @@ -0,0 +1,56 @@ +import { + initializeMocks, + render as testRender, + screen, +} from '@src/testUtils'; + +import { LibraryProvider } from '../common/context/LibraryContext'; +import { + mockContentLibrary, + mockGetCourseImports, +} from '../data/api.mocks'; +import { CourseImportHomePage } from './CourseImportHomePage'; + +mockContentLibrary.applyMock(); +mockGetCourseImports.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('', () => { + 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 + expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument(); + 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.getByText('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 000000000..041da431d --- /dev/null +++ b/src/library-authoring/import-course/CourseImportHomePage.tsx @@ -0,0 +1,93 @@ +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 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 { useCourseImports } from '../data/apiHooks'; +import { HelpSidebar } from './HelpSidebar'; +import { ImportedCourseCard } from './ImportedCourseCard'; +import messages from './messages'; + +const EmptyState = () => ( + + + + + + + + +); + +export const CourseImportHomePage = () => { + const intl = useIntl(); + const { libraryId, libraryData, readOnly } = useLibraryContext(); + const { data: courseImports } = useCourseImports(libraryId); + + if (!courseImports || !libraryData) { + return ; + } + + return ( +
+
+ + {libraryData.title} | {process.env.SITE_NAME} + +
+ +
+ +
+ + + {courseImports.length ? ( + +

+ +

+ {courseImports.map((courseImport) => ( + + ))} +
+ ) : ()} +
+ + + +
+
+
+
+ ); +}; diff --git a/src/library-authoring/import-course/HelpSidebar.tsx b/src/library-authoring/import-course/HelpSidebar.tsx new file mode 100644 index 000000000..3365f3fac --- /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/ImportedCourseCard.test.tsx b/src/library-authoring/import-course/ImportedCourseCard.test.tsx new file mode 100644 index 000000000..e5fa0e407 --- /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, +} 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'; + +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('', () => { + beforeEach(() => { + initializeMocks(); + }); + + 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); + await userEvent.click(collectionLink); + 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/ImportedCourseCard.tsx b/src/library-authoring/import-course/ImportedCourseCard.tsx new file mode 100644 index 000000000..651395a3e --- /dev/null +++ b/src/library-authoring/import-course/ImportedCourseCard.tsx @@ -0,0 +1,100 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Card, + Icon, +} from '@openedx/paragon'; +import { + ArrowForwardIos, + Check, + Error, + Folder, + IncompleteCircle, + 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'; +import messages from './messages'; + +interface ImportedCourseCardProps { + courseImport: CourseImport; +} + +const BORDER_CLASS = { + Succeeded: 'status-border-imported', + Failed: 'status-border-failed', + Partial: 'status-border-partial', + 'In Progress': 'status-border-in-progress', +}; + +const STATE_ICON = { + Succeeded: Check, + Failed: Error, + Partial: Warning, + 'In Progress': IncompleteCircle, +}; + +const STATE_ICON_COLOR_CLASS = { + Succeeded: undefined, + Failed: 'text-danger-500', + Partial: 'text-warning-500', + 'In Progress': undefined, +}; + +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 && ( + + )} +
+
+
+ + + +
+
+
+ ); +}; diff --git a/src/library-authoring/import-course/index.scss b/src/library-authoring/import-course/index.scss new file mode 100644 index 000000000..74012aea1 --- /dev/null +++ b/src/library-authoring/import-course/index.scss @@ -0,0 +1,19 @@ +.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; +} + +.text-decoration-underline { + text-decoration: underline; +} diff --git a/src/library-authoring/import-course/index.ts b/src/library-authoring/import-course/index.ts new file mode 100644 index 000000000..23de1b775 --- /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 000000000..e6a85b951 --- /dev/null +++ b/src/library-authoring/import-course/messages.ts @@ -0,0 +1,78 @@ +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', + }, + courseImportPreviousImports: { + id: 'course-authoring.library-authoring.import-course.previous-imports', + defaultMessage: 'Previous Imports', + description: 'Title for the list of previous imports', + }, + 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', + }, + 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', + 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 1404cb68f..43390d63d 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 33630af5b..f93975643 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 cafeccf26..00d5b304a 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 124264016..e052c6fc4 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 1432a2ce1..f57d434a0 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}

;