feat: add course import page [FC-0112] (#2580)

Adds the Library Import Home, which lists course migrations to the library
This commit is contained in:
Rômulo Penido
2025-11-07 18:32:11 -03:00
committed by GitHub
parent 6afe6095a5
commit 6d619b9c40
18 changed files with 601 additions and 7 deletions

View File

@@ -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[]) => <div>{chunk}</div>;
export const Paragraph = (chunk: string[]) => <p>{chunk}</p>;
export const LegacyMigrationHelpSidebar = () => (
<div className="legacy-libraries-migration-help bg-white pt-3 mt-1">
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
@@ -42,7 +40,7 @@ export const LegacyMigrationHelpSidebar = () => (
<span className="x-small">
<FormattedMessage
{...messages.helpAndSupportThirdQuestionBody}
values={{ div: SingleLineBreak, p: Paragraph }}
values={{ div: Div, p: Paragraph }}
/>
</span>
</Stack>

View File

@@ -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}
/>
<Route
path={ROUTES.IMPORT}
Component={CourseImportHomePage}
/>
</Route>
</Routes>
);

View File

@@ -1072,3 +1072,64 @@ mockGetEntityLinks.applyMock = () => jest.spyOn(
courseLibApi,
'getEntityLinks',
).mockImplementation(mockGetEntityLinks);
export async function mockGetCourseImports(libraryId: string): ReturnType<typeof api.getCourseImports> {
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);

View File

@@ -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<CourseImport[]> {
const { data } = await getAuthenticatedHttpClient().get(getCourseImportsApiUrl(libraryId));
return camelCaseObject(data);
}

View File

@@ -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),
})
);

View File

@@ -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(
<CourseImportHomePage />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={libraryId}>
{children}
</LibraryProvider>
),
path: '/libraries/:libraryId/import-course',
params: { libraryId },
},
)
);
describe('<CourseImportHomePage>', () => {
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();
});
});

View File

@@ -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 = () => (
<Container size="md" className="py-6">
<Card>
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
<FormattedMessage {...messages.emptyStateText} />
<Button iconBefore={Add} disabled>
<FormattedMessage {...messages.emptyStateButtonText} />
</Button>
</Stack>
</Card>
</Container>
);
export const CourseImportHomePage = () => {
const intl = useIntl();
const { libraryId, libraryData, readOnly } = useLibraryContext();
const { data: courseImports } = useCourseImports(libraryId);
if (!courseImports || !libraryData) {
return <Loading />;
}
return (
<div className="d-flex">
<div className="flex-grow-1">
<Helmet>
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
</Helmet>
<Header
number={libraryData.slug}
title={libraryData.title}
org={libraryData.org}
contextId={libraryId}
isLibrary
readOnly={readOnly}
containerProps={{
size: undefined,
}}
/>
<Container className="mt-4 mb-5">
<div className="px-4 bg-light-200 border-bottom">
<SubHeader
title={intl.formatMessage(messages.pageTitle)}
subtitle={intl.formatMessage(messages.pageSubtitle)}
hideBorder
/>
</div>
<Layout xs={[{ span: 9 }, { span: 3 }]}>
<Layout.Element>
{courseImports.length ? (
<Stack gap={3} className="pl-4 mt-4">
<h3>
<FormattedMessage {...messages.courseImportPreviousImports} />
</h3>
{courseImports.map((courseImport) => (
<ImportedCourseCard
key={courseImport.source.key}
courseImport={courseImport}
/>
))}
</Stack>
) : (<EmptyState />)}
</Layout.Element>
<Layout.Element>
<HelpSidebar />
</Layout.Element>
</Layout>
</Container>
</div>
</div>
);
};

View File

