Skip to content

Commit 9230c0e

Browse files
committed
feat: add course import page
1 parent f116740 commit 9230c0e

20 files changed

+593
-7
lines changed

src/header/hooks.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ export const useLibraryToolsMenuItems = itemId => {
135135
href: `/library/${itemId}/backup`,
136136
title: intl.formatMessage(messages['header.links.exportLibrary']),
137137
},
138+
{
139+
href: `/library/${itemId}/import`,
140+
title: intl.formatMessage(messages['header.links.importLibrary']),
141+
},
138142
];
139143

140144
return items;

src/header/messages.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ const messages = defineMessages({
106106
defaultMessage: 'Backup to local archive',
107107
description: 'Link to Studio Backup Library page',
108108
},
109+
'header.links.importLibrary': {
110+
id: 'header.links.importLibrary',
111+
defaultMessage: 'Import',
112+
description: 'Link to Library Import page',
113+
},
109114
'header.links.optimizer': {
110115
id: 'header.links.optimizer',
111116
defaultMessage: 'Course Optimizer',

src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import { FormattedMessage } from '@edx/frontend-platform/i18n';
22
import { Icon, Stack } from '@openedx/paragon';
33
import { Question } from '@openedx/paragon/icons';
4+
import { Div, Paragraph } from '@src/utils';
45

56
import messages from './messages';
67

7-
export const SingleLineBreak = (chunk: string[]) => <div>{chunk}</div>;
8-
export const Paragraph = (chunk: string[]) => <p>{chunk}</p>;
9-
108
export const LegacyMigrationHelpSidebar = () => (
119
<div className="legacy-libraries-migration-help bg-white pt-3 mt-1">
1210
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
@@ -42,7 +40,7 @@ export const LegacyMigrationHelpSidebar = () => (
4240
<span className="x-small">
4341
<FormattedMessage
4442
{...messages.helpAndSupportThirdQuestionBody}
45-
values={{ div: SingleLineBreak, p: Paragraph }}
43+
values={{ div: Div, p: Paragraph }}
4644
/>
4745
</span>
4846
</Stack>

src/library-authoring/LibraryLayout.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,16 @@ import {
66
useParams,
77
} from 'react-router-dom';
88

9-
import { LibraryBackupPage } from '@src/library-authoring/backup-restore';
109
import LibraryAuthoringPage from './LibraryAuthoringPage';
10+
import { LibraryBackupPage } from './backup-restore';
1111
import LibraryCollectionPage from './collections/LibraryCollectionPage';
1212
import { LibraryProvider } from './common/context/LibraryContext';
1313
import { SidebarProvider } from './common/context/SidebarContext';
1414
import { ComponentPicker } from './component-picker';
1515
import { ComponentEditorModal } from './components/ComponentEditorModal';
1616
import { CreateCollectionModal } from './create-collection';
1717
import { CreateContainerModal } from './create-container';
18+
import { CourseImportHomePage } from './import-course';
1819
import { ROUTES } from './routes';
1920
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
2021
import { LibraryUnitPage } from './units';
@@ -90,6 +91,10 @@ const LibraryLayout = () => (
9091
path={ROUTES.BACKUP}
9192
Component={LibraryBackupPage}
9293
/>
94+
<Route
95+
path={ROUTES.IMPORT}
96+
Component={CourseImportHomePage}
97+
/>
9398
</Route>
9499
</Routes>
95100
);

src/library-authoring/data/api.mocks.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,3 +1064,64 @@ mockGetEntityLinks.applyMock = () => jest.spyOn(
10641064
courseLibApi,
10651065
'getEntityLinks',
10661066
).mockImplementation(mockGetEntityLinks);
1067+
1068+
export async function mockGetCourseMigrations(libraryId: string): ReturnType<typeof api.getCourseMigrations> {
1069+
switch (libraryId) {
1070+
case mockContentLibrary.libraryId:
1071+
return [
1072+
mockGetCourseMigrations.succeedMigration,
1073+
mockGetCourseMigrations.succeedMigrationWithCollection,
1074+
mockGetCourseMigrations.failMigration,
1075+
mockGetCourseMigrations.inProgressMigration,
1076+
];
1077+
case mockGetCourseMigrations.emptyLibraryId:
1078+
return [];
1079+
default:
1080+
throw new Error(`mockGetCourseMigrations doesn't know how to mock ${JSON.stringify(libraryId)}`);
1081+
}
1082+
}
1083+
mockGetCourseMigrations.libraryId = mockContentLibrary.libraryId;
1084+
mockGetCourseMigrations.emptyLibraryId = mockContentLibrary.libraryId2;
1085+
mockGetCourseMigrations.succeedMigration = {
1086+
source: {
1087+
key: 'course-v1:edX+DemoX+2025_T1',
1088+
displayName: 'DemoX 2025 T1',
1089+
},
1090+
targetCollection: null,
1091+
state: 'Succeeded',
1092+
progress: 1,
1093+
} satisfies api.CourseMigration;
1094+
mockGetCourseMigrations.succeedMigrationWithCollection = {
1095+
source: {
1096+
key: 'course-v1:edX+DemoX+2025_T2',
1097+
displayName: 'DemoX 2025 T2',
1098+
},
1099+
targetCollection: {
1100+
key: 'sample-collection',
1101+
title: 'DemoX 2025 T1 (2)',
1102+
},
1103+
state: 'Succeeded',
1104+
progress: 1,
1105+
} satisfies api.CourseMigration;
1106+
mockGetCourseMigrations.failMigration = {
1107+
source: {
1108+
key: 'course-v1:edX+DemoX+2025_T3',
1109+
displayName: 'DemoX 2025 T3',
1110+
},
1111+
targetCollection: null,
1112+
state: 'Failed',
1113+
progress: 0.30,
1114+
} satisfies api.CourseMigration;
1115+
mockGetCourseMigrations.inProgressMigration = {
1116+
source: {
1117+
key: 'course-v1:edX+DemoX+2025_T4',
1118+
displayName: 'DemoX 2025 T4',
1119+
},
1120+
targetCollection: null,
1121+
state: 'InProgress',
1122+
progress: 0.5012,
1123+
} satisfies api.CourseMigration;
1124+
mockGetCourseMigrations.applyMock = () => jest.spyOn(
1125+
api,
1126+
'getCourseMigrations',
1127+
).mockImplementation(mockGetCourseMigrations);

src/library-authoring/data/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,10 @@ export const getLibraryBackupStatusApiUrl = (libraryId: string, taskId: string)
149149
* Get the URL for the API endpoint to copy a single container.
150150
*/
151151
export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`;
152+
/**
153+
* Get the url for the API endpoint to list library course migrations.
154+
*/
155+
export const getCourseMigrationsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`;
152156

153157
export interface ContentLibrary {
154158
id: string;
@@ -776,3 +780,24 @@ export async function getLibraryContainerHierarchy(
776780
export async function publishContainer(containerId: string) {
777781
await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId));
778782
}
783+
784+
export interface CourseMigration {
785+
source: {
786+
key: string;
787+
displayName: string;
788+
};
789+
targetCollection: {
790+
key: string;
791+
title: string;
792+
} | null;
793+
state: 'Succeeded' | 'Failed' | 'InProgress';
794+
progress: number;
795+
}
796+
797+
/**
798+
* Returns the course migrations which had this library as destination.
799+
*/
800+
export async function getCourseMigrations(libraryId: string): Promise<CourseMigration[]> {
801+
const { data } = await getAuthenticatedHttpClient().get(getCourseMigrationsApiUrl(libraryId));
802+
return camelCaseObject(data);
803+
}

src/library-authoring/data/apiHooks.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ export const libraryAuthoringQueryKeys = {
8989
}
9090
return ['hierarchy'];
9191
},
92+
migrations: (libraryId: string) => [
93+
...libraryAuthoringQueryKeys.contentLibrary(libraryId),
94+
'migrations',
95+
],
9296
};
9397

9498
export const xblockQueryKeys = {
@@ -946,3 +950,13 @@ export const useContentFromSearchIndex = (contentIds: string[]) => {
946950
skipBlockTypeFetch: true,
947951
});
948952
};
953+
954+
/**
955+
* Returns the course migrations which had this library as destination.
956+
*/
957+
export const useCourseMigrations = (libraryId: string) => (
958+
useQuery({
959+
queryKey: libraryAuthoringQueryKeys.migrations(libraryId),
960+
queryFn: () => api.getCourseMigrations(libraryId),
961+
})
962+
);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
initializeMocks,
3+
render as testRender,
4+
screen,
5+
} from '@src/testUtils';
6+
7+
import { LibraryProvider } from '../common/context/LibraryContext';
8+
import {
9+
mockContentLibrary,
10+
mockGetCourseMigrations,
11+
} from '../data/api.mocks';
12+
import { CourseImportHomePage } from './CourseImportHomePage';
13+
14+
initializeMocks();
15+
mockContentLibrary.applyMock();
16+
mockGetCourseMigrations.applyMock();
17+
18+
const mockNavigate = jest.fn();
19+
jest.mock('react-router-dom', () => ({
20+
...jest.requireActual('react-router-dom'),
21+
useNavigate: () => mockNavigate,
22+
}));
23+
24+
const render = (libraryId: string) => (
25+
testRender(
26+
<CourseImportHomePage />,
27+
{
28+
extraWrapper: ({ children }: { children: React.ReactNode }) => (
29+
<LibraryProvider libraryId={libraryId}>
30+
{children}
31+
</LibraryProvider>
32+
),
33+
path: '/libraries/:libraryId/import-course',
34+
params: { libraryId },
35+
},
36+
)
37+
);
38+
39+
describe('<CourseImportHomePage>', () => {
40+
it('should render the library course import home page', async () => {
41+
render(mockGetCourseMigrations.libraryId);
42+
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
43+
expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument();
44+
expect(screen.queryAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4);
45+
});
46+
47+
it('should render the empty state', async () => {
48+
render(mockGetCourseMigrations.emptyLibraryId);
49+
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
50+
expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument();
51+
expect(screen.queryByText('You have not imported any courses into this library.')).toBeInTheDocument();
52+
});
53+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {
2+
Button,
3+
Card,
4+
Container,
5+
Layout,
6+
Stack,
7+
} from '@openedx/paragon';
8+
import { Add } from '@openedx/paragon/icons';
9+
import { Helmet } from 'react-helmet';
10+
11+
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
12+
import NotFoundAlert from '@src/generic/NotFoundAlert';
13+
import Loading from '@src/generic/Loading';
14+
import SubHeader from '@src/generic/sub-header/SubHeader';
15+
import Header from '@src/header';
16+
17+
import { useLibraryContext } from '../common/context/LibraryContext';
18+
import { useContentLibrary, useCourseMigrations } from '../data/apiHooks';
19+
import { HelpSidebar } from './HelpSidebar';
20+
import { MigratedCourseCard } from './MigratedCourseCard';
21+
import messages from './messages';
22+
23+
const EmptyState = () => (
24+
<Container size="md" className="py-6">
25+
<Card>
26+
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
27+
<FormattedMessage {...messages.emptyStateText} />
28+
<Button iconBefore={Add} disabled>
29+
<FormattedMessage {...messages.emptyStateButtonText} />
30+
</Button>
31+
</Stack>
32+
</Card>
33+
</Container>
34+
);
35+
36+
export const CourseImportHomePage = () => {
37+
const intl = useIntl();
38+
const { libraryId } = useLibraryContext();
39+
const { data: libraryData } = useContentLibrary(libraryId);
40+
const { data: courseMigrations } = useCourseMigrations(libraryId);
41+
42+
if (!courseMigrations) {
43+
return <Loading />;
44+
}
45+
46+
if (!libraryData) {
47+
return <NotFoundAlert />;
48+
}
49+
50+
return (
51+
<div className="d-flex">
52+
<div className="flex-grow-1">
53+
<Helmet>
54+
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
55+
</Helmet>
56+
<Header
57+
number={libraryData.slug}
58+
title={libraryData.title}
59+
org={libraryData.org}
60+
contextId={libraryId}
61+
isLibrary
62+
containerProps={{
63+
size: undefined,
64+
}}
65+
/>
66+
<Container className="px-0 mt-4 mb-5 library-authoring-page">
67+
<div className="px-4 bg-light-200 border-bottom">
68+
<SubHeader
69+
title={intl.formatMessage(messages.pageTitle)}
70+
subtitle={intl.formatMessage(messages.pageSubtitle)}
71+
hideBorder
72+
/>
73+
</div>
74+
<Layout xs={[{ span: 9 }, { span: 3 }]}>
75+
<Layout.Element>
76+
{courseMigrations.length ? (
77+
<Stack gap={3} className="pl-4 mt-4">
78+
<h3>Previous Imports</h3>
79+
{courseMigrations.map((courseMigration) => (
80+
<MigratedCourseCard
81+
key={courseMigration.source.key}
82+
courseMigration={courseMigration}
83+
/>
84+
))}
85+
</Stack>
86+
) : (<EmptyState />)}
87+
</Layout.Element>
88+
<Layout.Element>
89+
<HelpSidebar />
90+
</Layout.Element>
91+
</Layout>
92+
</Container>
93+
</div>
94+
</div>
95+
);
96+
};
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { FormattedMessage } from '@edx/frontend-platform/i18n';
2+
import { Icon, Stack } from '@openedx/paragon';
3+
import { Question } from '@openedx/paragon/icons';
4+
import { Paragraph } from '@src/utils';
5+
6+
import messages from './messages';
7+
8+
export const HelpSidebar = () => (
9+
<div className="course-migration-help pt-3 border-left">
10+
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
11+
<Icon src={Question} />
12+
<span>
13+
<FormattedMessage {...messages.helpAndSupportTitle} />
14+
</span>
15+
</Stack>
16+
<hr />
17+
<Stack className="pl-4 pr-4">
18+
<Stack>
19+
<span className="h5">
20+
<FormattedMessage {...messages.helpAndSupportFirstQuestionTitle} />
21+
</span>
22+
<span className="x-small">
23+
<FormattedMessage
24+
{...messages.helpAndSupportFirstQuestionBody}
25+
values={{ p: Paragraph }}
26+
/>
27+
</span>
28+
</Stack>
29+
<hr />
30+
<Stack>
31+
<span className="h5">
32+
<FormattedMessage {...messages.helpAndSupportSecondQuestionTitle} />
33+
</span>
34+
<span className="x-small">
35+
<FormattedMessage
36+
{...messages.helpAndSupportSecondQuestionBody}
37+
values={{ p: Paragraph }}
38+
/>
39+
</span>
40+
</Stack>
41+
<hr />
42+
</Stack>
43+
</div>
44+
);

0 commit comments

Comments
 (0)