From e2f1aedf9a2974df6cb30ebc709f8e35ce50c3af Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 18 Nov 2025 22:11:27 +0530 Subject: [PATCH] feat: import analysis step (#2657) Shows course analysis information in review import details step in course import stepper page. Also handles alerts based on the import status, like, reimport or unsupported number of blocks. --- .env.development | 2 +- src/course-outline/data/apiHooks.ts | 7 +- .../data/api.mocks.ts | 2 +- .../data/api.test.ts | 12 +- src/data/api.ts | 69 +++++- src/data/apiHooks.ts | 46 +++- .../LegacyLibMigrationPage.test.tsx | 6 +- .../LegacyLibMigrationPage.tsx | 4 +- src/legacy-libraries-migration/data/api.ts | 71 ------ .../data/apiHooks.ts | 31 --- .../LibraryAuthoringPage.test.tsx | 2 +- .../LibraryAuthoringPage.tsx | 4 +- src/library-authoring/data/apiHooks.ts | 9 +- .../import-course/messages.ts | 55 +++++ .../stepper/ImportStepperPage.test.tsx | 67 +++++- .../stepper/ImportStepperPage.tsx | 50 +++- .../stepper/ReviewImportDetails.test.tsx | 173 ++++++++++++++ .../stepper/ReviewImportDetails.tsx | 214 +++++++++++++++--- .../import-course/stepper/SummaryCard.tsx | 183 +++++++++++---- src/library-authoring/index.scss | 5 + src/search-manager/data/apiHooks.ts | 1 + 21 files changed, 800 insertions(+), 213 deletions(-) rename src/{legacy-libraries-migration => }/data/api.mocks.ts (96%) rename src/{legacy-libraries-migration => }/data/api.test.ts (66%) delete mode 100644 src/legacy-libraries-migration/data/api.ts delete mode 100644 src/legacy-libraries-migration/data/apiHooks.ts create mode 100644 src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx diff --git a/.env.development b/.env.development index 78fc5621d..c24368594 100644 --- a/.env.development +++ b/.env.development @@ -48,7 +48,7 @@ INVITE_STUDENTS_EMAIL_TO="someone@domain.com" ENABLE_CHECKLIST_QUALITY=true ENABLE_GRADING_METHOD_IN_PROBLEMS=false # "Multi-level" blocks are unsupported in libraries -LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder" +LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder,library_content" # Fallback in local style files PARAGON_THEME_URLS={} COURSE_TEAM_SUPPORT_EMAIL='' diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 755a2b0b1..fd4b76827 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -29,14 +29,13 @@ export const useCreateCourseBlock = ( export const useCourseItemData = (itemId?: string, enabled: boolean = true) => ( useQuery({ queryKey: courseOutlineQueryKeys.courseItemId(itemId), - queryFn: () => getCourseItem(itemId!), - enabled: enabled && itemId !== undefined, + queryFn: enabled && itemId !== undefined ? () => getCourseItem(itemId!) : skipToken, }) ); -export const useCourseDetails = (courseId?: string) => ( +export const useCourseDetails = (courseId?: string, enabled: boolean = true) => ( useQuery({ queryKey: courseOutlineQueryKeys.courseDetails(courseId), - queryFn: courseId ? () => getCourseDetails(courseId) : skipToken, + queryFn: enabled && courseId ? () => getCourseDetails(courseId) : skipToken, }) ); diff --git a/src/legacy-libraries-migration/data/api.mocks.ts b/src/data/api.mocks.ts similarity index 96% rename from src/legacy-libraries-migration/data/api.mocks.ts rename to src/data/api.mocks.ts index 2386fb474..964e6f524 100644 --- a/src/legacy-libraries-migration/data/api.mocks.ts +++ b/src/data/api.mocks.ts @@ -132,4 +132,4 @@ mockGetMigrationStatus.migrationStatusFailedOneLibraryData = { }, ], } as api.MigrateTaskStatusData; -mockGetMigrationStatus.applyMock = () => jest.spyOn(api, 'getMigrationStatus').mockImplementation(mockGetMigrationStatus); +mockGetMigrationStatus.applyMock = () => jest.spyOn(api, 'getModulestoreMigrationStatus').mockImplementation(mockGetMigrationStatus); diff --git a/src/legacy-libraries-migration/data/api.test.ts b/src/data/api.test.ts similarity index 66% rename from src/legacy-libraries-migration/data/api.test.ts rename to src/data/api.test.ts index 21afef6d4..ce2b7f4f9 100644 --- a/src/legacy-libraries-migration/data/api.test.ts +++ b/src/data/api.test.ts @@ -1,4 +1,4 @@ -import { initializeMocks } from '../../testUtils'; +import { initializeMocks } from '../testUtils'; import * as api from './api'; let axiosMock; @@ -8,12 +8,12 @@ describe('legacy libraries migration API', () => { ({ axiosMock } = initializeMocks()); }); - describe('getMigrationStatus', () => { + describe('getModulestoreMigrationStatus', () => { it('should get migration status', async () => { const migrationId = '1'; - const url = api.getMigrationStatusUrl(migrationId); + const url = api.getModulestoreMigrationStatusUrl(migrationId); axiosMock.onGet(url).reply(200); - await api.getMigrationStatus(migrationId); + await api.getModulestoreMigrationStatus(migrationId); expect(axiosMock.history.get[0].url).toEqual(url); }); @@ -21,9 +21,9 @@ describe('legacy libraries migration API', () => { describe('bulkMigrateLegacyLibraries', () => { it('should call bulk migrate legacy libraries', async () => { - const url = api.bulkMigrateLegacyLibrariesUrl(); + const url = api.bulkModulestoreMigrateUrl(); axiosMock.onPost(url).reply(200); - await api.bulkMigrateLegacyLibraries({ + await api.bulkModulestoreMigrate({ sources: [], target: '1', }); diff --git a/src/data/api.ts b/src/data/api.ts index e76d95ede..e9bbad7b0 100644 --- a/src/data/api.ts +++ b/src/data/api.ts @@ -1,8 +1,18 @@ -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL as string; +/** + * Get the URL to check the migration task status + */ +export const getModulestoreMigrationStatusUrl = (migrationId: string) => `${getStudioBaseUrl()}/api/modulestore_migrator/v1/migrations/${migrationId}/`; + +/** + * Get the URL for bulk migrate content to libraries + */ +export const bulkModulestoreMigrateUrl = () => `${getStudioBaseUrl()}/api/modulestore_migrator/v1/bulk_migration/`; + export const getApiWaffleFlagsUrl = (courseId?: string): string => { const baseUrl = getStudioBaseUrl(); const apiPath = '/api/contentstore/v1/course_waffle_flags'; @@ -72,3 +82,60 @@ export async function getWaffleFlags(courseId?: string): Promise { + const client = getAuthenticatedHttpClient(); + const { data } = await client.get(getModulestoreMigrationStatusUrl(migrationId)); + return camelCaseObject(data); +} + +/** + * Bulk migrate content to libraries + */ +export async function bulkModulestoreMigrate( + requestData: BulkMigrateRequestData, +): Promise { + const client = getAuthenticatedHttpClient(); + const { data } = await client.post(bulkModulestoreMigrateUrl(), snakeCaseObject(requestData)); + return camelCaseObject(data); +} diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts index 8c81669a3..49a7b8992 100644 --- a/src/data/apiHooks.ts +++ b/src/data/apiHooks.ts @@ -1,5 +1,22 @@ -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { getWaffleFlags, waffleFlagDefaults } from './api'; +import { + skipToken, useMutation, useQuery, useQueryClient, +} from '@tanstack/react-query'; +import { libraryAuthoringQueryKeys } from '@src/library-authoring/data/apiHooks'; +import { + getWaffleFlags, + waffleFlagDefaults, + bulkModulestoreMigrate, + getModulestoreMigrationStatus, + BulkMigrateRequestData, +} from './api'; + +export const migrationQueryKeys = { + all: ['contentLibrary'], + /** + * Base key for data specific to a migration task + */ + migrationTask: (migrationId?: string | null) => [...migrationQueryKeys.all, migrationId], +}; /** * Get the waffle flags (which enable/disable specific features). They may @@ -30,3 +47,28 @@ export const useWaffleFlags = (courseId?: string) => { isError, }; }; + +/** + * Use this mutation to migrate multiple sources to a library + */ +export const useBulkModulestoreMigrate = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (requestData: BulkMigrateRequestData) => bulkModulestoreMigrate(requestData), + onSettled: (_data, _err, variables) => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.courseImports(variables.target) }); + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.allMigrationInfo() }); + }, + }); +}; + +/** + * Get the migration status + */ +export const useModulestoreMigrationStatus = (migrationId: string | null) => ( + useQuery({ + queryKey: migrationQueryKeys.migrationTask(migrationId), + queryFn: migrationId ? () => getModulestoreMigrationStatus(migrationId!) : skipToken, + refetchInterval: 1000, // Refresh every second + }) +); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx index 418e7f88e..9dcf61799 100644 --- a/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.test.tsx @@ -14,8 +14,8 @@ import { mockGetStudioHomeLibraries } from '@src/studio-home/data/api.mocks'; import { getContentLibraryV2CreateApiUrl } from '@src/library-authoring/create-library/data/api'; import { getStudioHomeApiUrl } from '@src/studio-home/data/api'; +import { bulkModulestoreMigrateUrl } from '@src/data/api'; import { LegacyLibMigrationPage } from './LegacyLibMigrationPage'; -import { bulkMigrateLegacyLibrariesUrl } from './data/api'; const path = '/libraries-v1/migrate/*'; let axiosMock: MockAdapter; @@ -320,7 +320,7 @@ describe('', () => { it('should confirm migration', async () => { const user = userEvent.setup(); - axiosMock.onPost(bulkMigrateLegacyLibrariesUrl()).reply(200); + axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(200); renderPage(); expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); expect(await screen.findByText('MBA')).toBeInTheDocument(); @@ -377,7 +377,7 @@ describe('', () => { it('should show error when confirm migration', async () => { const user = userEvent.setup(); - axiosMock.onPost(bulkMigrateLegacyLibrariesUrl()).reply(400); + axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(400); renderPage(); expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument(); expect(await screen.findByText('MBA')).toBeInTheDocument(); diff --git a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx index 4155167eb..3f402c20f 100644 --- a/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx +++ b/src/legacy-libraries-migration/LegacyLibMigrationPage.tsx @@ -25,11 +25,11 @@ import type { LibraryV1Data } from '@src/studio-home/data/api'; import { ToastContext } from '@src/generic/toast-context'; import { Filter, LibrariesList } from '@src/studio-home/tabs-section/libraries-tab'; +import { useBulkModulestoreMigrate } from '@src/data/apiHooks'; import messages from './messages'; import { SelectDestinationView } from './SelectDestinationView'; import { ConfirmationView } from './ConfirmationView'; import { LegacyMigrationHelpSidebar } from './LegacyMigrationHelpSidebar'; -import { useUpdateContainerCollections } from './data/apiHooks'; export type MigrationStep = 'select-libraries' | 'select-destination' | 'confirmation-view'; @@ -83,7 +83,7 @@ export const LegacyLibMigrationPage = () => { const [migrationFilter, setMigrationFilter] = useState([Filter.unmigrated]); const [destinationLibrary, setDestination] = useState(); const [confirmationButtonState, setConfirmationButtonState] = useState('default'); - const migrate = useUpdateContainerCollections(); + const migrate = useBulkModulestoreMigrate(); const handleMigrate = useCallback(async () => { if (destinationLibrary) { diff --git a/src/legacy-libraries-migration/data/api.ts b/src/legacy-libraries-migration/data/api.ts deleted file mode 100644 index ec894e4ba..000000000 --- a/src/legacy-libraries-migration/data/api.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; - -/** - * Get the URL to check the migration task status - */ -export const getMigrationStatusUrl = (migrationId: string) => `${getApiBaseUrl()}/api/modulestore_migrator/v1/migrations/${migrationId}/`; - -/** - * Get the URL for bulk migrate legacy libraries - */ -export const bulkMigrateLegacyLibrariesUrl = () => `${getApiBaseUrl()}/api/modulestore_migrator/v1/bulk_migration/`; - -export interface MigrateArtifacts { - source: string; - target: string; - compositionLevel: string; - repeatHandlingStrategy: 'update' | 'skip' | 'fork'; - preserveUrlSlugs: boolean; - targetCollectionSlug: string; - forwardSourceToTarget: boolean; - isFailed: boolean; -} - -export interface MigrateTaskStatusData { - state: string; - stateText: string; - completedSteps: number; - totalSteps: number; - attempts: number; - created: string; - modified: string; - artifacts: string[]; - uuid: string; - parameters: MigrateArtifacts[]; -} - -export interface BulkMigrateRequestData { - sources: string[]; - target: string; - targetCollectionSlugList?: string[]; - createCollections?: boolean; - compositionLevel?: string; - repeatHandlingStrategy?: string; - preserveUrlSlugs?: boolean; - forwardSourceToTarget?: boolean; -} - -/** - * Get migration task status - */ -export async function getMigrationStatus( - migrationId: string, -): Promise { - const client = getAuthenticatedHttpClient(); - const { data } = await client.get(getMigrationStatusUrl(migrationId)); - return camelCaseObject(data); -} - -/** - * Bulk migrate legacy libraries - */ -export async function bulkMigrateLegacyLibraries( - requestData: BulkMigrateRequestData, -): Promise { - const client = getAuthenticatedHttpClient(); - const { data } = await client.post(bulkMigrateLegacyLibrariesUrl(), snakeCaseObject(requestData)); - return camelCaseObject(data); -} diff --git a/src/legacy-libraries-migration/data/apiHooks.ts b/src/legacy-libraries-migration/data/apiHooks.ts deleted file mode 100644 index e8b8ffa6e..000000000 --- a/src/legacy-libraries-migration/data/apiHooks.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; - -import * as api from './api'; - -export const legacyMigrationQueryKeys = { - all: ['contentLibrary'], - /** - * Base key for data specific to a migration task - */ - migrationTask: (migrationId?: string | null) => [...legacyMigrationQueryKeys.all, migrationId], -}; - -/** - * Use this mutation to update container collections - */ -export const useUpdateContainerCollections = () => ( - useMutation({ - mutationFn: async (requestData: api.BulkMigrateRequestData) => api.bulkMigrateLegacyLibraries(requestData), - }) -); - -/** - * Get the migration status - */ -export const useMigrationStatus = (migrationId: string | null) => ( - useQuery({ - queryKey: legacyMigrationQueryKeys.migrationTask(migrationId), - queryFn: migrationId ? () => api.getMigrationStatus(migrationId!) : skipToken, - refetchInterval: 1000, // Refresh every second - }) -); diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 61bf28bc4..b590ace94 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -10,7 +10,7 @@ import { within, } from '@src/testUtils'; import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock'; -import { mockGetMigrationStatus } from '@src/legacy-libraries-migration/data/api.mocks'; +import { mockGetMigrationStatus } from '@src/data/api.mocks'; import mockEmptyResult from '@src/search-modal/__mocks__/empty-search-result.json'; import { mockContentSearchConfig } from '@src/search-manager/data/api.mock'; import { getStudioHomeApiUrl } from '@src/studio-home/data/api'; diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 4f5cde68e..5557fdd73 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -24,7 +24,7 @@ import { Add, InfoOutline } from '@openedx/paragon/icons'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; -import { useMigrationStatus } from '@src/legacy-libraries-migration/data/apiHooks'; +import { useModulestoreMigrationStatus } from '@src/data/apiHooks'; import Loading from '@src/generic/Loading'; import SubHeader from '@src/generic/sub-header/SubHeader'; import Header from '@src/header'; @@ -152,7 +152,7 @@ const LibraryAuthoringPage = ({ const migrationId = params.get('migration_task'); const { data: migrationStatusData, - } = useMigrationStatus(migrationId); + } = useModulestoreMigrationStatus(migrationId); const { isLoadingPage: isLoadingStudioHome, diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index cd312d23e..697f882da 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -7,6 +7,7 @@ import { type QueryClient, replaceEqualDeep, keepPreviousData, + skipToken, } from '@tanstack/react-query'; import { useCallback } from 'react'; import { type MeiliSearch } from 'meilisearch'; @@ -93,9 +94,9 @@ export const libraryAuthoringQueryKeys = { ...libraryAuthoringQueryKeys.contentLibrary(libraryId), 'courseImports', ], + allMigrationInfo: () => [...libraryAuthoringQueryKeys.all, 'migrationInfo'], migrationInfo: (sourceKeys: string[]) => [ - ...libraryAuthoringQueryKeys.all, - 'migrationInfo', + ...libraryAuthoringQueryKeys.allMigrationInfo(), ...sourceKeys, ], }; @@ -974,9 +975,9 @@ export const useCourseImports = (libraryId: string) => ( /** * Returns the migration info of a given source list */ -export const useMigrationInfo = (sourcesKeys: string[]) => ( +export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true) => ( useQuery({ queryKey: libraryAuthoringQueryKeys.migrationInfo(sourcesKeys), - queryFn: () => api.getMigrationInfo(sourcesKeys), + queryFn: enabled ? () => api.getMigrationInfo(sourcesKeys) : skipToken, }) ); diff --git a/src/library-authoring/import-course/messages.ts b/src/library-authoring/import-course/messages.ts index 8cafc2508..f93ad713b 100644 --- a/src/library-authoring/import-course/messages.ts +++ b/src/library-authoring/import-course/messages.ts @@ -164,6 +164,61 @@ const messages = defineMessages({ defaultMessage: 'Previously Imported', description: 'Chip that indicates that the course has been previously imported.', }, + importCourseAnalysisCompleteAllContentTitle: { + id: 'library-authoring.import-course.review-details.in-progress.title', + defaultMessage: 'Import Analysis Complete', + description: 'Title of the info card when course import analysis is complete and all data can be imported.', + }, + importCourseAnalysisCompleteAllContentBody: { + id: 'library-authoring.import-course.review-details.analysis-complete.100.body', + defaultMessage: 'All course content will imported into a collection in your library called {courseName}. See details below.', + description: 'Body of the info card when course import analysis is complete and all data can be imported.', + }, + importCourseAnalysisCompleteSomeContentTitle: { + id: 'library-authoring.import-course.review-details.in-progress.title', + defaultMessage: 'Import Analysis Complete', + description: 'Title of the info card when course import analysis is complete and some data can be imported.', + }, + importCourseAnalysisCompleteSomeContentBody: { + id: 'library-authoring.import-course.review-details.analysis-complete.100.body', + defaultMessage: '{unsupportedBlockPercentage}% of content cannot be imported. For details see below.', + description: 'Body of the info card when course import analysis is complete and some data can be imported.', + }, + importCourseAnalysisDetailsUnsupportedBlocksBody: { + id: 'library-authoring.import-course.review-details.analysis-details.unsupportedBlocks.body', + defaultMessage: 'The following block types cannot be imported into your library because they\'re are not yet supported. These block types will be replaced with a placeholder block in the library. For more information, reference the Help & Support sidebar.', + description: 'Body of analysis details when some unsupported blocks are present', + }, + importCourseComponentsUnsupportedInfo: { + id: 'library-authoring.import-course.review-details.analysis-summary.components.unsupportedInfo', + defaultMessage: '{count, plural, one {{count} block} other {{count} blocks}} unsupported', + description: 'Message to display on hovering over info icon near components number for unsupported blocks', + }, + importCourseAnalysisDetails: { + id: 'library-authoring.import-course.review-details.analysis-details.title', + defaultMessage: 'Analysis Details', + description: 'Title of the card for the analysis details of a imported course.', + }, + importCourseAnalysisCompleteReimportTitle: { + id: 'library-authoring.import-course.review-details.analysis-complete.reimport.title', + defaultMessage: 'Import Analysis Completed: Reimport', + description: 'Title of the info card when course import analysis is complete and it was already imported before.', + }, + importCourseAnalysisCompleteReimportBody: { + id: 'library-authoring.import-course.review-details.analysis-complete.reimport.body', + defaultMessage: '{courseName} has already been imported into the Library "{libraryName}". If this course is re-imported, all Sections, Subsections, Units and Content Blocks will be reimported again.', + description: 'Body of the info card when course import analysis is complete and it was already imported before.', + }, + importCourseCompleteToastMessage: { + id: 'library-authoring.import-course.complete-import.in-progress.toast.message', + defaultMessage: '{courseName} is being migrated.', + description: 'Toast message that indicates a course is being migrated', + }, + importCourseCompleteFailedToastMessage: { + id: 'library-authoring.import-course.complete-import.failed.toast.message', + defaultMessage: '{courseName} migration failed.', + description: 'Toast message that indicates course migration failed.', + }, }); export default messages; diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx index da5916cec..1d309c14f 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.test.tsx @@ -3,7 +3,6 @@ import { initializeMocks, render, screen, - fireEvent, waitFor, } from '@src/testUtils'; import { initialState } from '@src/studio-home/factories/mockApiResponses'; @@ -13,6 +12,8 @@ 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 { ImportStepperPage } from './ImportStepperPage'; let axiosMock; @@ -30,6 +31,11 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockNavigate, })); +// Mock the useGetBlockTypes hook +jest.mock('@src/search-manager', () => ({ + useGetBlockTypes: jest.fn().mockReturnValue({ isPending: true, data: null }), +})); + const renderComponent = (studioHomeState: Partial = {}) => { // Generate a custom initial state based on studioHomeCoursesRequestParams const customInitialState: Partial = { @@ -114,7 +120,7 @@ describe('', () => { // Select a course const courseCard = screen.getAllByRole('radio')[0]; - await fireEvent.click(courseCard); + await user.click(courseCard); expect(courseCard).toBeChecked(); // Click next @@ -125,10 +131,9 @@ describe('', () => { /managing risk in the information age is being analyzed for review prior to import/i, )).toBeInTheDocument()); - expect(screen.getByText('Analysis Summary')).toBeInTheDocument(); - expect(screen.getByText('Analysis Details')).toBeInTheDocument(); + expect(await screen.findByText('Analysis Summary')).toBeInTheDocument(); // The import details is loading - expect(screen.getByText('The selected course is being analyzed for import and review')).toBeInTheDocument(); + expect(await screen.findByText('Import Analysis in Progress')).toBeInTheDocument(); }); it('the course should remain selected on back', async () => { @@ -140,17 +145,65 @@ describe('', () => { // Select a course const courseCard = screen.getAllByRole('radio')[0]; - await fireEvent.click(courseCard); + await user.click(courseCard); expect(courseCard).toBeChecked(); // Click next expect(nextButton).toBeEnabled(); await user.click(nextButton); - const backButton = await screen.getByRole('button', { name: /back/i }); + const backButton = await screen.findByRole('button', { name: /back/i }); await user.click(backButton); expect(screen.getByText(/managing risk in the information age/i)).toBeInTheDocument(); expect(courseCard).toBeChecked(); }); + + it('should import select 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(); + axiosMock.onPost(bulkModulestoreMigrateUrl()).reply(200); + axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, { + courseId: 'course-v1:HarvardX+123+2023', + 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('Analysis Summary')).toBeInTheDocument(); + // Analysis completed + expect(await screen.findByText('Import Analysis Completed: Reimport')).toBeInTheDocument(); + const importBtn = await screen.findByRole('button', { name: 'Import Course' }); + await user.click(importBtn); + + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + }); + expect(axiosMock.history.post[0].data).toBe( + '{"sources":["course-v1:HarvardX+123+2023"],"target":"lib:Axim:TEST","create_collections":true,"repeat_handling_strategy":"fork","composition_level":"section"}', + ); + }); }); diff --git a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx index 0cecc44e4..8b182531f 100644 --- a/src/library-authoring/import-course/stepper/ImportStepperPage.tsx +++ b/src/library-authoring/import-course/stepper/ImportStepperPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useContext, useMemo, useState } from 'react'; import { Helmet } from 'react-helmet'; import { useNavigate } from 'react-router-dom'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; @@ -14,6 +14,10 @@ import Loading from '@src/generic/Loading'; import Header from '@src/header'; import SubHeader from '@src/generic/sub-header/SubHeader'; import { useMigrationInfo } from '@src/library-authoring/data/apiHooks'; +import { useBulkModulestoreMigrate } 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'; import { ReviewImportDetails } from './ReviewImportDetails'; import messages from '../messages'; import { HelpSidebar } from '../HelpSidebar'; @@ -67,12 +71,42 @@ 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); + // Using bulk migrate as it allows us to create collection automatically + // TODO: Modify single migration API to allow create collection + const migrate = useBulkModulestoreMigrate(); // Load the courses list // The loading state is handled in `CoursesList` useStudioHome(); + const handleImportCourse = async () => { + if (!selectedCourseId) { + return; + } + try { + const migrationTask = await migrate.mutateAsync({ + sources: [selectedCourseId], + target: libraryId, + createCollections: true, + repeatHandlingStrategy: 'fork', + compositionLevel: 'section', + }); + showToast(intl.formatMessage(messages.importCourseCompleteToastMessage, { + courseName: courseData?.title, + })); + // TODO: Update this URL to redirect user to import details page. + navigate(`/library/${libraryId}?migration_task=${migrationTask.uuid}`); + } catch (error) { + showToast(intl.formatMessage(messages.importCourseCompleteFailedToastMessage, { + courseName: courseData?.title, + })); + } + }; + if (!libraryData) { return ; } @@ -119,7 +153,10 @@ export const ImportStepperPage = () => { eventKey="review-details" title={intl.formatMessage(messages.importCourseReviewDetailsStep)} > - +
@@ -140,9 +177,12 @@ export const ImportStepperPage = () => { - + )}
diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx new file mode 100644 index 000000000..ec4c96c69 --- /dev/null +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.test.tsx @@ -0,0 +1,173 @@ +import { useCourseDetails } from '@src/course-outline/data/apiHooks'; +import { useMigrationInfo } from '@src/library-authoring/data/apiHooks'; +import { useGetBlockTypes } 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 { ReviewImportDetails } from './ReviewImportDetails'; +import messages from '../messages'; + +mockContentLibrary.applyMock(); +const { libraryId } = mockContentLibrary; +const markAnalysisComplete = jest.fn(); + +// Mock the useCourseDetails hook +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useCourseDetails: jest.fn().mockReturnValue({ isPending: true, data: null }), +})); + +// Mock the useMigrationInfo hook +jest.mock('@src/library-authoring/data/apiHooks', () => ({ + useMigrationInfo: jest.fn().mockReturnValue({ isPending: true, data: null }), + useContentLibrary: jest.fn().mockReturnValue({}), +})); + +// Mock the useGetBlockTypes hook +jest.mock('@src/search-manager', () => ({ + useGetBlockTypes: jest.fn().mockReturnValue({ isPending: true, data: null }), +})); + +const render = (element: React.ReactElement) => { + const params: { libraryId: string } = { libraryId }; + return baseRender(element, { + path: '/libraries/:libraryId/import/course', + params, + extraWrapper: ({ children }) => ( + + { children } + + ), + }); +}; + +describe('ReviewImportDetails', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('renders loading spinner when isPending is true', async () => { + 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 () => { + (useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } }); + (useMigrationInfo as jest.Mock).mockReturnValue({ + isPending: true, + data: null, + }); + + 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 () => { + (useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } }); + (useMigrationInfo as jest.Mock).mockReturnValue({ + isPending: false, + data: { + 'test-course-id': [{ + targetKey: libraryId, + targetTitle: 'Library title', + }], + }, + }); + (useGetBlockTypes as jest.Mock).mockReturnValue({ + isPending: false, + data: { html: 1 }, + }); + + render(); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(await screen.findByText(/Import Analysis Completed: Reimport/i)).toBeInTheDocument(); + expect(await screen.findByText( + messages.importCourseAnalysisCompleteReimportBody.defaultMessage + .replace('{courseName}', 'Test Course') + .replace('{libraryName}', 'Library title'), + )).toBeInTheDocument(); + expect(markAnalysisComplete).toHaveBeenCalledWith(true); + }); + + it('renders warning when unsupportedBlockPercentage > 0', async () => { + (useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } }); + (useMigrationInfo as jest.Mock).mockReturnValue({ + isPending: false, + data: null, + }); + (useGetBlockTypes as jest.Mock).mockReturnValue({ + isPending: false, + data: { + chapter: 1, + sequential: 2, + vertical: 3, + 'problem-builder': 1, + html: 1, + }, + }); + + render(); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(await screen.findByText(/Import Analysis Complete/i)).toBeInTheDocument(); + expect(await screen.findByText( + /12.50% of content cannot 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('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('1/2')).toBeInTheDocument(); + expect(markAnalysisComplete).toHaveBeenCalledWith(true); + }); + + it('renders success alert when no unsupported blocks', async () => { + (useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } }); + (useMigrationInfo as jest.Mock).mockReturnValue({ + isPending: false, + data: null, + }); + (useGetBlockTypes as jest.Mock).mockReturnValue({ + isPending: false, + data: { + chapter: 1, + sequential: 2, + vertical: 3, + html: 5, + problem: 3, + }, + }); + + render(); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(await screen.findByText( + messages.importCourseAnalysisCompleteAllContentBody.defaultMessage + .replace('{courseName}', 'Test Course'), + )).toBeInTheDocument(); + expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument(); + expect(await screen.findByText('14')).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('8')).toBeInTheDocument(); + expect(markAnalysisComplete).toHaveBeenCalledWith(true); + }); +}); diff --git a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx index b88f79db8..ebd7cfe6c 100644 --- a/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx +++ b/src/library-authoring/import-course/stepper/ReviewImportDetails.tsx @@ -1,44 +1,198 @@ +import { getConfig } from '@edx/frontend-platform'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Card, Stack } from '@openedx/paragon'; +import { Alert, Stack } from '@openedx/paragon'; import { LoadingSpinner } from '@src/generic/Loading'; import { useCourseDetails } from '@src/course-outline/data/apiHooks'; -import messages from '../messages'; +import { useEffect, useMemo } from 'react'; +import { CheckCircle, Warning } from '@openedx/paragon/icons'; +import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext'; +import { useMigrationInfo } from '@src/library-authoring/data/apiHooks'; +import { useGetBlockTypes } from '@src/search-manager'; import { SummaryCard } from './SummaryCard'; +import messages from '../messages'; -export const ReviewImportDetails = ({ courseId }: { courseId?: string }) => { +interface Props { + courseId?: string; + markAnalysisComplete: (analysisCompleted: boolean) => void; +} + +interface BannerProps { + courseId?: string; + isBlockDataPending?: boolean; + unsupportedBlockPercentage: number; +} + +const Banner = ({ courseId, isBlockDataPending, unsupportedBlockPercentage }: BannerProps) => { const { data, isPending } = useCourseDetails(courseId); + const { libraryId } = useLibraryContext(); + const { data: migrationInfoData, isPending: migrationInfoIsPending } = useMigrationInfo( + [courseId!], + (courseId !== undefined && libraryId !== undefined), + ); + + const currentMigrationInfo = useMemo(() => { + if (!migrationInfoData || !courseId) { + return undefined; + } + return Object.values(migrationInfoData)[0]?.find(info => info.targetKey === libraryId); + }, [migrationInfoData]); + + if (isPending) { + return ( + +
+ +
+
+ ); + } + + if (isBlockDataPending || migrationInfoIsPending) { + return ( + + +

+ +

+
+ ); + } + + if (currentMigrationInfo) { + return ( + <> + + + +

+ +

+ + ); + } + + if (unsupportedBlockPercentage > 0) { + return ( + + +

+ +

+
+ ); + } + + return ( + + +

+ +

+
+ ); +}; + +export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) => { + const { data: blockTypes, isPending: isBlockDataPending } = useGetBlockTypes([ + `context_key = "${courseId}"`, + ]); + + useEffect(() => { + markAnalysisComplete(!isBlockDataPending); + }, [isBlockDataPending]); + + const totalUnsupportedBlocks = useMemo(() => { + if (!blockTypes) { + return 0; + } + const unsupportedBlocks = Object.entries(blockTypes).reduce((total, [blockType, count]) => { + const isUnsupportedBlock = getConfig().LIBRARY_UNSUPPORTED_BLOCKS.includes(blockType); + if (isUnsupportedBlock) { + return total + count; + } + return total; + }, 0); + return unsupportedBlocks; + }, [blockTypes]); + + const totalBlocks = useMemo(() => { + if (!blockTypes) { + return undefined; + } + return Object.values(blockTypes).reduce((total, block) => total + block, 0) - totalUnsupportedBlocks; + }, [blockTypes]); + + 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, + ) - totalUnsupportedBlocks; + }, [blockTypes]); + + const unsupportedBlockPercentage = useMemo(() => { + if (!blockTypes || !totalBlocks) { + return 0; + } + return (totalUnsupportedBlocks / (totalBlocks + totalUnsupportedBlocks)) * 100; + }, [blockTypes]); return ( - - {data && !isPending ? ( - -

-

- -

-
- ) : ( -
- -
- )} -
+

- -

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

+ + + + + )}
); }; diff --git a/src/library-authoring/import-course/stepper/SummaryCard.tsx b/src/library-authoring/import-course/stepper/SummaryCard.tsx index 4b3c29057..650030660 100644 --- a/src/library-authoring/import-course/stepper/SummaryCard.tsx +++ b/src/library-authoring/import-course/stepper/SummaryCard.tsx @@ -1,51 +1,150 @@ import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Card, Icon, Stack } from '@openedx/paragon'; -import { Widgets } from '@openedx/paragon/icons'; +import type { MessageDescriptor } from 'react-intl'; +import { + Bubble, Card, Icon, OverlayTrigger, Stack, Tooltip, +} from '@openedx/paragon'; +import { Info, Widgets } from '@openedx/paragon/icons'; import { LoadingSpinner } from '@src/generic/Loading'; import { getItemIcon } from '@src/generic/block-type-utils'; import messages from '../messages'; -// TODO: The SummaryCard is always in loading state -export const SummaryCard = () => ( - - - - - - +interface DisplayNumberProps { + count?: string; + isPending?: boolean; +} + +const DisplayNumber = ({ count, isPending }: DisplayNumberProps) => { + if (isPending) { + return ; + } + return ( + {count} + ); +}; + +interface DisplayNumberComponentProps { + count?: string; + isPending?: boolean; + icon?: React.ComponentType; + title?: MessageDescriptor; + info?: React.ReactNode; +} + +const DisplayNumberComponent = ({ + count, isPending, icon, title, info, +}: DisplayNumberComponentProps) => ( + <> +
+ + {info + && ( + + {info} + + )} + > + + + + + )} +
+ {icon + ? ( + + + -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + ) + : } + ); + +interface Props { + totalBlocks?: number; + totalComponents?: number; + sections?: number; + subsections?: number; + units?: number; + unsupportedBlocks?: number; + isPending?: boolean; +} + +export const SummaryCard = ({ + totalBlocks, + totalComponents, + sections, + subsections, + units, + unsupportedBlocks, + isPending, +}: Props) => { + let totalBlocksStr = totalBlocks?.toString(); + if (unsupportedBlocks && totalBlocks) { + totalBlocksStr = `${totalBlocksStr}/${totalBlocks + unsupportedBlocks}`; + } + let totalComponentsStr = totalComponents?.toString(); + if (unsupportedBlocks && totalComponents) { + totalComponentsStr = `${totalComponentsStr}/${totalComponents + unsupportedBlocks}`; + } + return ( + + + + + + + + + + + + + + + + + + ) : null} + /> + + + + + ); +}; diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss index 43390d63d..156c21b45 100644 --- a/src/library-authoring/index.scss +++ b/src/library-authoring/index.scss @@ -14,3 +14,8 @@ grid-gap: 2rem; justify-items: center; } + +.min-1-rem { + min-height: 1rem; + min-width: 1rem; +} diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index 51881a1c2..0c1aa947b 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -299,5 +299,6 @@ export const useGetBlockTypes = (extraFilters: Filter) => { 'block_types', ], queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters), + refetchOnMount: 'always', }); };