Compare commits
14 Commits
master
...
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 { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
|
||||||
import { BoldText } from '@src/utils';
|
import { BoldText } from '@src/utils';
|
||||||
|
|
||||||
|
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
||||||
import ChildrenPreview from './ChildrenPreview';
|
import ChildrenPreview from './ChildrenPreview';
|
||||||
import ContainerRow from './ContainerRow';
|
import ContainerRow from './ContainerRow';
|
||||||
import { useCourseContainerChildren } from './data/apiHooks';
|
import { useCourseContainerChildren } from './data/apiHooks';
|
||||||
@@ -60,7 +61,7 @@ const CompareContainersWidgetInner = ({
|
|||||||
data: libData,
|
data: libData,
|
||||||
isError: isLibError,
|
isError: isLibError,
|
||||||
error: libError,
|
error: libError,
|
||||||
} = useContainerChildren(state === 'removed' ? undefined : upstreamBlockId, true);
|
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
|
||||||
const {
|
const {
|
||||||
data: containerData,
|
data: containerData,
|
||||||
isError: isContainerTitleError,
|
isError: isContainerTitleError,
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ const UnitButton = ({
|
|||||||
unitId,
|
unitId,
|
||||||
className,
|
className,
|
||||||
showTitle,
|
showTitle,
|
||||||
|
isActive, // passed from parent (SequenceNavigationTabs)
|
||||||
}) => {
|
}) => {
|
||||||
const courseId = useSelector(getCourseId);
|
const courseId = useSelector(getCourseId);
|
||||||
const sequenceId = useSelector(getSequenceId);
|
const sequenceId = useSelector(getSequenceId);
|
||||||
|
|
||||||
const unit = useSelector((state) => state.models.units[unitId]);
|
const unit = useSelector((state) => state.models.units[unitId]);
|
||||||
|
const { title, contentType } = unit || {};
|
||||||
const { title, contentType, isActive } = unit || {};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -37,11 +37,13 @@ UnitButton.propTypes = {
|
|||||||
className: PropTypes.string,
|
className: PropTypes.string,
|
||||||
showTitle: PropTypes.bool,
|
showTitle: PropTypes.bool,
|
||||||
unitId: PropTypes.string.isRequired,
|
unitId: PropTypes.string.isRequired,
|
||||||
|
isActive: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
UnitButton.defaultProps = {
|
UnitButton.defaultProps = {
|
||||||
className: undefined,
|
className: undefined,
|
||||||
showTitle: false,
|
showTitle: false,
|
||||||
|
isActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default UnitButton;
|
export default UnitButton;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
fetchCourseVerticalChildrenData,
|
fetchCourseVerticalChildrenData,
|
||||||
getCourseOutlineInfoQuery,
|
getCourseOutlineInfoQuery,
|
||||||
patchUnitItemQuery,
|
patchUnitItemQuery,
|
||||||
|
updateCourseUnitSidebar,
|
||||||
} from './data/thunk';
|
} from './data/thunk';
|
||||||
import {
|
import {
|
||||||
getCanEdit,
|
getCanEdit,
|
||||||
@@ -231,8 +232,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
|||||||
// edits the component using editor which has a separate store
|
// edits the component using editor which has a separate store
|
||||||
/* istanbul ignore next */
|
/* istanbul ignore next */
|
||||||
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
|
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
|
||||||
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
|
dispatch(updateCourseUnitSidebar(blockId));
|
||||||
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
|
|
||||||
localStorage.removeItem(event.key);
|
localStorage.removeItem(event.key);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// lint is disabled for this file due to strict spacing
|
// lint is disabled for this file due to strict spacing
|
||||||
|
|
||||||
export const checkboxesOLXWithFeedbackAndHintsOLX = {
|
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>
|
<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>
|
<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>
|
<label>Add the question text, or prompt, here. This text is required.</label>
|
||||||
|
|||||||
@@ -380,6 +380,8 @@ export const ignoredOlxAttributes = [
|
|||||||
'@_url_name',
|
'@_url_name',
|
||||||
'@_x-is-pointer-node',
|
'@_x-is-pointer-node',
|
||||||
'@_markdown_edited',
|
'@_markdown_edited',
|
||||||
|
'@_copied_from_block',
|
||||||
|
'@_copied_from_version',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
// Useful for the block creation workflow.
|
// Useful for the block creation workflow.
|
||||||
|
|||||||
@@ -126,9 +126,9 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => {
|
|||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
dispatch(actions.app.setSaveResponse(response));
|
dispatch(actions.app.setSaveResponse(response));
|
||||||
const parsedData = JSON.parse(response.config.data);
|
const parsedData = JSON.parse(response.config.data);
|
||||||
if (parsedData?.has_changes) {
|
if (parsedData?.has_changes || !('has_changes' in parsedData)) {
|
||||||
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
|
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
|
||||||
localStorage.setItem(storageKey, Date.now());
|
sessionStorage.setItem(storageKey, Date.now());
|
||||||
|
|
||||||
window.dispatchEvent(new StorageEvent('storage', {
|
window.dispatchEvent(new StorageEvent('storage', {
|
||||||
key: storageKey,
|
key: storageKey,
|
||||||
|
|||||||
@@ -457,6 +457,9 @@ export const editorConfig = ({
|
|||||||
valid_elements: '*[*]',
|
valid_elements: '*[*]',
|
||||||
// FIXME: this is passing 'utf-8', which is not a valid entity_encoding value. It should be 'named' etc.
|
// 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,
|
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': {
|
'header.links.exportLibrary': {
|
||||||
id: 'header.links.exportLibrary',
|
id: 'header.links.exportLibrary',
|
||||||
defaultMessage: 'Backup to local archive',
|
defaultMessage: 'Back up to local archive',
|
||||||
description: 'Link to Studio Backup Library page',
|
description: 'Link to Studio Library Backup page',
|
||||||
},
|
},
|
||||||
'header.links.optimizer': {
|
'header.links.optimizer': {
|
||||||
id: 'header.links.optimizer',
|
id: 'header.links.optimizer',
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ export const ConfirmationView = ({
|
|||||||
</Alert>
|
</Alert>
|
||||||
{legacyLibraries.map((legacyLib) => (
|
{legacyLibraries.map((legacyLib) => (
|
||||||
<ConfirmationCard
|
<ConfirmationCard
|
||||||
|
key={legacyLib.libraryKey}
|
||||||
legacyLib={legacyLib}
|
legacyLib={legacyLib}
|
||||||
destinationName={destination.title}
|
destinationName={destination.title}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
|
within,
|
||||||
} from '@src/testUtils';
|
} from '@src/testUtils';
|
||||||
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
|
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
|
||||||
import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks';
|
import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks';
|
||||||
@@ -184,7 +185,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
|||||||
nextButton.click();
|
nextButton.click();
|
||||||
|
|
||||||
// Should show alert of SelectDestinationView
|
// 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 });
|
const backButton = screen.getByRole('button', { name: /back/i });
|
||||||
backButton.click();
|
backButton.click();
|
||||||
@@ -210,7 +211,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
|||||||
nextButton.click();
|
nextButton.click();
|
||||||
|
|
||||||
// Should show alert of SelectDestinationView
|
// 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
|
// The next button is disabled
|
||||||
expect(nextButton).toBeDisabled();
|
expect(nextButton).toBeDisabled();
|
||||||
@@ -224,27 +225,31 @@ describe('<LegacyLibMigrationPage />', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should back to select library destination', async () => {
|
it('should back to select library destination', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
renderPage();
|
renderPage();
|
||||||
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
|
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
|
||||||
expect(await screen.findByText('MBA')).toBeInTheDocument();
|
expect(await screen.findByText('MBA')).toBeInTheDocument();
|
||||||
|
|
||||||
const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' });
|
const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' });
|
||||||
legacyLibrary.click();
|
await user.click(legacyLibrary);
|
||||||
|
|
||||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
const nextButton = await screen.findByRole('button', { name: /next/i });
|
||||||
nextButton.click();
|
await user.click(nextButton);
|
||||||
|
|
||||||
// Should show alert of SelectDestinationView
|
// 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();
|
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
||||||
radioButton.click();
|
await user.click(radioButton);
|
||||||
|
|
||||||
nextButton.click();
|
await user.click(nextButton);
|
||||||
expect(await screen.findByText(/these 1 legacy library will be migrated to/i)).toBeInTheDocument();
|
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 });
|
const backButton = screen.getByRole('button', { name: /back/i });
|
||||||
backButton.click();
|
await user.click(backButton);
|
||||||
|
|
||||||
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||||
// The selected v2 library remains checked
|
// The selected v2 library remains checked
|
||||||
@@ -269,7 +274,7 @@ describe('<LegacyLibMigrationPage />', () => {
|
|||||||
nextButton.click();
|
nextButton.click();
|
||||||
|
|
||||||
// Should show alert of SelectDestinationView
|
// 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 });
|
const createButton = await screen.findByRole('button', { name: /create new library/i });
|
||||||
expect(createButton).toBeInTheDocument();
|
expect(createButton).toBeInTheDocument();
|
||||||
@@ -336,18 +341,21 @@ describe('<LegacyLibMigrationPage />', () => {
|
|||||||
legacyLibrary3.click();
|
legacyLibrary3.click();
|
||||||
|
|
||||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||||
nextButton.click();
|
await user.click(nextButton);
|
||||||
|
|
||||||
// Should show alert of SelectDestinationView
|
// 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();
|
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
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
|
// 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('MBA')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
||||||
@@ -390,18 +398,22 @@ describe('<LegacyLibMigrationPage />', () => {
|
|||||||
legacyLibrary3.click();
|
legacyLibrary3.click();
|
||||||
|
|
||||||
const nextButton = screen.getByRole('button', { name: /next/i });
|
const nextButton = screen.getByRole('button', { name: /next/i });
|
||||||
nextButton.click();
|
await user.click(nextButton);
|
||||||
|
|
||||||
// Should show alert of SelectDestinationView
|
// 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();
|
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
|
||||||
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
|
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
|
// 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('MBA')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
|
||||||
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
expect(screen.getByText('MBA 1')).toBeInTheDocument();
|
||||||
|
|||||||
@@ -64,15 +64,17 @@ const messages = defineMessages({
|
|||||||
selectDestinationAlert: {
|
selectDestinationAlert: {
|
||||||
id: 'legacy-libraries-migration.select-destination.alert.text',
|
id: 'legacy-libraries-migration.select-destination.alert.text',
|
||||||
defaultMessage: 'All content from the'
|
defaultMessage: 'All content from the'
|
||||||
+ ' {count, plural, one {{count} legacy library} other {{count} legacy libraries}} you selected will'
|
+ ' {count, plural, one {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'
|
+ ' be migrated to the Content Library you select, organized into collections. Legacy library content used'
|
||||||
+ ' problem banks will maintain their link with migrated content the first time they are migrated.',
|
+ ' 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.',
|
description: 'Alert text in the select destination step of the legacy libraries migration page.',
|
||||||
},
|
},
|
||||||
confirmationViewAlert: {
|
confirmationViewAlert: {
|
||||||
id: 'legacy-libraries-migration.select-destination.alert.text',
|
id: 'legacy-libraries-migration.select-destination.alert.text',
|
||||||
defaultMessage: 'These {count, plural, one {{count} legacy library} other {{count} legacy libraries}}'
|
defaultMessage: 'All content from the'
|
||||||
+ ' will be migrated to <b>{libraryName}</b> and organized as collections. Legacy library content used'
|
+ ' {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,'
|
+ ' in courses will continue to work as-is. To receive any future changes to migrated content,'
|
||||||
+ ' you must update these references within your course.',
|
+ ' you must update these references within your course.',
|
||||||
description: 'Alert text in the confirmation step of the legacy libraries migration page.',
|
description: 'Alert text in the confirmation step of the legacy libraries migration page.',
|
||||||
@@ -80,7 +82,7 @@ const messages = defineMessages({
|
|||||||
previouslyMigratedAlert: {
|
previouslyMigratedAlert: {
|
||||||
id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text',
|
id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text',
|
||||||
defaultMessage: 'Previously migrated library. Any problem bank links were already'
|
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.',
|
description: 'Alert text when the legacy library is already migrated.',
|
||||||
},
|
},
|
||||||
helpAndSupportTitle: {
|
helpAndSupportTitle: {
|
||||||
@@ -96,8 +98,8 @@ const messages = defineMessages({
|
|||||||
helpAndSupportFirstQuestionBody: {
|
helpAndSupportFirstQuestionBody: {
|
||||||
id: 'legacy-libraries-migration.helpAndSupport.q1.body',
|
id: 'legacy-libraries-migration.helpAndSupport.q1.body',
|
||||||
defaultMessage: 'In the new Content Libraries experience, you can author sections,'
|
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,'
|
+ ' 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.',
|
+ ' 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',
|
description: 'Body of the first question in the Help & Support sidebar',
|
||||||
},
|
},
|
||||||
helpAndSupportSecondQuestionTitle: {
|
helpAndSupportSecondQuestionTitle: {
|
||||||
@@ -108,9 +110,9 @@ const messages = defineMessages({
|
|||||||
helpAndSupportSecondQuestionBody: {
|
helpAndSupportSecondQuestionBody: {
|
||||||
id: 'legacy-libraries-migration.helpAndSupport.q2.body',
|
id: 'legacy-libraries-migration.helpAndSupport.q2.body',
|
||||||
defaultMessage: 'All legacy library content is supported in the new experience.'
|
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.'
|
+ ' 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'
|
+ ' 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.',
|
+ ' 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',
|
description: 'Body of the second question in the Help & Support sidebar',
|
||||||
},
|
},
|
||||||
helpAndSupportThirdQuestionTitle: {
|
helpAndSupportThirdQuestionTitle: {
|
||||||
@@ -121,18 +123,18 @@ const messages = defineMessages({
|
|||||||
helpAndSupportThirdQuestionBody: {
|
helpAndSupportThirdQuestionBody: {
|
||||||
id: 'legacy-libraries-migration.helpAndSupport.q3.body.2',
|
id: 'legacy-libraries-migration.helpAndSupport.q3.body.2',
|
||||||
defaultMessage: '<p>There are three steps to migrating legacy libraries:</p>'
|
defaultMessage: '<p>There are three steps to migrating legacy libraries:</p>'
|
||||||
+ '<p><div>1 - Select Legacy Libraries</div>'
|
+ '<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'
|
+ '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.'
|
+ ' 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'
|
+ ' You can select up to 50 legacy libraries for migration, but only one destination'
|
||||||
+ ' v2 Content Library per migration.</p>'
|
+ ' v2 Content Library per migration.</p>'
|
||||||
+ '<p><div>2 - Select Destination</div>'
|
+ '<p><div>2 - Select Destination</div>'
|
||||||
+ 'You can migrate legacy libraries to an existing Content Library in the new experience,'
|
+ '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.'
|
+ ' 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>'
|
+ ' All your content will be migrated, and kept organized in collections.</p>'
|
||||||
+ '<p><div>3 - Confirm</div>'
|
+ '<p><div>3 - Confirm</div>'
|
||||||
+ 'In this step, review your migration. Once you confirm, migration will begin.'
|
+ 'In this step, review your migration. Once you confirm, migration will begin.'
|
||||||
+ ' It may take some time to complete.</p>',
|
+ ' It may take some time to complete.</p>',
|
||||||
description: 'Part 2 of the Body of the third question in the Help & Support sidebar',
|
description: 'Part 2 of the Body of the third question in the Help & Support sidebar',
|
||||||
},
|
},
|
||||||
migrationInProgress: {
|
migrationInProgress: {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
|
|
||||||
.library-authoring-sidebar {
|
.library-authoring-sidebar {
|
||||||
z-index: 1000; // same as header
|
z-index: 1000; // same as header
|
||||||
flex: 500px 0 0;
|
flex: 530px 0 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
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 { ContainerType } from '../../../generic/key-utils';
|
||||||
|
|
||||||
import type { ComponentPicker } from '../../component-picker';
|
import type { ComponentPicker } from '../../component-picker';
|
||||||
@@ -25,6 +27,7 @@ export type LibraryContextData = {
|
|||||||
libraryId: string;
|
libraryId: string;
|
||||||
libraryData?: ContentLibrary;
|
libraryData?: ContentLibrary;
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
|
canPublish: boolean;
|
||||||
isLoadingLibraryData: boolean;
|
isLoadingLibraryData: boolean;
|
||||||
/** The ID of the current collection/container, on the sidebar OR page */
|
/** The ID of the current collection/container, on the sidebar OR page */
|
||||||
collectionId: string | undefined;
|
collectionId: string | undefined;
|
||||||
@@ -107,6 +110,13 @@ export const LibraryProvider = ({
|
|||||||
componentPickerMode,
|
componentPickerMode,
|
||||||
} = useComponentPickerContext();
|
} = 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;
|
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;
|
||||||
|
|
||||||
// Parse the initial collectionId and/or container ID(s) from the current URL params
|
// Parse the initial collectionId and/or container ID(s) from the current URL params
|
||||||
@@ -131,7 +141,8 @@ export const LibraryProvider = ({
|
|||||||
containerId,
|
containerId,
|
||||||
setContainerId,
|
setContainerId,
|
||||||
readOnly,
|
readOnly,
|
||||||
isLoadingLibraryData,
|
canPublish,
|
||||||
|
isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions,
|
||||||
showOnlyPublished,
|
showOnlyPublished,
|
||||||
extraFilter,
|
extraFilter,
|
||||||
isCreateCollectionModalOpen,
|
isCreateCollectionModalOpen,
|
||||||
@@ -154,7 +165,9 @@ export const LibraryProvider = ({
|
|||||||
containerId,
|
containerId,
|
||||||
setContainerId,
|
setContainerId,
|
||||||
readOnly,
|
readOnly,
|
||||||
|
canPublish,
|
||||||
isLoadingLibraryData,
|
isLoadingLibraryData,
|
||||||
|
isLoadingUserPermissions,
|
||||||
showOnlyPublished,
|
showOnlyPublished,
|
||||||
extraFilter,
|
extraFilter,
|
||||||
isCreateCollectionModalOpen,
|
isCreateCollectionModalOpen,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface DefaultTabs {
|
|||||||
export interface SidebarItemInfo {
|
export interface SidebarItemInfo {
|
||||||
type: SidebarBodyItemId;
|
type: SidebarBodyItemId;
|
||||||
id: string;
|
id: string;
|
||||||
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SidebarActions {
|
export enum SidebarActions {
|
||||||
@@ -88,7 +89,7 @@ export type SidebarContextData = {
|
|||||||
openCollectionInfoSidebar: (collectionId: string) => void;
|
openCollectionInfoSidebar: (collectionId: string) => void;
|
||||||
openComponentInfoSidebar: (usageKey: string) => void;
|
openComponentInfoSidebar: (usageKey: string) => void;
|
||||||
openContainerInfoSidebar: (usageKey: string) => void;
|
openContainerInfoSidebar: (usageKey: string) => void;
|
||||||
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId) => void;
|
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId, index?: number) => void;
|
||||||
sidebarItemInfo?: SidebarItemInfo;
|
sidebarItemInfo?: SidebarItemInfo;
|
||||||
sidebarAction: SidebarActions;
|
sidebarAction: SidebarActions;
|
||||||
setSidebarAction: (action: SidebarActions) => void;
|
setSidebarAction: (action: SidebarActions) => void;
|
||||||
@@ -154,35 +155,38 @@ export const SidebarProvider = ({
|
|||||||
setSidebarItemInfo({ id: '', type: SidebarBodyItemId.Info });
|
setSidebarItemInfo({ id: '', type: SidebarBodyItemId.Info });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openComponentInfoSidebar = useCallback((usageKey: string) => {
|
const openComponentInfoSidebar = useCallback((usageKey: string, index?: number) => {
|
||||||
setSidebarItemInfo({
|
setSidebarItemInfo({
|
||||||
id: usageKey,
|
id: usageKey,
|
||||||
type: SidebarBodyItemId.ComponentInfo,
|
type: SidebarBodyItemId.ComponentInfo,
|
||||||
|
index,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openCollectionInfoSidebar = useCallback((newCollectionId: string) => {
|
const openCollectionInfoSidebar = useCallback((newCollectionId: string, index?: number) => {
|
||||||
setSidebarItemInfo({
|
setSidebarItemInfo({
|
||||||
id: newCollectionId,
|
id: newCollectionId,
|
||||||
type: SidebarBodyItemId.CollectionInfo,
|
type: SidebarBodyItemId.CollectionInfo,
|
||||||
|
index,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const openContainerInfoSidebar = useCallback((usageKey: string) => {
|
const openContainerInfoSidebar = useCallback((usageKey: string, index?: number) => {
|
||||||
setSidebarItemInfo({
|
setSidebarItemInfo({
|
||||||
id: usageKey,
|
id: usageKey,
|
||||||
type: SidebarBodyItemId.ContainerInfo,
|
type: SidebarBodyItemId.ContainerInfo,
|
||||||
|
index,
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const { navigateTo } = useLibraryRoutes();
|
const { navigateTo } = useLibraryRoutes();
|
||||||
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId) => {
|
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId, index?: number) => {
|
||||||
navigateTo({ selectedItemId });
|
navigateTo({ selectedItemId, index });
|
||||||
setSidebarItemInfo({ id: selectedItemId, type });
|
setSidebarItemInfo({ id: selectedItemId, type, index });
|
||||||
}, [navigateTo, setSidebarItemInfo]);
|
}, [navigateTo, setSidebarItemInfo]);
|
||||||
|
|
||||||
// Set the initial sidebar state based on the URL parameters and context.
|
// 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 { collectionId, containerId } = useLibraryContext();
|
||||||
const { componentPickerMode } = useComponentPickerContext();
|
const { componentPickerMode } = useComponentPickerContext();
|
||||||
|
|
||||||
@@ -198,12 +202,15 @@ export const SidebarProvider = ({
|
|||||||
|
|
||||||
// Handle selected item id changes
|
// Handle selected item id changes
|
||||||
if (selectedItemId) {
|
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:')) {
|
if (selectedItemId.startsWith('lct:')) {
|
||||||
openContainerInfoSidebar(selectedItemId);
|
openContainerInfoSidebar(selectedItemId, indexNumber);
|
||||||
} else if (selectedItemId.startsWith('lb:')) {
|
} else if (selectedItemId.startsWith('lb:')) {
|
||||||
openComponentInfoSidebar(selectedItemId);
|
openComponentInfoSidebar(selectedItemId, indexNumber);
|
||||||
} else {
|
} else {
|
||||||
openCollectionInfoSidebar(selectedItemId);
|
openCollectionInfoSidebar(selectedItemId, indexNumber);
|
||||||
}
|
}
|
||||||
} else if (collectionId) {
|
} else if (collectionId) {
|
||||||
openCollectionInfoSidebar(collectionId);
|
openCollectionInfoSidebar(collectionId);
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ const ComponentActions = ({
|
|||||||
const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
|
const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
|
||||||
const canEdit = canEditComponent(componentId);
|
const canEdit = canEditComponent(componentId);
|
||||||
|
|
||||||
|
const { sidebarItemInfo } = useSidebarContext();
|
||||||
|
|
||||||
if (isPublisherOpen) {
|
if (isPublisherOpen) {
|
||||||
return (
|
return (
|
||||||
<ComponentPublisher
|
<ComponentPublisher
|
||||||
@@ -141,7 +143,7 @@ const ComponentActions = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<ComponentMenu usageKey={componentId} />
|
<ComponentMenu usageKey={componentId} index={sidebarItemInfo?.index} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ const BaseCard = ({
|
|||||||
<Card.Header
|
<Card.Header
|
||||||
className={`library-item-header ${getComponentStyleColor(itemType)}`}
|
className={`library-item-header ${getComponentStyleColor(itemType)}`}
|
||||||
title={
|
title={
|
||||||
<Icon src={itemIcon} className="library-item-header-icon" />
|
<Icon src={itemIcon} className="library-item-header-icon my-2" />
|
||||||
}
|
}
|
||||||
actions={(
|
actions={(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -12,18 +12,23 @@ import { useClipboard } from '@src/generic/clipboard';
|
|||||||
import { getBlockType } from '@src/generic/key-utils';
|
import { getBlockType } from '@src/generic/key-utils';
|
||||||
import { ToastContext } from '@src/generic/toast-context';
|
import { ToastContext } from '@src/generic/toast-context';
|
||||||
|
|
||||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
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 { canEditComponent } from './ComponentEditorModal';
|
||||||
import ComponentDeleter from './ComponentDeleter';
|
import ComponentDeleter from './ComponentDeleter';
|
||||||
import ComponentRemover from './ComponentRemover';
|
import ComponentRemover from './ComponentRemover';
|
||||||
import messages from './messages';
|
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 intl = useIntl();
|
||||||
const {
|
const {
|
||||||
libraryId,
|
libraryId,
|
||||||
@@ -135,6 +140,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
|||||||
{isRemoveModalOpen && (
|
{isRemoveModalOpen && (
|
||||||
<ComponentRemover
|
<ComponentRemover
|
||||||
usageKey={usageKey}
|
usageKey={usageKey}
|
||||||
|
index={index}
|
||||||
close={closeRemoveModal}
|
close={closeRemoveModal}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,31 +4,38 @@ import { Warning } from '@openedx/paragon/icons';
|
|||||||
|
|
||||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||||
import { ToastContext } from '@src/generic/toast-context';
|
import { ToastContext } from '@src/generic/toast-context';
|
||||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||||
import {
|
import {
|
||||||
useContainer,
|
useContainer,
|
||||||
useRemoveContainerChildren,
|
useRemoveContainerChildren,
|
||||||
useAddItemsToContainer,
|
|
||||||
useLibraryBlockMetadata,
|
useLibraryBlockMetadata,
|
||||||
} from '../data/apiHooks';
|
useContainerChildren,
|
||||||
|
useUpdateContainerChildren,
|
||||||
|
} from '@src/library-authoring/data/apiHooks';
|
||||||
|
import { LibraryBlockMetadata } from '@src/library-authoring/data/api';
|
||||||
import messages from './messages';
|
import messages from './messages';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
usageKey: string;
|
usageKey: string;
|
||||||
|
index?: number;
|
||||||
close: () => void;
|
close: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComponentRemover = ({ usageKey, close }: Props) => {
|
const ComponentRemover = ({ usageKey, index, close }: Props) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
|
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
|
||||||
const { containerId } = useLibraryContext();
|
const { containerId, showOnlyPublished } = useLibraryContext();
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
|
|
||||||
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
|
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
|
||||||
const addItemToContainerMutation = useAddItemsToContainer(containerId);
|
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
|
||||||
const { data: container, isPending: isPendingParentContainer } = useContainer(containerId);
|
const { data: container, isPending: isPendingParentContainer } = useContainer(containerId);
|
||||||
const { data: component, isPending } = useLibraryBlockMetadata(usageKey);
|
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
|
// istanbul ignore if: loading state
|
||||||
if (isPending || isPendingParentContainer) {
|
if (isPending || isPendingParentContainer) {
|
||||||
@@ -36,28 +43,62 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
|
|||||||
return null;
|
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 removeFromContainer = () => {
|
||||||
const restoreComponent = () => {
|
|
||||||
addItemToContainerMutation.mutateAsync([usageKey]).then(() => {
|
|
||||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
|
||||||
}).catch(() => {
|
|
||||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
|
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
|
||||||
if (sidebarItemInfo?.id === usageKey) {
|
if (sidebarItemInfo?.id === usageKey) {
|
||||||
// Close sidebar if current component is open
|
// Close sidebar if current component is open
|
||||||
closeLibrarySidebar();
|
closeLibrarySidebar();
|
||||||
}
|
}
|
||||||
showToast(
|
showSuccessToast();
|
||||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
|
||||||
{
|
|
||||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
|
||||||
onClick: restoreComponent,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}).catch(() => {
|
}).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();
|
close();
|
||||||
@@ -76,7 +117,7 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
|
|||||||
title={intl.formatMessage(messages.removeComponentWarningTitle)}
|
title={intl.formatMessage(messages.removeComponentWarningTitle)}
|
||||||
icon={Warning}
|
icon={Warning}
|
||||||
description={removeText}
|
description={removeText}
|
||||||
onDeleteSubmit={removeFromContainer}
|
onDeleteSubmit={hasDuplicates ? excludeOneInstance : removeFromContainer}
|
||||||
btnLabel={intl.formatMessage(messages.componentRemoveButtonLabel)}
|
btnLabel={intl.formatMessage(messages.componentRemoveButtonLabel)}
|
||||||
buttonVariant="primary"
|
buttonVariant="primary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -17,23 +17,24 @@ import { type ContainerHit, Highlight, PublishStatus } from '@src/search-manager
|
|||||||
import { ToastContext } from '@src/generic/toast-context';
|
import { ToastContext } from '@src/generic/toast-context';
|
||||||
import { useRunOnNextRender } from '@src/utils';
|
import { useRunOnNextRender } from '@src/utils';
|
||||||
|
|
||||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
import { useComponentPickerContext } from '@src/library-authoring/common/context/ComponentPickerContext';
|
||||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||||
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
|
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||||
import { useRemoveItemsFromCollection } from '../data/apiHooks';
|
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
|
||||||
import { useLibraryRoutes } from '../routes';
|
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 messages from './messages';
|
||||||
import ContainerDeleter from './ContainerDeleter';
|
import ContainerDeleter from './ContainerDeleter';
|
||||||
import ContainerRemover from './ContainerRemover';
|
import ContainerRemover from './ContainerRemover';
|
||||||
import BaseCard from '../components/BaseCard';
|
|
||||||
import AddComponentWidget from '../components/AddComponentWidget';
|
|
||||||
|
|
||||||
type ContainerMenuProps = {
|
type ContainerMenuProps = {
|
||||||
containerKey: string;
|
containerKey: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
index?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
|
export const ContainerMenu = ({ containerKey, displayName, index } : ContainerMenuProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { libraryId, collectionId, containerId } = useLibraryContext();
|
const { libraryId, collectionId, containerId } = useLibraryContext();
|
||||||
const {
|
const {
|
||||||
@@ -144,6 +145,7 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps
|
|||||||
close={cancelRemove}
|
close={cancelRemove}
|
||||||
containerKey={containerKey}
|
containerKey={containerKey}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ import {
|
|||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
useToggle,
|
useToggle,
|
||||||
|
Alert,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
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 { useClipboard } from '@src/generic/clipboard';
|
||||||
import { ContainerType, getBlockType } from '@src/generic/key-utils';
|
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 ContainerInfo = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
@@ -222,7 +232,7 @@ const ContainerInfo = () => {
|
|||||||
{renderTab(
|
{renderTab(
|
||||||
CONTAINER_INFO_TABS.Settings,
|
CONTAINER_INFO_TABS.Settings,
|
||||||
intl.formatMessage(messages.settingsTabTitle),
|
intl.formatMessage(messages.settingsTabTitle),
|
||||||
// TODO: container settings component
|
<ContainerSettings />,
|
||||||
)}
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</Stack>
|
</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 { ToastContext } from '@src/generic/toast-context';
|
||||||
import { getBlockType } from '@src/generic/key-utils';
|
import { getBlockType } from '@src/generic/key-utils';
|
||||||
|
|
||||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
|
||||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
|
||||||
import { useContainer, useRemoveContainerChildren } from '../data/apiHooks';
|
import {
|
||||||
import messages from '../components/messages';
|
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 = {
|
type ContainerRemoverProps = {
|
||||||
close: () => void,
|
close: () => void,
|
||||||
containerKey: string,
|
containerKey: string,
|
||||||
displayName: string,
|
displayName: string,
|
||||||
|
index?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
const ContainerRemover = ({
|
const ContainerRemover = ({
|
||||||
close,
|
close,
|
||||||
containerKey,
|
containerKey,
|
||||||
displayName,
|
displayName,
|
||||||
|
index,
|
||||||
}: ContainerRemoverProps) => {
|
}: ContainerRemoverProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const {
|
const {
|
||||||
sidebarItemInfo,
|
sidebarItemInfo,
|
||||||
closeLibrarySidebar,
|
closeLibrarySidebar,
|
||||||
} = useSidebarContext();
|
} = useSidebarContext();
|
||||||
const { containerId } = useLibraryContext();
|
const { containerId, showOnlyPublished } = useLibraryContext();
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
|
|
||||||
const removeContainerMutation = useRemoveContainerChildren(containerId);
|
const removeContainerMutation = useRemoveContainerChildren(containerId);
|
||||||
|
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
|
||||||
const { data: container, isPending } = useContainer(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 itemType = getBlockType(containerKey);
|
||||||
|
|
||||||
const removeWarningTitle = intl.formatMessage(messages.removeContainerWarningTitle, {
|
const removeWarningTitle = intl.formatMessage(messages.removeContainerWarningTitle, {
|
||||||
@@ -50,9 +60,19 @@ const ContainerRemover = ({
|
|||||||
|
|
||||||
const onRemove = useCallback(async () => {
|
const onRemove = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await removeContainerMutation.mutateAsync([containerKey]);
|
if (hasDuplicates && childrenUsageIds && typeof index !== 'undefined') {
|
||||||
if (sidebarItemInfo?.id === containerKey) {
|
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== containerKey || idx !== index);
|
||||||
closeLibrarySidebar();
|
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);
|
showToast(removeSuccess);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -63,12 +83,16 @@ const ContainerRemover = ({
|
|||||||
}, [
|
}, [
|
||||||
containerKey,
|
containerKey,
|
||||||
removeContainerMutation,
|
removeContainerMutation,
|
||||||
|
updateContainerChildrenMutation,
|
||||||
sidebarItemInfo,
|
sidebarItemInfo,
|
||||||
closeLibrarySidebar,
|
closeLibrarySidebar,
|
||||||
showToast,
|
showToast,
|
||||||
removeSuccess,
|
removeSuccess,
|
||||||
removeError,
|
removeError,
|
||||||
close,
|
close,
|
||||||
|
hasDuplicates,
|
||||||
|
childrenUsageIds,
|
||||||
|
index,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// istanbul ignore if: loading state
|
// istanbul ignore if: loading state
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ const messages = defineMessages({
|
|||||||
defaultMessage: 'Container actions menu',
|
defaultMessage: 'Container actions menu',
|
||||||
description: 'Alt/title text for the container card menu button.',
|
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: {
|
menuOpen: {
|
||||||
id: 'course-authoring.library-authoring.menu.open',
|
id: 'course-authoring.library-authoring.menu.open',
|
||||||
defaultMessage: 'Open',
|
defaultMessage: 'Open',
|
||||||
|
|||||||
@@ -435,7 +435,7 @@ describe('<CreateLibrary />', () => {
|
|||||||
sections: 8,
|
sections: 8,
|
||||||
subsections: 12,
|
subsections: 12,
|
||||||
units: 20,
|
units: 20,
|
||||||
createdOnServer: '2025-01-01T10:00:00Z',
|
createdOnServer: 'test.com',
|
||||||
createdAt: '2025-01-01T10:00:00Z',
|
createdAt: '2025-01-01T10:00:00Z',
|
||||||
createdBy: {
|
createdBy: {
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
@@ -478,7 +478,67 @@ describe('<CreateLibrary />', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
|
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
|
||||||
expect(screen.getByText('TestOrg / test-archive')).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 {
|
import {
|
||||||
AccessTime,
|
AccessTime,
|
||||||
Widgets,
|
Widgets,
|
||||||
|
PersonOutline,
|
||||||
} from '@openedx/paragon/icons';
|
} from '@openedx/paragon/icons';
|
||||||
import AlertError from '@src/generic/alert-error';
|
import AlertError from '@src/generic/alert-error';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
@@ -203,22 +204,38 @@ export const CreateLibrary = ({
|
|||||||
<Card.Body>
|
<Card.Body>
|
||||||
<div className="d-flex flex-column flex-md-row justify-content-between align-items-start p-4 text-primary-700">
|
<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">
|
<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">
|
<p className="small mb-0">
|
||||||
{restoreStatus.result.org} / {restoreStatus.result.slug}
|
{restoreStatus.result.org} / {restoreStatus.result.slug}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<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">
|
<span className="x-small">
|
||||||
{intl.formatMessage(messages.archiveComponentsCount, {
|
{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>
|
</span>
|
||||||
</div>
|
</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">
|
<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">
|
<span className="x-small">
|
||||||
{intl.formatMessage(messages.archiveBackupDate, {
|
{intl.formatMessage(messages.archiveBackupDate, {
|
||||||
date: new Date(restoreStatus.result.createdAt).toLocaleDateString(),
|
date: new Date(restoreStatus.result.createdAt).toLocaleDateString(),
|
||||||
@@ -236,7 +253,8 @@ export const CreateLibrary = ({
|
|||||||
|
|
||||||
{(restoreTaskId || isError || restoreMutation.isError) && (
|
{(restoreTaskId || isError || restoreMutation.isError) && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
{restoreStatus?.state === LibraryRestoreStatus.Pending && (
|
{(restoreStatus?.state === LibraryRestoreStatus.Pending
|
||||||
|
|| restoreStatus?.state === LibraryRestoreStatus.InProgress) && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
{intl.formatMessage(messages.restoreInProgress)}
|
{intl.formatMessage(messages.restoreInProgress)}
|
||||||
</Alert>
|
</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}`);
|
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 () => {
|
it('should handle failed status', async () => {
|
||||||
const taskId = 'failed-task-id';
|
const taskId = 'failed-task-id';
|
||||||
const failedResult = {
|
const failedResult = {
|
||||||
|
|||||||
@@ -41,7 +41,10 @@ export const useGetLibraryRestoreStatus = (taskId: string) => useQuery<GetLibrar
|
|||||||
queryKey: libraryRestoreQueryKeys.restoreStatus(taskId),
|
queryKey: libraryRestoreQueryKeys.restoreStatus(taskId),
|
||||||
queryFn: () => getLibraryRestoreStatus(taskId),
|
queryFn: () => getLibraryRestoreStatus(taskId),
|
||||||
enabled: !!taskId, // Only run the query if taskId is provided
|
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>({
|
export const useCreateLibraryRestore = () => useMutation<CreateLibraryRestoreResponse, Error, File>({
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface GetLibraryRestoreStatusResponse {
|
|||||||
|
|
||||||
export enum LibraryRestoreStatus {
|
export enum LibraryRestoreStatus {
|
||||||
Pending = 'Pending',
|
Pending = 'Pending',
|
||||||
|
InProgress = 'In Progress',
|
||||||
Succeeded = 'Succeeded',
|
Succeeded = 'Succeeded',
|
||||||
Failed = 'Failed',
|
Failed = 'Failed',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,8 +120,13 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
archiveComponentsCount: {
|
archiveComponentsCount: {
|
||||||
id: 'course-authoring.library-authoring.create-library.form.archive.components-count',
|
id: 'course-authoring.library-authoring.create-library.form.archive.components-count',
|
||||||
defaultMessage: 'Contains {count} Components',
|
defaultMessage: 'Contains {countSections} sections, {countSubsections} subsections, {countUnits} units, {countComponents} components',
|
||||||
description: 'Text showing the number of components in the restored archive.',
|
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: {
|
archiveBackupDate: {
|
||||||
id: 'course-authoring.library-authoring.create-library.form.archive.backup-date',
|
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[]> {
|
export async function mockGetContainerChildren(containerId: string): Promise<api.LibraryBlockMetadata[]> {
|
||||||
let numChildren: number;
|
let numChildren: number;
|
||||||
let blockType = 'html';
|
let blockType = 'html';
|
||||||
|
let addDuplicate = false;
|
||||||
switch (containerId) {
|
switch (containerId) {
|
||||||
case mockGetContainerMetadata.unitId:
|
case mockGetContainerMetadata.unitId:
|
||||||
case mockGetContainerMetadata.sectionId:
|
case mockGetContainerMetadata.sectionId:
|
||||||
@@ -615,6 +616,10 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
|
|||||||
case mockGetContainerChildren.sixChildren:
|
case mockGetContainerChildren.sixChildren:
|
||||||
numChildren = 6;
|
numChildren = 6;
|
||||||
break;
|
break;
|
||||||
|
case mockGetContainerChildren.unitIdWithDuplicate:
|
||||||
|
numChildren = 3;
|
||||||
|
addDuplicate = true;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
numChildren = 0;
|
numChildren = 0;
|
||||||
break;
|
break;
|
||||||
@@ -630,19 +635,22 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
|
|||||||
name = blockType;
|
name = blockType;
|
||||||
typeNamespace = 'lct';
|
typeNamespace = 'lct';
|
||||||
}
|
}
|
||||||
return Promise.resolve(
|
let result = Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
|
||||||
Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
|
{
|
||||||
{
|
...child,
|
||||||
...child,
|
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
|
||||||
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
|
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
|
||||||
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
|
displayName: `${name} block ${idx}`,
|
||||||
displayName: `${name} block ${idx}`,
|
publishedDisplayName: `${name} block published ${idx}`,
|
||||||
publishedDisplayName: `${name} block published ${idx}`,
|
blockType,
|
||||||
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.fiveChildren = 'lct:org1:Demo_Course:unit:unit-5';
|
||||||
mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6';
|
mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6';
|
||||||
mockGetContainerChildren.childTemplate = {
|
mockGetContainerChildren.childTemplate = {
|
||||||
|
|||||||
@@ -702,10 +702,10 @@ export async function restoreContainer(containerId: string) {
|
|||||||
/**
|
/**
|
||||||
* Fetch a library container's children's metadata.
|
* Fetch a library container's children's metadata.
|
||||||
*/
|
*/
|
||||||
export async function getLibraryContainerChildren(
|
export async function getLibraryContainerChildren<ChildType = LibraryBlockMetadata | Container>(
|
||||||
containerId: string,
|
containerId: string,
|
||||||
published: boolean = false,
|
published: boolean = false,
|
||||||
): Promise<LibraryBlockMetadata[] | Container[]> {
|
): Promise<ChildType[]> {
|
||||||
const { data } = await getAuthenticatedHttpClient().get(
|
const { data } = await getAuthenticatedHttpClient().get(
|
||||||
getLibraryContainerChildrenApiUrl(containerId, published),
|
getLibraryContainerChildrenApiUrl(containerId, published),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -329,10 +329,11 @@ describe('library api hooks', () => {
|
|||||||
// Keys should be invalidated:
|
// Keys should be invalidated:
|
||||||
// 1. library
|
// 1. library
|
||||||
// 2. containerChildren
|
// 2. containerChildren
|
||||||
// 3. containerHierarchy
|
// 3. container
|
||||||
// 4 & 5. subsections
|
// 4. containerHierarchy
|
||||||
// 6 all hierarchies
|
// 5 & 6. subsections
|
||||||
expect(spy).toHaveBeenCalledTimes(6);
|
// 7 all hierarchies
|
||||||
|
expect(spy).toHaveBeenCalledTimes(7);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('publishContainer', () => {
|
describe('publishContainer', () => {
|
||||||
|
|||||||
@@ -736,32 +736,35 @@ export const useRestoreContainer = (containerId: string) => {
|
|||||||
/**
|
/**
|
||||||
* Get the metadata and children for a container in a library
|
* Get the metadata and children for a container in a library
|
||||||
*/
|
*/
|
||||||
export const useContainerChildren = (containerId?: string, published: boolean = false) => (
|
export const useContainerChildren = <ChildType extends {
|
||||||
useQuery({
|
id: string;
|
||||||
enabled: !!containerId,
|
isNew?: boolean;
|
||||||
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
|
} = api.LibraryBlockMetadata | api.Container>(
|
||||||
queryFn: () => api.getLibraryContainerChildren(containerId!, published),
|
containerId?: string,
|
||||||
structuralSharing: (
|
published: boolean = false,
|
||||||
oldData: api.LibraryBlockMetadata[] | api.Container[],
|
) => (
|
||||||
newData: api.LibraryBlockMetadata[] | api.Container[],
|
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
|
// This just sets `isNew` flag to new children components
|
||||||
if (oldData) {
|
if (oldData) {
|
||||||
const oldDataIds = oldData.map((obj) => obj.id);
|
const oldDataIds = oldData.map((obj) => obj.id);
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
newData = newData.map((newObj) => {
|
newData = newData.map((newObj) => {
|
||||||
if (!oldDataIds.includes(newObj.id)) {
|
if (!oldDataIds.includes(newObj.id)) {
|
||||||
// Set isNew = true if we have new child on refetch
|
// Set isNew = true if we have new child on refetch
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
newObj.isNew = true;
|
newObj.isNew = true;
|
||||||
}
|
}
|
||||||
return newObj;
|
return newObj;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return replaceEqualDeep(oldData, newData);
|
return replaceEqualDeep(oldData, newData);
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If you work with `useContentFromSearchIndex`, you can use this
|
* 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.
|
// 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: libraryAuthoringQueryKeys.containerHierarchy(undefined) });
|
||||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentHierarchy(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);
|
const containerType = getBlockType(containerId);
|
||||||
if (containerType === 'section') {
|
if (containerType === 'section') {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
waitFor,
|
waitFor,
|
||||||
initializeMocks,
|
initializeMocks,
|
||||||
} from '@src/testUtils';
|
} from '@src/testUtils';
|
||||||
|
import { validateUserPermissions } from '@src/authz/data/api';
|
||||||
import { mockContentLibrary } from '../data/api.mocks';
|
import { mockContentLibrary } from '../data/api.mocks';
|
||||||
import { getCommitLibraryChangesUrl } from '../data/api';
|
import { getCommitLibraryChangesUrl } from '../data/api';
|
||||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||||
@@ -33,6 +34,7 @@ const render = (libraryId: string = mockLibraryId) => baseRender(<LibraryInfo />
|
|||||||
|
|
||||||
let axiosMock: MockAdapter;
|
let axiosMock: MockAdapter;
|
||||||
let mockShowToast: (message: string) => void;
|
let mockShowToast: (message: string) => void;
|
||||||
|
let validateUserPermissionsMock: jest.SpiedFunction<typeof validateUserPermissions>;
|
||||||
|
|
||||||
mockContentLibrary.applyMock();
|
mockContentLibrary.applyMock();
|
||||||
|
|
||||||
@@ -41,6 +43,9 @@ describe('<LibraryInfo />', () => {
|
|||||||
const mocks = initializeMocks();
|
const mocks = initializeMocks();
|
||||||
axiosMock = mocks.axiosMock;
|
axiosMock = mocks.axiosMock;
|
||||||
mockShowToast = mocks.mockShowToast;
|
mockShowToast = mocks.mockShowToast;
|
||||||
|
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
|
||||||
|
|
||||||
|
validateUserPermissionsMock.mockResolvedValue({ canPublish: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import messages from './messages';
|
|||||||
|
|
||||||
const LibraryPublishStatus = () => {
|
const LibraryPublishStatus = () => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { libraryData, readOnly } = useLibraryContext();
|
const { libraryData, readOnly, canPublish } = useLibraryContext();
|
||||||
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
|
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
|
||||||
|
|
||||||
const commitLibraryChanges = useCommitLibraryChanges();
|
const commitLibraryChanges = useCommitLibraryChanges();
|
||||||
@@ -51,10 +51,10 @@ const LibraryPublishStatus = () => {
|
|||||||
<>
|
<>
|
||||||
<StatusWidget
|
<StatusWidget
|
||||||
{...libraryData}
|
{...libraryData}
|
||||||
onCommit={!readOnly ? commit : undefined}
|
onCommit={!readOnly && canPublish ? commit : undefined}
|
||||||
onCommitStatus={commitLibraryChanges.status}
|
onCommitStatus={commitLibraryChanges.status}
|
||||||
onCommitLabel={intl.formatMessage(messages.publishLibraryButtonLabel)}
|
onCommitLabel={intl.formatMessage(messages.publishLibraryButtonLabel)}
|
||||||
onRevert={!readOnly ? openConfirmModal : undefined}
|
onRevert={!readOnly && canPublish ? openConfirmModal : undefined}
|
||||||
/>
|
/>
|
||||||
<DeleteModal
|
<DeleteModal
|
||||||
isOpen={isConfirmModalOpen}
|
isOpen={isConfirmModalOpen}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ export const ROUTES = {
|
|||||||
COLLECTION: '/collection/:collectionId/:selectedItemId?',
|
COLLECTION: '/collection/:collectionId/:selectedItemId?',
|
||||||
// LibrarySectionPage route:
|
// LibrarySectionPage route:
|
||||||
// * with a selected containerId and an optionally selected subsection.
|
// * with a selected containerId and an optionally selected subsection.
|
||||||
SECTION: '/section/:containerId/:selectedItemId?',
|
SECTION: '/section/:containerId/:selectedItemId?/:index?',
|
||||||
// LibrarySubsectionPage route:
|
// LibrarySubsectionPage route:
|
||||||
// * with a selected containerId and an optionally selected unit.
|
// * with a selected containerId and an optionally selected unit.
|
||||||
SUBSECTION: '/subsection/:containerId/:selectedItemId?',
|
SUBSECTION: '/subsection/:containerId/:selectedItemId?/:index?',
|
||||||
// LibraryUnitPage route:
|
// LibraryUnitPage route:
|
||||||
// * with a selected containerId and/or an optionally selected componentId.
|
// * with a selected containerId and/or an optionally selected componentId.
|
||||||
UNIT: '/unit/:containerId/:selectedItemId?',
|
UNIT: '/unit/:containerId/:selectedItemId?/:index?',
|
||||||
// LibraryBackupPage route:
|
// LibraryBackupPage route:
|
||||||
BACKUP: '/backup',
|
BACKUP: '/backup',
|
||||||
};
|
};
|
||||||
@@ -60,6 +60,7 @@ export type NavigateToData = {
|
|||||||
collectionId?: string,
|
collectionId?: string,
|
||||||
containerId?: string,
|
containerId?: string,
|
||||||
contentType?: ContentType,
|
contentType?: ContentType,
|
||||||
|
index?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LibraryRoutesData = {
|
export type LibraryRoutesData = {
|
||||||
@@ -122,6 +123,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
|||||||
collectionId,
|
collectionId,
|
||||||
containerId,
|
containerId,
|
||||||
contentType,
|
contentType,
|
||||||
|
index,
|
||||||
}: NavigateToData = {}) => {
|
}: NavigateToData = {}) => {
|
||||||
const routeParams = {
|
const routeParams = {
|
||||||
...params,
|
...params,
|
||||||
@@ -129,6 +131,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
|||||||
...((selectedItemId !== undefined) && { selectedItemId }),
|
...((selectedItemId !== undefined) && { selectedItemId }),
|
||||||
...((containerId !== undefined) && { containerId }),
|
...((containerId !== undefined) && { containerId }),
|
||||||
...((collectionId !== undefined) && { collectionId }),
|
...((collectionId !== undefined) && { collectionId }),
|
||||||
|
...((index !== undefined) && { index }),
|
||||||
};
|
};
|
||||||
let route: string;
|
let route: string;
|
||||||
|
|
||||||
@@ -230,6 +233,12 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
|
|||||||
route = ROUTES.HOME;
|
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.
|
// Also remove the `sa` (sidebar action) search param if it exists.
|
||||||
searchParams.delete('sa');
|
searchParams.delete('sa');
|
||||||
|
|
||||||
|
|||||||
@@ -39,9 +39,12 @@ interface LibraryContainerMetadataWithUniqueId extends Container {
|
|||||||
|
|
||||||
interface ContainerRowProps extends LibraryContainerChildrenProps {
|
interface ContainerRowProps extends LibraryContainerChildrenProps {
|
||||||
container: LibraryContainerMetadataWithUniqueId;
|
container: LibraryContainerMetadataWithUniqueId;
|
||||||
|
index?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps) => {
|
const ContainerRow = ({
|
||||||
|
containerKey, container, readOnly, index,
|
||||||
|
}: ContainerRowProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
const updateMutation = useUpdateContainer(container.originalId, containerKey);
|
const updateMutation = useUpdateContainer(container.originalId, containerKey);
|
||||||
@@ -112,6 +115,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
|
|||||||
<ContainerMenu
|
<ContainerMenu
|
||||||
containerKey={container.originalId}
|
containerKey={container.originalId}
|
||||||
displayName={container.displayName}
|
displayName={container.displayName}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -148,7 +152,7 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
|||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useContainerChildren(containerKey, showOnlyPublished);
|
} = useContainerChildren<Container>(containerKey, showOnlyPublished);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Create new ids which are unique using index.
|
// Create new ids which are unique using index.
|
||||||
@@ -164,14 +168,18 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
|||||||
return setOrderedChildren(newChildren || []);
|
return setOrderedChildren(newChildren || []);
|
||||||
}, [children, setOrderedChildren]);
|
}, [children, setOrderedChildren]);
|
||||||
|
|
||||||
const handleChildClick = useCallback((child: LibraryContainerMetadataWithUniqueId, numberOfClicks: number) => {
|
const handleChildClick = useCallback((
|
||||||
|
child: LibraryContainerMetadataWithUniqueId,
|
||||||
|
numberOfClicks: number,
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
if (readOnly) {
|
if (readOnly) {
|
||||||
// don't allow interaction if rendered as preview
|
// don't allow interaction if rendered as preview
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const doubleClicked = numberOfClicks > 1;
|
const doubleClicked = numberOfClicks > 1;
|
||||||
if (!doubleClicked) {
|
if (!doubleClicked) {
|
||||||
openItemSidebar(child.originalId, SidebarBodyItemId.ContainerInfo);
|
openItemSidebar(child.originalId, SidebarBodyItemId.ContainerInfo, index);
|
||||||
} else {
|
} else {
|
||||||
navigateTo({ containerId: child.originalId });
|
navigateTo({ containerId: child.originalId });
|
||||||
}
|
}
|
||||||
@@ -215,7 +223,7 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
|||||||
activeId={activeDraggingId}
|
activeId={activeDraggingId}
|
||||||
setActiveId={setActiveDraggingId}
|
setActiveId={setActiveDraggingId}
|
||||||
>
|
>
|
||||||
{orderedChildren?.map((child) => (
|
{orderedChildren?.map((child, index) => (
|
||||||
// A container can have multiple instances of the same block
|
// A container can have multiple instances of the same block
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
<SortableItem
|
<SortableItem
|
||||||
@@ -229,19 +237,20 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
|
|||||||
borderLeft: '8px solid #E1DDDB',
|
borderLeft: '8px solid #E1DDDB',
|
||||||
}}
|
}}
|
||||||
isClickable={!readOnly}
|
isClickable={!readOnly}
|
||||||
onClick={(e) => skipIfUnwantedTarget(e, (event) => handleChildClick(child, event.detail))}
|
onClick={(e) => skipIfUnwantedTarget(e, (event) => handleChildClick(child, event.detail, index))}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
handleChildClick(child, 1);
|
handleChildClick(child, 1, index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={readOnly || libReadOnly}
|
disabled={readOnly || libReadOnly}
|
||||||
cardClassName={sidebarItemInfo?.id === child.originalId ? 'selected' : undefined}
|
cardClassName={sidebarItemInfo?.id === child.originalId && sidebarItemInfo?.index === index ? 'selected' : undefined}
|
||||||
actions={(
|
actions={(
|
||||||
<ContainerRow
|
<ContainerRow
|
||||||
containerKey={containerKey}
|
containerKey={containerKey}
|
||||||
container={child}
|
container={child}
|
||||||
readOnly={readOnly || libReadOnly}
|
readOnly={readOnly || libReadOnly}
|
||||||
|
index={index}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -46,13 +46,14 @@ interface LibraryBlockMetadataWithUniqueId extends LibraryBlockMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ComponentBlockProps {
|
interface ComponentBlockProps {
|
||||||
|
index: number;
|
||||||
block: LibraryBlockMetadataWithUniqueId;
|
block: LibraryBlockMetadataWithUniqueId;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
isDragging?: boolean;
|
isDragging?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Component header */
|
/** Component header */
|
||||||
const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
|
const BlockHeader = ({ block, index, readOnly }: ComponentBlockProps) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const { showOnlyPublished } = useLibraryContext();
|
const { showOnlyPublished } = useLibraryContext();
|
||||||
const { showToast } = useContext(ToastContext);
|
const { showToast } = useContext(ToastContext);
|
||||||
@@ -118,17 +119,18 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<TagCount size="sm" count={block.tagsCount} onClick={readOnly ? undefined : jumpToManageTags} />
|
<TagCount size="sm" count={block.tagsCount} onClick={readOnly ? undefined : jumpToManageTags} />
|
||||||
{!readOnly && <ComponentMenu usageKey={block.originalId} />}
|
{!readOnly && <ComponentMenu index={index} usageKey={block.originalId} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** ComponentBlock to render preview of given component under Unit */
|
/** ComponentBlock to render preview of given component under Unit */
|
||||||
const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) => {
|
const ComponentBlock = ({
|
||||||
const { showOnlyPublished } = useLibraryContext();
|
block, readOnly, isDragging, index,
|
||||||
|
}: ComponentBlockProps) => {
|
||||||
|
const { showOnlyPublished, openComponentEditor } = useLibraryContext();
|
||||||
|
|
||||||
const { openComponentEditor } = useLibraryContext();
|
|
||||||
const { sidebarItemInfo, openItemSidebar } = useSidebarContext();
|
const { sidebarItemInfo, openItemSidebar } = useSidebarContext();
|
||||||
|
|
||||||
const handleComponentSelection = useCallback((numberOfClicks: number) => {
|
const handleComponentSelection = useCallback((numberOfClicks: number) => {
|
||||||
@@ -136,7 +138,11 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
|
|||||||
// don't allow interaction if rendered as preview
|
// don't allow interaction if rendered as preview
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
openItemSidebar(block.originalId, SidebarBodyItemId.ComponentInfo);
|
openItemSidebar(
|
||||||
|
block.originalId,
|
||||||
|
SidebarBodyItemId.ComponentInfo,
|
||||||
|
index,
|
||||||
|
);
|
||||||
const canEdit = canEditComponent(block.originalId);
|
const canEdit = canEditComponent(block.originalId);
|
||||||
if (numberOfClicks > 1 && canEdit) {
|
if (numberOfClicks > 1 && canEdit) {
|
||||||
// Open editor on double click.
|
// Open editor on double click.
|
||||||
@@ -174,7 +180,7 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
|
|||||||
<SortableItem
|
<SortableItem
|
||||||
id={block.id}
|
id={block.id}
|
||||||
componentStyle={getComponentStyle()}
|
componentStyle={getComponentStyle()}
|
||||||
actions={<BlockHeader block={block} readOnly={readOnly} />}
|
actions={<BlockHeader block={block} index={index} readOnly={readOnly} />}
|
||||||
actionStyle={{
|
actionStyle={{
|
||||||
borderRadius: '8px 8px 0px 0px',
|
borderRadius: '8px 8px 0px 0px',
|
||||||
padding: '0.5rem 1rem',
|
padding: '0.5rem 1rem',
|
||||||
@@ -189,7 +195,7 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabled={readOnly}
|
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 */}
|
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
|
||||||
<div
|
<div
|
||||||
@@ -236,7 +242,7 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
|
|||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
} = useContainerChildren(unitId, showOnlyPublished);
|
} = useContainerChildren<LibraryBlockMetadata>(unitId, showOnlyPublished);
|
||||||
|
|
||||||
const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => {
|
const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => {
|
||||||
if (!newOrder) {
|
if (!newOrder) {
|
||||||
@@ -294,6 +300,7 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
|
|||||||
// eslint-disable-next-line react/no-array-index-key
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
key={`${block.originalId}-${idx}-${block.modified}`}
|
key={`${block.originalId}-${idx}-${block.modified}`}
|
||||||
block={block}
|
block={block}
|
||||||
|
index={idx}
|
||||||
isDragging={hidePreviewFor === block.id}
|
isDragging={hidePreviewFor === block.id}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -381,15 +381,44 @@ describe('<LibraryUnitPage />', () => {
|
|||||||
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
||||||
|
|
||||||
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||||
axiosMock.onPost(restoreUrl).reply(200);
|
axiosMock.onPatch(restoreUrl).reply(200);
|
||||||
// restore collection
|
// restore collection
|
||||||
restoreFn();
|
restoreFn();
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(axiosMock.history.post.length).toEqual(1);
|
expect(axiosMock.history.patch.length).toEqual(1);
|
||||||
});
|
});
|
||||||
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
|
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 () => {
|
it('should show error on remove a component', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||||
@@ -444,11 +473,11 @@ describe('<LibraryUnitPage />', () => {
|
|||||||
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
|
||||||
|
|
||||||
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
|
||||||
axiosMock.onPost(restoreUrl).reply(404);
|
axiosMock.onPatch(restoreUrl).reply(404);
|
||||||
// restore collection
|
// restore collection
|
||||||
restoreFn();
|
restoreFn();
|
||||||
await waitFor(() => {
|
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');
|
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.
|
// content has been loaded - prior to proceeding with our expectations.
|
||||||
await waitForElementToBeRemoved(screen.queryByRole('status'));
|
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));
|
await user.click(queryByText(container, messages.nextButton.defaultMessage));
|
||||||
|
|
||||||
expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
|
expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();
|
||||||
|
|||||||
@@ -107,13 +107,13 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
'appName-legacy': {
|
'appName-legacy': {
|
||||||
id: 'authoring.discussions.appConfigForm.appName-legacy',
|
id: 'authoring.discussions.appConfigForm.appName-legacy',
|
||||||
defaultMessage: 'edX',
|
defaultMessage: 'Open edX (legacy)',
|
||||||
description: 'The name of the Legacy edX Discussions app.',
|
description: 'The name of the Legacy Open edX Discussions app.',
|
||||||
},
|
},
|
||||||
'appName-openedx': {
|
'appName-openedx': {
|
||||||
id: 'authoring.discussions.appConfigForm.appName-openedx',
|
id: 'authoring.discussions.appConfigForm.appName-openedx',
|
||||||
defaultMessage: 'edX (new)',
|
defaultMessage: 'Open edX',
|
||||||
description: 'The name of the new edX Discussions app.',
|
description: 'The name of the new Open edX Discussions app.',
|
||||||
},
|
},
|
||||||
divisionByGroup: {
|
divisionByGroup: {
|
||||||
id: 'authoring.discussions.builtIn.divisionByGroup',
|
id: 'authoring.discussions.builtIn.divisionByGroup',
|
||||||
|
|||||||
@@ -117,7 +117,6 @@ const ScheduleAndDetails = ({ courseId }) => {
|
|||||||
license,
|
license,
|
||||||
language,
|
language,
|
||||||
subtitle,
|
subtitle,
|
||||||
overview,
|
|
||||||
duration,
|
duration,
|
||||||
selfPaced,
|
selfPaced,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -128,7 +127,6 @@ const ScheduleAndDetails = ({ courseId }) => {
|
|||||||
instructorInfo,
|
instructorInfo,
|
||||||
enrollmentStart,
|
enrollmentStart,
|
||||||
shortDescription,
|
shortDescription,
|
||||||
aboutSidebarHtml,
|
|
||||||
preRequisiteCourses,
|
preRequisiteCourses,
|
||||||
entranceExamEnabled,
|
entranceExamEnabled,
|
||||||
courseImageAssetPath,
|
courseImageAssetPath,
|
||||||
@@ -140,6 +138,12 @@ const ScheduleAndDetails = ({ courseId }) => {
|
|||||||
} = editedValues;
|
} = editedValues;
|
||||||
|
|
||||||
useScrollToHashElement({ isLoading });
|
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) {
|
if (isLoading) {
|
||||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||||
@@ -277,12 +281,12 @@ const ScheduleAndDetails = ({ courseId }) => {
|
|||||||
)}
|
)}
|
||||||
<IntroducingSection
|
<IntroducingSection
|
||||||
title={title}
|
title={title}
|
||||||
overview={overview}
|
overview={initialOverview}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
subtitle={subtitle}
|
subtitle={subtitle}
|
||||||
introVideo={introVideo}
|
introVideo={introVideo}
|
||||||
description={description}
|
description={description}
|
||||||
aboutSidebarHtml={aboutSidebarHtml}
|
aboutSidebarHtml={initialAboutSidebarHtml}
|
||||||
shortDescription={shortDescription}
|
shortDescription={shortDescription}
|
||||||
aboutPageEditable={aboutPageEditable}
|
aboutPageEditable={aboutPageEditable}
|
||||||
sidebarHtmlEnabled={sidebarHtmlEnabled}
|
sidebarHtmlEnabled={sidebarHtmlEnabled}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Routes,
|
Routes,
|
||||||
} from 'react-router-dom';
|
} from 'react-router-dom';
|
||||||
|
|
||||||
|
import * as authzApi from '@src/authz/data/api';
|
||||||
import { ToastContext, type ToastContextData } from './generic/toast-context';
|
import { ToastContext, type ToastContextData } from './generic/toast-context';
|
||||||
import initializeReduxStore, { type DeprecatedReduxState } from './store';
|
import initializeReduxStore, { type DeprecatedReduxState } from './store';
|
||||||
import { getApiWaffleFlagsUrl } from './data/api';
|
import { getApiWaffleFlagsUrl } from './data/api';
|
||||||
@@ -31,6 +32,7 @@ import { getApiWaffleFlagsUrl } from './data/api';
|
|||||||
let reduxStore: Store;
|
let reduxStore: Store;
|
||||||
let queryClient: QueryClient;
|
let queryClient: QueryClient;
|
||||||
let axiosMock: MockAdapter;
|
let axiosMock: MockAdapter;
|
||||||
|
let validateUserPermissionsMock: jest.SpiedFunction<typeof authzApi.validateUserPermissions>;
|
||||||
|
|
||||||
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
|
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
|
||||||
let mockToastContext: ToastContextData = {
|
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.
|
// Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history.
|
||||||
jest.clearAllMocks();
|
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 {
|
return {
|
||||||
reduxStore,
|
reduxStore,
|
||||||
axiosMock,
|
axiosMock,
|
||||||
mockShowToast: mockToastContext.showToast,
|
mockShowToast: mockToastContext.showToast,
|
||||||
mockToastAction: mockToastContext.toastAction,
|
mockToastAction: mockToastContext.toastAction,
|
||||||
queryClient,
|
queryClient,
|
||||||
|
validateUserPermissionsMock,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user