Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
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
58 changes: 58 additions & 0 deletions src/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,61 @@ mockGetMigrationStatus.migrationStatusInProgressData = {
],
} as api.MigrateTaskStatusData;
mockGetMigrationStatus.applyMock = () => jest.spyOn(api, 'getModulestoreMigrationStatus').mockImplementation(mockGetMigrationStatus);

export async function mockGetPreviewModulestoreMigration(
// @ts-ignore-next-line
Comment thread
ChrisChV marked this conversation as resolved.
Outdated
libraryKey: string,
sourceKey: string,
): Promise<api.PreviewMigrationInfo> {
switch (sourceKey) {
case mockGetPreviewModulestoreMigration.sourceKeyGood:
return mockGetPreviewModulestoreMigration.goodData;
case mockGetPreviewModulestoreMigration.sourceKeyUnsupported:
return mockGetPreviewModulestoreMigration.unsupportedData;
case mockGetPreviewModulestoreMigration.sourceKeyBlockLimit:
return mockGetPreviewModulestoreMigration.blockLimitData;
case mockGetPreviewModulestoreMigration.sourceKeyBlockLoading:
return new Promise(() => {});
default:
/* istanbul ignore next */
throw new Error(`mockGetPreviewModulestoreMigration: unknown sourceKey "${sourceKey}"`);
}
}
mockGetPreviewModulestoreMigration.sourceKeyGood = 'course-v1:HarvardX+123+2023';
mockGetPreviewModulestoreMigration.goodData = {
state: 'success',
unsupportedBlocks: 0,
unsupportedPercentage: 0,
blocksLimit: 1000,
totalBlocks: 10,
totalComponents: 5,
sections: 1,
subsections: 2,
units: 3,
} as api.PreviewMigrationInfo;
mockGetPreviewModulestoreMigration.sourceKeyUnsupported = 'course-v1:HarvardX+2+2023';
mockGetPreviewModulestoreMigration.unsupportedData = {
state: 'partial',
unsupportedBlocks: 5,
unsupportedPercentage: 25,
blocksLimit: 1000,
totalBlocks: 20,
totalComponents: 10,
sections: 2,
subsections: 3,
units: 5,
} as api.PreviewMigrationInfo;
mockGetPreviewModulestoreMigration.sourceKeyBlockLimit = 'course-v1:HarvardX+3+2023';
mockGetPreviewModulestoreMigration.blockLimitData = {
state: 'block_limit_reached',
unsupportedBlocks: 5,
unsupportedPercentage: 25,
blocksLimit: 1000,
totalBlocks: 20,
totalComponents: 10,
sections: 2,
subsections: 3,
units: 5,
} as api.PreviewMigrationInfo;
mockGetPreviewModulestoreMigration.sourceKeyBlockLoading = 'course-v1:HarvardX+4+2023';
mockGetPreviewModulestoreMigration.applyMock = () => jest.spyOn(api, 'getPreviewModulestoreMigration').mockImplementation(mockGetPreviewModulestoreMigration);
10 changes: 10 additions & 0 deletions src/data/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,14 @@ describe('legacy libraries migration API', () => {
expect(axiosMock.history.post[0].url).toEqual(url);
});
});

