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
469 changes: 360 additions & 109 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions src/library-authoring/data/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,13 @@ describe('library data API', () => {
await api.getContentLibraryV2List({ type: 'complex' });
expect(axiosMock.history.get[0].url).toEqual(url);
});

it('getLibraryBlockLimits', async () => {
const url = api.getLibraryBlockLimitsUrl();

axiosMock.onGet(url).reply(200, { some: 'data' });

await api.getLibraryBlockLimits();
expect(axiosMock.history.get[0].url).toEqual(url);
});
});
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 @@ -306,6 +311,10 @@ export interface LibraryBlockMetadata {
isNew?: boolean;
}

export interface LibraryBlockLimits {
maxBlocksPerContentLibrary: number;
}

export interface UpdateLibraryDataRequest {
id: string;
title?: string;
Expand Down Expand Up @@ -487,6 +496,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
14 changes: 14 additions & 0 deletions src/library-authoring/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
getLibraryContainerRestoreApiUrl,
getLibraryContainerChildrenApiUrl,
getLibraryContainerPublishApiUrl,
getLibraryBlockLimitsUrl,
} from './api';
import {
useCommitLibraryChanges,
Expand All @@ -33,6 +34,7 @@ import {
useUpdateContainerChildren,
useRemoveContainerChildren,
usePublishContainer,
useLibraryBlockLimits,
} from './apiHooks';

let axiosMock;
Expand Down Expand Up @@ -336,6 +338,18 @@ describe('library api hooks', () => {
expect(spy).toHaveBeenCalledTimes(7);
});

it('should get the library content limit', async () => {
const url = getLibraryBlockLimitsUrl();

axiosMock.onGet(url).reply(200, { 'test-data': 'test-value' });
const { result } = renderHook(() => useLibraryBlockLimits(), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toEqual({ testData: 'test-value' });
expect(axiosMock.history.get[0].url).toEqual(url);
});

describe('publishContainer', () => {
it('should publish a container', async () => {
const containerId = 'lct:org:lib:unit:1';
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 @@ -137,6 +137,7 @@ export const xblockQueryKeys = {
}
return ['hierarchy'];
},
xblockLimits: () => [...xblockQueryKeys.all, 'limits'],
};

/**
Expand Down Expand Up @@ -988,6 +989,16 @@ export const useMigrationInfo = (sourcesKeys: string[], enabled: boolean = true)
})
);

/**
* Returns the migration info of a given source list
*/
export const useLibraryBlockLimits = () => (
useQuery({
queryKey: xblockQueryKeys.xblockLimits(),
queryFn: api.getLibraryBlockLimits,
})
);

/**
* Returns the migration blocks info of a given library
*/
Expand Down
17 changes: 17 additions & 0 deletions src/library-authoring/import-course/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,23 @@ const messages = defineMessages({
defaultMessage: 'Reason For Failed import',
description: 'Label for the Reason For Failed import field in the Reasons table in the import details',
},
importBlockedTitle: {
id: 'library-authoring.import-course.review-details.import-blocked.title',
defaultMessage: 'Import Blocked',
description: 'Title for the alert in review details when the import is blocked',
},
importBlockedBody: {
id: 'library-authoring.import-course.review-details.import-blocked.body',
defaultMessage: 'This import would exceed the Content Library limit of {limitNumber} items.'
+ ' To prevent incomplete or lost content, the import has been blocked. For more information,'
+ ' view the Content Library documentation.',
description: 'Body for the alert in review details when the import is blocked',
},
importNotPossibleTooltip: {
id: 'library-authoring.import-course.review-details.import-blocked.import-course-btn.tooltip',
defaultMessage: 'Import not possible',
description: 'Label for the tooltip for the import button in review details when the import is blocked',
},
});

export default messages;
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { LibraryProvider } from '@src/library-authoring/common/context/LibraryCo
import { mockContentLibrary, mockGetMigrationInfo } from '@src/library-authoring/data/api.mocks';
import { useGetBlockTypes } from '@src/search-manager';
import { bulkModulestoreMigrateUrl } from '@src/data/api';
import { useLibraryBlockLimits } from '@src/library-authoring/data/apiHooks';
import { ImportStepperPage } from './ImportStepperPage';

let axiosMock;
Expand All @@ -37,6 +38,11 @@ jest.mock('@src/search-manager', () => ({
useGetContentHits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));

jest.mock('@src/library-authoring/data/apiHooks', () => ({
...jest.requireActual('@src/library-authoring/data/apiHooks'),
useLibraryBlockLimits: jest.fn().mockReturnValue({ isPending: true, data: null }),
}));

const renderComponent = (studioHomeState: Partial<StudioHomeState> = {}) => {
// Generate a custom initial state based on studioHomeCoursesRequestParams
const customInitialState: Partial<DeprecatedReduxState> = {
Expand Down Expand Up @@ -106,6 +112,10 @@ describe('<ImportStepperModal />', () => {
});

it('should go to review import details step', async () => {
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 100 },
});
const user = userEvent.setup();
renderComponent();
axiosMock.onGet(getCourseDetailsApiUrl('course-v1:HarvardX+123+2023')).reply(200, {
Expand Down Expand Up @@ -137,6 +147,71 @@ describe('<ImportStepperModal />', () => {
expect(await screen.findByText('Import Analysis in Progress')).toBeInTheDocument();
});

it('should block import when content limit is reached', async () => {
(useLibraryBlockLimits as jest.Mock).mockReturnValue({
isPending: false,
data: { maxBlocksPerContentLibrary: 20 },
});

(useGetBlockTypes as jest.Mock).mockImplementation((args) => {
// Block types query for children of unsupported blocks
if (args.length === 2) {
return {
isPending: false,
data: {},
};
}

// Block types query from the course
if (args[0] === 'context_key = "course-v1:HarvardX+123+2023"') {
return {
isPending: false,
data: {
chapter: 1,
sequential: 2,
vertical: 3,
'problem-builder': 1,
html: 25,
},
};
}

return {
isPending: true,
data: null,
};
});

const user = userEvent.setup();
renderComponent();
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(/Import Blocked/i)).toBeInTheDocument();
expect(await screen.findByText(
/This import would exceed the Content Library limit of 20 items/i,
)).toBeInTheDocument();

expect(screen.getByRole('button', { name: /import course/i })).toBeDisabled();
});

it('the course should remain selected on back', async () => {
const user = userEvent.setup();
renderComponent();
Expand Down
32 changes: 25 additions & 7 deletions src/library-authoring/import-course/stepper/ImportStepperPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { Helmet } from 'react-helmet';
import { useNavigate } from 'react-router-dom';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow, Button, Chip, Container, Layout, Stepper,
ActionRow, Button, Chip, Container, Layout, OverlayTrigger, Stepper,
Tooltip,
} from '@openedx/paragon';

import { CoursesList, MigrationStatusProps } from '@src/studio-home/tabs-section/courses-tab';
Expand Down Expand Up @@ -72,6 +73,7 @@ export const ImportStepperPage = () => {
const [currentStep, setCurrentStep] = useState<MigrationStep>('select-course');
const [selectedCourseId, setSelectedCourseId] = useState<string>();
const [analysisCompleted, setAnalysisCompleted] = useState<boolean>(false);
const [importIsBlocked, setImportIsBlocked] = useState<boolean>(false);
const { data: courseData } = useCourseDetails(selectedCourseId);
const { libraryId, libraryData, readOnly } = useLibraryContext();
const { showToast } = useContext(ToastContext);
Expand Down Expand Up @@ -152,6 +154,7 @@ export const ImportStepperPage = () => {
>
<ReviewImportDetails
markAnalysisComplete={setAnalysisCompleted}
setImportIsBlocked={setImportIsBlocked}
courseId={selectedCourseId}
/>
</Stepper.Step>
Expand All @@ -175,12 +178,27 @@ export const ImportStepperPage = () => {
<Button onClick={() => setCurrentStep('select-course')} variant="tertiary">
<FormattedMessage {...messages.importCourseBack} />
</Button>
<LoadingButton
onClick={handleImportCourse}
label={intl.formatMessage(messages.importCourseButton)}
variant="primary"
disabled={!analysisCompleted}
/>
{importIsBlocked ? (
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id="tooltip-import-course-button">
<FormattedMessage {...messages.importNotPossibleTooltip} />
</Tooltip>
)}
>
<Button variant="primary" disabled>
<FormattedMessage {...messages.importCourseButton} />
</Button>
</OverlayTrigger>
) : (
<LoadingButton
onClick={handleImportCourse}
label={intl.formatMessage(messages.importCourseButton)}
variant="primary"
disabled={!analysisCompleted}
/>
)}
</ActionRow>
)}
</div>
Expand Down
Loading