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.
This commit is contained in:
@@ -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=''
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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<WaffleFlagsStat
|
||||
.get(getApiWaffleFlagsUrl(courseId));
|
||||
return normalizeCourseDetail(data);
|
||||
}
|
||||
|
||||
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 getModulestoreMigrationStatus(
|
||||
migrationId: string,
|
||||
): Promise<MigrateTaskStatusData> {
|
||||
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<MigrateTaskStatusData> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(bulkModulestoreMigrateUrl(), snakeCaseObject(requestData));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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('<LegacyLibMigrationPage />', () => {
|
||||
|
||||
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('<LegacyLibMigrationPage />', () => {
|
||||
|
||||
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();
|
||||
|
||||
@@ -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[]>([Filter.unmigrated]);
|
||||
const [destinationLibrary, setDestination] = useState<ContentLibrary>();
|
||||
const [confirmationButtonState, setConfirmationButtonState] = useState('default');
|
||||
const migrate = useUpdateContainerCollections();
|
||||
const migrate = useBulkModulestoreMigrate();
|
||||
|
||||
const handleMigrate = useCallback(async () => {
|
||||
if (destinationLibrary) {
|
||||
|
||||
@@ -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<MigrateTaskStatusData> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.get(getMigrationStatusUrl(migrationId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk migrate legacy libraries
|
||||
*/
|
||||
export async function bulkMigrateLegacyLibraries(
|
||||
requestData: BulkMigrateRequestData,
|
||||
): Promise<MigrateTaskStatusData> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(bulkMigrateLegacyLibrariesUrl(), snakeCaseObject(requestData));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<StudioHomeState> = {}) => {
|
||||
// Generate a custom initial state based on studioHomeCoursesRequestParams
|
||||
const customInitialState: Partial<DeprecatedReduxState> = {
|
||||
@@ -114,7 +120,7 @@ describe('<ImportStepperModal />', () => {
|
||||
|
||||
// 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('<ImportStepperModal />', () => {
|
||||
/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('<ImportStepperModal />', () => {
|
||||
|
||||
// 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"}',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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);
|
||||
// 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 <Loading />;
|
||||
}
|
||||
@@ -119,7 +153,10 @@ export const ImportStepperPage = () => {
|
||||
eventKey="review-details"
|
||||
title={intl.formatMessage(messages.importCourseReviewDetailsStep)}
|
||||
>
|
||||
<ReviewImportDetails courseId={selectedCourseId} />
|
||||
<ReviewImportDetails
|
||||
markAnalysisComplete={setAnalysisCompleted}
|
||||
courseId={selectedCourseId}
|
||||
/>
|
||||
</Stepper.Step>
|
||||
</Stepper>
|
||||
<div className="mt-4">
|
||||
@@ -140,9 +177,12 @@ export const ImportStepperPage = () => {
|
||||
<Button onClick={() => setCurrentStep('select-course')} variant="tertiary">
|
||||
<FormattedMessage {...messages.importCourseBack} />
|
||||
</Button>
|
||||
<Button disabled>
|
||||
<FormattedMessage {...messages.importCourseButton} />
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleImportCourse}
|
||||
label={intl.formatMessage(messages.importCourseButton)}
|
||||
variant="primary"
|
||||
disabled={!analysisCompleted}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 }) => (
|
||||
<LibraryProvider
|
||||
libraryId={libraryId}
|
||||
>
|
||||
{ children }
|
||||
</LibraryProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe('ReviewImportDetails', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('renders loading spinner when isPending is true', async () => {
|
||||
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} 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 () => {
|
||||
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
|
||||
(useMigrationInfo as jest.Mock).mockReturnValue({
|
||||
isPending: true,
|
||||
data: null,
|
||||
});
|
||||
|
||||
render(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} 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 () => {
|
||||
(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(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
|
||||
|
||||
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(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
|
||||
|
||||
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(<ReviewImportDetails markAnalysisComplete={markAnalysisComplete} courseId="test-course-id" />);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Alert>
|
||||
<div className="text-center p-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isBlockDataPending || migrationInfoIsPending) {
|
||||
return (
|
||||
<Alert>
|
||||
<Alert.Heading><FormattedMessage {...messages.importCourseInProgressStatusTitle} /></Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.importCourseInProgressStatusBody}
|
||||
values={{
|
||||
courseName: data?.title || '',
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (currentMigrationInfo) {
|
||||
return (
|
||||
<>
|
||||
<Alert variant="warning" icon={Warning}>
|
||||
<Alert.Heading><FormattedMessage {...messages.importCourseAnalysisCompleteReimportTitle} /></Alert.Heading>
|
||||
</Alert>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.importCourseAnalysisCompleteReimportBody}
|
||||
values={{
|
||||
courseName: data?.title || '',
|
||||
libraryName: currentMigrationInfo?.targetTitle || '',
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (unsupportedBlockPercentage > 0) {
|
||||
return (
|
||||
<Alert variant="warning" icon={Warning}>
|
||||
<Alert.Heading><FormattedMessage {...messages.importCourseAnalysisCompleteSomeContentTitle} /></Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.importCourseAnalysisCompleteSomeContentBody}
|
||||
values={{
|
||||
unsupportedBlockPercentage: unsupportedBlockPercentage.toFixed(2),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="success" icon={CheckCircle}>
|
||||
<Alert.Heading><FormattedMessage {...messages.importCourseAnalysisCompleteAllContentTitle} /></Alert.Heading>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.importCourseAnalysisCompleteAllContentBody}
|
||||
values={{
|
||||
courseName: data?.title || '',
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Stack gap={4}>
|
||||
<Card>
|
||||
{data && !isPending ? (
|
||||
<Card.Section>
|
||||
<h4><FormattedMessage {...messages.importCourseInProgressStatusTitle} /></h4>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
{...messages.importCourseInProgressStatusBody}
|
||||
values={{
|
||||
courseName: data?.title || '',
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</Card.Section>
|
||||
) : (
|
||||
<div className="text-center p-3">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<Banner
|
||||
courseId={courseId}
|
||||
isBlockDataPending={isBlockDataPending}
|
||||
unsupportedBlockPercentage={unsupportedBlockPercentage}
|
||||
/>
|
||||
<h4><FormattedMessage {...messages.importCourseAnalysisSummary} /></h4>
|
||||
<SummaryCard />
|
||||
<h4><FormattedMessage {...messages.importCourseDetailsTitle} /></h4>
|
||||
<Card className="p-6">
|
||||
<Stack className="align-items-center" gap={3}>
|
||||
<LoadingSpinner />
|
||||
<FormattedMessage {...messages.importCourseDetailsLoadingBody} />
|
||||
</Stack>
|
||||
</Card>
|
||||
<SummaryCard
|
||||
totalBlocks={totalBlocks}
|
||||
totalComponents={totalComponents}
|
||||
sections={blockTypes?.chapter}
|
||||
subsections={blockTypes?.sequential}
|
||||
units={blockTypes?.vertical}
|
||||
unsupportedBlocks={totalUnsupportedBlocks}
|
||||
isPending={isBlockDataPending}
|
||||
/>
|
||||
{!isBlockDataPending && totalUnsupportedBlocks > 0
|
||||
&& (
|
||||
<>
|
||||
<h4><FormattedMessage {...messages.importCourseAnalysisDetails} /></h4>
|
||||
<Stack className="align-items-center" gap={3}>
|
||||
<FormattedMessage {...messages.importCourseAnalysisDetailsUnsupportedBlocksBody} />
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => (
|
||||
<Card>
|
||||
<Card.Section>
|
||||
<Stack direction="horizontal">
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseTotalBlocks} />
|
||||
<LoadingSpinner />
|
||||
interface DisplayNumberProps {
|
||||
count?: string;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
const DisplayNumber = ({ count, isPending }: DisplayNumberProps) => {
|
||||
if (isPending) {
|
||||
return <LoadingSpinner />;
|
||||
}
|
||||
return (
|
||||
<span className="lead">{count}</span>
|
||||
);
|
||||
};
|
||||
|
||||
interface DisplayNumberComponentProps {
|
||||
count?: string;
|
||||
isPending?: boolean;
|
||||
icon?: React.ComponentType;
|
||||
title?: MessageDescriptor;
|
||||
info?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DisplayNumberComponent = ({
|
||||
count, isPending, icon, title, info,
|
||||
}: DisplayNumberComponentProps) => (
|
||||
<>
|
||||
<div className="d-flex align-items-start">
|
||||
<FormattedMessage {...title} />
|
||||
{info
|
||||
&& (
|
||||
<OverlayTrigger
|
||||
placement="top"
|
||||
overlay={(
|
||||
<Tooltip variant="light" id={`${title}-info`}>
|
||||
{info}
|
||||
</Tooltip>
|
||||
)}
|
||||
>
|
||||
<Bubble className="ml-2 min-1-rem">
|
||||
<Icon size="xs" src={Info} />
|
||||
</Bubble>
|
||||
</OverlayTrigger>
|
||||
)}
|
||||
</div>
|
||||
{icon
|
||||
? (
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<Icon src={icon} />
|
||||
<DisplayNumber count={count} isPending={isPending} />
|
||||
</Stack>
|
||||
<div className="border-light-400" style={{ borderLeft: '2px solid', height: '50px' }} />
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseSections} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={getItemIcon('section')} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseSubsections} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={getItemIcon('subsection')} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseUnits} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={getItemIcon('unit')} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
<Stack className="align-items-center" gap={2}>
|
||||
<FormattedMessage {...messages.importCourseComponents} />
|
||||
<Stack className="justify-content-center" direction="horizontal" gap={3}>
|
||||
<Icon src={Widgets} />
|
||||
<LoadingSpinner />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
)
|
||||
: <DisplayNumber count={count} isPending={isPending} />}
|
||||
</>
|
||||
);
|
||||
|
||||
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 (
|
||||
<Card>
|
||||
<Card.Section>
|
||||
<Stack direction="horizontal" gap={3}>
|
||||
<Stack className="align-items-center border-right py-3" gap={1}>
|
||||
<DisplayNumberComponent
|
||||
count={totalBlocksStr}
|
||||
isPending={isPending}
|
||||
title={messages.importCourseTotalBlocks}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack className="ml-3 py-3" gap={1}>
|
||||
<DisplayNumberComponent
|
||||
count={sections?.toString()}
|
||||
isPending={isPending}
|
||||
icon={getItemIcon('section')}
|
||||
title={messages.importCourseSections}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack className="py-3" gap={1}>
|
||||
<DisplayNumberComponent
|
||||
count={subsections?.toString()}
|
||||
isPending={isPending}
|
||||
icon={getItemIcon('subsection')}
|
||||
title={messages.importCourseSubsections}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack className="py-3" gap={1}>
|
||||
<DisplayNumberComponent
|
||||
count={units?.toString()}
|
||||
isPending={isPending}
|
||||
icon={getItemIcon('unit')}
|
||||
title={messages.importCourseUnits}
|
||||
/>
|
||||
</Stack>
|
||||
<Stack className="py-3" gap={1}>
|
||||
<DisplayNumberComponent
|
||||
count={totalComponentsStr}
|
||||
isPending={isPending}
|
||||
icon={Widgets}
|
||||
title={messages.importCourseComponents}
|
||||
info={unsupportedBlocks ? (
|
||||
<FormattedMessage
|
||||
{...messages.importCourseComponentsUnsupportedInfo}
|
||||
values={{
|
||||
count: unsupportedBlocks,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,3 +14,8 @@
|
||||
grid-gap: 2rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.min-1-rem {
|
||||
min-height: 1rem;
|
||||
min-width: 1rem;
|
||||
}
|
||||
|
||||
@@ -299,5 +299,6 @@ export const useGetBlockTypes = (extraFilters: Filter) => {
|
||||
'block_types',
|
||||
],
|
||||
queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
|
||||
refetchOnMount: 'always',
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user