Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ export const getBlockTypesMetaDataUrl = (libraryId: string) => `${getApiBaseUrl(
*/
export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`;

/**
* Get the URL for library block limits.
*/
export const getLibraryBlockLimitsUrl = () => `${getApiBaseUrl()}/api/libraries/v2/block_limits/`;

/**
* Get the URL for restoring deleted library block.
*/
Expand Down Expand Up @@ -298,6 +303,10 @@ export interface LibraryBlockMetadata {
isNew?: boolean;
}

export interface LibraryBlockLimits {
maxBlocksPerContentLibrary: number;
}

export interface UpdateLibraryDataRequest {
id: string;
title?: string;
Expand Down Expand Up @@ -479,6 +488,14 @@ export async function getLibraryBlockMetadata(usageKey: string): Promise<Library
return camelCaseObject(data);
}

/**
* Fetch library block limits
*/
export async function getLibraryBlockLimits(): Promise<LibraryBlockLimits> {
const { data } = await getAuthenticatedHttpClient().get(getLibraryBlockLimitsUrl());
return camelCaseObject(data);
}

/**
* Fetch xblock fields.
*/
Expand Down
11 changes: 11 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export const xblockQueryKeys = {
}
return ['hierarchy'];
},
xblockLimits: () => [...xblockQueryKeys.all, 'limits'],
};

/**
Expand Down Expand Up @@ -981,3 +982,13 @@ export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true)
queryFn: enabled ? () => api.getMigrationInfo(sourcesKeys) : skipToken,
})
);

/**
* Returns the migration info of a given source list
*/
export const useLibraryBlockLimits = () => (
useQuery({
queryKey: xblockQueryKeys.xblockLimits(),
queryFn: api.getLibraryBlockLimits,
})
);
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCourseDetails } from '@src/course-outline/data/apiHooks';
import { useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { useLibraryBlockLimits, useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { useGetBlockTypes, useGetContentHits } from '@src/search-manager';
import { render as baseRender, screen, initializeMocks } from '@src/testUtils';
import { LibraryProvider } from '@src/library-authoring/common/context/LibraryContext';
Expand All @@ -20,6 +20,7 @@ jest.mock('@src/course-outline/data/apiHooks', () => ({
jest.mock('@src/library-authoring/data/apiHooks', () => ({
useMigrationInfo: jest.fn().mockReturnValue({ isPending: true, data: null }),
useContentLibrary: jest.fn().mockReturnValue({}),
useLibraryBlockLimits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));

// Mock the useGetBlockTypes hook
Expand Down Expand Up @@ -58,6 +59,10 @@ describe('ReviewImportDetails', () => {

it('renders import progress status when isBlockDataPending or migrationInfoIsPending is true', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 100 },
});
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: true,
data: null,
Expand All @@ -72,6 +77,10 @@ describe('ReviewImportDetails', () => {

it('renders warning when reimport', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 100 },
});
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: {
Expand Down Expand Up @@ -100,6 +109,10 @@ describe('ReviewImportDetails', () => {

it('renders warning when unsupportedBlockPercentage > 0', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 100 },
});
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: null,
Expand Down Expand Up @@ -135,8 +148,53 @@ describe('ReviewImportDetails', () => {
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
});

it('renders warning when components exceed the limit', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 20 },
});
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: null,
});
(useGetBlockTypes as jest.Mock).mockReturnValueOnce({
isPending: false,
data: {
chapter: 1,
sequential: 2,
vertical: 3,
'problem-builder': 1,
html: 25,
},
});

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(
/18.75% of content cannot be imported. For details see below./i,
)).toBeInTheDocument();
expect(await screen.findByText(/Total Blocks/i)).toBeInTheDocument();
expect(await screen.findByText('26/32')).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('20/26')).toBeInTheDocument();
expect(markAnalysisComplete).toHaveBeenCalledWith(true);
});

it('considers children blocks of unsupportedBlocks', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 100 },
});
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: null,
Expand Down Expand Up @@ -187,6 +245,10 @@ describe('ReviewImportDetails', () => {

it('renders success alert when no unsupported blocks', async () => {
(useCourseDetails as jest.Mock).mockReturnValue({ isPending: false, data: { title: 'Test Course' } });
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 100 },
});
(useMigrationInfo as jest.Mock).mockReturnValue({
isPending: false,
data: null,
Expand Down
66 changes: 45 additions & 21 deletions src/library-authoring/import-course/stepper/ReviewImportDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { useCourseDetails } from '@src/course-outline/data/apiHooks';
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 { useLibraryBlockLimits, useMigrationInfo } from '@src/library-authoring/data/apiHooks';
import { useGetBlockTypes, useGetContentHits } from '@src/search-manager';
import { SummaryCard } from './SummaryCard';
import messages from '../messages';
Expand Down Expand Up @@ -118,11 +118,15 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) =
const { data: blockTypes, isPending: isBlockDataPending } = useGetBlockTypes([
`context_key = "${courseId}"`,
]);
const {
data: libraryBlockLimits,
isPending: isPendinglibraryBlockLimits,
} = useLibraryBlockLimits();

useEffect(() => {
// Mark complete to inform parent component of analysis completion.
markAnalysisComplete(!isBlockDataPending);
}, [isBlockDataPending]);
markAnalysisComplete(!isBlockDataPending && !isPendinglibraryBlockLimits);
}, [isBlockDataPending, isPendinglibraryBlockLimits]);

