Skip to content

Commit bb4eff0

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

File tree

19 files changed

+535
-7
lines changed

19 files changed

+535
-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 { CourseImportPage } 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={CourseImportPage}
97+
/>
9398
</Route>
9499
</Routes>
95100
);

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

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,3 +1064,58 @@ mockGetEntityLinks.applyMock = () => jest.spyOn(
10641064
courseLibApi,
10651065
'getEntityLinks',
10661066
).mockImplementation(mockGetEntityLinks);
1067+
1068+
export async function mockGetCourseMigrations(libraryId: string): ReturnType<typeof api.getCourseMigrations> {
1069+
if (libraryId !== mockContentLibrary.libraryId) {
1070+
throw new Error(`mockGetCourseMigrations doesn't know how to mock ${JSON.stringify(libraryId)}`);
1071+
}
1072+
return [
1073+
mockGetCourseMigrations.succeedMigration,
1074+
mockGetCourseMigrations.succeedMigrationWithCollection,
1075+
mockGetCourseMigrations.failMigration,
1076+
mockGetCourseMigrations.inProgressMigration,
1077+
];
1078+
}
1079+
mockGetCourseMigrations.succeedMigration = {
1080+
source: {
1081+
key: 'course-v1:edX+DemoX+2025_T1',
1082+
displayName: 'DemoX 2025 T1',
1083+
},
1084+
targetCollection: null,
1085+
state: 'Succeeded',
1086+
progress: 1,
1087+
} satisfies api.CourseMigration;
1088+
mockGetCourseMigrations.succeedMigrationWithCollection = {
1089+
source: {
1090+
key: 'course-v1:edX+DemoX+2025_T2',
1091+
displayName: 'DemoX 2025 T2',
1092+
},
1093+
targetCollection: {
1094+
key: 'sample-collection',
1095+
title: 'DemoX 2014 T1',
1096+
},
1097+
state: 'Succeeded',
1098+
progress: 1,
1099+
} satisfies api.CourseMigration;
1100+
mockGetCourseMigrations.failMigration = {
1101+
source: {
1102+
key: 'course-v1:edX+DemoX+2025_T3',
1103+
displayName: 'DemoX 2025 T3',
1104+
},
1105+
targetCollection: null,
1106+
state: 'Failed',
1107+
progress: 0.30,
1108+
} satisfies api.CourseMigration;
1109+
mockGetCourseMigrations.inProgressMigration = {
1110+
source: {
1111+
key: 'course-v1:edX+DemoX+2025_T4',
1112+
displayName: 'DemoX 2025 T4',
1113+
},
1114+
targetCollection: null,
1115+
state: 'InProgress',
1116+
progress: 0.5012,
1117+
} satisfies api.CourseMigration;
1118+
mockGetCourseMigrations.applyMock = () => jest.spyOn(
1119+
api,
1120+
'getCourseMigrations',
1121+
).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: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
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 CourseImportPage = () => {
37+
const intl = useIntl();
38+
const { libraryId } = useLibraryContext();
39+
const { data: libraryData } = useContentLibrary(libraryId);
40+
41+
const { data: courseMigrations } = useCourseMigrations(libraryId);
42+
43+
if (!libraryData) {
44+
return <NotFoundAlert />;
45+
}
46+
47+
if (!courseMigrations) {
48+
return <Loading />;
49+
}
50+
51+
return (
52+
<div className="d-flex">
53+
<div className="flex-grow-1">
54+
<Helmet>
55+
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
56+
</Helmet>
57+
<Header
58+
number={libraryData.slug}
59+
title={libraryData.title}
60+
org={libraryData.org}
61+
contextId={libraryId}
62+
isLibrary
63+
containerProps={{
64+
size: undefined,
65+
}}
66+
/>
67+
<Container className="px-0 mt-4 mb-5 library-authoring-page">
68+
<div className="px-4 bg-light-200 border-bottom">
69+
<SubHeader
70+
title={intl.formatMessage(messages.pageTitle)}
71+
subtitle={intl.formatMessage(messages.pageSubtitle)}
72+
hideBorder
73+
/>
74+
</div>
75+
<Layout xs={[{ span: 9 }, { span: 3 }]}>
76+
<Layout.Element>
77+
{courseMigrations.length ? (
78+
<Stack gap={3} className="pl-4 mt-4">
79+
<h3>Previous Imports</h3>
80+
{courseMigrations.map((courseMigration) => (
81+
<MigratedCourseCard
82+
key={courseMigration.source.key}
83+
courseMigration={courseMigration}
84+
/>
85+
))}
86+
</Stack>
87+
) : (<EmptyState />)}
88+
</Layout.Element>
89+
<Layout.Element>
90+
<HelpSidebar />
91+
</Layout.Element>
92+
</Layout>
93+
</Container>
94+
</div>
95+
</div>
96+
);
97+
};
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)