refactor: Use preview migration API & feat: Block import when the import would exceed the block limit [FC-0112] (#2700)

- Use the new preview migration API implemented in https://github.com/openedx/edx-platform/pull/37818
- Clean the code for the preview.
- Implements the commented in https://github.com/openedx/frontend-app-authoring/issues/2525#issuecomment-3554310315
This commit is contained in:
Chris Chávez
2026-01-20 14:44:35 -05:00
committed by GitHub
parent f57d33a74e
commit 82e24193a8
9 changed files with 330 additions and 250 deletions

View File

@@ -179,3 +179,60 @@ mockGetMigrationStatus.migrationStatusInProgressData = {
],
} as api.MigrateTaskStatusData;
mockGetMigrationStatus.applyMock = () => jest.spyOn(api, 'getModulestoreMigrationStatus').mockImplementation(mockGetMigrationStatus);
export async function mockGetPreviewModulestoreMigration(
_: 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);

View File

@@ -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);
});
});
});

View File

@@ -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';
@@ -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);
}

View File

@@ -10,6 +10,7 @@ import {
getModulestoreMigrationStatus,
BulkMigrateRequestData,
getCourseDetails,
getPreviewModulestoreMigration,
} from './api';
import { RequestStatus, RequestStatusType } from './constants';
@@ -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 = {
@@ -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
*/

View File

@@ -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',

View File

@@ -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'),
@@ -44,7 +78,7 @@ const renderComponent = (studioHomeState: Partial<StudioHomeState> = {}) => {
studioHome: {
...initialState.studioHome,
studioHomeData: {
courses: studioHomeMock.courses,
courses,
numPages,
coursesCount,
},
@@ -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();
@@ -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
@@ -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',
@@ -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();
@@ -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();
@@ -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();
});
@@ -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();
@@ -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',

View File

@@ -3,7 +3,8 @@ import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Button, Chip, Container, Layout, Stepper,
ActionRow, Button, Chip, Container, Layout, OverlayTrigger, Stepper,
Tooltip,
} from '@openedx/paragon';
import { CoursesList, MigrationStatusProps } from '@src/studio-home/tabs-section/courses-tab';
@@ -12,7 +13,7 @@ import Loading from '@src/generic/Loading';
import Header from '@src/header';
import SubHeader from '@src/generic/sub-header/SubHeader';
import { useBulkModulestoreMigrate } from '@src/data/apiHooks';
import { useBulkModulestoreMigrate, usePreviewMigration } from '@src/data/apiHooks';
import { ToastContext } from '@src/generic/toast-context';
import LoadingButton from '@src/generic/loading-button';
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
@@ -55,7 +56,6 @@ export const ImportStepperPage = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState<MigrationStep>('select-course');
const [selectedCourseId, setSelectedCourseId] = useState<string>('');
const [analysisCompleted, setAnalysisCompleted] = useState<boolean>(false);
const { data: courseData } = useCourseDetails(selectedCourseId);
const { libraryId, libraryData, readOnly } = useLibraryContext();
const { showToast } = useContext(ToastContext);
@@ -63,6 +63,14 @@ export const ImportStepperPage = () => {
// TODO: Modify single migration API to allow create collection
const migrate = useBulkModulestoreMigrate();
const {
data: previewMigrationData,
isPending: isPreviewMigrationPending,
} = usePreviewMigration(libraryId, selectedCourseId);
const analysisCompleted = !isPreviewMigrationPending;
const importIsBlocked = previewMigrationData?.state === 'block_limit_reached';
const handleImportCourse = async () => {
// istanbul ignore if: this can never happen, just for satisfying type checker.
if (!selectedCourseId) {
@@ -136,10 +144,7 @@ export const ImportStepperPage = () => {
eventKey="review-details"
title={intl.formatMessage(messages.importCourseReviewDetailsStep)}
>
<ReviewImportDetails
markAnalysisComplete={setAnalysisCompleted}
courseId={selectedCourseId}
/>
<ReviewImportDetails courseId={selectedCourseId} />
</Stepper.Step>
</Stepper>
</div>
@@ -161,12 +166,27 @@ export const ImportStepperPage = () => {
<Button onClick={() => setCurrentStep('select-course')} variant="tertiary">
<FormattedMessage {...messages.importCourseBack} />
</Button>
<LoadingButton
onClick={handleImportCourse}
label={intl.formatMessage(messages.importCourseButton)}
variant="primary"
disabled={!analysisCompleted}
/>
{importIsBlocked ? (
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id="tooltip-import-course-button">
<FormattedMessage {...messages.importNotPossibleTooltip} />
</Tooltip>
)}
>
<Button variant="primary" disabled>
<FormattedMessage {...messages.importCourseButton} />
</Button>
</OverlayTrigger>
) : (
<LoadingButton
onClick={handleImportCourse}
label={intl.formatMessage(messages.importCourseButton)}
variant="primary"
disabled={!analysisCompleted}
/>
)}
</ActionRow>
)}
</div>

View File

@@ -1,15 +1,15 @@
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { useGetBlockTypes, useGetContentHits } from '@src/search-manager';
import { render as baseRender, screen, initializeMocks } from '@src/testUtils';
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
import { mockContentLibrary } from '@src/library-authoring/data/api.mocks';
import { mockGetPreviewModulestoreMigration } from '@src/data/api.mocks';
import { ReviewImportDetails } from './ReviewImportDetails';
import messages from '../messages';
mockContentLibrary.applyMock();
mockGetPreviewModulestoreMigration.applyMock();
const { libraryId } = mockContentLibrary;
const markAnalysisComplete = jest.fn();
// Mock the useCourseDetails hook
jest.mock('@src/course-outline/data/apiHooks', () => ({
@@ -22,12 +22,6 @@ jest.mock('@src/library-authoring/data/apiHooks', () => ({
useContentLibrary: jest.fn().mockReturnValue({}),
}));
// Mock the useGetBlockTypes hook
jest.mock('@src/search-manager', () => ({
useGetBlockTypes: jest.fn().mockReturnValue({ isPending: true, data: null }),
useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));
const render = (element: React.ReactElement) => {
const params: { libraryId: string } = { libraryId };
return baseRender(element, {
@@ -49,11 +43,10 @@ describe('ReviewImportDetails', () => {
});
it('renders loading spinner when isPending is true', async () => {
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
render(<ReviewImportDetails courseId="test-course-id" />);
const spinners = await screen.findAllByRole('status');
spinners.every((spinner) => expect(spinner.textContent).toEqual('Loading...'));
expect(markAnalysisComplete).toHaveBeenCalledWith(false);
});
it('renders import progress status when isBlockDataPending or migrationInfoIsPending is true', async () => {
@@ -63,30 +56,26 @@ describe('ReviewImportDetails', () => {
data: null,
});
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
render(<ReviewImportDetails courseId="test-course-id" />);
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(await screen.findByText(/Import Analysis in Progress/i)).toBeInTheDocument();
expect(markAnalysisComplete).toHaveBeenCalledWith(false);
});
it('renders warning when reimport', async () => {
const courseKey = mockGetPreviewModulestoreMigration.sourceKeyGood;
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: {
'test-course-id': [{
courseKey: [{
targetKey: libraryId,
targetTitle: 'Library title',
}],
},
});
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: { html: 1 },
});
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
render(<ReviewImportDetails courseId={courseKey} />);
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(await screen.findByText(/Import Analysis Completed: Reimport/i)).toBeInTheDocument();
@@ -95,7 +84,6 @@ describe('ReviewImportDetails', () => {
.replace('{courseName}', 'Test Course')
.replace('{libraryName}', 'Library title'),
)).toBeInTheDocument();
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
});
it('renders warning when unsupportedBlockPercentage > 0', async () => {
@@ -104,85 +92,44 @@ describe('ReviewImportDetails', () => {
isPending: false,
data: null,
});
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: {
chapter: 1,
sequential: 2,
vertical: 3,
'problem-builder': 1,
html: 1,
},
});
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
render(<ReviewImportDetails
courseId={mockGetPreviewModulestoreMigration.sourceKeyUnsupported}
/>);
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(await screen.findByText(/Import Analysis Complete/i)).toBeInTheDocument();
expect(await screen.findByText(
/88% of course content will be imported into a collection in your library called Test Course. Some content will not be imported. For details see below./i,
/75% of course content will be imported into a collection in your library called Test Course. Some content will not be imported. For details see below./i,
)).toBeInTheDocument();
expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument();
expect(await screen.findByText('7/8')).toBeInTheDocument();
expect(await screen.findByText('15/20')).toBeInTheDocument();
expect(await screen.findByText('Sections')).toBeInTheDocument();
expect(await screen.findByText('1')).toBeInTheDocument();
expect(await screen.findByText('Subsections')).toBeInTheDocument();
expect(await screen.findByText('2')).toBeInTheDocument();
expect(await screen.findByText('Units')).toBeInTheDocument();
expect(await screen.findByText('Subsections')).toBeInTheDocument();
expect(await screen.findByText('3')).toBeInTheDocument();
expect(await screen.findByText('Units')).toBeInTheDocument();
expect(await screen.findByText('5')).toBeInTheDocument();
expect(await screen.findByText('Components')).toBeInTheDocument();
expect(await screen.findByText('1/2')).toBeInTheDocument();
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
expect(await screen.findByText('5/10')).toBeInTheDocument();
});
it('skips children blocks from total counts', async () => {
it('renders warning when components exceed the limit', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: null,
});
(useGetContentHits as jest.Mock).mockReturnValue({
isPending: false,
data: {
hits: [{ usage_key: 'some-usage-key' }],
estimatedTotalHits: 1,
},
});
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: {
chapter: 1,
sequential: 2,
vertical: 3,
library_content: 1,
html: 1,
problem: 4,
},
}).mockReturnValueOnce({
isPending: false,
data: {
problem: 2, // should be ignored from total count.
},
});
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
render(<ReviewImportDetails
courseId={mockGetPreviewModulestoreMigration.sourceKeyBlockLimit}
/>);
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(await screen.findByText(/Import Analysis Complete/i)).toBeInTheDocument();
expect(await screen.findByText(/Import Blocked/i)).toBeInTheDocument();
expect(await screen.findByText(
/90% of course content will be imported into a collection in your library called Test Course. Some content will not be imported. For details see below./i,
/This import would exceed the Content Library limit of 1000 items/i,
)).toBeInTheDocument();
expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument();
expect(await screen.findByText('9/10')).toBeInTheDocument();
expect(await screen.findByText('Sections')).toBeInTheDocument();
expect(await screen.findByText('1')).toBeInTheDocument();
expect(await screen.findByText('Subsections')).toBeInTheDocument();
expect(await screen.findByText('2')).toBeInTheDocument();
expect(await screen.findByText('Units')).toBeInTheDocument();
expect(await screen.findByText('3')).toBeInTheDocument();
expect(await screen.findByText('Components')).toBeInTheDocument();
expect(await screen.findByText('3/4')).toBeInTheDocument();
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
});
it('renders success alert when no unsupported blocks', async () => {
@@ -191,18 +138,10 @@ describe('ReviewImportDetails', () => {
isPending: false,
data: null,
});
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: {
chapter: 1,
sequential: 2,
vertical: 3,
html: 5,
problem: 3,
},
});
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
render(<ReviewImportDetails
courseId={mockGetPreviewModulestoreMigration.sourceKeyGood}
/>);
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(await screen.findByText(
@@ -210,7 +149,7 @@ describe('ReviewImportDetails', () => {
.replace('{courseName}', 'Test Course'),
)).toBeInTheDocument();
expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument();
expect(await screen.findByText('14')).toBeInTheDocument();
expect(await screen.findByText('10')).toBeInTheDocument();
expect(await screen.findByText('Sections')).toBeInTheDocument();
expect(await screen.findByText('1')).toBeInTheDocument();
expect(await screen.findByText('Subsections')).toBeInTheDocument();
@@ -218,7 +157,6 @@ describe('ReviewImportDetails', () => {
expect(await screen.findByText('Units')).toBeInTheDocument();
expect(await screen.findByText('3')).toBeInTheDocument();
expect(await screen.findByText('Components')).toBeInTheDocument();
expect(await screen.findByText('8')).toBeInTheDocument();
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
expect(await screen.findByText('5')).toBeInTheDocument();
});
});

View File

@@ -1,29 +1,31 @@
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Alert, Stack } from '@openedx/paragon';
import { LoadingSpinner } from '@src/generic/Loading';
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
import { useEffect, useMemo } from 'react';
import { CheckCircle, Warning } from '@openedx/paragon/icons';
import { useMemo } from 'react';
import { CheckCircle, Info, Warning } from '@openedx/paragon/icons';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { useGetBlockTypes, useGetContentHits } from '@src/search-manager';
import { usePreviewMigration } from '@src/data/apiHooks';
import { SummaryCard } from './SummaryCard';
import messages from '../messages';
interface Props {
courseId?: string;
markAnalysisComplete: (analysisCompleted: boolean) => void;
}
interface BannerProps {
courseId?: string;
isBlockDataPending?: boolean;
limitIsExceeded?: boolean;
limitNumber?: number;
unsupportedBlockPercentage: number;
}
const Banner = ({ courseId, isBlockDataPending, unsupportedBlockPercentage }: BannerProps) => {
const Banner = ({
courseId,
isBlockDataPending,
limitIsExceeded,
limitNumber,
unsupportedBlockPercentage,
}: BannerProps) => {
const { data, isPending } = useCourseDetails(courseId);
const { libraryId } = useLibraryContext();
const { data: migrationInfoData, isPending: migrationInfoIsPending } = useMigrationInfo(
@@ -64,6 +66,22 @@ const Banner = ({ courseId, isBlockDataPending, unsupportedBlockPercentage }: Ba
);
}
if (limitIsExceeded) {
return (
<>
<Alert variant="danger" icon={Info}>
<Alert.Heading>
<FormattedMessage {...messages.importBlockedTitle} />
</Alert.Heading>
</Alert>
<FormattedMessage
{...messages.importBlockedBody}
values={{ limitNumber }}
/>
</>
);
}
if (currentMigrationInfo) {
return (
<>
@@ -115,130 +133,51 @@ const Banner = ({ courseId, isBlockDataPending, unsupportedBlockPercentage }: Ba
);
};
export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) => {
const { data: blockTypes, isPending: isBlockDataPending } = useGetBlockTypes([
`context_key = "${courseId}"`,
]);
export const ReviewImportDetails = ({ courseId }: { courseId: string }) => {
const { libraryId } = useLibraryContext();
useEffect(() => {
// Mark complete to inform parent component of analysis completion.
markAnalysisComplete(!isBlockDataPending);
}, [isBlockDataPending]);
const {
data: previewMigrationData,
isPending: isPreviewMigrationPending,
} = usePreviewMigration(libraryId, courseId);
/** Filter unsupported blocks by checking if the block type is in the library's list of unsupported blocks. */
const unsupportedBlockTypes = useMemo(() => {
if (!blockTypes) {
return undefined;
}
return Object.entries(blockTypes).filter(([blockType]) => (
getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType)
));
}, [blockTypes]);
/** Calculate the total number of unsupported blocks by summing up the count for each block type. */
const totalUnsupportedBlocks = useMemo(() => {
if (!unsupportedBlockTypes) {
return 0;
}
const unsupportedBlocks = unsupportedBlockTypes.reduce((total, [, count]) => total + count, 0);
return unsupportedBlocks;
}, [unsupportedBlockTypes]);
// Fetch unsupported blocks usage_key information from meilisearch index.
const { data: unsupportedBlocksData } = useGetContentHits(
[
`context_key = "${courseId}"`,
`block_type IN [${unsupportedBlockTypes?.flatMap(([value]) => `"${value}"`).join(',')}]`,
],
totalUnsupportedBlocks > 0,
['usage_key'],
totalUnsupportedBlocks,
'always',
);
// Fetch children blocks for each block in the unsupportedBlocks array.
const { data: unsupportedBlocksChildren } = useGetBlockTypes([
`context_key = "${courseId}"`,
`breadcrumbs.usage_key IN [${unsupportedBlocksData?.hits.map((value) => `"${value.usage_key}"`).join(',')}]`,
], (unsupportedBlocksData?.estimatedTotalHits || 0) > 0);
/** Calculate the total number of unsupported children blocks by summing up the count for each block. */
const totalUnsupportedBlockChildren = useMemo(() => {
if (!unsupportedBlocksChildren) {
return 0;
}
const unsupportedBlocks = Object.values(unsupportedBlocksChildren).reduce((total, count) => total + count, 0);
return unsupportedBlocks;
}, [unsupportedBlocksChildren]);
/** Finally calculate the final number of unsupported blocks by adding parent unsupported and children
unsupported blocks. */
const finalUnssupportedBlocks = useMemo(
() => totalUnsupportedBlocks + totalUnsupportedBlockChildren,
[totalUnsupportedBlocks, totalUnsupportedBlockChildren],
);
/** Calculate total supported blocks by subtracting final unsupported blocks from the total number of blocks */
const totalBlocks = useMemo(() => {
if (!blockTypes) {
return undefined;
}
return Object.values(blockTypes).reduce((total, block) => total + block, 0) - finalUnssupportedBlocks;
}, [blockTypes, finalUnssupportedBlocks]);
/** Calculate total components by excluding those that are chapters, sequential, or vertical. */
const totalComponents = useMemo(() => {
if (!blockTypes) {
return undefined;
}
return Object.entries(blockTypes).reduce(
(total, [blockType, count]) => {
const isComponent = !['chapter', 'sequential', 'vertical'].includes(blockType);
if (isComponent) {
return total + count;
}
return total;
},
0,
) - finalUnssupportedBlocks;
}, [blockTypes, finalUnssupportedBlocks]);
/** Calculate the unsupported block percentage based on the final total blocks and unsupported blocks. */
const unsupportedBlockPercentage = useMemo(() => {
if (!blockTypes || !totalBlocks) {
return 0;
}
return (totalUnsupportedBlocks / (totalBlocks + totalUnsupportedBlocks)) * 100;
}, [blockTypes, totalUnsupportedBlocks]);
const limitIsExceeded = previewMigrationData?.state === 'block_limit_reached';
const unssuportedBlocks = previewMigrationData?.unsupportedBlocks || 0;
const totalBlocks = (previewMigrationData?.totalBlocks || 0) - unssuportedBlocks;
const totalComponents = (previewMigrationData?.totalComponents || 0) - unssuportedBlocks;
return (
<Stack gap={4}>
<Banner
courseId={courseId}
isBlockDataPending={isBlockDataPending}
unsupportedBlockPercentage={unsupportedBlockPercentage}
isBlockDataPending={isPreviewMigrationPending}
limitIsExceeded={limitIsExceeded}
limitNumber={previewMigrationData?.blocksLimit}
unsupportedBlockPercentage={previewMigrationData?.unsupportedPercentage || 0}
/>
<Stack gap={2}>
<h4><FormattedMessage {...messages.importCourseAnalysisSummary} /></h4>
<SummaryCard
totalBlocks={totalBlocks}
totalComponents={totalComponents}
sections={blockTypes?.chapter}
subsections={blockTypes?.sequential}
units={blockTypes?.vertical}
unsupportedBlocks={totalUnsupportedBlocks}
isPending={isBlockDataPending}
/>
</Stack>
{!isBlockDataPending && totalUnsupportedBlocks > 0
&& (
<Stack gap={2}>
<h4><FormattedMessage {...messages.importCourseAnalysisDetails} /></h4>
<Stack className="align-items-center" gap={3}>
<FormattedMessage {...messages.importCourseAnalysisDetailsUnsupportedBlocksBody} />
</Stack>
</Stack>
)}
{!limitIsExceeded && (
<>
<h4><FormattedMessage {...messages.importCourseAnalysisSummary} /></h4>
<SummaryCard
totalBlocks={totalBlocks}
totalComponents={totalComponents}
sections={previewMigrationData?.sections}
subsections={previewMigrationData?.subsections}
units={previewMigrationData?.units}
unsupportedBlocks={unssuportedBlocks}
isPending={isPreviewMigrationPending}
/>
{!isPreviewMigrationPending && unssuportedBlocks > 0
&& (
<>
<h4><FormattedMessage {...messages.importCourseAnalysisDetails} /></h4>
<Stack className="align-items-center" gap={3}>
<FormattedMessage {...messages.importCourseAnalysisDetailsUnsupportedBlocksBody} />
</Stack>
</>
)}
</>
)}
</Stack>
);
};