/** Filter unsupported blocks by checking if the block type is in the library's list of unsupported blocks. */
const unsupportedBlockTypes = useMemo(() => {
Expand Down Expand Up @@ -172,25 +176,21 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) =

/** Finally calculate the final number of unsupported blocks by adding parent unsupported and children
unsupported blocks. */
const finalUnssupportedBlocks = useMemo(
let finalUnsupportedBlocks = useMemo(
() => totalUnsupportedBlocks + totalUnsupportedBlockChildren,
[totalUnsupportedBlocks, totalUnsupportedBlockChildren],
);

/** Calculate total supported blocks by subtracting final unsupported blocks from the total number of blocks */
const totalBlocks = useMemo(() => {
if (!blockTypes) {
return undefined;
}
return Object.values(blockTypes).reduce((total, block) => total + block, 0) - finalUnssupportedBlocks;
}, [blockTypes, finalUnssupportedBlocks]);

/** Calculate total components by excluding those that are chapters, sequential, or vertical. */
const totalComponents = useMemo(() => {
/** Also, calculate if the total components exceed the limits */
const { totalComponents, unsupportedByLimit } = useMemo(() => {
if (!blockTypes) {
return undefined;
return {
totalComponents: undefined,
unsupportedByLimit: 0,
};
}
return Object.entries(blockTypes).reduce(
let resultTotalComponents = Object.entries(blockTypes).reduce(
(total, [blockType, count]) => {
const isComponent = !['chapter', 'sequential', 'vertical'].includes(blockType);
if (isComponent) {
Expand All @@ -199,16 +199,40 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) =
return total;
},
0,
) - finalUnssupportedBlocks;
}, [blockTypes, finalUnssupportedBlocks]);
) - finalUnsupportedBlocks;

let resultUnsupportedByLimit = 0;
if (libraryBlockLimits && resultTotalComponents > libraryBlockLimits.maxBlocksPerContentLibrary) {
resultUnsupportedByLimit = resultTotalComponents - libraryBlockLimits.maxBlocksPerContentLibrary;
resultTotalComponents -= resultUnsupportedByLimit;
}

return {
totalComponents: resultTotalComponents,
unsupportedByLimit: resultUnsupportedByLimit,
};
}, [blockTypes, finalUnsupportedBlocks, libraryBlockLimits]);

// Adds the components exceed the limit to the final unsupported count
if (unsupportedByLimit) {
finalUnsupportedBlocks += unsupportedByLimit;
}

/** Calculate total supported blocks by subtracting final unsupported blocks from the total number of blocks */
const totalBlocks = useMemo(() => {
if (!blockTypes) {
return undefined;
}
return Object.values(blockTypes).reduce((total, block) => total + block, 0) - finalUnsupportedBlocks;
}, [blockTypes, finalUnsupportedBlocks]);

/** Calculate the unsupported block percentage based on the final total blocks and unsupported blocks. */
const unsupportedBlockPercentage = useMemo(() => {
if (!blockTypes || !totalBlocks) {
return 0;
}
return (finalUnssupportedBlocks / (totalBlocks + finalUnssupportedBlocks)) * 100;
}, [blockTypes, finalUnssupportedBlocks]);
return (finalUnsupportedBlocks / (totalBlocks + finalUnsupportedBlocks)) * 100;
}, [blockTypes, finalUnsupportedBlocks]);

return (
<Stack gap={4}>
Expand All @@ -224,10 +248,10 @@ export const ReviewImportDetails = ({ courseId, markAnalysisComplete }: Props) =
sections={blockTypes?.chapter}
subsections={blockTypes?.sequential}
units={blockTypes?.vertical}
unsupportedBlocks={finalUnssupportedBlocks}
unsupportedBlocks={finalUnsupportedBlocks}
isPending={isBlockDataPending}
/>
{!isBlockDataPending && finalUnssupportedBlocks > 0
{!isBlockDataPending && finalUnsupportedBlocks > 0
&& (
<>
<h4><FormattedMessage {...messages.importCourseAnalysisDetails} /></h4>
Expand Down