@@ -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 = () => (
<div className="pt-3 border-left h-100">
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
<Icon src={Question} />
<span>
<FormattedMessage {...messages.helpAndSupportTitle} />
</span>
</Stack>
<hr />
<Stack className="pl-4 pr-4">
<Stack>
<span className="h5">
<FormattedMessage {...messages.helpAndSupportFirstQuestionTitle} />
</span>
<span className="x-small">
<FormattedMessage
{...messages.helpAndSupportFirstQuestionBody}
values={{ p: Paragraph }}
/>
</span>
</Stack>
<hr />
<Stack>
<span className="h5">
<FormattedMessage {...messages.helpAndSupportSecondQuestionTitle} />
</span>
<span className="x-small">
<FormattedMessage
{...messages.helpAndSupportSecondQuestionBody}
values={{ p: Paragraph }}
/>
</span>
</Stack>
</Stack>
<hr className="w-100" />
</div>
);

View File

@@ -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(
<ImportedCourseCard courseImport={courseImport} />,
{
extraWrapper: ({ children }: { children: React.ReactNode }) => (
<LibraryProvider libraryId={mockContentLibrary.libraryId}>
{children}
</LibraryProvider>
),
path: '/libraries/:libraryId/import-course',
params: { libraryId },
},
)
);
describe('<ImportedCourseCard>', () => {
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}`);
});
});

View File

@@ -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'] }) => (
<Icon
src={STATE_ICON[state]}
size="sm"
className={classNames('mr-2', STATE_ICON_COLOR_CLASS[state])}
/>
);
export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) => {
const intl = useIntl();
const { navigateTo } = useLibraryRoutes();
return (
<Card className={BORDER_CLASS[courseImport.state]}>
<Card.Section className="d-flex flex-row">
<div>
<Link to={`/course/${courseImport.source.key}`} target="_blank">
<h4>{courseImport.source.displayName}</h4>
</Link>
<div className="d-inline-flex small align-items-center">
<StateIcon state={courseImport.state} />
{courseImport.state === 'Failed' ? (
<FormattedMessage {...messages.courseImportTextFailed} />
) : (
<>
{Math.round(courseImport.progress * 100)}
<FormattedMessage {...messages.courseImportTextProgress} />
</>
)}
{courseImport.targetCollection && (
<Button
iconBefore={Folder}
variant="link"
className="ml-4 text-black text-decoration-underline"
onClick={() => navigateTo({ collectionId: courseImport.targetCollection!.key })}
>
{courseImport.targetCollection.title}
</Button>
)}
</div>
</div>
<div className="d-flex align-items-center ml-auto">
<Link
to={`/course/${courseImport.source.key}`}
aria-label={intl.formatMessage(messages.courseImportNavigateAlt)}
className="text-primary-500"
>
<Icon src={ArrowForwardIos} />
</Link>
</div>
</Card.Section>
</Card>
);
};

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export { CourseImportHomePage } from './CourseImportHomePage';

View File

@@ -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: '<p>You can import existing courses into a library in order to reference '
+ 'course content across courses.</p>'
+ '<p>Courses with content you or others may want to reuse or reference in the future are '
+ 'excellent candidates for import.</p>',
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: '<p>You can select a course to import and decide whether to import all sections, '
+ 'subsections, units, or blocks from this course.</p>'
+ '<p>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.</p>'
+ '<p>For additional details you can review the Library Import documentation.</p>',
description: 'Body of the second question in the Help & Support sidebar',
},
});
export default messages;

View File

@@ -6,6 +6,7 @@
@import "./section-subsections";
@import "./containers";
@import "./hierarchy";
@import "./import-course";
.library-cards-grid {
display: grid;

View File

@@ -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';

View File

@@ -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';

View File

@@ -47,6 +47,8 @@ export const ROUTES = {
UNIT: '/unit/:containerId/:selectedItemId?/:index?',
// LibraryBackupPage route:
BACKUP: '/backup',
// LibraryImportPage route:
IMPORT: '/import',
};
export enum ContentType {

View File

@@ -349,3 +349,5 @@ export const skipIfUnwantedTarget = (
};
export const BoldText = (chunk: string[]) => <b>{chunk}</b>;
export const Div = (chunk: string[]) => <div>{chunk}</div>;
export const Paragraph = (chunk: string[]) => <p>{chunk}</p>;