Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions src/legacy-libraries-migration/LegacyMigrationHelpSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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">
Expand Down Expand Up @@ -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>
Expand Down
7 changes: 6 additions & 1 deletion src/library-authoring/LibraryLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,16 @@ 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';
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';
Expand Down Expand Up @@ -92,6 +93,10 @@ const LibraryLayout = () => (
path={ROUTES.BACKUP}
Component={LibraryBackupPage}
/>
<Route
path={ROUTES.IMPORT}
Component={CourseImportHomePage}
/>
</Route>
</Routes>
);
Expand Down
61 changes: 61 additions & 0 deletions src/library-authoring/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,3 +1072,64 @@ mockGetEntityLinks.applyMock = () => jest.spyOn(
courseLibApi,
'getEntityLinks',
).mockImplementation(mockGetEntityLinks);

export async function mockGetCourseMigrations(libraryId: string): ReturnType<typeof api.getCourseMigrations> {
switch (libraryId) {
case mockContentLibrary.libraryId:
return [
mockGetCourseMigrations.succeedMigration,
mockGetCourseMigrations.succeedMigrationWithCollection,
mockGetCourseMigrations.failMigration,
mockGetCourseMigrations.inProgressMigration,
];
case mockGetCourseMigrations.emptyLibraryId:
return [];
default:
throw new Error(`mockGetCourseMigrations doesn't know how to mock ${JSON.stringify(libraryId)}`);
}
}
mockGetCourseMigrations.libraryId = mockContentLibrary.libraryId;
mockGetCourseMigrations.emptyLibraryId = mockContentLibrary.libraryId2;
mockGetCourseMigrations.succeedMigration = {
source: {
key: 'course-v1:edX+DemoX+2025_T1',
displayName: 'DemoX 2025 T1',
},
targetCollection: null,
state: 'Succeeded',
progress: 1,
} satisfies api.CourseMigration;
mockGetCourseMigrations.succeedMigrationWithCollection = {
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.CourseMigration;
mockGetCourseMigrations.failMigration = {
source: {
key: 'course-v1:edX+DemoX+2025_T3',
displayName: 'DemoX 2025 T3',
},
targetCollection: null,
state: 'Failed',
progress: 0.30,
} satisfies api.CourseMigration;
mockGetCourseMigrations.inProgressMigration = {
source: {
key: 'course-v1:edX+DemoX+2025_T4',
displayName: 'DemoX 2025 T4',
},
targetCollection: null,
state: 'InProgress',
progress: 0.5012,
} satisfies api.CourseMigration;
mockGetCourseMigrations.applyMock = () => jest.spyOn(
api,
'getCourseMigrations',
).mockImplementation(mockGetCourseMigrations);
25 changes: 25 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 migrations.
*/
export const getCourseMigrationsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/library/${libraryId}/migrations/courses/`;

export interface ContentLibrary {
id: string;
Expand Down Expand Up @@ -784,3 +788,24 @@ export async function getLibraryContainerHierarchy(
export async function publishContainer(containerId: string) {
await getAuthenticatedHttpClient().post(getLibraryContainerPublishApiUrl(containerId));
}

export interface CourseMigration {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NIT: rename to indicate import instead of migration

source: {
key: string;
displayName: string;
};
targetCollection: {
key: string;
title: string;
} | null;
state: 'Succeeded' | 'Failed' | 'InProgress';
progress: number;
}

/**
* Returns the course migrations which had this library as destination.
*/
export async function getCourseMigrations(libraryId: string): Promise<CourseMigration[]> {
const { data } = await getAuthenticatedHttpClient().get(getCourseMigrationsApiUrl(libraryId));
return camelCaseObject(data);
}
14 changes: 14 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ export const libraryAuthoringQueryKeys = {
}
return ['hierarchy'];
},
migrations: (libraryId: string) => [
Comment thread
ChrisChV marked this conversation as resolved.
Outdated
...libraryAuthoringQueryKeys.contentLibrary(libraryId),
'migrations',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Rename to import.

],
};

export const xblockQueryKeys = {
Expand Down Expand Up @@ -951,3 +955,13 @@ export const useContentFromSearchIndex = (contentIds: string[]) => {
skipBlockTypeFetch: true,
});
};

/**
* Returns the course migrations which had this library as destination.
*/
export const useCourseMigrations = (libraryId: string) => (
useQuery({
queryKey: libraryAuthoringQueryKeys.migrations(libraryId),
queryFn: () => api.getCourseMigrations(libraryId),
})
);
53 changes: 53 additions & 0 deletions src/library-authoring/import-course/CourseImportHomePage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import {
initializeMocks,
render as testRender,
screen,
} from '@src/testUtils';

import { LibraryProvider } from '../common/context/LibraryContext';
import {
mockContentLibrary,
mockGetCourseMigrations,
} from '../data/api.mocks';
import { CourseImportHomePage } from './CourseImportHomePage';

initializeMocks();
Comment thread
ChrisChV marked this conversation as resolved.
Outdated
mockContentLibrary.applyMock();
mockGetCourseMigrations.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>', () => {
it('should render the library course import home page', async () => {
render(mockGetCourseMigrations.libraryId);
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
expect(screen.getByRole('heading', { name: 'Previous Imports' })).toBeInTheDocument();
expect(screen.queryAllByRole('link', { name: /DemoX 2025 T[0-5]/ })).toHaveLength(4);
Comment thread
ChrisChV marked this conversation as resolved.
Outdated
});

it('should render the empty state', async () => {
render(mockGetCourseMigrations.emptyLibraryId);
expect(await screen.findByRole('heading', { name: /Tools.*Import/ })).toBeInTheDocument(); // Header
expect(screen.queryByRole('heading', { name: 'Previous Imports' })).not.toBeInTheDocument();
expect(screen.queryByText('You have not imported any courses into this library.')).toBeInTheDocument();
Comment thread
ChrisChV marked this conversation as resolved.
Outdated
});
});
96 changes: 96 additions & 0 deletions src/library-authoring/import-course/CourseImportHomePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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 NotFoundAlert from '@src/generic/NotFoundAlert';
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 { useContentLibrary, useCourseMigrations } from '../data/apiHooks';
import { HelpSidebar } from './HelpSidebar';
import { MigratedCourseCard } from './MigratedCourseCard';
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 } = useLibraryContext();
const { data: libraryData } = useContentLibrary(libraryId);
const { data: courseMigrations } = useCourseMigrations(libraryId);

if (!courseMigrations) {
return <Loading />;
}

if (!libraryData) {
return <NotFoundAlert />;
}

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass readOnly from library context here.

Suggested change
isLibrary
isLibrary
readOnly={readOnly}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Fixed here: f17820b

containerProps={{
size: undefined,
}}
/>
<Container className="px-0 mt-4 mb-5 library-authoring-page">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<Container className="px-0 mt-4 mb-5 library-authoring-page">
<Container className="mt-4 mb-5">

px-0 adds unnecessary horizontal scroll and library-authoring-page has no effect

Copy link
Copy Markdown
Contributor Author

@rpenido rpenido Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Fixed: ab24e7c

<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>
{courseMigrations.length ? (
<Stack gap={3} className="pl-4 mt-4">
<h3>Previous Imports</h3>
Comment thread
ChrisChV marked this conversation as resolved.
Outdated
{courseMigrations.map((courseMigration) => (
<MigratedCourseCard
key={courseMigration.source.key}
courseMigration={courseMigration}
/>
))}
</Stack>
) : (<EmptyState />)}
</Layout.Element>
<Layout.Element>
<HelpSidebar />
</Layout.Element>
</Layout>
</Container>
</div>
</div>
);
};
44 changes: 44 additions & 0 deletions src/library-authoring/import-course/HelpSidebar.tsx
Original file line number Diff line number Diff line change
@@ -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="course-migration-help pt-3 border-left">
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div className="course-migration-help pt-3 border-left">
<div className="pt-3 border-left">

Copy link
Copy Markdown
Contributor Author

@rpenido rpenido Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed cab000a

<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>
<hr />
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<hr />
<hr className="w-100" />

Copy link
Copy Markdown
Contributor Author

@rpenido rpenido Nov 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed: cab000a

</Stack>
</div>
);
Loading