Skip to content

Commit 6d619b9

Browse files
authored
feat: add course import page [FC-0112] (#2580)
Adds the Library Import Home, which lists course migrations to the library
1 parent 6afe609 commit 6d619b9

18 files changed

+601
-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 mockGetCourseImports(libraryId: string): ReturnType<typeof api.getCourseImports> {
1077+
switch (libraryId) {
1078+
case mockContentLibrary.libraryId:
1079+
return [
1080+
mockGetCourseImports.succeedImport,
1081+
mockGetCourseImports.succeedImportWithCollection,
1082+
mockGetCourseImports.failImport,
1083+
mockGetCourseImports.inProgressImport,
1084+
];
1085+
case mockGetCourseImports.emptyLibraryId:
1086+
return [];
1087+
default:
1088+
throw new Error(`mockGetCourseImports doesn't know how to mock ${JSON.stringify(libraryId)}`);
1089+
}
1090+
}
1091+
mockGetCourseImports.libraryId = mockContentLibrary.libraryId;
1092+
mockGetCourseImports.emptyLibraryId = mockContentLibrary.libraryId2;
1093+
mockGetCourseImports.succeedImport = {
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.CourseImport;
1102+
mockGetCourseImports.succeedImportWithCollection = {
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.CourseImport;
1114+
mockGetCourseImports.failImport = {
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.CourseImport;
1123+
mockGetCourseImports.inProgressImport = {
1124+
source: {
1125+
key: 'course-v1:edX+DemoX+2025_T4',
1126+
displayName: 'DemoX 2025 T4',
1127+
},
1128+
targetCollection: null,
1129+
state: 'In Progress',
1130+
progress: 0.5012,
1131+
} satisfies api.CourseImport;
1132+
mockGetCourseImports.applyMock = () => jest.spyOn(
1133+
api,
1134+
'getCourseImports',
1135+
).mockImplementation(mockGetCourseImports);

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 imports.
162+
*/
163+
export const getCourseImportsApiUrl = (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 CourseImport {
793+
source: {
794+
key: string;
795+
displayName: string;
796+
};
797+
targetCollection: {
798+
key: string;
799+
title: string;
800+
} | null;
801+
state: 'Succeeded' | 'Failed' | 'In Progress';
802+
progress: number;
803+
}
804+
805+
/**
806+
* Returns the course imports which had this library as destination.
807+
*/
808+
export async function getCourseImports(libraryId: string): Promise<CourseImport[]> {
809+
const { data } = await getAuthenticatedHttpClient().get(getCourseImportsApiUrl(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+
courseImports: (libraryId: string) => [
93+
...libraryAuthoringQueryKeys.contentLibrary(libraryId),
94+
'courseImports',
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 imports which had this library as destination.
961+
*/
962+
export const useCourseImports = (libraryId: string) => (
963+
useQuery({
964+
queryKey: libraryAuthoringQueryKeys.courseImports(libraryId),
965+
queryFn: () => api.getCourseImports(libraryId),
966+
})
967+
);
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
mockGetCourseImports,
11+
} from '../data/api.mocks';
12+
import { CourseImportHomePage } from './CourseImportHomePage';
13+
14+
mockContentLibrary.applyMock();
15+
mockGetCourseImports.applyMock();
16+
17+
const mockNavigate = jest.fn();
18+
jest.mock('react-router-dom', () => ({
19+
...jest.requireActual('react-router-dom'),
20+
useNavigate: () => mockNavigate,
21+
}));
22+
23+
const render = (libraryId: string) => (
24+
testRender(
25+
<CourseImportHomePage />,
26+
{
27+
extraWrapper: ({ children }: { children: React.ReactNode }) => (
28+
<LibraryProvider libraryId={libraryId}>
29+
{children}
30+
</LibraryProvider>
31+
),
32+
path: '/libraries/:libraryId/import-course',
33+
params: { libraryId },
34+
},
35+
)
36+
);
37+
38+
describe('<CourseImportHomePage>', () => {
39+
beforeEach(() => {
40+
initializeMocks();
41+
});
42+
43+
it('should render the library course import home page', async () => {
44+
render(mockGetCourseImports.libraryId);
45+
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
46+
expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument();
47+
expect(screen.getAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4);
48+
});
49+
50+
it('should render the empty state', async () => {
51+
render(mockGetCourseImports.emptyLibraryId);
52+
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
53+
expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument();
54+
expect(screen.getByText('You have not imported any courses into this library.')).toBeInTheDocument();
55+
});
56+
});
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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 Loading from '@src/generic/Loading';
13+
import SubHeader from '@src/generic/sub-header/SubHeader';
14+
import Header from '@src/header';
15+
16+
import { useLibraryContext } from '../common/context/LibraryContext';
17+
import { useCourseImports } from '../data/apiHooks';
18+
import { HelpSidebar } from './HelpSidebar';
19+
import { ImportedCourseCard } from './ImportedCourseCard';
20+
import messages from './messages';
21+
22+
const EmptyState = () => (
23+
<Container size="md" className="py-6">
24+
<Card>
25+
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
26+
<FormattedMessage {...messages.emptyStateText} />
27+
<Button iconBefore={Add} disabled>
28+
<FormattedMessage {...messages.emptyStateButtonText} />
29+
</Button>
30+
</Stack>
31+
</Card>
32+
</Container>
33+
);
34+
35+
export const CourseImportHomePage = () => {
36+
const intl = useIntl();
37+
const { libraryId, libraryData, readOnly } = useLibraryContext();
38+
const { data: courseImports } = useCourseImports(libraryId);
39+
40+
if (!courseImports || !libraryData) {
41+
return <Loading />;
42+
}
43+
44+
return (
45+
<div className="d-flex">
46+
<div className="flex-grow-1">
47+
<Helmet>
48+
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
49+
</Helmet>
50+
<Header
51+
number={libraryData.slug}
52+
title={libraryData.title}
53+
org={libraryData.org}
54+
contextId={libraryId}
55+
isLibrary
56+
readOnly={readOnly}
57+
containerProps={{
58+
size: undefined,
59+
}}
60+
/>
61+
<Container className="mt-4 mb-5">
62+
<div className="px-4 bg-light-200 border-bottom">
63+
<SubHeader
64+
title={intl.formatMessage(messages.pageTitle)}
65+
subtitle={intl.formatMessage(messages.pageSubtitle)}
66+
hideBorder
67+
/>
68+
</div>
69+
<Layout xs={[{ span: 9 }, { span: 3 }]}>
70+
<Layout.Element>
71+
{courseImports.length ? (
72+
<Stack gap={3} className="pl-4 mt-4">
73+
<h3>
74+
<FormattedMessage {...messages.courseImportPreviousImports} />
75+
</h3>
76+
{courseImports.map((courseImport) => (
77+
<ImportedCourseCard
78+
key={courseImport.source.key}
79+
courseImport={courseImport}
80+
/>
81+
))}
82+
</Stack>
83+
) : (<EmptyState />)}
84+
</Layout.Element>
85+
<Layout.Element>
86+
<HelpSidebar />
87+
</Layout.Element>
88+
</Layout>
89+
</Container>
90+
</div>
91+
</div>
92+
);
93+
};
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="pt-3 border-left h-100">
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+
</Stack>
42+
<hr className="w-100" />
43+
</div>
44+
);

0 commit comments

Comments
 (0)