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:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user