diff --git a/src/data/api.mocks.ts b/src/data/api.mocks.ts index 2f51de41c5..1ea874f961 100644 --- a/src/data/api.mocks.ts +++ b/src/data/api.mocks.ts @@ -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 { + 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); diff --git a/src/data/api.test.ts b/src/data/api.test.ts index ce2b7f4f90..e85b982ec5 100644 --- a/src/data/api.test.ts +++ b/src/data/api.test.ts @@ -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); + }); + }); }); diff --git a/src/data/api.ts b/src/data/api.ts index de0cd2ecb2..cae3212d48 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -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 { + 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); +} diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index 8ba2e60e90..dc098e7ace 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -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 */ diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts index 5f4d51a4c0..fba1b3ef3a 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -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', diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx index 023dab71e0..3b6d7f2048 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx @@ -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 = {}) => { studioHome: { ...initialState.studioHome, studioHomeData: { - courses: studioHomeMock.courses, + courses, numPages, coursesCount, }, @@ -86,9 +120,6 @@ describe('', () => { 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('', () => { // 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('', () => { 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('', () => { 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('', () => { 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('', () => { 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('', () => { }); 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('', () => { 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', diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx index 2e8cb55b22..c05e017c89 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx @@ -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('select-course'); const [selectedCourseId, setSelectedCourseId] = useState(''); - const [analysisCompleted, setAnalysisCompleted] = useState(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)} > - + @@ -161,12 +166,27 @@ export const ImportStepperPage = () => { - + {importIsBlocked ? ( + + + + )} + > + + + ) : ( + + )} )} diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx index 6739444cf0..f87509601a 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx @@ -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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); + render(); 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(); }); }); diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx index 0bd23b9f55..903f0cd2a6 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx @@ -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 ( + <> + + + + + + + + ); + } + 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}"`, - ]); - - useEffect(() => { - // Mark complete to inform parent component of analysis completion. - markAnalysisComplete(!isBlockDataPending); - }, [isBlockDataPending]); - - /** 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]); +export const ReviewImportDetails = ({ courseId }: { courseId: string }) => { + const { libraryId } = useLibraryContext(); - /** 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]); + const { + data: previewMigrationData, + isPending: isPreviewMigrationPending, + } = usePreviewMigration(libraryId, courseId); - /** 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 ( - -

- -
- {!isBlockDataPending && totalUnsupportedBlocks > 0 - && ( - -

- - - -
- )} + {!limitIsExceeded && ( + <> +

+ + {!isPreviewMigrationPending && unssuportedBlocks > 0 + && ( + <> +

+ + + + + )} + + )}
); };