Skip to content

Commit ca3c068

Browse files
committed
feat: add course import page
1 parent 436ac31 commit ca3c068

18 files changed

+584
-7
lines changed

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';
@@ -92,6 +93,10 @@ const LibraryLayout = () => (
9293
path={ROUTES.BACKUP}
9394
Component={LibraryBackupPage}
9495
/>
96+
<Route
97+
path={ROUTES.IMPORT}
98+
Component={CourseImportHomePage}
99+
/>
95100
</Route>
96101
</Routes>
97102
);

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

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

src/library-authoring/data/api.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUr
157157
* Get the URL for the API endpoint to copy a single container.
158158
*/
159159
export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`;
160+
/**
161+
* Get the url for the API endpoint to list library course migrations.
162+
*/
163+
export const getCourseMigrationsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`;
160164

161165
export interface ContentLibrary {
162166
id: string;
@@ -784,3 +788,24 @@ export async function getLibraryContainerHierarchy(
784788
export async function publishContainer(containerId: string) {
785789
await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId));
786790
}
791+
792+
export interface CourseMigration {
793+
source: {
794+
key: string;
795+
displayName: string;
796+
};
797+
targetCollection: {
798+
key: string;
799+
title: string;
800+
} | null;
801+
state: 'Succeeded' | 'Failed' | 'InProgress';
802+
progress: number;
803+
}
804+
805+
/**
806+
* Returns the course migrations which had this library as destination.
807+
*/
808+
export async function getCourseMigrations(libraryId: string): Promise<CourseMigration[]> {
809+
const { data } = await getAuthenticatedHttpClient().get(getCourseMigrationsApiUrl(libraryId));
810+
return camelCaseObject(data);
811+
}

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 = {
@@ -951,3 +955,13 @@ export const useContentFromSearchIndex = (contentIds: string[]) => {
951955
skipBlockTypeFetch: true,
952956
});
953957
};
958+
959+
/**
960+
* Returns the course migrations which had this library as destination.
961+
*/
962+
export const useCourseMigrations = (libraryId: string) => (
963+
useQuery({
964+
queryKey: libraryAuthoringQueryKeys.migrations(libraryId),
965+
queryFn: () => api.getCourseMigrations(libraryId),
966+
})
967+
);
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)