Skip to content

Commit 6522174

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

17 files changed

Lines changed: 383 additions & 7 deletions

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.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 migrations.
154+
*/
155+
export const getLibraryMigrationsApiUrl = (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(getLibraryMigrationsApiUrl(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+
);
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { FormattedMessage } from '@edx/frontend-platform/i18n';
2+
import { Button, Card, Icon } from '@openedx/paragon';
3+
import {
4+
Check,
5+
Error,
6+
Folder,
7+
IncompleteCircle,
8+
Warning,
9+
} from '@openedx/paragon/icons';
10+
import classNames from 'classnames';
11+
12+
import { type CourseMigration } from '../data/api';
13+
import { useLibraryRoutes } from '../routes';
14+
import messages from './messages';
15+
16+
interface MigratedCourseCardProps {
17+
courseMigration: CourseMigration;
18+
}
19+
20+
const BORDER_CLASS = {
21+
Succeeded: 'status-border-imported',
22+
Failed: 'status-border-failed',
23+
Partial: 'status-border-partial',
24+
InProgress: 'status-border-in-progress',
25+
};
26+
27+
const STATE_ICON = {
28+
Succeeded: Check,
29+
Failed: Error,
30+
Partial: Warning,
31+
InProgress: IncompleteCircle,
32+
};
33+
34+
const STATE_ICON_COLOR_CLASS = {
35+
Succeeded: undefined,
36+
Failed: 'text-danger-500',
37+
Partial: 'text-warning-500',
38+
InProgress: undefined,
39+
};
40+
41+
const StateIcon = ({ state }: { state: CourseMigration['state'] }) => (
42+
<Icon
43+
src={STATE_ICON[state]}
44+
size="sm"
45+
className={classNames('mr-2', STATE_ICON_COLOR_CLASS[state])}
46+
/>
47+
);
48+
49+
export const MigratedCourseCard = ({ courseMigration }: MigratedCourseCardProps) => {
50+
const { navigateTo } = useLibraryRoutes();
51+
52+
return (
53+
<Card className={BORDER_CLASS[courseMigration.state]}>
54+
<Card.Section>
55+
<h4>{courseMigration.source.displayName}</h4>
56+
<div className="d-inline-flex small align-items-center">
57+
<StateIcon state={courseMigration.state} />
58+
{courseMigration.state === 'Failed' ? (
59+
<FormattedMessage {...messages.courseImportTextFailed} />
60+
) : (
61+
<>
62+
{Math.round(courseMigration.progress * 100)}
63+
<FormattedMessage {...messages.courseImportTextProgress} />
64+
</>
65+
)}
66+
{courseMigration.targetCollection && (
67+
<Button
68+
iconBefore={Folder}
69+
variant="link"
70+
className="ml-4"
71+
onClick={() => navigateTo({ collectionId: courseMigration.targetCollection!.key })}
72+
>
73+
{courseMigration.targetCollection.title}
74+
</Button>
75+
)}
76+
</div>
77+
</Card.Section>
78+
</Card>
79+
);
80+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.course-migration-help {
2+
z-index: 1000; // same as header
3+
flex: 350px 0 0;
4+
position: sticky;
5+
top: 0;
6+
right: 0;
7+
height: 100vh;
8+
overflow-y: auto;
9+
10+
hr {
11+
width: 100%;
12+
}
13+
}
14+
15+
.status-border-imported {
16+
border-left: 8px solid #5690BB;
17+
}
18+
19+
.status-border-failed {
20+
border-left: 8px solid var(--pgn-color-danger-500);
21+
}
22+
23+
.status-border-partial {
24+
border-left: 8px solid var(--pgn-color-warning-500);
25+
}
26+
27+
.status-border-in-progress {
28+
border-left: 8px solid #F4B57B;
29+
}

0 commit comments

Comments
 (0)