Compare commits
14 Commits
feat/add_g
...
release/ul
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fedb85577e | ||
|
|
18e51db70a | ||
|
|
4a1d0a2716 | ||
|
|
2ba6f96142 | ||
|
|
28f0c9943d | ||
|
|
067806a0e6 | ||
|
|
7ebf349789 | ||
|
|
7a1bc3931a | ||
|
|
9bea56b3ae | ||
|
|
c7a84a1a9c | ||
|
|
ad0e1ae570 | ||
|
|
bd00c3b271 | ||
|
|
de8b4b460b | ||
|
|
fa2bd8a604 |
16
src/authz/constants.ts
Normal file
16
src/authz/constants.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export const CONTENT_LIBRARY_PERMISSIONS = {
|
||||
DELETE_LIBRARY: 'content_libraries.delete_library',
|
||||
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
|
||||
VIEW_LIBRARY: 'content_libraries.view_library',
|
||||
|
||||
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
|
||||
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
|
||||
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
|
||||
|
||||
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
|
||||
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
|
||||
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
|
||||
|
||||
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
|
||||
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
|
||||
};
|
||||
41
src/authz/data/api.ts
Normal file
41
src/authz/data/api.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
PermissionValidationAnswer,
|
||||
PermissionValidationQuery,
|
||||
PermissionValidationRequestItem,
|
||||
PermissionValidationResponseItem,
|
||||
} from '@src/authz/types';
|
||||
import { getApiUrl } from './utils';
|
||||
|
||||
export const validateUserPermissions = async (
|
||||
query: PermissionValidationQuery,
|
||||
): Promise<PermissionValidationAnswer> => {
|
||||
// Convert the validations query object into an array for the API request
|
||||
const request: PermissionValidationRequestItem[] = Object.values(query);
|
||||
|
||||
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
|
||||
getApiUrl('/api/authz/v1/permissions/validate/me'),
|
||||
request,
|
||||
);
|
||||
|
||||
// Convert the API response back into the expected answer format
|
||||
const result: PermissionValidationAnswer = {};
|
||||
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
|
||||
const key = Object.keys(query).find(
|
||||
(k) => query[k].action === item.action
|
||||
&& query[k].scope === item.scope,
|
||||
);
|
||||
if (key) {
|
||||
result[key] = item.allowed;
|
||||
}
|
||||
});
|
||||
|
||||
// Fill any missing keys with false
|
||||
Object.keys(query).forEach((key) => {
|
||||
if (!(key in result)) {
|
||||
result[key] = false;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
168
src/authz/data/apiHooks.test.tsx
Normal file
168
src/authz/data/apiHooks.test.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { act, ReactNode } from 'react';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { useUserPermissions } from './apiHooks';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
getAuthenticatedHttpClient: jest.fn(),
|
||||
}));
|
||||
|
||||
const createWrapper = () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
const singlePermission = {
|
||||
canRead: {
|
||||
action: 'example.read',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
};
|
||||
|
||||
const mockValidSinglePermission = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
];
|
||||
|
||||
const mockInvalidSinglePermission = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
];
|
||||
|
||||
const mockEmptyPermissions = [
|
||||
// No permissions returned
|
||||
];
|
||||
|
||||
const multiplePermissions = {
|
||||
canRead: {
|
||||
action: 'example.read',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
canWrite: {
|
||||
action: 'example.write',
|
||||
scope: 'lib:example-org:test-lib',
|
||||
},
|
||||
};
|
||||
|
||||
const mockValidMultiplePermissions = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
|
||||
];
|
||||
|
||||
const mockInvalidMultiplePermissions = [
|
||||
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
|
||||
];
|
||||
|
||||
describe('useUserPermissions', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns allowed true when permission is valid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(true);
|
||||
});
|
||||
|
||||
it('returns allowed false when permission is invalid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
});
|
||||
|
||||
it('returns allowed true when multiple permissions are valid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(true);
|
||||
expect(result.current.data!.canWrite).toBe(true);
|
||||
});
|
||||
|
||||
it('returns allowed false when multiple permissions are invalid', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
expect(result.current.data!.canWrite).toBe(false);
|
||||
});
|
||||
|
||||
it('returns allowed false when the permission is not included in the server response', async () => {
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
await waitFor(() => expect(result.current).toBeDefined());
|
||||
await waitFor(() => expect(result.current.data).toBeDefined());
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
expect(result.current.data!.canRead).toBe(false);
|
||||
});
|
||||
|
||||
it('handles error when the API call fails', async () => {
|
||||
const mockError = new Error('API Error');
|
||||
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
post: jest.fn().mockRejectedValue(new Error('API Error')),
|
||||
});
|
||||
|
||||
try {
|
||||
act(() => {
|
||||
renderHook(() => useUserPermissions(singlePermission), {
|
||||
wrapper: createWrapper(),
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
expect(error).toEqual(mockError); // Check for the expected error
|
||||
}
|
||||
});
|
||||
});
|
||||
36
src/authz/data/apiHooks.ts
Normal file
36
src/authz/data/apiHooks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
|
||||
import { validateUserPermissions } from './api';
|
||||
|
||||
const adminConsoleQueryKeys = {
|
||||
all: ['authz'],
|
||||
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* React Query hook to validate if the current user has permissions over a certain object in the instance.
|
||||
* It helps to:
|
||||
* - Determine whether the current user can access certain object.
|
||||
* - Provide role-based rendering logic for UI components.
|
||||
*
|
||||
* @param permissions - A key/value map of objects and actions to validate.
|
||||
* The key is an arbitrary string to identify the permission check,
|
||||
* and the value is an object containing the action and optional scope.
|
||||
*
|
||||
* @example
|
||||
* const { isLoading, data } = useUserPermissions({
|
||||
* canRead: {
|
||||
* action: "content_libraries.view_library",
|
||||
* scope: "lib:OpenedX:CSPROB"
|
||||
* }
|
||||
* });
|
||||
* if (data.canRead) { ... }
|
||||
*
|
||||
*/
|
||||
export const useUserPermissions = (
|
||||
permissions: PermissionValidationQuery,
|
||||
) => useQuery<PermissionValidationAnswer, Error>({
|
||||
queryKey: adminConsoleQueryKeys.permissions(permissions),
|
||||
queryFn: () => validateUserPermissions(permissions),
|
||||
retry: false,
|
||||
});
|
||||
4
src/authz/data/utils.ts
Normal file
4
src/authz/data/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
|
||||
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;
|
||||
16
src/authz/types.ts
Normal file
16
src/authz/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export interface PermissionValidationRequestItem {
|
||||
action: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
|
||||
allowed: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionValidationQuery {
|
||||
[permissionKey: string]: PermissionValidationRequestItem;
|
||||
}
|
||||
|
||||
export interface PermissionValidationAnswer {
|
||||
[permissionKey: string]: boolean;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { LoadingSpinner } from '@src/generic/Loading';
|
||||
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
|
||||
import { BoldText } from '@src/utils';
|
||||
|
||||
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
||||
import ChildrenPreview from './ChildrenPreview';
|
||||
import ContainerRow from './ContainerRow';
|
||||
import { useCourseContainerChildren } from './data/apiHooks';
|
||||
@@ -60,7 +61,7 @@ const CompareContainersWidgetInner = ({
|
||||
data: libData,
|
||||
isError: isLibError,
|
||||
error: libError,
|
||||
} = useContainerChildren(state === 'removed' ? undefined : upstreamBlockId, true);
|
||||
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
|
||||
const {
|
||||
data: containerData,
|
||||
isError: isContainerTitleError,
|
||||
|
||||
@@ -10,13 +10,13 @@ const UnitButton = ({
|
||||
unitId,
|
||||
className,
|
||||
showTitle,
|
||||
isActive, // passed from parent (SequenceNavigationTabs)
|
||||
}) => {
|
||||
const courseId = useSelector(getCourseId);
|
||||
const sequenceId = useSelector(getSequenceId);
|
||||
|
||||
const unit = useSelector((state) => state.models.units[unitId]);
|
||||
|
||||
const { title, contentType, isActive } = unit || {};
|
||||
const { title, contentType } = unit || {};
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -37,11 +37,13 @@ UnitButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
showTitle: PropTypes.bool,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
className: undefined,
|
||||
showTitle: false,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
export default UnitButton;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
fetchCourseVerticalChildrenData,
|
||||
getCourseOutlineInfoQuery,
|
||||
patchUnitItemQuery,
|
||||
updateCourseUnitSidebar,
|
||||
} from './data/thunk';
|
||||
import {
|
||||
getCanEdit,
|
||||
@@ -231,8 +232,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
// edits the component using editor which has a separate store
|
||||
/* istanbul ignore next */
|
||||
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
|
||||
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
|
||||
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
|
||||
dispatch(updateCourseUnitSidebar(blockId));
|
||||
localStorage.removeItem(event.key);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// lint is disabled for this file due to strict spacing
|
||||
|
||||
export const checkboxesOLXWithFeedbackAndHintsOLX = {
|
||||
rawOLX: `<problem url_name="this_should_be_ignored">
|
||||
rawOLX: `<problem url_name="this_should_be_ignored" copied_from_version="2" copied_from_block="some-block">
|
||||
<choiceresponse>
|
||||
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.</p>
|
||||
<label>Add the question text, or prompt, here. This text is required.</label>
|
||||
|
||||
@@ -380,6 +380,8 @@ export const ignoredOlxAttributes = [
|
||||
'@_url_name',
|
||||
'@_x-is-pointer-node',
|
||||
'@_markdown_edited',
|
||||
'@_copied_from_block',
|
||||
'@_copied_from_version',
|
||||
] as const;
|
||||
|
||||
// Useful for the block creation workflow.
|
||||
|
||||
@@ -126,9 +126,9 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => {
|
||||
onSuccess: (response) => {
|
||||
dispatch(actions.app.setSaveResponse(response));
|
||||
const parsedData = JSON.parse(response.config.data);
|
||||
if (parsedData?.has_changes) {
|
||||
if (parsedData?.has_changes || !('has_changes' in parsedData)) {
|
||||
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
|
||||
localStorage.setItem(storageKey, Date.now());
|
||||
sessionStorage.setItem(storageKey, Date.now());
|
||||
|
||||
window.dispatchEvent(new StorageEvent('storage', {
|
||||
key: storageKey,
|
||||
|
||||
@@ -457,6 +457,9 @@ export const editorConfig = ({
|
||||
valid_elements: '*[*]',
|
||||
// FIXME: this is passing 'utf-8', which is not a valid entity_encoding value. It should be 'named' etc.
|
||||
entity_encoding: 'utf-8' as any,
|
||||
// Protect self-closing <script /> tags from being mangled,
|
||||
// to preserve backwards compatibility with content that relied on this behavior
|
||||
protect: [/<script[^>]*\/>/g],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -103,8 +103,8 @@ const messages = defineMessages({
|
||||
},
|
||||
'header.links.exportLibrary': {
|
||||
id: 'header.links.exportLibrary',
|
||||
defaultMessage: 'Backup to local archive',
|
||||
description: 'Link to Studio Backup Library page',
|
||||
defaultMessage: 'Back up to local archive',
|
||||
description: 'Link to Studio Library Backup page',
|
||||
},
|
||||
'header.links.optimizer': {
|
||||
id: 'header.links.optimizer',
|
||||
|
||||
@@ -81,6 +81,7 @@ export const ConfirmationView = ({
|
||||
</Alert>
|
||||
{legacyLibraries.map((legacyLib) => (
|
||||
<ConfirmationCard
|
||||
key={legacyLib.libraryKey}
|
||||
legacyLib={legacyLib}
|
||||
destinationName={destination.title}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from '@src/testUtils';
|
||||
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
|
||||
import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks';
|
||||
@@ -184,7 +185,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
nextButton.click();
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
backButton.click();
|
||||
@@ -210,7 +211,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
nextButton.click();
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
|
||||
|
||||
// The next button is disabled
|
||||
expect(nextButton).toBeDisabled();
|
||||
@@ -224,27 +225,31 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
});
|
||||
|
||||
it('should back to select library destination', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderPage();
|
||||
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
|
||||
expect(await screen.findByText('MBA')).toBeInTheDocument();
|
||||
|
||||
const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' });
|
||||
legacyLibrary.click();
|
||||
await user.click(legacyLibrary);
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
nextButton.click();
|
||||
const nextButton = await screen.findByRole('button', { name: /next/i });
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
||||
radioButton.click();
|
||||
await user.click(radioButton);
|
||||
|
||||
nextButton.click();
|
||||
expect(await screen.findByText(/these 1 legacy library will be migrated to/i)).toBeInTheDocument();
|
||||
await user.click(nextButton);
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
/All content from the legacy library you selected will be migrated to/,
|
||||
)).toBeInTheDocument();
|
||||
|
||||
const backButton = screen.getByRole('button', { name: /back/i });
|
||||
backButton.click();
|
||||
await user.click(backButton);
|
||||
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
// The selected v2 library remains checked
|
||||
@@ -269,7 +274,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
nextButton.click();
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
|
||||
|
||||
const createButton = await screen.findByRole('button', { name: /create new library/i });
|
||||
expect(createButton).toBeInTheDocument();
|
||||
@@ -336,18 +341,21 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
legacyLibrary3.click();
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
||||
radioButton.click();
|
||||
await user.click(radioButton);
|
||||
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of ConfirmationView
|
||||
expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
/All content from the 3 legacy libraries you selected will be migrated to/,
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
||||
@@ -390,18 +398,22 @@ describe('<LegacyLibMigrationPage />', () => {
|
||||
legacyLibrary3.click();
|
||||
|
||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of SelectDestinationView
|
||||
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
|
||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
||||
radioButton.click();
|
||||
await user.click(radioButton);
|
||||
|
||||
nextButton.click();
|
||||
await user.click(nextButton);
|
||||
|
||||
// Should show alert of ConfirmationView
|
||||
expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(await within(alert).findByText(
|
||||
/All content from the 3 legacy libraries you selected will be migrated to/,
|
||||
{ exact: false },
|
||||
)).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA')).toBeInTheDocument();
|
||||
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
||||
|
||||
@@ -64,15 +64,17 @@ const messages = defineMessages({
|
||||
selectDestinationAlert: {
|
||||
id: 'legacy-libraries-migration.select-destination.alert.text',
|
||||
defaultMessage: 'All content from the'
|
||||
+ ' {count, plural, one {{count} legacy library} other {{count} legacy libraries}} you selected will'
|
||||
+ ' be migrated to this new library, organized into collections. Any legacy libraries that are used in'
|
||||
+ ' problem banks will maintain their link with migrated content the first time they are migrated.',
|
||||
+ ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will'
|
||||
+ ' be migrated to the Content Library you select, organized into collections. Legacy library content used'
|
||||
+ ' in courses will continue to work as-is. To receive any future changes to migrated content,'
|
||||
+ ' you must update these references within your course.',
|
||||
description: 'Alert text in the select destination step of the legacy libraries migration page.',
|
||||
},
|
||||
confirmationViewAlert: {
|
||||
id: 'legacy-libraries-migration.select-destination.alert.text',
|
||||
defaultMessage: 'These {count, plural, one {{count} legacy library} other {{count} legacy libraries}}'
|
||||
+ ' will be migrated to <b>{libraryName}</b> and organized as collections. Legacy library content used'
|
||||
defaultMessage: 'All content from the'
|
||||
+ ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will'
|
||||
+ ' be migrated to <b>{libraryName}</b> and organized into collections. Legacy library content used'
|
||||
+ ' in courses will continue to work as-is. To receive any future changes to migrated content,'
|
||||
+ ' you must update these references within your course.',
|
||||
description: 'Alert text in the confirmation step of the legacy libraries migration page.',
|
||||
@@ -80,7 +82,7 @@ const messages = defineMessages({
|
||||
previouslyMigratedAlert: {
|
||||
id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text',
|
||||
defaultMessage: 'Previously migrated library. Any problem bank links were already'
|
||||
+ ' moved will be migrated to <b>{libraryName}</b>',
|
||||
+ ' moved will be migrated to <b>{libraryName}</b>',
|
||||
description: 'Alert text when the legacy library is already migrated.',
|
||||
},
|
||||
helpAndSupportTitle: {
|
||||
@@ -96,8 +98,8 @@ const messages = defineMessages({
|
||||
helpAndSupportFirstQuestionBody: {
|
||||
id: 'legacy-libraries-migration.helpAndSupport.q1.body',
|
||||
defaultMessage: 'In the new Content Libraries experience, you can author sections,'
|
||||
+ ' subsections, units, and many types of components. Library content can be reused across many courses,'
|
||||
+ ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.',
|
||||
+ ' subsections, units, and many types of components. Library content can be reused across many courses,'
|
||||
+ ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.',
|
||||
description: 'Body of the first question in the Help & Support sidebar',
|
||||
},
|
||||
helpAndSupportSecondQuestionTitle: {
|
||||
@@ -108,9 +110,9 @@ const messages = defineMessages({
|
||||
helpAndSupportSecondQuestionBody: {
|
||||
id: 'legacy-libraries-migration.helpAndSupport.q2.body',
|
||||
defaultMessage: 'All legacy library content is supported in the new experience.'
|
||||
+ ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.'
|
||||
+ ' This collection will have the same name as your original library. Courses that use legacy library content will'
|
||||
+ ' continue to function as usual, linked to the migrated version within the new libraries experience.',
|
||||
+ ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.'
|
||||
+ ' This collection will have the same name as your original library. Courses that use legacy library content will'
|
||||
+ ' continue to function as usual, linked to the migrated version within the new libraries experience.',
|
||||
description: 'Body of the second question in the Help & Support sidebar',
|
||||
},
|
||||
helpAndSupportThirdQuestionTitle: {
|
||||
@@ -121,18 +123,18 @@ const messages = defineMessages({
|
||||
helpAndSupportThirdQuestionBody: {
|
||||
id: 'legacy-libraries-migration.helpAndSupport.q3.body.2',
|
||||
defaultMessage: '<p>There are three steps to migrating legacy libraries:</p>'
|
||||
+ '<p><div>1 - Select Legacy Libraries</div>'
|
||||
+ 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have'
|
||||
+ ' not yet been migrated are shown. To see all libraries, remove the filter.'
|
||||
+ ' You can select up to 50 legacy libraries for migration, but only one destination'
|
||||
+ ' v2 Content Library per migration.</p>'
|
||||
+ '<p><div>2 - Select Destination</div>'
|
||||
+ 'You can migrate legacy libraries to an existing Content Library in the new experience,'
|
||||
+ ' or you can create a new destination. You can only select one v2 Content Library per migration.'
|
||||
+ ' All your content will be migrated, and kept organized in collections.</p>'
|
||||
+ '<p><div>3 - Confirm</div>'
|
||||
+ 'In this step, review your migration. Once you confirm, migration will begin.'
|
||||
+ ' It may take some time to complete.</p>',
|
||||
+ '<p><div>1 - Select Legacy Libraries</div>'
|
||||
+ 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have'
|
||||
+ ' not yet been migrated are shown. To see all libraries, remove the filter.'
|
||||
+ ' You can select up to 50 legacy libraries for migration, but only one destination'
|
||||
+ ' v2 Content Library per migration.</p>'
|
||||
+ '<p><div>2 - Select Destination</div>'
|
||||
+ 'You can migrate legacy libraries to an existing Content Library in the new experience,'
|
||||
+ ' or you can create a new destination. You can only select one v2 Content Library per migration.'
|
||||
+ ' All your content will be migrated, and kept organized in collections.</p>'
|
||||
+ '<p><div>3 - Confirm</div>'
|
||||
+ 'In this step, review your migration. Once you confirm, migration will begin.'
|
||||
+ ' It may take some time to complete.</p>',
|
||||
description: 'Part 2 of the Body of the third question in the Help & Support sidebar',
|
||||
},
|
||||
migrationInProgress: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
.library-authoring-sidebar {
|
||||
z-index: 1000; // same as header
|
||||
flex: 500px 0 0;
|
||||
flex: 530px 0 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
right: 0;
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useUserPermissions } from '@src/authz/data/apiHooks';
|
||||
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants';
|
||||
import { ContainerType } from '../../../generic/key-utils';
|
||||
|
||||
import type { ComponentPicker } from '../../component-picker';
|
||||
@@ -25,6 +27,7 @@ export type LibraryContextData = {
|
||||
libraryId: string;
|
||||
libraryData?: ContentLibrary;
|
||||
readOnly: boolean;
|
||||
canPublish: boolean;
|
||||
isLoadingLibraryData: boolean;
|
||||
/** The ID of the current collection/container, on the sidebar OR page */
|
||||
collectionId: string | undefined;
|
||||
@@ -107,6 +110,13 @@ export const LibraryProvider = ({
|
||||
componentPickerMode,
|
||||
} = useComponentPickerContext();
|
||||
|
||||
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
|
||||
canPublish: {
|
||||
action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
|
||||
scope: libraryId,
|
||||
},
|
||||
});
|
||||
const canPublish = userPermissions?.canPublish || false;
|
||||
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;
|
||||
|
||||
// Parse the initial collectionId and/or container ID(s) from the current URL params
|
||||
@@ -131,7 +141,8 @@ export const LibraryProvider = ({
|
||||
containerId,
|
||||
setContainerId,
|
||||
readOnly,
|
||||
isLoadingLibraryData,
|
||||
canPublish,
|
||||
isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions,
|
||||
showOnlyPublished,
|
||||
extraFilter,
|
||||
isCreateCollectionModalOpen,
|
||||
@@ -154,7 +165,9 @@ export const LibraryProvider = ({
|
||||
containerId,
|
||||
setContainerId,
|
||||
readOnly,
|
||||
canPublish,
|
||||
isLoadingLibraryData,
|
||||
isLoadingUserPermissions,
|
||||
showOnlyPublished,
|
||||
extraFilter,
|
||||
isCreateCollectionModalOpen,
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface DefaultTabs {
|
||||
export interface SidebarItemInfo {
|
||||
type: SidebarBodyItemId;
|
||||
id: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export enum SidebarActions {
|
||||
@@ -88,7 +89,7 @@ export type SidebarContextData = {
|
||||
openCollectionInfoSidebar: (collectionId: string) => void;
|
||||
openComponentInfoSidebar: (usageKey: string) => void;
|
||||
openContainerInfoSidebar: (usageKey: string) => void;
|
||||
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId) => void;
|
||||
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId, index?: number) => void;
|
||||
sidebarItemInfo?: SidebarItemInfo;
|
||||
sidebarAction: SidebarActions;
|
||||
setSidebarAction: (action: SidebarActions) => void;
|
||||
@@ -154,35 +155,38 @@ export const SidebarProvider = ({
|
||||
setSidebarItemInfo({ id: '', type: SidebarBodyItemId.Info });
|
||||
}, []);
|
||||
|
||||
const openComponentInfoSidebar = useCallback((usageKey: string) => {
|
||||
const openComponentInfoSidebar = useCallback((usageKey: string, index?: number) => {
|
||||
setSidebarItemInfo({
|
||||
id: usageKey,
|
||||
type: SidebarBodyItemId.ComponentInfo,
|
||||
index,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openCollectionInfoSidebar = useCallback((newCollectionId: string) => {
|
||||
const openCollectionInfoSidebar = useCallback((newCollectionId: string, index?: number) => {
|
||||
setSidebarItemInfo({
|
||||
id: newCollectionId,
|
||||
type: SidebarBodyItemId.CollectionInfo,
|
||||
index,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openContainerInfoSidebar = useCallback((usageKey: string) => {
|
||||
const openContainerInfoSidebar = useCallback((usageKey: string, index?: number) => {
|
||||
setSidebarItemInfo({
|
||||
id: usageKey,
|
||||
type: SidebarBodyItemId.ContainerInfo,
|
||||
index,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const { navigateTo } = useLibraryRoutes();
|
||||
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId) => {
|
||||
navigateTo({ selectedItemId });
|
||||
setSidebarItemInfo({ id: selectedItemId, type });
|
||||
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId, index?: number) => {
|
||||
navigateTo({ selectedItemId, index });
|
||||
setSidebarItemInfo({ id: selectedItemId, type, index });
|
||||
}, [navigateTo, setSidebarItemInfo]);
|
||||
|
||||
// Set the initial sidebar state based on the URL parameters and context.
|
||||
const { selectedItemId } = useParams();
|
||||
const { selectedItemId, index: indexParam } = useParams();
|
||||
const { collectionId, containerId } = useLibraryContext();
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
|
||||
@@ -198,12 +202,15 @@ export const SidebarProvider = ({
|
||||
|
||||
// Handle selected item id changes
|
||||
if (selectedItemId) {
|
||||
// if a item is selected that means we have list of items displayed
|
||||
// which means we can get the index from url and set it.
|
||||
const indexNumber = indexParam ? Number(indexParam) : undefined;
|
||||
if (selectedItemId.startsWith('lct:')) {
|
||||
openContainerInfoSidebar(selectedItemId);
|
||||
openContainerInfoSidebar(selectedItemId, indexNumber);
|
||||
} else if (selectedItemId.startsWith('lb:')) {
|
||||
openComponentInfoSidebar(selectedItemId);
|
||||
openComponentInfoSidebar(selectedItemId, indexNumber);
|
||||
} else {
|
||||
openCollectionInfoSidebar(selectedItemId);
|
||||
openCollectionInfoSidebar(selectedItemId, indexNumber);
|
||||
}
|
||||
} else if (collectionId) {
|
||||
openCollectionInfoSidebar(collectionId);
|
||||
|
||||
@@ -111,6 +111,8 @@ const ComponentActions = ({
|
||||
const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
|
||||
const canEdit = canEditComponent(componentId);
|
||||
|
||||
const { sidebarItemInfo } = useSidebarContext();
|
||||
|
||||
if (isPublisherOpen) {
|
||||
return (
|
||||
<ComponentPublisher
|
||||
@@ -141,7 +143,7 @@ const ComponentActions = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<ComponentMenu usageKey={componentId} />
|
||||
<ComponentMenu usageKey={componentId} index={sidebarItemInfo?.index} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -64,7 +64,7 @@ const BaseCard = ({
|
||||
<Card.Header
|
||||
className={`library-item-header ${getComponentStyleColor(itemType)}`}
|
||||
title={
|
||||
<Icon src={itemIcon} className="library-item-header-icon" />
|
||||
<Icon src={itemIcon} className="library-item-header-icon my-2" />
|
||||
}
|
||||
actions={(
|
||||
<div
|
||||
|
||||
@@ -12,18 +12,23 @@ import { useClipboard } from '@src/generic/clipboard';
|
||||
import { getBlockType } from '@src/generic/key-utils';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
|
||||
import containerMessages from '@src/library-authoring/containers/messages';
|
||||
import { useLibraryRoutes } from '@src/library-authoring/routes';
|
||||
import { useRunOnNextRender } from '@src/utils';
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
import ComponentRemover from './ComponentRemover';
|
||||
import messages from './messages';
|
||||
import containerMessages from '../containers/messages';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import { useRunOnNextRender } from '../../utils';
|
||||
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export const ComponentMenu = ({ usageKey, index }: Props) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
libraryId,
|
||||
@@ -135,6 +140,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
{isRemoveModalOpen && (
|
||||
<ComponentRemover
|
||||
usageKey={usageKey}
|
||||
index={index}
|
||||
close={closeRemoveModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,31 +4,38 @@ import { Warning } from '@openedx/paragon/icons';
|
||||
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import {
|
||||
useContainer,
|
||||
useRemoveContainerChildren,
|
||||
useAddItemsToContainer,
|
||||
useLibraryBlockMetadata,
|
||||
} from '../data/apiHooks';
|
||||
useContainerChildren,
|
||||
useUpdateContainerChildren,
|
||||
} from '@src/library-authoring/data/apiHooks';
|
||||
import { LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
||||
import messages from './messages';
|
||||
|
||||
interface Props {
|
||||
usageKey: string;
|
||||
index?: number;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
const ComponentRemover = ({ usageKey, close }: Props) => {
|
||||
const ComponentRemover = ({ usageKey, index, close }: Props) => {
|
||||
const intl = useIntl();
|
||||
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
|
||||
const { containerId } = useLibraryContext();
|
||||
const { containerId, showOnlyPublished } = useLibraryContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
|
||||
const addItemToContainerMutation = useAddItemsToContainer(containerId);
|
||||
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
|
||||
const { data: container, isPending: isPendingParentContainer } = useContainer(containerId);
|
||||
const { data: component, isPending } = useLibraryBlockMetadata(usageKey);
|
||||
// Use update api for children if duplicates are present to avoid removing all instances of the child
|
||||
const { data: children } = useContainerChildren<LibraryBlockMetadata>(containerId, showOnlyPublished);
|
||||
const childrenUsageIds = children?.map((child) => child.id);
|
||||
const hasDuplicates = (childrenUsageIds?.filter((child) => child === usageKey).length || 0) > 1;
|
||||
|
||||
// istanbul ignore if: loading state
|
||||
if (isPending || isPendingParentContainer) {
|
||||
@@ -36,28 +43,62 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const restoreComponent = () => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!childrenUsageIds) {
|
||||
return;
|
||||
}
|
||||
updateContainerChildrenMutation.mutateAsync(childrenUsageIds).then(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
||||
});
|
||||
};
|
||||
|
||||
const showSuccessToast = () => {
|
||||
showToast(
|
||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const showFailureToast = () => showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
|
||||
|
||||
const removeFromContainer = () => {
|
||||
const restoreComponent = () => {
|
||||
addItemToContainerMutation.mutateAsync([usageKey]).then(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
||||
});
|
||||
};
|
||||
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
|
||||
if (sidebarItemInfo?.id === usageKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(
|
||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
showSuccessToast();
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
|
||||
showFailureToast();
|
||||
});
|
||||
|
||||
close();
|
||||
};
|
||||
|
||||
const excludeOneInstance = () => {
|
||||
if (!childrenUsageIds || typeof index === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== usageKey || idx !== index);
|
||||
updateContainerChildrenMutation.mutateAsync(updatedKeys).then(() => {
|
||||
// istanbul ignore if
|
||||
if (sidebarItemInfo?.id === usageKey && sidebarItemInfo?.index === index) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
// Already tested as part of removeFromContainer
|
||||
// istanbul ignore next
|
||||
showSuccessToast();
|
||||
}).catch(() => {
|
||||
// Already tested as part of removeFromContainer
|
||||
// istanbul ignore next
|
||||
showFailureToast();
|
||||
});
|
||||
|
||||
close();
|
||||
@@ -76,7 +117,7 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
|
||||
title={intl.formatMessage(messages.removeComponentWarningTitle)}
|
||||
icon={Warning}
|
||||
description={removeText}
|
||||
onDeleteSubmit={removeFromContainer}
|
||||
onDeleteSubmit={hasDuplicates ? excludeOneInstance : removeFromContainer}
|
||||
btnLabel={intl.formatMessage(messages.componentRemoveButtonLabel)}
|
||||
buttonVariant="primary"
|
||||
/>
|
||||
|
||||
@@ -17,23 +17,24 @@ import { type ContainerHit, Highlight, PublishStatus } from '@src/search-manager
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { useRunOnNextRender } from '@src/utils';
|
||||
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import { useComponentPickerContext } from '@src/library-authoring/common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
|
||||
import { useLibraryRoutes } from '@src/library-authoring/routes';
|
||||
import BaseCard from '@src/library-authoring/components/BaseCard';
|
||||
import AddComponentWidget from '@src/library-authoring/components/AddComponentWidget';
|
||||
import messages from './messages';
|
||||
import ContainerDeleter from './ContainerDeleter';
|
||||
import ContainerRemover from './ContainerRemover';
|
||||
import BaseCard from '../components/BaseCard';
|
||||
import AddComponentWidget from '../components/AddComponentWidget';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
containerKey: string;
|
||||
displayName: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
|
||||
export const ContainerMenu = ({ containerKey, displayName, index } : ContainerMenuProps) => {
|
||||
const intl = useIntl();
|
||||
const { libraryId, collectionId, containerId } = useLibraryContext();
|
||||
const {
|
||||
@@ -144,6 +145,7 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps
|
||||
close={cancelRemove}
|
||||
containerKey={containerKey}
|
||||
displayName={displayName}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
Icon,
|
||||
IconButton,
|
||||
useToggle,
|
||||
Alert,
|
||||
} from '@openedx/paragon';
|
||||
import React, { useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
import { InfoOutline, MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
import { useClipboard } from '@src/generic/clipboard';
|
||||
import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
||||
@@ -149,6 +150,15 @@ const ContainerActions = ({
|
||||
);
|
||||
};
|
||||
|
||||
/* istanbul ignore next */
|
||||
/* istanbul ignore next */
|
||||
const ContainerSettings = () => (
|
||||
<Alert icon={InfoOutline} variant="info">
|
||||
<p>
|
||||
<FormattedMessage {...messages.containerSettingsMsg} />
|
||||
</p>
|
||||
</Alert>
|
||||
);
|
||||
const ContainerInfo = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
@@ -222,7 +232,7 @@ const ContainerInfo = () => {
|
||||
{renderTab(
|
||||
CONTAINER_INFO_TABS.Settings,
|
||||
intl.formatMessage(messages.settingsTabTitle),
|
||||
// TODO: container settings component
|
||||
<ContainerSettings />,
|
||||
)}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
|
||||
73
src/library-authoring/containers/ContainerRemover.test.tsx
Normal file
73
src/library-authoring/containers/ContainerRemover.test.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
initializeMocks,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@src/testUtils';
|
||||
import { ToastProvider } from '@src/generic/toast-context';
|
||||
import {
|
||||
getLibraryContainerChildrenApiUrl,
|
||||
} from '../data/api';
|
||||
import {
|
||||
mockContentLibrary,
|
||||
mockGetContainerChildren,
|
||||
} from '../data/api.mocks';
|
||||
import ContainerRemover from './ContainerRemover';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
|
||||
mockGetContainerChildren.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
|
||||
const mockClose = jest.fn();
|
||||
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const renderModal = (element: React.ReactNode) => {
|
||||
render(
|
||||
<ToastProvider>
|
||||
<LibraryProvider libraryId={libraryId}>
|
||||
{element}
|
||||
</LibraryProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: jest.fn().mockImplementation(() => ({
|
||||
containerId: mockGetContainerChildren.unitIdWithDuplicate,
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('<ContainerRemover />', () => {
|
||||
beforeEach(() => {
|
||||
({ axiosMock } = initializeMocks());
|
||||
});
|
||||
|
||||
it('triggers update container children api call when duplicates are present', async () => {
|
||||
const user = userEvent.setup();
|
||||
const url = getLibraryContainerChildrenApiUrl(mockGetContainerChildren.unitIdWithDuplicate);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
const result = await mockGetContainerChildren(mockGetContainerChildren.unitIdWithDuplicate);
|
||||
const resultIds = result.map((obj) => obj.id);
|
||||
renderModal(<ContainerRemover
|
||||
close={mockClose}
|
||||
containerKey={result[0].id}
|
||||
displayName="Title"
|
||||
index={0}
|
||||
/>);
|
||||
const btn = await screen.findByRole('button', { name: 'Remove' });
|
||||
await user.click(btn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.patch[0].url).toEqual(url);
|
||||
});
|
||||
// Only the first element is removed even though the last element has the same id.
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data).usage_keys).toEqual(resultIds.slice(1));
|
||||
expect(mockClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -7,32 +7,42 @@ import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import { getBlockType } from '@src/generic/key-utils';
|
||||
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useContainer, useRemoveContainerChildren } from '../data/apiHooks';
|
||||
import messages from '../components/messages';
|
||||
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||
import {
|
||||
useContainer, useContainerChildren, useRemoveContainerChildren, useUpdateContainerChildren,
|
||||
} from '@src/library-authoring/data/apiHooks';
|
||||
import messages from '@src/library-authoring/components/messages';
|
||||
import { Container } from '@src/library-authoring/data/api';
|
||||
|
||||
type ContainerRemoverProps = {
|
||||
close: () => void,
|
||||
containerKey: string,
|
||||
displayName: string,
|
||||
index?: number,
|
||||
};
|
||||
|
||||
const ContainerRemover = ({
|
||||
close,
|
||||
containerKey,
|
||||
displayName,
|
||||
index,
|
||||
}: ContainerRemoverProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
sidebarItemInfo,
|
||||
closeLibrarySidebar,
|
||||
} = useSidebarContext();
|
||||
const { containerId } = useLibraryContext();
|
||||
const { containerId, showOnlyPublished } = useLibraryContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const removeContainerMutation = useRemoveContainerChildren(containerId);
|
||||
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
|
||||
const { data: container, isPending } = useContainer(containerId);
|
||||
// Use update api for children if duplicates are present to avoid removing all instances of the child
|
||||
const { data: children } = useContainerChildren<Container>(containerId, showOnlyPublished);
|
||||
const childrenUsageIds = children?.map((child) => child.id);
|
||||
const hasDuplicates = (childrenUsageIds?.filter((child) => child === containerKey).length || 0) > 1;
|
||||
const itemType = getBlockType(containerKey);
|
||||
|
||||
const removeWarningTitle = intl.formatMessage(messages.removeContainerWarningTitle, {
|
||||
@@ -50,9 +60,19 @@ const ContainerRemover = ({
|
||||
|
||||
const onRemove = useCallback(async () => {
|
||||
try {
|
||||
await removeContainerMutation.mutateAsync([containerKey]);
|
||||
if (sidebarItemInfo?.id === containerKey) {
|
||||
closeLibrarySidebar();
|
||||
if (hasDuplicates && childrenUsageIds && typeof index !== 'undefined') {
|
||||
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== containerKey || idx !== index);
|
||||
await updateContainerChildrenMutation.mutateAsync(updatedKeys);
|
||||
// istanbul ignore if
|
||||
if (sidebarItemInfo?.id === containerKey && sidebarItemInfo?.index === index) {
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
} else {
|
||||
await removeContainerMutation.mutateAsync([containerKey]);
|
||||
// istanbul ignore if
|
||||
if (sidebarItemInfo?.id === containerKey) {
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
}
|
||||
showToast(removeSuccess);
|
||||
} catch (e) {
|
||||
@@ -63,12 +83,16 @@ const ContainerRemover = ({
|
||||
}, [
|
||||
containerKey,
|
||||
removeContainerMutation,
|
||||
updateContainerChildrenMutation,
|
||||
sidebarItemInfo,
|
||||
closeLibrarySidebar,
|
||||
showToast,
|
||||
removeSuccess,
|
||||
removeError,
|
||||
close,
|
||||
hasDuplicates,
|
||||
childrenUsageIds,
|
||||
index,
|
||||
]);
|
||||
|
||||
// istanbul ignore if: loading state
|
||||
|
||||
@@ -66,6 +66,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Container actions menu',
|
||||
description: 'Alt/title text for the container card menu button.',
|
||||
},
|
||||
containerSettingsMsg: {
|
||||
id: 'course-authoring.library-authoring.container.settings.alert.message',
|
||||
defaultMessage: 'Section settings cannot be configured within Libraries and must be set within a course. In a future release, Libraries may support configuring some settings.',
|
||||
description: 'Temporary message for settings tab being',
|
||||
},
|
||||
menuOpen: {
|
||||
id: 'course-authoring.library-authoring.menu.open',
|
||||
defaultMessage: 'Open',
|
||||
|
||||
@@ -435,7 +435,7 @@ describe('<CreateLibrary />', () => {
|
||||
sections: 8,
|
||||
subsections: 12,
|
||||
units: 20,
|
||||
createdOnServer: '2025-01-01T10:00:00Z',
|
||||
createdOnServer: 'test.com',
|
||||
createdAt: '2025-01-01T10:00:00Z',
|
||||
createdBy: {
|
||||
username: 'testuser',
|
||||
@@ -478,7 +478,67 @@ describe('<CreateLibrary />', () => {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
|
||||
expect(screen.getByText('TestOrg / test-archive')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Contains 15 Components/i)).toBeInTheDocument();
|
||||
// Testing the archive details summary
|
||||
expect(screen.getByText(/Contains 8 sections, 12 subsections, 20 units, 15 components/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Created on instance test.com/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/by user test@example.com/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows success state without instance and user email information', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
|
||||
const mockResult = {
|
||||
learningPackageId: 123,
|
||||
title: 'Test Archive Library',
|
||||
org: 'TestOrg',
|
||||
slug: 'test-archive',
|
||||
key: 'TestOrg/test-archive',
|
||||
archiveKey: 'archive-key',
|
||||
containers: 5,
|
||||
components: 15,
|
||||
collections: 3,
|
||||
sections: 8,
|
||||
subsections: 12,
|
||||
units: 20,
|
||||
createdOnServer: null,
|
||||
createdAt: '2025-01-01T10:00:00Z',
|
||||
createdBy: null,
|
||||
};
|
||||
|
||||
// Pre-set the restore status to succeeded
|
||||
mockRestoreStatusData = {
|
||||
state: LibraryRestoreStatus.Succeeded,
|
||||
result: mockResult,
|
||||
error: null,
|
||||
errorLog: null,
|
||||
};
|
||||
|
||||
// Mock the restore mutation to return a task ID
|
||||
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
|
||||
onSuccess({ taskId: 'task-123' });
|
||||
});
|
||||
|
||||
render(<CreateLibrary />);
|
||||
|
||||
// Switch to archive mode
|
||||
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
|
||||
await user.click(createFromArchiveBtn);
|
||||
|
||||
// Upload a file to trigger the restore process
|
||||
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
|
||||
const dropzone = screen.getByRole('presentation', { hidden: true });
|
||||
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
|
||||
|
||||
await user.upload(input, file);
|
||||
|
||||
// Wait for the restore to complete and archive details to be shown
|
||||
await waitFor(() => {
|
||||
// Testing the archive details summary without instance and user email
|
||||
expect(screen.getByText(/Contains 8 sections, 12 subsections, 20 units, 15 components/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Created on instance/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/by user/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import {
|
||||
AccessTime,
|
||||
Widgets,
|
||||
PersonOutline,
|
||||
} from '@openedx/paragon/icons';
|
||||
import AlertError from '@src/generic/alert-error';
|
||||
import classNames from 'classnames';
|
||||
@@ -203,22 +204,38 @@ export const CreateLibrary = ({
|
||||
<Card.Body>
|
||||
<div className="d-flex flex-column flex-md-row justify-content-between align-items-start p-4 text-primary-700">
|
||||
<div className="flex-grow-1 mb-4 mb-md-0">
|
||||
<span className="mb-2">{restoreStatus.result.title}</span>
|
||||
<span className="mb-4">{restoreStatus.result.title}</span>
|
||||
<p className="small mb-0">
|
||||
{restoreStatus.result.org} / {restoreStatus.result.slug}
|
||||
</p>
|
||||
</div>
|
||||
<div className="d-flex flex-column gap-2 align-items-md-end">
|
||||
<div className="d-flex flex-column gap-2 align-items-md-start">
|
||||
<div className="d-flex align-items-md-center gap-2">
|
||||
<Icon src={Widgets} style={{ width: '20px', height: '20px', marginRight: '8px' }} />
|
||||
<Icon src={Widgets} className="mr-2" style={{ width: '20px', height: '20px' }} />
|
||||
<span className="x-small">
|
||||
{intl.formatMessage(messages.archiveComponentsCount, {
|
||||
count: restoreStatus.result.components,
|
||||
countSections: restoreStatus.result.sections,
|
||||
countSubsections: restoreStatus.result.subsections,
|
||||
countUnits: restoreStatus.result.units,
|
||||
countComponents: restoreStatus.result.components,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{
|
||||
(restoreStatus.result.createdBy?.email && restoreStatus.result.createdOnServer) && (
|
||||
<div className="d-flex align-items-md-center gap-2">
|
||||
<Icon src={PersonOutline} className="mr-2" style={{ width: '20px', height: '20px' }} />
|
||||
<span className="x-small">
|
||||
{intl.formatMessage(messages.archiveRestoredCreatedBy, {
|
||||
createdBy: restoreStatus.result.createdBy?.email,
|
||||
server: restoreStatus.result.createdOnServer,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="d-flex align-items-md-center gap-2">
|
||||
<Icon src={AccessTime} style={{ width: '20px', height: '20px', marginRight: '8px' }} />
|
||||
<Icon src={AccessTime} className="mr-2" style={{ width: '20px', height: '20px' }} />
|
||||
<span className="x-small">
|
||||
{intl.formatMessage(messages.archiveBackupDate, {
|
||||
date: new Date(restoreStatus.result.createdAt).toLocaleDateString(),
|
||||
@@ -236,7 +253,8 @@ export const CreateLibrary = ({
|
||||
|
||||
{(restoreTaskId || isError || restoreMutation.isError) && (
|
||||
<div className="mb-4">
|
||||
{restoreStatus?.state === LibraryRestoreStatus.Pending && (
|
||||
{(restoreStatus?.state === LibraryRestoreStatus.Pending
|
||||
|| restoreStatus?.state === LibraryRestoreStatus.InProgress) && (
|
||||
<Alert variant="info">
|
||||
{intl.formatMessage(messages.restoreInProgress)}
|
||||
</Alert>
|
||||
|
||||
@@ -173,6 +173,34 @@ describe('create library apiHooks', () => {
|
||||
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
|
||||
});
|
||||
|
||||
it('should handle in-progress status with refetch interval', async () => {
|
||||
const taskId = 'in-progress-task-id';
|
||||
const inProgressResult = {
|
||||
state: LibraryRestoreStatus.InProgress,
|
||||
result: null,
|
||||
error: null,
|
||||
error_log: null,
|
||||
};
|
||||
|
||||
const expectedResult = {
|
||||
state: LibraryRestoreStatus.InProgress,
|
||||
result: null,
|
||||
error: null,
|
||||
errorLog: null,
|
||||
};
|
||||
|
||||
axiosMock.onGet(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`).reply(200, inProgressResult);
|
||||
|
||||
const { result } = renderHook(() => useGetLibraryRestoreStatus(taskId), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBeFalsy();
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(expectedResult);
|
||||
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
|
||||
});
|
||||
|
||||
it('should handle failed status', async () => {
|
||||
const taskId = 'failed-task-id';
|
||||
const failedResult = {
|
||||
|
||||
@@ -41,7 +41,10 @@ export const useGetLibraryRestoreStatus = (taskId: string) => useQuery<GetLibrar
|
||||
queryKey: libraryRestoreQueryKeys.restoreStatus(taskId),
|
||||
queryFn: () => getLibraryRestoreStatus(taskId),
|
||||
enabled: !!taskId, // Only run the query if taskId is provided
|
||||
refetchInterval: (query) => (query.state.data?.state === LibraryRestoreStatus.Pending ? 2000 : false),
|
||||
refetchInterval: (query) => (
|
||||
(query.state.data?.state === LibraryRestoreStatus.Pending
|
||||
|| query.state.data?.state === LibraryRestoreStatus.InProgress
|
||||
) ? 2000 : false),
|
||||
});
|
||||
|
||||
export const useCreateLibraryRestore = () => useMutation<CreateLibraryRestoreResponse, Error, File>({
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface GetLibraryRestoreStatusResponse {
|
||||
|
||||
export enum LibraryRestoreStatus {
|
||||
Pending = 'Pending',
|
||||
InProgress = 'In Progress',
|
||||
Succeeded = 'Succeeded',
|
||||
Failed = 'Failed',
|
||||
}
|
||||
|
||||
@@ -120,8 +120,13 @@ const messages = defineMessages({
|
||||
},
|
||||
archiveComponentsCount: {
|
||||
id: 'course-authoring.library-authoring.create-library.form.archive.components-count',
|
||||
defaultMessage: 'Contains {count} Components',
|
||||
description: 'Text showing the number of components in the restored archive.',
|
||||
defaultMessage: 'Contains {countSections} sections, {countSubsections} subsections, {countUnits} units, {countComponents} components',
|
||||
description: 'Text showing the number of sections, subsections, units, and components in the restored archive.',
|
||||
},
|
||||
archiveRestoredCreatedBy: {
|
||||
id: 'course-authoring.library-authoring.create-library.form.archive.restored-created-by',
|
||||
defaultMessage: 'Created on instance {server}, by user {createdBy}',
|
||||
description: 'Text showing who restored the archive.',
|
||||
},
|
||||
archiveBackupDate: {
|
||||
id: 'course-authoring.library-authoring.create-library.form.archive.backup-date',
|
||||
|
||||
@@ -603,6 +603,7 @@ mockGetContainerMetadata.applyMock = () => {
|
||||
export async function mockGetContainerChildren(containerId: string): Promise<api.LibraryBlockMetadata[]> {
|
||||
let numChildren: number;
|
||||
let blockType = 'html';
|
||||
let addDuplicate = false;
|
||||
switch (containerId) {
|
||||
case mockGetContainerMetadata.unitId:
|
||||
case mockGetContainerMetadata.sectionId:
|
||||
@@ -615,6 +616,10 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
|
||||
case mockGetContainerChildren.sixChildren:
|
||||
numChildren = 6;
|
||||
break;
|
||||
case mockGetContainerChildren.unitIdWithDuplicate:
|
||||
numChildren = 3;
|
||||
addDuplicate = true;
|
||||
break;
|
||||
default:
|
||||
numChildren = 0;
|
||||
break;
|
||||
@@ -630,19 +635,22 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
|
||||
name = blockType;
|
||||
typeNamespace = 'lct';
|
||||
}
|
||||
return Promise.resolve(
|
||||
Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
|
||||
{
|
||||
...child,
|
||||
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
|
||||
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
|
||||
displayName: `${name} block ${idx}`,
|
||||
publishedDisplayName: `${name} block published ${idx}`,
|
||||
blockType,
|
||||
}
|
||||
)),
|
||||
);
|
||||
let result = Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
|
||||
{
|
||||
...child,
|
||||
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
|
||||
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
|
||||
displayName: `${name} block ${idx}`,
|
||||
publishedDisplayName: `${name} block published ${idx}`,
|
||||
blockType,
|
||||
}
|
||||
));
|
||||
if (addDuplicate) {
|
||||
result = [...result, result[0]];
|
||||
}
|
||||
return Promise.resolve(result);
|
||||
}
|
||||
mockGetContainerChildren.unitIdWithDuplicate = 'lct:org1:Demo_Course:unit:unit-duplicate';
|
||||
mockGetContainerChildren.fiveChildren = 'lct:org1:Demo_Course:unit:unit-5';
|
||||
mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6';
|
||||
mockGetContainerChildren.childTemplate = {
|
||||
|
||||
@@ -702,10 +702,10 @@ export async function restoreContainer(containerId: string) {
|
||||
/**
|
||||
* Fetch a library container's children's metadata.
|
||||
*/
|
||||
export async function getLibraryContainerChildren(
|
||||
export async function getLibraryContainerChildren<ChildType = LibraryBlockMetadata | Container>(
|
||||
containerId: string,
|
||||
published: boolean = false,
|
||||
): Promise<LibraryBlockMetadata[] | Container[]> {
|
||||
): Promise<ChildType[]> {
|
||||
const { data } = await getAuthenticatedHttpClient().get(
|
||||
getLibraryContainerChildrenApiUrl(containerId, published),
|
||||
);
|
||||
|
||||
@@ -329,10 +329,11 @@ describe('library api hooks', () => {
|
||||
// Keys should be invalidated:
|
||||
// 1. library
|
||||
// 2. containerChildren
|
||||
// 3. containerHierarchy
|
||||
// 4 & 5. subsections
|
||||
// 6 all hierarchies
|
||||
expect(spy).toHaveBeenCalledTimes(6);
|
||||
// 3. container
|
||||
// 4. containerHierarchy
|
||||
// 5 & 6. subsections
|
||||
// 7 all hierarchies
|
||||
expect(spy).toHaveBeenCalledTimes(7);
|
||||
});
|
||||
|
||||
describe('publishContainer', () => {
|
||||
|
||||
@@ -736,32 +736,35 @@ export const useRestoreContainer = (containerId: string) => {
|
||||
/**
|
||||
* Get the metadata and children for a container in a library
|
||||
*/
|
||||
export const useContainerChildren = (containerId?: string, published: boolean = false) => (
|
||||
useQuery({
|
||||
enabled: !!containerId,
|
||||
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
|
||||
queryFn: () => api.getLibraryContainerChildren(containerId!, published),
|
||||
structuralSharing: (
|
||||
oldData: api.LibraryBlockMetadata[] | api.Container[],
|
||||
newData: api.LibraryBlockMetadata[] | api.Container[],
|
||||
) => {
|
||||
export const useContainerChildren = <ChildType extends {
|
||||
id: string;
|
||||
isNew?: boolean;
|
||||
} = api.LibraryBlockMetadata | api.Container>(
|
||||
containerId?: string,
|
||||
published: boolean = false,
|
||||
) => (
|
||||
useQuery({
|
||||
enabled: !!containerId,
|
||||
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
|
||||
queryFn: () => api.getLibraryContainerChildren<ChildType>(containerId!, published),
|
||||
structuralSharing: (oldData: ChildType[], newData: ChildType[]) => {
|
||||
// This just sets `isNew` flag to new children components
|
||||
if (oldData) {
|
||||
const oldDataIds = oldData.map((obj) => obj.id);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
newData = newData.map((newObj) => {
|
||||
if (!oldDataIds.includes(newObj.id)) {
|
||||
if (oldData) {
|
||||
const oldDataIds = oldData.map((obj) => obj.id);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
newData = newData.map((newObj) => {
|
||||
if (!oldDataIds.includes(newObj.id)) {
|
||||
// Set isNew = true if we have new child on refetch
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
newObj.isNew = true;
|
||||
}
|
||||
return newObj;
|
||||
});
|
||||
}
|
||||
return replaceEqualDeep(oldData, newData);
|
||||
},
|
||||
})
|
||||
);
|
||||
newObj.isNew = true;
|
||||
}
|
||||
return newObj;
|
||||
});
|
||||
}
|
||||
return replaceEqualDeep(oldData, newData);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* If you work with `useContentFromSearchIndex`, you can use this
|
||||
@@ -814,6 +817,8 @@ export const useAddItemsToContainer = (containerId?: string) => {
|
||||
// It would be complex to bring the entire hierarchy and only update the items within that hierarchy.
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(undefined) });
|
||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentHierarchy(undefined) });
|
||||
// Invalidate the container to update its publish status
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
|
||||
|
||||
const containerType = getBlockType(containerId);
|
||||
if (containerType === 'section') {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
waitFor,
|
||||
initializeMocks,
|
||||
} from '@src/testUtils';
|
||||
import { validateUserPermissions } from '@src/authz/data/api';
|
||||
import { mockContentLibrary } from '../data/api.mocks';
|
||||
import { getCommitLibraryChangesUrl } from '../data/api';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
@@ -33,6 +34,7 @@ const render = (libraryId: string = mockLibraryId) => baseRender(<LibraryInfo />
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string) => void;
|
||||
let validateUserPermissionsMock: jest.SpiedFunction<typeof validateUserPermissions>;
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
|
||||
@@ -41,6 +43,9 @@ describe('<LibraryInfo />', () => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
|
||||
|
||||
validateUserPermissionsMock.mockResolvedValue({ canPublish: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import messages from './messages';
|
||||
|
||||
const LibraryPublishStatus = () => {
|
||||
const intl = useIntl();
|
||||
const { libraryData, readOnly } = useLibraryContext();
|
||||
const { libraryData, readOnly, canPublish } = useLibraryContext();
|
||||
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
|
||||
|
||||
const commitLibraryChanges = useCommitLibraryChanges();
|
||||
@@ -51,10 +51,10 @@ const LibraryPublishStatus = () => {
|
||||
<>
|
||||
<StatusWidget
|
||||
{...libraryData}
|
||||
onCommit={!readOnly ? commit : undefined}
|
||||
onCommit={!readOnly && canPublish ? commit : undefined}
|
||||
onCommitStatus={commitLibraryChanges.status}
|
||||
onCommitLabel={intl.formatMessage(messages.publishLibraryButtonLabel)}
|
||||
onRevert={!readOnly ? openConfirmModal : undefined}
|
||||
onRevert={!readOnly && canPublish ? openConfirmModal : undefined}
|
||||
/>
|
||||
<DeleteModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
|
||||
@@ -33,13 +33,13 @@ export const ROUTES = {
|
||||
COLLECTION: '/collection/:collectionId/:selectedItemId?',
|
||||
// LibrarySectionPage route:
|
||||
// * with a selected containerId and an optionally selected subsection.
|
||||
SECTION: '/section/:containerId/:selectedItemId?',
|
||||
SECTION: '/section/:containerId/:selectedItemId?/:index?',
|
||||
// LibrarySubsectionPage route:
|
||||
// * with a selected containerId and an optionally selected unit.
|
||||
SUBSECTION: '/subsection/:containerId/:selectedItemId?',
|
||||
SUBSECTION: '/subsection/:containerId/:selectedItemId?/:index?',
|
||||
// LibraryUnitPage route:
|
||||
// * with a selected containerId and/or an optionally selected componentId.
|
||||
UNIT: '/unit/:containerId/:selectedItemId?',
|
||||
UNIT: '/unit/:containerId/:selectedItemId?/:index?',
|
||||
// LibraryBackupPage route:
|
||||
BACKUP: '/backup',
|
||||
};
|
||||
@@ -60,6 +60,7 @@ export type NavigateToData = {
|
||||
collectionId?: string,
|
||||
containerId?: string,
|
||||
contentType?: ContentType,
|
||||
index?: number,
|
||||
};
|
||||
|
||||
export type LibraryRoutesData = {
|
||||
@@ -122,6 +123,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
||||
collectionId,
|
||||
containerId,
|
||||
contentType,
|
||||
index,
|
||||
}: NavigateToData = {}) => {
|
||||
const routeParams = {
|
||||
...params,
|
||||
@@ -129,6 +131,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
||||
...((selectedItemId !== undefined) && { selectedItemId }),
|
||||
...((containerId !== undefined) && { containerId }),
|
||||
...((collectionId !== undefined) && { collectionId }),
|
||||
...((index !== undefined) && { index }),
|
||||
};
|
||||
let route: string;
|
||||
|
||||
@@ -230,6 +233,12 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
||||
route = ROUTES.HOME;
|
||||
}
|
||||
|
||||
// Since index is just the order number of the selectedItemId
|
||||
// clear index if selectedItemId is undefined
|
||||
if (routeParams.selectedItemId === undefined) {
|
||||
routeParams.index = undefined;
|
||||
}
|
||||
|
||||
// Also remove the `sa` (sidebar action) search param if it exists.
|
||||
searchParams.delete('sa');
|
||||
|
||||
|
||||
@@ -39,9 +39,12 @@ interface LibraryContainerMetadataWithUniqueId extends Container {
|
||||
|
||||
interface ContainerRowProps extends LibraryContainerChildrenProps {
|
||||
container: LibraryContainerMetadataWithUniqueId;
|
||||
index?: number;
|
||||
}
|
||||
|
||||
const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps) => {
|
||||
const ContainerRow = ({
|
||||
containerKey, container, readOnly, index,
|
||||
}: ContainerRowProps) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const updateMutation = useUpdateContainer(container.originalId, containerKey);
|
||||
@@ -112,6 +115,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
|
||||
<ContainerMenu
|
||||
containerKey={container.originalId}
|
||||
displayName={container.displayName}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
@@ -148,7 +152,7 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useContainerChildren(containerKey, showOnlyPublished);
|
||||
} = useContainerChildren<Container>(containerKey, showOnlyPublished);
|
||||
|
||||
useEffect(() => {
|
||||
// Create new ids which are unique using index.
|
||||
@@ -164,14 +168,18 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
||||
return setOrderedChildren(newChildren || []);
|
||||
}, [children, setOrderedChildren]);
|
||||
|
||||
const handleChildClick = useCallback((child: LibraryContainerMetadataWithUniqueId, numberOfClicks: number) => {
|
||||
const handleChildClick = useCallback((
|
||||
child: LibraryContainerMetadataWithUniqueId,
|
||||
numberOfClicks: number,
|
||||
index: number,
|
||||
) => {
|
||||
if (readOnly) {
|
||||
// don't allow interaction if rendered as preview
|
||||
return;
|
||||
}
|
||||
const doubleClicked = numberOfClicks > 1;
|
||||
if (!doubleClicked) {
|
||||
openItemSidebar(child.originalId, SidebarBodyItemId.ContainerInfo);
|
||||
openItemSidebar(child.originalId, SidebarBodyItemId.ContainerInfo, index);
|
||||
} else {
|
||||
navigateTo({ containerId: child.originalId });
|
||||
}
|
||||
@@ -215,7 +223,7 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
||||
activeId={activeDraggingId}
|
||||
setActiveId={setActiveDraggingId}
|
||||
>
|
||||
{orderedChildren?.map((child) => (
|
||||
{orderedChildren?.map((child, index) => (
|
||||
// A container can have multiple instances of the same block
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<SortableItem
|
||||
@@ -229,19 +237,20 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
||||
borderLeft: '8px solid #E1DDDB',
|
||||
}}
|
||||
isClickable={!readOnly}
|
||||
onClick={(e) => skipIfUnwantedTarget(e, (event) => handleChildClick(child, event.detail))}
|
||||
onClick={(e) => skipIfUnwantedTarget(e, (event) => handleChildClick(child, event.detail, index))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleChildClick(child, 1);
|
||||
handleChildClick(child, 1, index);
|
||||
}
|
||||
}}
|
||||
disabled={readOnly || libReadOnly}
|
||||
cardClassName={sidebarItemInfo?.id === child.originalId ? 'selected' : undefined}
|
||||
cardClassName={sidebarItemInfo?.id === child.originalId && sidebarItemInfo?.index === index ? 'selected' : undefined}
|
||||
actions={(
|
||||
<ContainerRow
|
||||
containerKey={containerKey}
|
||||
container={child}
|
||||
readOnly={readOnly || libReadOnly}
|
||||
index={index}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -46,13 +46,14 @@ interface LibraryBlockMetadataWithUniqueId extends LibraryBlockMetadata {
|
||||
}
|
||||
|
||||
interface ComponentBlockProps {
|
||||
index: number;
|
||||
block: LibraryBlockMetadataWithUniqueId;
|
||||
readOnly?: boolean;
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
/** Component header */
|
||||
const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
|
||||
const BlockHeader = ({ block, index, readOnly }: ComponentBlockProps) => {
|
||||
const intl = useIntl();
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
@@ -118,17 +119,18 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
|
||||
</Badge>
|
||||
)}
|
||||
<TagCount size="sm" count={block.tagsCount} onClick={readOnly ? undefined : jumpToManageTags} />
|
||||
{!readOnly && <ComponentMenu usageKey={block.originalId} />}
|
||||
{!readOnly && <ComponentMenu index={index} usageKey={block.originalId} />}
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** ComponentBlock to render preview of given component under Unit */
|
||||
const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) => {
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const ComponentBlock = ({
|
||||
block, readOnly, isDragging, index,
|
||||
}: ComponentBlockProps) => {
|
||||
const { showOnlyPublished, openComponentEditor } = useLibraryContext();
|
||||
|
||||
const { openComponentEditor } = useLibraryContext();
|
||||
const { sidebarItemInfo, openItemSidebar } = useSidebarContext();
|
||||
|
||||
const handleComponentSelection = useCallback((numberOfClicks: number) => {
|
||||
@@ -136,7 +138,11 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
|
||||
// don't allow interaction if rendered as preview
|
||||
return;
|
||||
}
|
||||
openItemSidebar(block.originalId, SidebarBodyItemId.ComponentInfo);
|
||||
openItemSidebar(
|
||||
block.originalId,
|
||||
SidebarBodyItemId.ComponentInfo,
|
||||
index,
|
||||
);
|
||||
const canEdit = canEditComponent(block.originalId);
|
||||
if (numberOfClicks > 1 && canEdit) {
|
||||
// Open editor on double click.
|
||||
@@ -174,7 +180,7 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
|
||||
<SortableItem
|
||||
id={block.id}
|
||||
componentStyle={getComponentStyle()}
|
||||
actions={<BlockHeader block={block} readOnly={readOnly} />}
|
||||
actions={<BlockHeader block={block} index={index} readOnly={readOnly} />}
|
||||
actionStyle={{
|
||||
borderRadius: '8px 8px 0px 0px',
|
||||
padding: '0.5rem 1rem',
|
||||
@@ -189,7 +195,7 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
|
||||
}
|
||||
}}
|
||||
disabled={readOnly}
|
||||
cardClassName={sidebarItemInfo?.id === block.originalId ? 'selected' : undefined}
|
||||
cardClassName={sidebarItemInfo?.id === block.originalId && sidebarItemInfo?.index === index ? 'selected' : undefined}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||
<div
|
||||
@@ -236,7 +242,7 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useContainerChildren(unitId, showOnlyPublished);
|
||||
} = useContainerChildren<LibraryBlockMetadata>(unitId, showOnlyPublished);
|
||||
|
||||
const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => {
|
||||
if (!newOrder) {
|
||||
@@ -294,6 +300,7 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`${block.originalId}-${idx}-${block.modified}`}
|
||||
block={block}
|
||||
index={idx}
|
||||
isDragging={hidePreviewFor === block.id}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
@@ -381,15 +381,44 @@ describe('<LibraryUnitPage />', () => {
|
||||
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
||||
|
||||
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||
axiosMock.onPost(restoreUrl).reply(200);
|
||||
axiosMock.onPatch(restoreUrl).reply(200);
|
||||
// restore collection
|
||||
restoreFn();
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
expect(axiosMock.history.patch.length).toEqual(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
|
||||
});
|
||||
|
||||
it('should remove only one instance of component even if it is present multiple times in this page', async () => {
|
||||
const user = userEvent.setup();
|
||||
const url = getLibraryContainerChildrenApiUrl(mockGetContainerChildren.unitIdWithDuplicate);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
renderLibraryUnitPage(mockGetContainerChildren.unitIdWithDuplicate);
|
||||
|
||||
expect((await screen.findAllByText('text block 0')).length).toEqual(2);
|
||||
const menu = (await screen.findAllByRole('button', { name: /component actions menu/i }))[0];
|
||||
await user.click(menu);
|
||||
|
||||
const removeButton = await screen.findByText('Remove from unit');
|
||||
await user.click(removeButton);
|
||||
|
||||
const modal = await screen.findByRole('dialog', { name: 'Remove Component' });
|
||||
expect(modal).toBeVisible();
|
||||
|
||||
const confirmButton = await within(modal).findByRole('button', { name: 'Remove' });
|
||||
await user.click(confirmButton);
|
||||
const result = await mockGetContainerChildren(mockGetContainerChildren.unitIdWithDuplicate);
|
||||
const resultIds = result.map((obj) => obj.id);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.patch[0].url).toEqual(url);
|
||||
});
|
||||
// Only the first element is removed even though the last element has the same id.
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data).usage_keys).toEqual(resultIds.slice(1));
|
||||
await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('should show error on remove a component', async () => {
|
||||
const user = userEvent.setup();
|
||||
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||
@@ -444,11 +473,11 @@ describe('<LibraryUnitPage />', () => {
|
||||
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
||||
|
||||
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||
axiosMock.onPost(restoreUrl).reply(404);
|
||||
axiosMock.onPatch(restoreUrl).reply(404);
|
||||
// restore collection
|
||||
restoreFn();
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
expect(axiosMock.history.patch.length).toEqual(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Failed to undo remove component operation');
|
||||
});
|
||||
|
||||
@@ -147,7 +147,7 @@ describe('DiscussionsSettings', () => {
|
||||
// content has been loaded - prior to proceeding with our expectations.
|
||||
await waitForElementToBeRemoved(screen.queryByRole('status'));
|
||||
|
||||
await user.click(queryByLabelText(container, 'Select edX'));
|
||||
await user.click(queryByLabelText(container, 'Select Open edX (legacy)'));
|
||||
await user.click(queryByText(container, messages.nextButton.defaultMessage));
|
||||
|
||||
expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
|
||||
|
||||
@@ -107,13 +107,13 @@ const messages = defineMessages({
|
||||
},
|
||||
'appName-legacy': {
|
||||
id: 'authoring.discussions.appConfigForm.appName-legacy',
|
||||
defaultMessage: 'edX',
|
||||
description: 'The name of the Legacy edX Discussions app.',
|
||||
defaultMessage: 'Open edX (legacy)',
|
||||
description: 'The name of the Legacy Open edX Discussions app.',
|
||||
},
|
||||
'appName-openedx': {
|
||||
id: 'authoring.discussions.appConfigForm.appName-openedx',
|
||||
defaultMessage: 'edX (new)',
|
||||
description: 'The name of the new edX Discussions app.',
|
||||
defaultMessage: 'Open edX',
|
||||
description: 'The name of the new Open edX Discussions app.',
|
||||
},
|
||||
divisionByGroup: {
|
||||
id: 'authoring.discussions.builtIn.divisionByGroup',
|
||||
|
||||
@@ -117,7 +117,6 @@ const ScheduleAndDetails = ({ courseId }) => {
|
||||
license,
|
||||
language,
|
||||
subtitle,
|
||||
overview,
|
||||
duration,
|
||||
selfPaced,
|
||||
startDate,
|
||||
@@ -128,7 +127,6 @@ const ScheduleAndDetails = ({ courseId }) => {
|
||||
instructorInfo,
|
||||
enrollmentStart,
|
||||
shortDescription,
|
||||
aboutSidebarHtml,
|
||||
preRequisiteCourses,
|
||||
entranceExamEnabled,
|
||||
courseImageAssetPath,
|
||||
@@ -140,6 +138,12 @@ const ScheduleAndDetails = ({ courseId }) => {
|
||||
} = editedValues;
|
||||
|
||||
useScrollToHashElement({ isLoading });
|
||||
// No need to get overview and aboutSidebarHtml from editedValues
|
||||
// As updating them re-renders TinyMCE
|
||||
// Which causes issues with TinyMCE editor cursor position
|
||||
// https://www.tiny.cloud/docs/tinymce/5/react/#initialvalue
|
||||
const { overview: initialOverview } = courseDetails || {};
|
||||
const { aboutSidebarHtml: initialAboutSidebarHtml } = courseDetails || {};
|
||||
|
||||
if (isLoading) {
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
@@ -277,12 +281,12 @@ const ScheduleAndDetails = ({ courseId }) => {
|
||||
)}
|
||||
<IntroducingSection
|
||||
title={title}
|
||||
overview={overview}
|
||||
overview={initialOverview}
|
||||
duration={duration}
|
||||
subtitle={subtitle}
|
||||
introVideo={introVideo}
|
||||
description={description}
|
||||
aboutSidebarHtml={aboutSidebarHtml}
|
||||
aboutSidebarHtml={initialAboutSidebarHtml}
|
||||
shortDescription={shortDescription}
|
||||
aboutPageEditable={aboutPageEditable}
|
||||
sidebarHtmlEnabled={sidebarHtmlEnabled}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Routes,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import * as authzApi from '@src/authz/data/api';
|
||||
import { ToastContext, type ToastContextData } from './generic/toast-context';
|
||||
import initializeReduxStore, { type DeprecatedReduxState } from './store';
|
||||
import { getApiWaffleFlagsUrl } from './data/api';
|
||||
@@ -31,6 +32,7 @@ import { getApiWaffleFlagsUrl } from './data/api';
|
||||
let reduxStore: Store;
|
||||
let queryClient: QueryClient;
|
||||
let axiosMock: MockAdapter;
|
||||
let validateUserPermissionsMock: jest.SpiedFunction<typeof authzApi.validateUserPermissions>;
|
||||
|
||||
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
|
||||
let mockToastContext: ToastContextData = {
|
||||
@@ -192,12 +194,17 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
|
||||
// Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history.
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock user permissions to avoid breaking tests that monitor axios calls
|
||||
// If needed, override the mockResolvedValue in your test
|
||||
validateUserPermissionsMock = jest.spyOn(authzApi, 'validateUserPermissions').mockResolvedValue({});
|
||||
|
||||
return {
|
||||
reduxStore,
|
||||
axiosMock,
|
||||
mockShowToast: mockToastContext.showToast,
|
||||
mockToastAction: mockToastContext.toastAction,
|
||||
queryClient,
|
||||
validateUserPermissionsMock,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user