describe('getPreviewModulestoreMigration', () => {
it('should call get preview modulestore migration', async () => {
const url = api.getPreviewModulestoreMigrationUrl();
axiosMock.onGet(url).reply(200);
await api.getPreviewModulestoreMigration('1', '2');

expect(axiosMock.history.get[0].url).toEqual(url);
});
});
});
34 changes: 34 additions & 0 deletions src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ export const getModulestoreMigrationStatusUrl = (migrationId: string) => `${getS
*/
export const bulkModulestoreMigrateUrl = () => `${getStudioBaseUrl()}/api/modulestore_migrator/v1/bulk_migration/`;

/**
* Get the url for the API endpoint to get preview migration
*/
export const getPreviewModulestoreMigrationUrl = () => `${getStudioBaseUrl()}/api/modulestore_migrator/v1/migration_preview/`;

export const getApiWaffleFlagsUrl = (courseId?: string): string => {
const baseUrl = getStudioBaseUrl();
const apiPath = '/api/contentstore/v1/course_waffle_flags';
Expand Down Expand Up @@ -173,3 +178,32 @@ export async function bulkModulestoreMigrate(
const { data } = await client.post(bulkModulestoreMigrateUrl(), snakeCaseObject(requestData));
return camelCaseObject(data);
}

export interface PreviewMigrationInfo {
state: 'partial' | 'success' | 'block_limit_reached';
unsupportedBlocks: number;
unsupportedPercentage: number;
blocksLimit: number;
totalBlocks: number;
totalComponents: number;
sections: number;
subsections: number;
units: number;
}

/**
* Get the preview for a modulestore migration given a source key and a library key
*/
export async function getPreviewModulestoreMigration(
libraryKey: string,
sourceKey: string,
): Promise<PreviewMigrationInfo> {
const client = getAuthenticatedHttpClient();

const params = new URLSearchParams();
params.append('target_key', libraryKey);
params.append('source_key', sourceKey);

const { data } = await client.get(getPreviewModulestoreMigrationUrl(), { params });
return camelCaseObject(data);
}
12 changes: 12 additions & 0 deletions src/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getModulestoreMigrationStatus,
BulkMigrateRequestData,
getCourseDetails,
getPreviewModulestoreMigration,
} from './api';
import { RequestStatus, RequestStatusType } from './constants';

Expand All @@ -19,6 +20,7 @@ export const migrationQueryKeys = {
* Base key for data specific to a migration task
*/
migrationTask: (migrationId?: string | null) => [...migrationQueryKeys.all, migrationId],
migrationPreview: (library_key: string, source_key?: string) => [...migrationQueryKeys.all, 'preview', source_key, library_key],
};

export const courseDetailsKey = {
Expand Down Expand Up @@ -84,6 +86,16 @@ export const useModulestoreMigrationStatus = (migrationId: string | null, refetc
})
);

/**
* Get the preview migration given a library key and a source key
*/
export const usePreviewMigration = (libraryKey: string, sourceKey?: string) => (
useQuery({
queryKey: migrationQueryKeys.migrationPreview(libraryKey, sourceKey),
queryFn: sourceKey ? () => getPreviewModulestoreMigration(libraryKey, sourceKey) : skipToken,
})
);

/**
* Get details of a course
*/
Expand Down
17 changes: 17 additions & 0 deletions src/library-authoring/import-course/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,23 @@ const messages = defineMessages({
defaultMessage: 'Reason For Failed import',
description: 'Label for the Reason For Failed import field in the Reasons table in the import details',
},
importBlockedTitle: {
id: 'library-authoring.import-course.review-details.import-blocked.title',
defaultMessage: 'Import Blocked',
description: 'Title for the alert in review details when the import is blocked',
},
importBlockedBody: {
id: 'library-authoring.import-course.review-details.import-blocked.body',
defaultMessage: 'This import would exceed the Content Library limit of {limitNumber} items.'
+ ' To prevent incomplete or lost content, the import has been blocked. For more information,'
+ ' view the Content Library documentation.',
description: 'Body for the alert in review details when the import is blocked',
},
importNotPossibleTooltip: {
id: 'library-authoring.import-course.review-details.import-blocked.import-course-btn.tooltip',
defaultMessage: 'Import not possible',
description: 'Label for the tooltip for the import button in review details when the import is blocked',
},
placeholderCardDescription: {
id: 'library-authoring.import-course.import-failed.placeholder.description',
defaultMessage: 'This content type is not currently supported',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,53 @@ import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
import { getCourseDetailsApiUrl } from '@src/course-outline/data/api';
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
import { mockContentLibrary, mockGetMigrationInfo } from '@src/library-authoring/data/api.mocks';
import { useGetBlockTypes } from '@src/search-manager';
import { bulkModulestoreMigrateUrl } from '@src/data/api';
import { mockGetPreviewModulestoreMigration } from '@src/data/api.mocks';
import { ImportStepperPage } from './ImportStepperPage';

let axiosMock;
mockGetMigrationInfo.applyMock();
mockContentLibrary.applyMock();
mockGetPreviewModulestoreMigration.applyMock();
type StudioHomeState = DeprecatedReduxState['studioHome'];

const libraryKey = mockContentLibrary.libraryId;
const numPages = 1;
const coursesCount = studioHomeMock.courses.length;

const courses = [
{
courseKey: mockGetPreviewModulestoreMigration.sourceKeyGood,
displayName: 'Managing Risk in the Information Age',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '123',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
{
courseKey: mockGetPreviewModulestoreMigration.sourceKeyBlockLimit,
displayName: 'Course with a lot of components',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '3',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
{
courseKey: mockGetPreviewModulestoreMigration.sourceKeyBlockLoading,
displayName: 'Course with a loading',
lmsLink: '//localhost:18000/courses/course-v1:HarvardX+123+2023/jump_to/block-v1:HarvardX+123+2023+type@course+block@course',
number: '4',
org: 'HarvardX',
rerunLink: '/course_rerun/course-v1:HarvardX+123+2023',
run: '2023',
url: '/course/course-v1:HarvardX+123+2023',
},
];

const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Expand All @@ -44,7 +78,7 @@ const renderComponent = (studioHomeState: Partial<StudioHomeState> = {}) => {
studioHome: {
...initialState.studioHome,
studioHomeData: {
courses: studioHomeMock.courses,
courses,
numPages,
coursesCount,
},
Expand Down Expand Up @@ -86,9 +120,6 @@ describe('<ImportStepperModal />', () => {
expect(await screen.findByText('Select Course')).toBeInTheDocument();
expect(await screen.findByText('Review Import Details')).toBeInTheDocument();

// Renders the course list and hides previously imported courses
expect(screen.queryByText(/run 0/i)).toBeInTheDocument(); // not imported before

// Hides previously imported courses.
expect(screen.queryByText(/managing risk in the information age/i)).not.toBeInTheDocument();
expect(screen.queryByText('Previously Imported')).not.toBeInTheDocument();
Expand All @@ -100,7 +131,6 @@ describe('<ImportStepperModal />', () => {

// Renders previously imported courses and badge
expect(await screen.findByText(/managing risk in the information age/i)).toBeInTheDocument();
expect(await screen.findByText(/run 0/i)).toBeInTheDocument();
expect(await screen.findByText('Previously Imported')).toBeInTheDocument();

// Renders cancel and next step buttons
Expand All @@ -121,8 +151,9 @@ describe('<ImportStepperModal />', () => {
it('should go to review import details step', async () => {
const user = userEvent.setup();
renderComponent();
axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, {
courseId: 'course-v1:HarvardX+123+2023',
const courseId = mockGetPreviewModulestoreMigration.sourceKeyBlockLoading;
axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, {
courseId,
title: 'Managing Risk in the Information Age',
subtitle: '',
org: 'HarvardX',
Expand All @@ -138,7 +169,7 @@ describe('<ImportStepperModal />', () => {
expect(nextButton).toBeDisabled();

// Select a course
const courseCard = screen.getAllByRole('radio')[0];
const courseCard = screen.getAllByRole('radio')[2];
await user.click(courseCard);
expect(courseCard).toBeChecked();

Expand All @@ -155,6 +186,38 @@ describe('<ImportStepperModal />', () => {
expect(await screen.findByText('Import Analysis in Progress')).toBeInTheDocument();
});

it('should block import when content limit is reached', async () => {
const user = userEvent.setup();
renderComponent();
const courseId = mockGetPreviewModulestoreMigration.sourceKeyBlockLimit;
axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, {
courseId,
title: 'Managing Risk in the Information Age',
subtitle: '',
org: 'HarvardX',
description: 'This is a test course',
});

const nextButton = await screen.findByRole('button', { name: /next step/i });
expect(nextButton).toBeDisabled();

// Select a course
const courseCard = screen.getAllByRole('radio')[0];
await user.click(courseCard);
expect(courseCard).toBeChecked();

// Click next
expect(nextButton).toBeEnabled();
await user.click(nextButton);

expect(await screen.findByText(/Import Blocked/i)).toBeInTheDocument();
expect(await screen.findByText(
/This import would exceed the Content Library limit of 1000 items/i,
)).toBeInTheDocument();

expect(screen.getByRole('button', { name: /import course/i })).toBeDisabled();
});

it('the course should remain selected on back only for non-imported courses', async () => {
const user = userEvent.setup();
renderComponent();
Expand All @@ -174,7 +237,6 @@ describe('<ImportStepperModal />', () => {
const backButton = await screen.findByRole('button', { name: /back/i });
await user.click(backButton);

expect(screen.getByText(/Run 0/i)).toBeInTheDocument();
expect(courseCard).toBeChecked();
expect(nextButton).toBeEnabled();
});
Expand Down Expand Up @@ -224,16 +286,6 @@ describe('<ImportStepperModal />', () => {
});

it('should import selected course on button click', async () => {
(useGetBlockTypes as jest.Mock).mockReturnValue({
isPending: false,
data: {
chapter: 1,
sequential: 2,
vertical: 3,
html: 5,
problem: 3,
},
});
const user = userEvent.setup();
renderComponent();

Expand All @@ -243,8 +295,9 @@ describe('<ImportStepperModal />', () => {
await user.click(await screen.findByRole('button', { name: 'Save' }));

axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(200);
axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, {
courseId: 'course-v1:HarvardX+123+2023',
const courseId = mockGetPreviewModulestoreMigration.sourceKeyGood;
axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, {
courseId,
title: 'Managing Risk in the Information Age',
subtitle: '',
org: 'HarvardX',
Expand Down
Loading