feat: add course import page [FC-0112] (#2580)
Adds the Library Import Home, which lists course migrations to the library
This commit is contained in:
@@ -1,12 +1,10 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Stack } from '@openedx/paragon';
|
||||
import { Question } from '@openedx/paragon/icons';
|
||||
import { Div, Paragraph } from '@src/utils';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const SingleLineBreak = (chunk: string[]) => <div>{chunk}</div>;
|
||||
export const Paragraph = (chunk: string[]) => <p>{chunk}</p>;
|
||||
|
||||
export const LegacyMigrationHelpSidebar = () => (
|
||||
<div className="legacy-libraries-migration-help bg-white pt-3 mt-1">
|
||||
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
|
||||
@@ -42,7 +40,7 @@ export const LegacyMigrationHelpSidebar = () => (
|
||||
<span className="x-small">
|
||||
<FormattedMessage
|
||||
{...messages.helpAndSupportThirdQuestionBody}
|
||||
values={{ div: SingleLineBreak, p: Paragraph }}
|
||||
values={{ div: Div, p: Paragraph }}
|
||||
/>
|
||||
</span>
|
||||
</Stack>
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { LibraryBackupPage } from '@src/library-authoring/backup-restore';
|
||||
import LibraryAuthoringPage from './LibraryAuthoringPage';
|
||||
import { LibraryBackupPage } from './backup-restore';
|
||||
import LibraryCollectionPage from './collections/LibraryCollectionPage';
|
||||
import { LibraryProvider } from './common/context/LibraryContext';
|
||||
import { SidebarProvider } from './common/context/SidebarContext';
|
||||
@@ -15,6 +15,7 @@ import { ComponentPicker } from './component-picker';
|
||||
import { ComponentEditorModal } from './components/ComponentEditorModal';
|
||||
import { CreateCollectionModal } from './create-collection';
|
||||
import { CreateContainerModal } from './create-container';
|
||||
import { CourseImportHomePage } from './import-course';
|
||||
import { ROUTES } from './routes';
|
||||
import { LibrarySectionPage, LibrarySubsectionPage } from './section-subsections';
|
||||
import { LibraryUnitPage } from './units';
|
||||
@@ -92,6 +93,10 @@ const LibraryLayout = () => (
|
||||
path={ROUTES.BACKUP}
|
||||
Component={LibraryBackupPage}
|
||||
/>
|
||||
<Route
|
||||
path={ROUTES.IMPORT}
|
||||
Component={CourseImportHomePage}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
|
||||
@@ -1072,3 +1072,64 @@ mockGetEntityLinks.applyMock = () => jest.spyOn(
|
||||
courseLibApi,
|
||||
'getEntityLinks',
|
||||
).mockImplementation(mockGetEntityLinks);
|
||||
|
||||
export async function mockGetCourseImports(libraryId: string): ReturnType<typeof api.getCourseImports> {
|
||||
switch (libraryId) {
|
||||
case mockContentLibrary.libraryId:
|
||||
return [
|
||||
mockGetCourseImports.succeedImport,
|
||||
mockGetCourseImports.succeedImportWithCollection,
|
||||
mockGetCourseImports.failImport,
|
||||
mockGetCourseImports.inProgressImport,
|
||||
];
|
||||
case mockGetCourseImports.emptyLibraryId:
|
||||
return [];
|
||||
default:
|
||||
throw new Error(`mockGetCourseImports doesn't know how to mock ${JSON.stringify(libraryId)}`);
|
||||
}
|
||||
}
|
||||
mockGetCourseImports.libraryId = mockContentLibrary.libraryId;
|
||||
mockGetCourseImports.emptyLibraryId = mockContentLibrary.libraryId2;
|
||||
mockGetCourseImports.succeedImport = {
|
||||
source: {
|
||||
key: 'course-v1:edX+DemoX+2025_T1',
|
||||
displayName: 'DemoX 2025 T1',
|
||||
},
|
||||
targetCollection: null,
|
||||
state: 'Succeeded',
|
||||
progress: 1,
|
||||
} satisfies api.CourseImport;
|
||||
mockGetCourseImports.succeedImportWithCollection = {
|
||||
source: {
|
||||
key: 'course-v1:edX+DemoX+2025_T2',
|
||||
displayName: 'DemoX 2025 T2',
|
||||
},
|
||||
targetCollection: {
|
||||
key: 'sample-collection',
|
||||
title: 'DemoX 2025 T1 (2)',
|
||||
},
|
||||
state: 'Succeeded',
|
||||
progress: 1,
|
||||
} satisfies api.CourseImport;
|
||||
mockGetCourseImports.failImport = {
|
||||
source: {
|
||||
key: 'course-v1:edX+DemoX+2025_T3',
|
||||
displayName: 'DemoX 2025 T3',
|
||||
},
|
||||
targetCollection: null,
|
||||
state: 'Failed',
|
||||
progress: 0.30,
|
||||
} satisfies api.CourseImport;
|
||||
mockGetCourseImports.inProgressImport = {
|
||||
source: {
|
||||
key: 'course-v1:edX+DemoX+2025_T4',
|
||||
displayName: 'DemoX 2025 T4',
|
||||
},
|
||||
targetCollection: null,
|
||||
state: 'In Progress',
|
||||
progress: 0.5012,
|
||||
} satisfies api.CourseImport;
|
||||
mockGetCourseImports.applyMock = () => jest.spyOn(
|
||||
api,
|
||||
'getCourseImports',
|
||||
).mockImplementation(mockGetCourseImports);
|
||||
|
||||
@@ -157,6 +157,10 @@ export const getLibraryRestoreStatusApiUrl = (taskId: string) => `${getApiBaseUr
|
||||
* Get the URL for the API endpoint to copy a single container.
|
||||
*/
|
||||
export const getLibraryContainerCopyApiUrl = (containerId: string) => `${getLibraryContainerApiUrl(containerId)}copy/`;
|
||||
/**
|
||||
* Get the url for the API endpoint to list library course imports.
|
||||
*/
|
||||
export const getCourseImportsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
@@ -784,3 +788,24 @@ export async function getLibraryContainerHierarchy(
|
||||
export async function publishContainer(containerId: string) {
|
||||
await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId));
|
||||
}
|
||||
|
||||
export interface CourseImport {
|
||||
source: {
|
||||
key: string;
|
||||
displayName: string;
|
||||
};
|
||||
targetCollection: {
|
||||
key: string;
|
||||
title: string;
|
||||
} | null;
|
||||
state: 'Succeeded' | 'Failed' | 'In Progress';
|
||||
progress: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the course imports which had this library as destination.
|
||||
*/
|
||||
export async function getCourseImports(libraryId: string): Promise<CourseImport[]> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getCourseImportsApiUrl(libraryId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ export const libraryAuthoringQueryKeys = {
|
||||
}
|
||||
return ['hierarchy'];
|
||||
},
|
||||
courseImports: (libraryId: string) => [
|
||||
...libraryAuthoringQueryKeys.contentLibrary(libraryId),
|
||||
'courseImports',
|
||||
],
|
||||
};
|
||||
|
||||
export const xblockQueryKeys = {
|
||||
@@ -951,3 +955,13 @@ export const useContentFromSearchIndex = (contentIds: string[]) => {
|
||||
skipBlockTypeFetch: true,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the course imports which had this library as destination.
|
||||
*/
|
||||
export const useCourseImports = (libraryId: string) => (
|
||||
useQuery({
|
||||
queryKey: libraryAuthoringQueryKeys.courseImports(libraryId),
|
||||
queryFn: () => api.getCourseImports(libraryId),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
initializeMocks,
|
||||
render as testRender,
|
||||
screen,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockGetCourseImports,
|
||||
} from '../data/api.mocks';
|
||||
import { CourseImportHomePage } from './CourseImportHomePage';
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
mockGetCourseImports.applyMock();
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const render = (libraryId: string) => (
|
||||
testRender(
|
||||
<CourseImportHomePage />,
|
||||
{
|
||||
extraWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<LibraryProvider libraryId={libraryId}>
|
||||
{children}
|
||||
</LibraryProvider>
|
||||
),
|
||||
path: '/libraries/:libraryId/import-course',
|
||||
params: { libraryId },
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
describe('<CourseImportHomePage>', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('should render the library course import home page', async () => {
|
||||
render(mockGetCourseImports.libraryId);
|
||||
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
|
||||
expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should render the empty state', async () => {
|
||||
render(mockGetCourseImports.emptyLibraryId);
|
||||
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
|
||||
expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('You have not imported any courses into this library.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
93
src/library-authoring/import-course/CourseImportHomePage.tsx
Normal file
93
src/library-authoring/import-course/CourseImportHomePage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Container,
|
||||
Layout,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Add } from '@openedx/paragon/icons';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import Loading from '@src/generic/Loading';
|
||||
import SubHeader from '@src/generic/sub-header/SubHeader';
|
||||
import Header from '@src/header';
|
||||
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useCourseImports } from '../data/apiHooks';
|
||||
import { HelpSidebar } from './HelpSidebar';
|
||||
import { ImportedCourseCard } from './ImportedCourseCard';
|
||||
import messages from './messages';
|
||||
|
||||
const EmptyState = () => (
|
||||
<Container size="md" className="py-6">
|
||||
<Card>
|
||||
<Stack direction="horizontal" gap={3} className="my-6 justify-content-center">
|
||||
<FormattedMessage {...messages.emptyStateText} />
|
||||
<Button iconBefore={Add} disabled>
|
||||
<FormattedMessage {...messages.emptyStateButtonText} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Container>
|
||||
);
|
||||
|
||||
export const CourseImportHomePage = () => {
|
||||
const intl = useIntl();
|
||||
const { libraryId, libraryData, readOnly } = useLibraryContext();
|
||||
const { data: courseImports } = useCourseImports(libraryId);
|
||||
|
||||
if (!courseImports || !libraryData) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
<Helmet>
|
||||
<title>{libraryData.title} | {process.env.SITE_NAME}</title>
|
||||
</Helmet>
|
||||
<Header
|
||||
number={libraryData.slug}
|
||||
title={libraryData.title}
|
||||
org={libraryData.org}
|
||||
contextId={libraryId}
|
||||
isLibrary
|
||||
readOnly={readOnly}
|
||||
containerProps={{
|
||||
size: undefined,
|
||||
}}
|
||||
/>
|
||||
<Container className="mt-4 mb-5">
|
||||
<div className="px-4 bg-light-200 border-bottom">
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.pageTitle)}
|
||||
subtitle={intl.formatMessage(messages.pageSubtitle)}
|
||||
hideBorder
|
||||
/>
|
||||
</div>
|
||||
<Layout xs={[{ span: 9 }, { span: 3 }]}>
|
||||
<Layout.Element>
|
||||
{courseImports.length ? (
|
||||
<Stack gap={3} className="pl-4 mt-4">
|
||||
<h3>
|
||||
<FormattedMessage {...messages.courseImportPreviousImports} />
|
||||
</h3>
|
||||
{courseImports.map((courseImport) => (
|
||||
<ImportedCourseCard
|
||||
key={courseImport.source.key}
|
||||
courseImport={courseImport}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (<EmptyState />)}
|
||||
</Layout.Element>
|
||||
<Layout.Element>
|
||||
<HelpSidebar />
|
||||
</Layout.Element>
|
||||
</Layout>
|
||||
</Container>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
src/library-authoring/import-course/HelpSidebar.tsx
Normal file
44
src/library-authoring/import-course/HelpSidebar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Icon, Stack } from '@openedx/paragon';
|
||||
import { Question } from '@openedx/paragon/icons';
|
||||
import { Paragraph } from '@src/utils';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
export const HelpSidebar = () => (
|
||||
<div className="pt-3 border-left h-100">
|
||||
<Stack gap={1} direction="horizontal" className="pl-4 h4 text-primary-700">
|
||||
<Icon src={Question} />
|
||||
<span>
|
||||
<FormattedMessage {...messages.helpAndSupportTitle} />
|
||||
</span>
|
||||
</Stack>
|
||||
<hr />
|
||||
<Stack className="pl-4 pr-4">
|
||||
<Stack>
|
||||
<span className="h5">
|
||||
<FormattedMessage {...messages.helpAndSupportFirstQuestionTitle} />
|
||||
</span>
|
||||
<span className="x-small">
|
||||
<FormattedMessage
|
||||
{...messages.helpAndSupportFirstQuestionBody}
|
||||
values={{ p: Paragraph }}
|
||||
/>
|
||||
</span>
|
||||
</Stack>
|
||||
<hr />
|
||||
<Stack>
|
||||
<span className="h5">
|
||||
<FormattedMessage {...messages.helpAndSupportSecondQuestionTitle} />
|
||||
</span>
|
||||
<span className="x-small">
|
||||
<FormattedMessage
|
||||
{...messages.helpAndSupportSecondQuestionBody}
|
||||
values={{ p: Paragraph }}
|
||||
/>
|
||||
</span>
|
||||
</Stack>
|
||||
</Stack>
|
||||
<hr className="w-100" />
|
||||
</div>
|
||||
);
|
||||
@@ -0,0 +1,94 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import {
|
||||
initializeMocks,
|
||||
render as testRender,
|
||||
screen,
|
||||
} from '@src/testUtils';
|
||||
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockGetCourseImports,
|
||||
} from '../data/api.mocks';
|
||||
import { type CourseImport } from '../data/api';
|
||||
import { ImportedCourseCard } from './ImportedCourseCard';
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
const { libraryId } = mockContentLibrary;
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
const render = (courseImport: CourseImport) => (
|
||||
testRender(
|
||||
<ImportedCourseCard courseImport={courseImport} />,
|
||||
{
|
||||
extraWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<LibraryProvider libraryId={mockContentLibrary.libraryId}>
|
||||
{children}
|
||||
</LibraryProvider>
|
||||
),
|
||||
path: '/libraries/:libraryId/import-course',
|
||||
params: { libraryId },
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
describe('<ImportedCourseCard>', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('should render a card for a successful import', () => {
|
||||
const { succeedImport } = mockGetCourseImports;
|
||||
render(succeedImport);
|
||||
expect(screen.getByText(succeedImport.source.displayName)).toBeInTheDocument();
|
||||
expect(screen.getByText(/100% Imported/)).toBeInTheDocument();
|
||||
|
||||
const courseLink = screen.getByRole('link', { name: succeedImport.source.displayName });
|
||||
expect(courseLink).toHaveAttribute('href', `/course/${succeedImport.source.key}`);
|
||||
});
|
||||
|
||||
it('should render a card for a successful import with a collection', async () => {
|
||||
const { succeedImportWithCollection } = mockGetCourseImports;
|
||||
render(succeedImportWithCollection);
|
||||
expect(screen.getByText(succeedImportWithCollection.source.displayName)).toBeInTheDocument();
|
||||
expect(screen.getByText(/100% Imported/)).toBeInTheDocument();
|
||||
|
||||
const courseLink = screen.getByRole('link', { name: succeedImportWithCollection.source.displayName });
|
||||
expect(courseLink).toHaveAttribute('href', `/course/${succeedImportWithCollection.source.key}`);
|
||||
|
||||
const collectionLink = await screen.findByText(succeedImportWithCollection.targetCollection.title);
|
||||
await userEvent.click(collectionLink);
|
||||
expect(mockNavigate).toHaveBeenCalledWith(
|
||||
{
|
||||
pathname: `/library/${libraryId}/collection/${succeedImportWithCollection.targetCollection.key}`,
|
||||
search: '',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should render a card for a failed import', () => {
|
||||
const { failImport } = mockGetCourseImports;
|
||||
render(failImport);
|
||||
expect(screen.getByText(failImport.source.displayName)).toBeInTheDocument();
|
||||
expect(screen.getByText('Import Failed')).toBeInTheDocument();
|
||||
|
||||
const courseLink = screen.getByRole('link', { name: failImport.source.displayName });
|
||||
expect(courseLink).toHaveAttribute('href', `/course/${failImport.source.key}`);
|
||||
});
|
||||
|
||||
it('should render a card for an in-progress import', () => {
|
||||
const { inProgressImport } = mockGetCourseImports;
|
||||
render(inProgressImport);
|
||||
expect(screen.getByText(inProgressImport.source.displayName)).toBeInTheDocument();
|
||||
expect(screen.getByText(/50% Imported/)).toBeInTheDocument();
|
||||
|
||||
const courseLink = screen.getByRole('link', { name: inProgressImport.source.displayName });
|
||||
expect(courseLink).toHaveAttribute('href', `/course/${inProgressImport.source.key}`);
|
||||
});
|
||||
});
|
||||
100
src/library-authoring/import-course/ImportedCourseCard.tsx
Normal file
100
src/library-authoring/import-course/ImportedCourseCard.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Icon,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
ArrowForwardIos,
|
||||
Check,
|
||||
Error,
|
||||
Folder,
|
||||
IncompleteCircle,
|
||||
Warning,
|
||||
} from '@openedx/paragon/icons';
|
||||
import classNames from 'classnames';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { type CourseImport } from '../data/api';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import messages from './messages';
|
||||
|
||||
interface ImportedCourseCardProps {
|
||||
courseImport: CourseImport;
|
||||
}
|
||||
|
||||
const BORDER_CLASS = {
|
||||
Succeeded: 'status-border-imported',
|
||||
Failed: 'status-border-failed',
|
||||
Partial: 'status-border-partial',
|
||||
'In Progress': 'status-border-in-progress',
|
||||
};
|
||||
|
||||
const STATE_ICON = {
|
||||
Succeeded: Check,
|
||||
Failed: Error,
|
||||
Partial: Warning,
|
||||
'In Progress': IncompleteCircle,
|
||||
};
|
||||
|
||||
const STATE_ICON_COLOR_CLASS = {
|
||||
Succeeded: undefined,
|
||||
Failed: 'text-danger-500',
|
||||
Partial: 'text-warning-500',
|
||||
'In Progress': undefined,
|
||||
};
|
||||
|
||||
const StateIcon = ({ state }: { state: CourseImport['state'] }) => (
|
||||
<Icon
|
||||
src={STATE_ICON[state]}
|
||||
size="sm"
|
||||
className={classNames('mr-2', STATE_ICON_COLOR_CLASS[state])}
|
||||
/>
|
||||
);
|
||||
|
||||
export const ImportedCourseCard = ({ courseImport }: ImportedCourseCardProps) => {
|
||||
const intl = useIntl();
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
|
||||
return (
|
||||
<Card className={BORDER_CLASS[courseImport.state]}>
|
||||
<Card.Section className="d-flex flex-row">
|
||||
<div>
|
||||
<Link to={`/course/${courseImport.source.key}`} target="_blank">
|
||||
<h4>{courseImport.source.displayName}</h4>
|
||||
</Link>
|
||||
<div className="d-inline-flex small align-items-center">
|
||||
<StateIcon state={courseImport.state} />
|
||||
{courseImport.state === 'Failed' ? (
|
||||
<FormattedMessage {...messages.courseImportTextFailed} />
|
||||
) : (
|
||||
<>
|
||||
{Math.round(courseImport.progress * 100)}
|
||||
<FormattedMessage {...messages.courseImportTextProgress} />
|
||||
</>
|
||||
)}
|
||||
{courseImport.targetCollection && (
|
||||
<Button
|
||||
iconBefore={Folder}
|
||||
variant="link"
|
||||
className="ml-4 text-black text-decoration-underline"
|
||||
onClick={() => navigateTo({ collectionId: courseImport.targetCollection!.key })}
|
||||
>
|
||||
{courseImport.targetCollection.title}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="d-flex align-items-center ml-auto">
|
||||
<Link
|
||||
to={`/course/${courseImport.source.key}`}
|
||||
aria-label={intl.formatMessage(messages.courseImportNavigateAlt)}
|
||||
className="text-primary-500"
|
||||
>
|
||||
<Icon src={ArrowForwardIos} />
|
||||
</Link>
|
||||
</div>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
19
src/library-authoring/import-course/index.scss
Normal file
19
src/library-authoring/import-course/index.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
.status-border-imported {
|
||||
border-left: 8px solid #5690BB;
|
||||
}
|
||||
|
||||
.status-border-failed {
|
||||
border-left: 8px solid var(--pgn-color-danger-500);
|
||||
}
|
||||
|
||||
.status-border-partial {
|
||||
border-left: 8px solid var(--pgn-color-warning-500);
|
||||
}
|
||||
|
||||
.status-border-in-progress {
|
||||
border-left: 8px solid #F4B57B;
|
||||
}
|
||||
|
||||
.text-decoration-underline {
|
||||
text-decoration: underline;
|
||||
}
|
||||
1
src/library-authoring/import-course/index.ts
Normal file
1
src/library-authoring/import-course/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CourseImportHomePage } from './CourseImportHomePage';
|
||||
78
src/library-authoring/import-course/messages.ts
Normal file
78
src/library-authoring/import-course/messages.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
pageTitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.title',
|
||||
defaultMessage: 'Import',
|
||||
description: 'Title for the library import course',
|
||||
},
|
||||
pageSubtitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.subtitle',
|
||||
defaultMessage: 'Tools',
|
||||
description: 'Subtitle for the library import course',
|
||||
},
|
||||
emptyStateText: {
|
||||
id: 'course-authoring.library-authoring.import-course.empty-state.text',
|
||||
defaultMessage: 'You have not imported any courses into this library.',
|
||||
description: 'Text for the empty state of the library import course',
|
||||
},
|
||||
emptyStateButtonText: {
|
||||
id: 'course-authoring.library-authoring.import-course.empty-state.button.text',
|
||||
defaultMessage: 'Import Course',
|
||||
description: 'Text for the button to import a course into the library',
|
||||
},
|
||||
courseImportPreviousImports: {
|
||||
id: 'course-authoring.library-authoring.import-course.previous-imports',
|
||||
defaultMessage: 'Previous Imports',
|
||||
description: 'Title for the list of previous imports',
|
||||
},
|
||||
courseImportTextProgress: {
|
||||
id: 'course-authoring.library-authoring.import-course.course.text',
|
||||
defaultMessage: '% Imported',
|
||||
description: 'Text for the course import state',
|
||||
},
|
||||
courseImportTextFailed: {
|
||||
id: 'course-authoring.library-authoring.import-course.course.text-failed',
|
||||
defaultMessage: 'Import Failed',
|
||||
description: 'Text for the course import failed state',
|
||||
},
|
||||
courseImportNavigateAlt: {
|
||||
id: 'course-authoring.library-authoring.import-course.course.navigate-alt',
|
||||
defaultMessage: 'Navigate to course',
|
||||
description: 'Alt text for the course import navigate button',
|
||||
},
|
||||
helpAndSupportTitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.help-and-support.title',
|
||||
defaultMessage: 'Help & Support',
|
||||
description: 'Title of the Help & Support sidebar',
|
||||
},
|
||||
helpAndSupportFirstQuestionTitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.help-and-support.q1.title',
|
||||
defaultMessage: 'Why import a course?',
|
||||
description: 'Title of the first question in the Help & Support sidebar',
|
||||
},
|
||||
helpAndSupportFirstQuestionBody: {
|
||||
id: 'course-authoring.library-authoring.import-course.help-and-support.q1.body',
|
||||
defaultMessage: '<p>You can import existing courses into a library in order to reference '
|
||||
+ 'course content across courses.</p>'
|
||||
+ '<p>Courses with content you or others may want to reuse or reference in the future are '
|
||||
+ 'excellent candidates for import.</p>',
|
||||
description: 'Body of the first question in the Help & Support sidebar',
|
||||
},
|
||||
helpAndSupportSecondQuestionTitle: {
|
||||
id: 'course-authoring.library-authoring.import-course.help-and-support.q2.title',
|
||||
defaultMessage: 'What content is imported?',
|
||||
description: 'Title of the second question in the Help & Support sidebar',
|
||||
},
|
||||
helpAndSupportSecondQuestionBody: {
|
||||
id: 'course-authoring.library-authoring.import-course.help-and-support.q2.body',
|
||||
defaultMessage: '<p>You can select a course to import and decide whether to import all sections, '
|
||||
+ 'subsections, units, or blocks from this course.</p>'
|
||||
+ '<p>Not all courses content types can be imported, but this page will convey the status of imports '
|
||||
+ 'and share any import errors found while importing your course.</p>'
|
||||
+ '<p>For additional details you can review the Library Import documentation.</p>',
|
||||
description: 'Body of the second question in the Help & Support sidebar',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -6,6 +6,7 @@
|
||||
@import "./section-subsections";
|
||||
@import "./containers";
|
||||
@import "./hierarchy";
|
||||
@import "./import-course";
|
||||
|
||||
.library-cards-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
initializeMocks,
|
||||
} from '../../testUtils';
|
||||
} from '@src/testUtils';
|
||||
|
||||
import { mockContentLibrary } from '../data/api.mocks';
|
||||
import { getContentLibraryApiUrl } from '../data/api';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useUpdateLibraryMetadata } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
@@ -47,6 +47,8 @@ export const ROUTES = {
|
||||
UNIT: '/unit/:containerId/:selectedItemId?/:index?',
|
||||
// LibraryBackupPage route:
|
||||
BACKUP: '/backup',
|
||||
// LibraryImportPage route:
|
||||
IMPORT: '/import',
|
||||
};
|
||||
|
||||
export enum ContentType {
|
||||
|
||||
@@ -349,3 +349,5 @@ export const skipIfUnwantedTarget = (
|
||||
};
|
||||
|
||||
export const BoldText = (chunk: string[]) => <b>{chunk}</b>;
|
||||
export const Div = (chunk: string[]) => <div>{chunk}</div>;
|
||||
export const Paragraph = (chunk: string[]) => <p>{chunk}</p>;
|
||||
|
||||
Reference in New Issue
Block a user