Compare commits

...

14 Commits

Author SHA1 Message Date
Navin Karkera
fedb85577e feat: add temporary message alert in sections settings tab in libraries (#2734) (#2766)
- add temporary message alert in sections settings tab in libraries
- increase sidebar width to remove `More` option and display all tabs
together

(cherry picked from commit 3eeca244d7)
2025-12-19 16:36:31 -08:00
David Ormsbee
18e51db70a fix: support "in progress" status for lib upload
When uploading a library archive file during the creation of a new
library, the code prior to this commit did not properly handle the "In
Progress" state, which is when the celery task doing the archive
processing is actively running. Note that this is distinct from the
"Pending" state, which is when the task is waiting in the queue to be
run (which in practice should almost never happen unless there is an
operational issue).

Since celery tasks run in-process during local development, the task
was always finished by the time that the browser made a call to check
on the status. The problem only happened on slower sandboxes, where
processing truly runs asynchronously and might take a few seconds.
Because this case wasn't handled, the frontend would never poll for
updates either, so the upload was basically lost as far as the user
was concerned.
2025-12-12 21:37:59 -05:00
Rodrigo Mendez
4a1d0a2716 feat: Implement querying openedx-authz for publish permissions (#2685) (#2733) 2025-12-08 15:58:35 -05:00
Daniel Wong
2ba6f96142 feat: add support for origin server and user info (#2663) (#2710)
* feat: add support for origin server and user info

* test: add coverage for restore archive summary

* test: increase coverage for restore archive summary

* fix: address comments
2025-12-04 13:24:06 -06:00
Rômulo Penido
28f0c9943d fix: migrate library alert text (#2727)
Backport of #2651
2025-12-04 09:41:52 -05:00
Asad Ali
067806a0e6 fix: do not reload multiple tabs on block save (#2600) (#2705) 2025-12-01 18:13:45 -05:00
Kyle McCormick
7ebf349789 fix: "Back up" is two words when used as a verb (#2706)
There is a new menu item "Backup to local archive". Backup is the correct
spelling when using it as a noun or adjective, but the menu item uses as a
verb, so it should be two words, back up, i.e. "Back up to local archive"

Backports 70c19a3ffb
2025-11-26 12:18:57 -05:00
Navin Karkera
7a1bc3931a fix: don't revert to advanced editor if block contains copied_from fields (#2661) (#2695)
(cherry picked from commit 2215fc53cc)
2025-11-25 16:17:03 -05:00
Kyle McCormick
9bea56b3ae fix: Rename builtin discussion providers, "edX" -> "Open edX" (#2662)
Backports 5fadccabe2 to Ulmo
2025-11-18 10:46:38 -05:00
Muhammad Anas
c7a84a1a9c fix: unit button active state (#2617) (#2650) (backport) 2025-11-13 12:24:20 -05:00
Muhammad Arslan
ad0e1ae570 fix: broken Course Overview editor on Schedule & Details page (#2604) (backport) 2025-11-13 11:10:32 -05:00
Muhammad Arslan
bd00c3b271 fix: self-closing script tag fixed for TinyMceEditor (#2608) (backport) 2025-11-07 09:42:32 -08:00
Chris Chávez
de8b4b460b style: Update some texts in legacy libraries migration flow (#2601) (#2603) 2025-11-05 18:46:32 -05:00
Navin Karkera
fa2bd8a604 chore: backport latest bug fixes (#2602)
Backport of https://github.com/openedx/frontend-app-authoring/pull/2584 and https://github.com/openedx/frontend-app-authoring/pull/2587
2025-11-05 17:23:28 -05:00
49 changed files with 891 additions and 205 deletions

16
src/authz/constants.ts Normal file
View 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
View 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;
};

View 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
}
});
});

View 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
View 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
View 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;
}

View File

@@ -13,6 +13,7 @@ import { LoadingSpinner } from '@src/generic/Loading';
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
import { BoldText } from '@src/utils';
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
import ChildrenPreview from './ChildrenPreview';
import ContainerRow from './ContainerRow';
import { useCourseContainerChildren } from './data/apiHooks';
@@ -60,7 +61,7 @@ const CompareContainersWidgetInner = ({
data: libData,
isError: isLibError,
error: libError,
} = useContainerChildren(state === 'removed' ? undefined : upstreamBlockId, true);
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
const {
data: containerData,
isError: isContainerTitleError,

View File

@@ -10,13 +10,13 @@ const UnitButton = ({
unitId,
className,
showTitle,
isActive, // passed from parent (SequenceNavigationTabs)
}) => {
const courseId = useSelector(getCourseId);
const sequenceId = useSelector(getSequenceId);
const unit = useSelector((state) => state.models.units[unitId]);
const { title, contentType, isActive } = unit || {};
const { title, contentType } = unit || {};
return (
<Button
@@ -37,11 +37,13 @@ UnitButton.propTypes = {
className: PropTypes.string,
showTitle: PropTypes.bool,
unitId: PropTypes.string.isRequired,
isActive: PropTypes.bool,
};
UnitButton.defaultProps = {
className: undefined,
showTitle: false,
isActive: false,
};
export default UnitButton;

View File

@@ -24,6 +24,7 @@ import {
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
updateCourseUnitSidebar,
} from './data/thunk';
import {
getCanEdit,
@@ -231,8 +232,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
// edits the component using editor which has a separate store
/* istanbul ignore next */
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
dispatch(updateCourseUnitSidebar(blockId));
localStorage.removeItem(event.key);
}
};

View File

@@ -2,7 +2,7 @@
// lint is disabled for this file due to strict spacing
export const checkboxesOLXWithFeedbackAndHintsOLX = {
rawOLX: `<problem url_name="this_should_be_ignored">
rawOLX: `<problem url_name="this_should_be_ignored" copied_from_version="2" copied_from_block="some-block">
<choiceresponse>
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for checkboxes with hints and feedback problems. Edit this component to replace this template with your own assessment.</p>
<label>Add the question text, or prompt, here. This text is required.</label>

View File

@@ -380,6 +380,8 @@ export const ignoredOlxAttributes = [
'@_url_name',
'@_x-is-pointer-node',
'@_markdown_edited',
'@_copied_from_block',
'@_copied_from_version',
] as const;
// Useful for the block creation workflow.

View File

@@ -126,9 +126,9 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => {
onSuccess: (response) => {
dispatch(actions.app.setSaveResponse(response));
const parsedData = JSON.parse(response.config.data);
if (parsedData?.has_changes) {
if (parsedData?.has_changes || !('has_changes' in parsedData)) {
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
localStorage.setItem(storageKey, Date.now());
sessionStorage.setItem(storageKey, Date.now());
window.dispatchEvent(new StorageEvent('storage', {
key: storageKey,

View File

@@ -457,6 +457,9 @@ export const editorConfig = ({
valid_elements: '*[*]',
// FIXME: this is passing 'utf-8', which is not a valid entity_encoding value. It should be 'named' etc.
entity_encoding: 'utf-8' as any,
// Protect self-closing <script /> tags from being mangled,
// to preserve backwards compatibility with content that relied on this behavior
protect: [/<script[^>]*\/>/g],
},
};
};

View File

@@ -103,8 +103,8 @@ const messages = defineMessages({
},
'header.links.exportLibrary': {
id: 'header.links.exportLibrary',
defaultMessage: 'Backup to local archive',
description: 'Link to Studio Backup Library page',
defaultMessage: 'Back up to local archive',
description: 'Link to Studio Library Backup page',
},
'header.links.optimizer': {
id: 'header.links.optimizer',

View File

@@ -81,6 +81,7 @@ export const ConfirmationView = ({
</Alert>
{legacyLibraries.map((legacyLib) => (
<ConfirmationCard
key={legacyLib.libraryKey}
legacyLib={legacyLib}
destinationName={destination.title}
/>

View File

@@ -6,6 +6,7 @@ import {
render,
screen,
waitFor,
within,
} from '@src/testUtils';
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
import { mockGetContentLibraryV2List } from '@src/library-authoring/data/api.mocks';
@@ -184,7 +185,7 @@ describe('<LegacyLibMigrationPage />', () => {
nextButton.click();
// Should show alert of SelectDestinationView
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
const backButton = screen.getByRole('button', { name: /back/i });
backButton.click();
@@ -210,7 +211,7 @@ describe('<LegacyLibMigrationPage />', () => {
nextButton.click();
// Should show alert of SelectDestinationView
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
// The next button is disabled
expect(nextButton).toBeDisabled();
@@ -224,27 +225,31 @@ describe('<LegacyLibMigrationPage />', () => {
});
it('should back to select library destination', async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText('Migrate Legacy Libraries')).toBeInTheDocument();
expect(await screen.findByText('MBA')).toBeInTheDocument();
const legacyLibrary = screen.getByRole('checkbox', { name: 'MBA' });
legacyLibrary.click();
await user.click(legacyLibrary);
const nextButton = screen.getByRole('button', { name: /next/i });
nextButton.click();
const nextButton = await screen.findByRole('button', { name: /next/i });
await user.click(nextButton);
// Should show alert of SelectDestinationView
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
radioButton.click();
await user.click(radioButton);
nextButton.click();
expect(await screen.findByText(/these 1 legacy library will be migrated to/i)).toBeInTheDocument();
await user.click(nextButton);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
/All content from the legacy library you selected will be migrated to/,
)).toBeInTheDocument();
const backButton = screen.getByRole('button', { name: /back/i });
backButton.click();
await user.click(backButton);
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
// The selected v2 library remains checked
@@ -269,7 +274,7 @@ describe('<LegacyLibMigrationPage />', () => {
nextButton.click();
// Should show alert of SelectDestinationView
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
const createButton = await screen.findByRole('button', { name: /create new library/i });
expect(createButton).toBeInTheDocument();
@@ -336,18 +341,21 @@ describe('<LegacyLibMigrationPage />', () => {
legacyLibrary3.click();
const nextButton = screen.getByRole('button', { name: /next/i });
nextButton.click();
await user.click(nextButton);
// Should show alert of SelectDestinationView
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
radioButton.click();
await user.click(radioButton);
nextButton.click();
await user.click(nextButton);
// Should show alert of ConfirmationView
expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
/All content from the 3 legacy libraries you selected will be migrated to/,
)).toBeInTheDocument();
expect(screen.getByText('MBA')).toBeInTheDocument();
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
expect(screen.getByText('MBA 1')).toBeInTheDocument();
@@ -390,18 +398,22 @@ describe('<LegacyLibMigrationPage />', () => {
legacyLibrary3.click();
const nextButton = screen.getByRole('button', { name: /next/i });
nextButton.click();
await user.click(nextButton);
// Should show alert of SelectDestinationView
expect(await screen.findByText(/any legacy libraries that are used/i)).toBeInTheDocument();
expect(await screen.findByText(/you selected will be migrated to the Content Library you/)).toBeInTheDocument();
expect(await screen.findByText('Test Library 1')).toBeInTheDocument();
const radioButton = screen.getByRole('radio', { name: /test library 1/i });
radioButton.click();
await user.click(radioButton);
nextButton.click();
await user.click(nextButton);
// Should show alert of ConfirmationView
expect(await screen.findByText(/these 3 legacy libraries will be migrated to/i)).toBeInTheDocument();
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
/All content from the 3 legacy libraries you selected will be migrated to/,
{ exact: false },
)).toBeInTheDocument();
expect(screen.getByText('MBA')).toBeInTheDocument();
expect(screen.getByText('Legacy library 1')).toBeInTheDocument();
expect(screen.getByText('MBA 1')).toBeInTheDocument();

View File

@@ -64,15 +64,17 @@ const messages = defineMessages({
selectDestinationAlert: {
id: 'legacy-libraries-migration.select-destination.alert.text',
defaultMessage: 'All content from the'
+ ' {count, plural, one {{count} legacy library} other {{count} legacy libraries}} you selected will'
+ ' be migrated to this new library, organized into collections. Any legacy libraries that are used in'
+ ' problem banks will maintain their link with migrated content the first time they are migrated.',
+ ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will'
+ ' be migrated to the Content Library you select, organized into collections. Legacy library content used'
+ ' in courses will continue to work as-is. To receive any future changes to migrated content,'
+ ' you must update these references within your course.',
description: 'Alert text in the select destination step of the legacy libraries migration page.',
},
confirmationViewAlert: {
id: 'legacy-libraries-migration.select-destination.alert.text',
defaultMessage: 'These {count, plural, one {{count} legacy library} other {{count} legacy libraries}}'
+ ' will be migrated to <b>{libraryName}</b> and organized as collections. Legacy library content used'
defaultMessage: 'All content from the'
+ ' {count, plural, one {legacy library} other {{count} legacy libraries}} you selected will'
+ ' be migrated to <b>{libraryName}</b> and organized into collections. Legacy library content used'
+ ' in courses will continue to work as-is. To receive any future changes to migrated content,'
+ ' you must update these references within your course.',
description: 'Alert text in the confirmation step of the legacy libraries migration page.',
@@ -80,7 +82,7 @@ const messages = defineMessages({
previouslyMigratedAlert: {
id: 'legacy-libraries-migration.confirmation-step.card.previously-migrated.text',
defaultMessage: 'Previously migrated library. Any problem bank links were already'
+ ' moved will be migrated to <b>{libraryName}</b>',
+ ' moved will be migrated to <b>{libraryName}</b>',
description: 'Alert text when the legacy library is already migrated.',
},
helpAndSupportTitle: {
@@ -96,8 +98,8 @@ const messages = defineMessages({
helpAndSupportFirstQuestionBody: {
id: 'legacy-libraries-migration.helpAndSupport.q1.body',
defaultMessage: 'In the new Content Libraries experience, you can author sections,'
+ ' subsections, units, and many types of components. Library content can be reused across many courses,'
+ ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.',
+ ' subsections, units, and many types of components. Library content can be reused across many courses,'
+ ' and kept up-to-date. Content libraries now support increased collaboration across authoring teams.',
description: 'Body of the first question in the Help & Support sidebar',
},
helpAndSupportSecondQuestionTitle: {
@@ -108,9 +110,9 @@ const messages = defineMessages({
helpAndSupportSecondQuestionBody: {
id: 'legacy-libraries-migration.helpAndSupport.q2.body',
defaultMessage: 'All legacy library content is supported in the new experience.'
+ ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.'
+ ' This collection will have the same name as your original library. Courses that use legacy library content will'
+ ' continue to function as usual, linked to the migrated version within the new libraries experience.',
+ ' Content from legacy libraries will be migrated to its own collection in the new Content Libraries experience.'
+ ' This collection will have the same name as your original library. Courses that use legacy library content will'
+ ' continue to function as usual, linked to the migrated version within the new libraries experience.',
description: 'Body of the second question in the Help & Support sidebar',
},
helpAndSupportThirdQuestionTitle: {
@@ -121,18 +123,18 @@ const messages = defineMessages({
helpAndSupportThirdQuestionBody: {
id: 'legacy-libraries-migration.helpAndSupport.q3.body.2',
defaultMessage: '<p>There are three steps to migrating legacy libraries:</p>'
+ '<p><div>1 - Select Legacy Libraries</div>'
+ 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have'
+ ' not yet been migrated are shown. To see all libraries, remove the filter.'
+ ' You can select up to 50 legacy libraries for migration, but only one destination'
+ ' v2 Content Library per migration.</p>'
+ '<p><div>2 - Select Destination</div>'
+ 'You can migrate legacy libraries to an existing Content Library in the new experience,'
+ ' or you can create a new destination. You can only select one v2 Content Library per migration.'
+ ' All your content will be migrated, and kept organized in collections.</p>'
+ '<p><div>3 - Confirm</div>'
+ 'In this step, review your migration. Once you confirm, migration will begin.'
+ ' It may take some time to complete.</p>',
+ '<p><div>1 - Select Legacy Libraries</div>'
+ 'You can select up to 50 legacy libraries for migration in this step. By default, only libraries that have'
+ ' not yet been migrated are shown. To see all libraries, remove the filter.'
+ ' You can select up to 50 legacy libraries for migration, but only one destination'
+ ' v2 Content Library per migration.</p>'
+ '<p><div>2 - Select Destination</div>'
+ 'You can migrate legacy libraries to an existing Content Library in the new experience,'
+ ' or you can create a new destination. You can only select one v2 Content Library per migration.'
+ ' All your content will be migrated, and kept organized in collections.</p>'
+ '<p><div>3 - Confirm</div>'
+ 'In this step, review your migration. Once you confirm, migration will begin.'
+ ' It may take some time to complete.</p>',
description: 'Part 2 of the Body of the third question in the Help & Support sidebar',
},
migrationInProgress: {

View File

@@ -13,7 +13,7 @@
.library-authoring-sidebar {
z-index: 1000; // same as header
flex: 500px 0 0;
flex: 530px 0 0;
position: sticky;
top: 0;
right: 0;

View File

@@ -7,6 +7,8 @@ import {
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import { useUserPermissions } from '@src/authz/data/apiHooks';
import { CONTENT_LIBRARY_PERMISSIONS } from '@src/authz/constants';
import { ContainerType } from '../../../generic/key-utils';
import type { ComponentPicker } from '../../component-picker';
@@ -25,6 +27,7 @@ export type LibraryContextData = {
libraryId: string;
libraryData?: ContentLibrary;
readOnly: boolean;
canPublish: boolean;
isLoadingLibraryData: boolean;
/** The ID of the current collection/container, on the sidebar OR page */
collectionId: string | undefined;
@@ -107,6 +110,13 @@ export const LibraryProvider = ({
componentPickerMode,
} = useComponentPickerContext();
const { isLoading: isLoadingUserPermissions, data: userPermissions } = useUserPermissions({
canPublish: {
action: CONTENT_LIBRARY_PERMISSIONS.PUBLISH_LIBRARY_CONTENT,
scope: libraryId,
},
});
const canPublish = userPermissions?.canPublish || false;
const readOnly = !!componentPickerMode || !libraryData?.canEditLibrary;
// Parse the initial collectionId and/or container ID(s) from the current URL params
@@ -131,7 +141,8 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
isLoadingLibraryData,
canPublish,
isLoadingLibraryData: isLoadingLibraryData || isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
@@ -154,7 +165,9 @@ export const LibraryProvider = ({
containerId,
setContainerId,
readOnly,
canPublish,
isLoadingLibraryData,
isLoadingUserPermissions,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,

View File

@@ -72,6 +72,7 @@ export interface DefaultTabs {
export interface SidebarItemInfo {
type: SidebarBodyItemId;
id: string;
index?: number;
}
export enum SidebarActions {
@@ -88,7 +89,7 @@ export type SidebarContextData = {
openCollectionInfoSidebar: (collectionId: string) => void;
openComponentInfoSidebar: (usageKey: string) => void;
openContainerInfoSidebar: (usageKey: string) => void;
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId) => void;
openItemSidebar: (selectedItemId: string, type: SidebarBodyItemId, index?: number) => void;
sidebarItemInfo?: SidebarItemInfo;
sidebarAction: SidebarActions;
setSidebarAction: (action: SidebarActions) => void;
@@ -154,35 +155,38 @@ export const SidebarProvider = ({
setSidebarItemInfo({ id: '', type: SidebarBodyItemId.Info });
}, []);
const openComponentInfoSidebar = useCallback((usageKey: string) => {
const openComponentInfoSidebar = useCallback((usageKey: string, index?: number) => {
setSidebarItemInfo({
id: usageKey,
type: SidebarBodyItemId.ComponentInfo,
index,
});
}, []);
const openCollectionInfoSidebar = useCallback((newCollectionId: string) => {
const openCollectionInfoSidebar = useCallback((newCollectionId: string, index?: number) => {
setSidebarItemInfo({
id: newCollectionId,
type: SidebarBodyItemId.CollectionInfo,
index,
});
}, []);
const openContainerInfoSidebar = useCallback((usageKey: string) => {
const openContainerInfoSidebar = useCallback((usageKey: string, index?: number) => {
setSidebarItemInfo({
id: usageKey,
type: SidebarBodyItemId.ContainerInfo,
index,
});
}, []);
const { navigateTo } = useLibraryRoutes();
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId) => {
navigateTo({ selectedItemId });
setSidebarItemInfo({ id: selectedItemId, type });
const openItemSidebar = useCallback((selectedItemId: string, type: SidebarBodyItemId, index?: number) => {
navigateTo({ selectedItemId, index });
setSidebarItemInfo({ id: selectedItemId, type, index });
}, [navigateTo, setSidebarItemInfo]);
// Set the initial sidebar state based on the URL parameters and context.
const { selectedItemId } = useParams();
const { selectedItemId, index: indexParam } = useParams();
const { collectionId, containerId } = useLibraryContext();
const { componentPickerMode } = useComponentPickerContext();
@@ -198,12 +202,15 @@ export const SidebarProvider = ({
// Handle selected item id changes
if (selectedItemId) {
// if a item is selected that means we have list of items displayed
// which means we can get the index from url and set it.
const indexNumber = indexParam ? Number(indexParam) : undefined;
if (selectedItemId.startsWith('lct:')) {
openContainerInfoSidebar(selectedItemId);
openContainerInfoSidebar(selectedItemId, indexNumber);
} else if (selectedItemId.startsWith('lb:')) {
openComponentInfoSidebar(selectedItemId);
openComponentInfoSidebar(selectedItemId, indexNumber);
} else {
openCollectionInfoSidebar(selectedItemId);
openCollectionInfoSidebar(selectedItemId, indexNumber);
}
} else if (collectionId) {
openCollectionInfoSidebar(collectionId);

View File

@@ -111,6 +111,8 @@ const ComponentActions = ({
const [isPublisherOpen, openPublisher, closePublisher] = useToggle(false);
const canEdit = canEditComponent(componentId);
const { sidebarItemInfo } = useSidebarContext();
if (isPublisherOpen) {
return (
<ComponentPublisher
@@ -141,7 +143,7 @@ const ComponentActions = ({
)}
</div>
<div className="mt-2">
<ComponentMenu usageKey={componentId} />
<ComponentMenu usageKey={componentId} index={sidebarItemInfo?.index} />
</div>
</div>
);

View File

@@ -64,7 +64,7 @@ const BaseCard = ({
<Card.Header
className={`library-item-header ${getComponentStyleColor(itemType)}`}
title={
<Icon src={itemIcon} className="library-item-header-icon" />
<Icon src={itemIcon} className="library-item-header-icon my-2" />
}
actions={(
<div

View File

@@ -12,18 +12,23 @@ import { useClipboard } from '@src/generic/clipboard';
import { getBlockType } from '@src/generic/key-utils';
import { ToastContext } from '@src/generic/toast-context';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '../data/apiHooks';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
import containerMessages from '@src/library-authoring/containers/messages';
import { useLibraryRoutes } from '@src/library-authoring/routes';
import { useRunOnNextRender } from '@src/utils';
import { canEditComponent } from './ComponentEditorModal';
import ComponentDeleter from './ComponentDeleter';
import ComponentRemover from './ComponentRemover';
import messages from './messages';
import containerMessages from '../containers/messages';
import { useLibraryRoutes } from '../routes';
import { useRunOnNextRender } from '../../utils';
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
interface Props {
usageKey: string;
index?: number;
}
export const ComponentMenu = ({ usageKey, index }: Props) => {
const intl = useIntl();
const {
libraryId,
@@ -135,6 +140,7 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
{isRemoveModalOpen && (
<ComponentRemover
usageKey={usageKey}
index={index}
close={closeRemoveModal}
/>
)}

View File

@@ -4,31 +4,38 @@ import { Warning } from '@openedx/paragon/icons';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import {
useContainer,
useRemoveContainerChildren,
useAddItemsToContainer,
useLibraryBlockMetadata,
} from '../data/apiHooks';
useContainerChildren,
useUpdateContainerChildren,
} from '@src/library-authoring/data/apiHooks';
import { LibraryBlockMetadata } from '@src/library-authoring/data/api';
import messages from './messages';
interface Props {
usageKey: string;
index?: number;
close: () => void;
}
const ComponentRemover = ({ usageKey, close }: Props) => {
const ComponentRemover = ({ usageKey, index, close }: Props) => {
const intl = useIntl();
const { sidebarItemInfo, closeLibrarySidebar } = useSidebarContext();
const { containerId } = useLibraryContext();
const { containerId, showOnlyPublished } = useLibraryContext();
const { showToast } = useContext(ToastContext);
const removeContainerItemMutation = useRemoveContainerChildren(containerId);
const addItemToContainerMutation = useAddItemsToContainer(containerId);
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
const { data: container, isPending: isPendingParentContainer } = useContainer(containerId);
const { data: component, isPending } = useLibraryBlockMetadata(usageKey);
// Use update api for children if duplicates are present to avoid removing all instances of the child
const { data: children } = useContainerChildren<LibraryBlockMetadata>(containerId, showOnlyPublished);
const childrenUsageIds = children?.map((child) => child.id);
const hasDuplicates = (childrenUsageIds?.filter((child) => child === usageKey).length || 0) > 1;
// istanbul ignore if: loading state
if (isPending || isPendingParentContainer) {
@@ -36,28 +43,62 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
return null;
}
const restoreComponent = () => {
// istanbul ignore if: this should never happen
if (!childrenUsageIds) {
return;
}
updateContainerChildrenMutation.mutateAsync(childrenUsageIds).then(() => {
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
}).catch(() => {
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
});
};
const showSuccessToast = () => {
showToast(
intl.formatMessage(messages.removeComponentFromContainerSuccess),
{
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
onClick: restoreComponent,
},
);
};
const showFailureToast = () => showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
const removeFromContainer = () => {
const restoreComponent = () => {
addItemToContainerMutation.mutateAsync([usageKey]).then(() => {
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
}).catch(() => {
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
});
};
removeContainerItemMutation.mutateAsync([usageKey]).then(() => {
if (sidebarItemInfo?.id === usageKey) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
showToast(
intl.formatMessage(messages.removeComponentFromContainerSuccess),
{
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
onClick: restoreComponent,
},
);
showSuccessToast();
}).catch(() => {
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
showFailureToast();
});
close();
};
const excludeOneInstance = () => {
if (!childrenUsageIds || typeof index === 'undefined') {
return;
}
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== usageKey || idx !== index);
updateContainerChildrenMutation.mutateAsync(updatedKeys).then(() => {
// istanbul ignore if
if (sidebarItemInfo?.id === usageKey && sidebarItemInfo?.index === index) {
// Close sidebar if current component is open
closeLibrarySidebar();
}
// Already tested as part of removeFromContainer
// istanbul ignore next
showSuccessToast();
}).catch(() => {
// Already tested as part of removeFromContainer
// istanbul ignore next
showFailureToast();
});
close();
@@ -76,7 +117,7 @@ const ComponentRemover = ({ usageKey, close }: Props) => {
title={intl.formatMessage(messages.removeComponentWarningTitle)}
icon={Warning}
description={removeText}
onDeleteSubmit={removeFromContainer}
onDeleteSubmit={hasDuplicates ? excludeOneInstance : removeFromContainer}
btnLabel={intl.formatMessage(messages.componentRemoveButtonLabel)}
buttonVariant="primary"
/>

View File

@@ -17,23 +17,24 @@ import { type ContainerHit, Highlight, PublishStatus } from '@src/search-manager
import { ToastContext } from '@src/generic/toast-context';
import { useRunOnNextRender } from '@src/utils';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '../common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '../data/apiHooks';
import { useLibraryRoutes } from '../routes';
import { useComponentPickerContext } from '@src/library-authoring/common/context/ComponentPickerContext';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import { SidebarActions, SidebarBodyItemId, useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import { useRemoveItemsFromCollection } from '@src/library-authoring/data/apiHooks';
import { useLibraryRoutes } from '@src/library-authoring/routes';
import BaseCard from '@src/library-authoring/components/BaseCard';
import AddComponentWidget from '@src/library-authoring/components/AddComponentWidget';
import messages from './messages';
import ContainerDeleter from './ContainerDeleter';
import ContainerRemover from './ContainerRemover';
import BaseCard from '../components/BaseCard';
import AddComponentWidget from '../components/AddComponentWidget';
type ContainerMenuProps = {
containerKey: string;
displayName: string;
index?: number;
};
export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps) => {
export const ContainerMenu = ({ containerKey, displayName, index } : ContainerMenuProps) => {
const intl = useIntl();
const { libraryId, collectionId, containerId } = useLibraryContext();
const {
@@ -144,6 +145,7 @@ export const ContainerMenu = ({ containerKey, displayName } : ContainerMenuProps
close={cancelRemove}
containerKey={containerKey}
displayName={displayName}
index={index}
/>
)}
</>

View File

@@ -8,10 +8,11 @@ import {
Icon,
IconButton,
useToggle,
Alert,
} from '@openedx/paragon';
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { MoreVert } from '@openedx/paragon/icons';
import { InfoOutline, MoreVert } from '@openedx/paragon/icons';
import { useClipboard } from '@src/generic/clipboard';
import { ContainerType, getBlockType } from '@src/generic/key-utils';
@@ -149,6 +150,15 @@ const ContainerActions = ({
);
};
/* istanbul ignore next */
/* istanbul ignore next */
const ContainerSettings = () => (
<Alert icon={InfoOutline} variant="info">
<p>
<FormattedMessage {...messages.containerSettingsMsg} />
</p>
</Alert>
);
const ContainerInfo = () => {
const intl = useIntl();
const {
@@ -222,7 +232,7 @@ const ContainerInfo = () => {
{renderTab(
CONTAINER_INFO_TABS.Settings,
intl.formatMessage(messages.settingsTabTitle),
// TODO: container settings component
<ContainerSettings />,
)}
</Tabs>
</Stack>

View 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();
});
});

View File

@@ -7,32 +7,42 @@ import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { getBlockType } from '@src/generic/key-utils';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import { useContainer, useRemoveContainerChildren } from '../data/apiHooks';
import messages from '../components/messages';
import { useSidebarContext } from '@src/library-authoring/common/context/SidebarContext';
import { useLibraryContext } from '@src/library-authoring/common/context/LibraryContext';
import {
useContainer, useContainerChildren, useRemoveContainerChildren, useUpdateContainerChildren,
} from '@src/library-authoring/data/apiHooks';
import messages from '@src/library-authoring/components/messages';
import { Container } from '@src/library-authoring/data/api';
type ContainerRemoverProps = {
close: () => void,
containerKey: string,
displayName: string,
index?: number,
};
const ContainerRemover = ({
close,
containerKey,
displayName,
index,
}: ContainerRemoverProps) => {
const intl = useIntl();
const {
sidebarItemInfo,
closeLibrarySidebar,
} = useSidebarContext();
const { containerId } = useLibraryContext();
const { containerId, showOnlyPublished } = useLibraryContext();
const { showToast } = useContext(ToastContext);
const removeContainerMutation = useRemoveContainerChildren(containerId);
const updateContainerChildrenMutation = useUpdateContainerChildren(containerId);
const { data: container, isPending } = useContainer(containerId);
// Use update api for children if duplicates are present to avoid removing all instances of the child
const { data: children } = useContainerChildren<Container>(containerId, showOnlyPublished);
const childrenUsageIds = children?.map((child) => child.id);
const hasDuplicates = (childrenUsageIds?.filter((child) => child === containerKey).length || 0) > 1;
const itemType = getBlockType(containerKey);
const removeWarningTitle = intl.formatMessage(messages.removeContainerWarningTitle, {
@@ -50,9 +60,19 @@ const ContainerRemover = ({
const onRemove = useCallback(async () => {
try {
await removeContainerMutation.mutateAsync([containerKey]);
if (sidebarItemInfo?.id === containerKey) {
closeLibrarySidebar();
if (hasDuplicates && childrenUsageIds && typeof index !== 'undefined') {
const updatedKeys = childrenUsageIds.filter((childId, idx) => childId !== containerKey || idx !== index);
await updateContainerChildrenMutation.mutateAsync(updatedKeys);
// istanbul ignore if
if (sidebarItemInfo?.id === containerKey && sidebarItemInfo?.index === index) {
closeLibrarySidebar();
}
} else {
await removeContainerMutation.mutateAsync([containerKey]);
// istanbul ignore if
if (sidebarItemInfo?.id === containerKey) {
closeLibrarySidebar();
}
}
showToast(removeSuccess);
} catch (e) {
@@ -63,12 +83,16 @@ const ContainerRemover = ({
}, [
containerKey,
removeContainerMutation,
updateContainerChildrenMutation,
sidebarItemInfo,
closeLibrarySidebar,
showToast,
removeSuccess,
removeError,
close,
hasDuplicates,
childrenUsageIds,
index,
]);
// istanbul ignore if: loading state

View File

@@ -66,6 +66,11 @@ const messages = defineMessages({
defaultMessage: 'Container actions menu',
description: 'Alt/title text for the container card menu button.',
},
containerSettingsMsg: {
id: 'course-authoring.library-authoring.container.settings.alert.message',
defaultMessage: 'Section settings cannot be configured within Libraries and must be set within a course. In a future release, Libraries may support configuring some settings.',
description: 'Temporary message for settings tab being',
},
menuOpen: {
id: 'course-authoring.library-authoring.menu.open',
defaultMessage: 'Open',

View File

@@ -435,7 +435,7 @@ describe('<CreateLibrary />', () => {
sections: 8,
subsections: 12,
units: 20,
createdOnServer: '2025-01-01T10:00:00Z',
createdOnServer: 'test.com',
createdAt: '2025-01-01T10:00:00Z',
createdBy: {
username: 'testuser',
@@ -478,7 +478,67 @@ describe('<CreateLibrary />', () => {
await waitFor(() => {
expect(screen.getByText('Test Archive Library')).toBeInTheDocument();
expect(screen.getByText('TestOrg / test-archive')).toBeInTheDocument();
expect(screen.getByText(/Contains 15 Components/i)).toBeInTheDocument();
// Testing the archive details summary
expect(screen.getByText(/Contains 8 sections, 12 subsections, 20 units, 15 components/i)).toBeInTheDocument();
expect(screen.getByText(/Created on instance test.com/i)).toBeInTheDocument();
expect(screen.getByText(/by user test@example.com/i)).toBeInTheDocument();
});
});
test('shows success state without instance and user email information', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
const mockResult = {
learningPackageId: 123,
title: 'Test Archive Library',
org: 'TestOrg',
slug: 'test-archive',
key: 'TestOrg/test-archive',
archiveKey: 'archive-key',
containers: 5,
components: 15,
collections: 3,
sections: 8,
subsections: 12,
units: 20,
createdOnServer: null,
createdAt: '2025-01-01T10:00:00Z',
createdBy: null,
};
// Pre-set the restore status to succeeded
mockRestoreStatusData = {
state: LibraryRestoreStatus.Succeeded,
result: mockResult,
error: null,
errorLog: null,
};
// Mock the restore mutation to return a task ID
mockRestoreMutate.mockImplementation((_file: File, { onSuccess }: any) => {
onSuccess({ taskId: 'task-123' });
});
render(<CreateLibrary />);
// Switch to archive mode
const createFromArchiveBtn = await screen.findByRole('button', { name: messages.createFromArchiveButton.defaultMessage });
await user.click(createFromArchiveBtn);
// Upload a file to trigger the restore process
const file = new File(['test content'], 'test-archive.zip', { type: 'application/zip' });
const dropzone = screen.getByRole('presentation', { hidden: true });
const input = dropzone.querySelector('input[type="file"]') as HTMLInputElement;
await user.upload(input, file);
// Wait for the restore to complete and archive details to be shown
await waitFor(() => {
// Testing the archive details summary without instance and user email
expect(screen.getByText(/Contains 8 sections, 12 subsections, 20 units, 15 components/i)).toBeInTheDocument();
expect(screen.queryByText(/Created on instance/i)).not.toBeInTheDocument();
expect(screen.queryByText(/by user/i)).not.toBeInTheDocument();
});
});

View File

@@ -15,6 +15,7 @@ import {
import {
AccessTime,
Widgets,
PersonOutline,
} from '@openedx/paragon/icons';
import AlertError from '@src/generic/alert-error';
import classNames from 'classnames';
@@ -203,22 +204,38 @@ export const CreateLibrary = ({
<Card.Body>
<div className="d-flex flex-column flex-md-row justify-content-between align-items-start p-4 text-primary-700">
<div className="flex-grow-1 mb-4 mb-md-0">
<span className="mb-2">{restoreStatus.result.title}</span>
<span className="mb-4">{restoreStatus.result.title}</span>
<p className="small mb-0">
{restoreStatus.result.org} / {restoreStatus.result.slug}
</p>
</div>
<div className="d-flex flex-column gap-2 align-items-md-end">
<div className="d-flex flex-column gap-2 align-items-md-start">
<div className="d-flex align-items-md-center gap-2">
<Icon src={Widgets} style={{ width: '20px', height: '20px', marginRight: '8px' }} />
<Icon src={Widgets} className="mr-2" style={{ width: '20px', height: '20px' }} />
<span className="x-small">
{intl.formatMessage(messages.archiveComponentsCount, {
count: restoreStatus.result.components,
countSections: restoreStatus.result.sections,
countSubsections: restoreStatus.result.subsections,
countUnits: restoreStatus.result.units,
countComponents: restoreStatus.result.components,
})}
</span>
</div>
{
(restoreStatus.result.createdBy?.email && restoreStatus.result.createdOnServer) && (
<div className="d-flex align-items-md-center gap-2">
<Icon src={PersonOutline} className="mr-2" style={{ width: '20px', height: '20px' }} />
<span className="x-small">
{intl.formatMessage(messages.archiveRestoredCreatedBy, {
createdBy: restoreStatus.result.createdBy?.email,
server: restoreStatus.result.createdOnServer,
})}
</span>
</div>
)
}
<div className="d-flex align-items-md-center gap-2">
<Icon src={AccessTime} style={{ width: '20px', height: '20px', marginRight: '8px' }} />
<Icon src={AccessTime} className="mr-2" style={{ width: '20px', height: '20px' }} />
<span className="x-small">
{intl.formatMessage(messages.archiveBackupDate, {
date: new Date(restoreStatus.result.createdAt).toLocaleDateString(),
@@ -236,7 +253,8 @@ export const CreateLibrary = ({
{(restoreTaskId || isError || restoreMutation.isError) && (
<div className="mb-4">
{restoreStatus?.state === LibraryRestoreStatus.Pending && (
{(restoreStatus?.state === LibraryRestoreStatus.Pending
|| restoreStatus?.state === LibraryRestoreStatus.InProgress) && (
<Alert variant="info">
{intl.formatMessage(messages.restoreInProgress)}
</Alert>

View File

@@ -173,6 +173,34 @@ describe('create library apiHooks', () => {
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
});
it('should handle in-progress status with refetch interval', async () => {
const taskId = 'in-progress-task-id';
const inProgressResult = {
state: LibraryRestoreStatus.InProgress,
result: null,
error: null,
error_log: null,
};
const expectedResult = {
state: LibraryRestoreStatus.InProgress,
result: null,
error: null,
errorLog: null,
};
axiosMock.onGet(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`).reply(200, inProgressResult);
const { result } = renderHook(() => useGetLibraryRestoreStatus(taskId), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toEqual(expectedResult);
expect(axiosMock.history.get[0].url).toEqual(`http://localhost:18010/api/libraries/v2/restore/?task_id=${taskId}`);
});
it('should handle failed status', async () => {
const taskId = 'failed-task-id';
const failedResult = {

View File

@@ -41,7 +41,10 @@ export const useGetLibraryRestoreStatus = (taskId: string) => useQuery<GetLibrar
queryKey: libraryRestoreQueryKeys.restoreStatus(taskId),
queryFn: () => getLibraryRestoreStatus(taskId),
enabled: !!taskId, // Only run the query if taskId is provided
refetchInterval: (query) => (query.state.data?.state === LibraryRestoreStatus.Pending ? 2000 : false),
refetchInterval: (query) => (
(query.state.data?.state === LibraryRestoreStatus.Pending
|| query.state.data?.state === LibraryRestoreStatus.InProgress
) ? 2000 : false),
});
export const useCreateLibraryRestore = () => useMutation<CreateLibraryRestoreResponse, Error, File>({

View File

@@ -32,6 +32,7 @@ export interface GetLibraryRestoreStatusResponse {
export enum LibraryRestoreStatus {
Pending = 'Pending',
InProgress = 'In Progress',
Succeeded = 'Succeeded',
Failed = 'Failed',
}

View File

@@ -120,8 +120,13 @@ const messages = defineMessages({
},
archiveComponentsCount: {
id: 'course-authoring.library-authoring.create-library.form.archive.components-count',
defaultMessage: 'Contains {count} Components',
description: 'Text showing the number of components in the restored archive.',
defaultMessage: 'Contains {countSections} sections, {countSubsections} subsections, {countUnits} units, {countComponents} components',
description: 'Text showing the number of sections, subsections, units, and components in the restored archive.',
},
archiveRestoredCreatedBy: {
id: 'course-authoring.library-authoring.create-library.form.archive.restored-created-by',
defaultMessage: 'Created on instance {server}, by user {createdBy}',
description: 'Text showing who restored the archive.',
},
archiveBackupDate: {
id: 'course-authoring.library-authoring.create-library.form.archive.backup-date',

View File

@@ -603,6 +603,7 @@ mockGetContainerMetadata.applyMock = () => {
export async function mockGetContainerChildren(containerId: string): Promise<api.LibraryBlockMetadata[]> {
let numChildren: number;
let blockType = 'html';
let addDuplicate = false;
switch (containerId) {
case mockGetContainerMetadata.unitId:
case mockGetContainerMetadata.sectionId:
@@ -615,6 +616,10 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
case mockGetContainerChildren.sixChildren:
numChildren = 6;
break;
case mockGetContainerChildren.unitIdWithDuplicate:
numChildren = 3;
addDuplicate = true;
break;
default:
numChildren = 0;
break;
@@ -630,19 +635,22 @@ export async function mockGetContainerChildren(containerId: string): Promise<api
name = blockType;
typeNamespace = 'lct';
}
return Promise.resolve(
Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
{
...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
displayName: `${name} block ${idx}`,
publishedDisplayName: `${name} block published ${idx}`,
blockType,
}
)),
);
let result = Array(numChildren).fill(mockGetContainerChildren.childTemplate).map((child, idx) => (
{
...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `${typeNamespace}:org1:Demo_course_generated:${blockType}:${name}-${idx}`,
displayName: `${name} block ${idx}`,
publishedDisplayName: `${name} block published ${idx}`,
blockType,
}
));
if (addDuplicate) {
result = [...result, result[0]];
}
return Promise.resolve(result);
}
mockGetContainerChildren.unitIdWithDuplicate = 'lct:org1:Demo_Course:unit:unit-duplicate';
mockGetContainerChildren.fiveChildren = 'lct:org1:Demo_Course:unit:unit-5';
mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6';
mockGetContainerChildren.childTemplate = {

View File

@@ -702,10 +702,10 @@ export async function restoreContainer(containerId: string) {
/**
* Fetch a library container's children's metadata.
*/
export async function getLibraryContainerChildren(
export async function getLibraryContainerChildren<ChildType = LibraryBlockMetadata | Container>(
containerId: string,
published: boolean = false,
): Promise<LibraryBlockMetadata[] | Container[]> {
): Promise<ChildType[]> {
const { data } = await getAuthenticatedHttpClient().get(
getLibraryContainerChildrenApiUrl(containerId, published),
);

View File

@@ -329,10 +329,11 @@ describe('library api hooks', () => {
// Keys should be invalidated:
// 1. library
// 2. containerChildren
// 3. containerHierarchy
// 4 & 5. subsections
// 6 all hierarchies
expect(spy).toHaveBeenCalledTimes(6);
// 3. container
// 4. containerHierarchy
// 5 & 6. subsections
// 7 all hierarchies
expect(spy).toHaveBeenCalledTimes(7);
});
describe('publishContainer', () => {

View File

@@ -736,32 +736,35 @@ export const useRestoreContainer = (containerId: string) => {
/**
* Get the metadata and children for a container in a library
*/
export const useContainerChildren = (containerId?: string, published: boolean = false) => (
useQuery({
enabled: !!containerId,
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
queryFn: () => api.getLibraryContainerChildren(containerId!, published),
structuralSharing: (
oldData: api.LibraryBlockMetadata[] | api.Container[],
newData: api.LibraryBlockMetadata[] | api.Container[],
) => {
export const useContainerChildren = <ChildType extends {
id: string;
isNew?: boolean;
} = api.LibraryBlockMetadata | api.Container>(
containerId?: string,
published: boolean = false,
) => (
useQuery({
enabled: !!containerId,
queryKey: libraryAuthoringQueryKeys.containerChildren(containerId!),
queryFn: () => api.getLibraryContainerChildren<ChildType>(containerId!, published),
structuralSharing: (oldData: ChildType[], newData: ChildType[]) => {
// This just sets `isNew` flag to new children components
if (oldData) {
const oldDataIds = oldData.map((obj) => obj.id);
// eslint-disable-next-line no-param-reassign
newData = newData.map((newObj) => {
if (!oldDataIds.includes(newObj.id)) {
if (oldData) {
const oldDataIds = oldData.map((obj) => obj.id);
// eslint-disable-next-line no-param-reassign
newData = newData.map((newObj) => {
if (!oldDataIds.includes(newObj.id)) {
// Set isNew = true if we have new child on refetch
// eslint-disable-next-line no-param-reassign
newObj.isNew = true;
}
return newObj;
});
}
return replaceEqualDeep(oldData, newData);
},
})
);
newObj.isNew = true;
}
return newObj;
});
}
return replaceEqualDeep(oldData, newData);
},
})
);
/**
* If you work with `useContentFromSearchIndex`, you can use this
@@ -814,6 +817,8 @@ export const useAddItemsToContainer = (containerId?: string) => {
// It would be complex to bring the entire hierarchy and only update the items within that hierarchy.
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerHierarchy(undefined) });
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentHierarchy(undefined) });
// Invalidate the container to update its publish status
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) });
const containerType = getBlockType(containerId);
if (containerType === 'section') {

View File

@@ -8,6 +8,7 @@ import {
waitFor,
initializeMocks,
} from '@src/testUtils';
import { validateUserPermissions } from '@src/authz/data/api';
import { mockContentLibrary } from '../data/api.mocks';
import { getCommitLibraryChangesUrl } from '../data/api';
import { LibraryProvider } from '../common/context/LibraryContext';
@@ -33,6 +34,7 @@ const render = (libraryId: string = mockLibraryId) => baseRender(<LibraryInfo />
let axiosMock: MockAdapter;
let mockShowToast: (message: string) => void;
let validateUserPermissionsMock: jest.SpiedFunction<typeof validateUserPermissions>;
mockContentLibrary.applyMock();
@@ -41,6 +43,9 @@ describe('<LibraryInfo />', () => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
validateUserPermissionsMock.mockResolvedValue({ canPublish: true });
});
afterEach(() => {

View File

@@ -12,7 +12,7 @@ import messages from './messages';
const LibraryPublishStatus = () => {
const intl = useIntl();
const { libraryData, readOnly } = useLibraryContext();
const { libraryData, readOnly, canPublish } = useLibraryContext();
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const commitLibraryChanges = useCommitLibraryChanges();
@@ -51,10 +51,10 @@ const LibraryPublishStatus = () => {
<>
<StatusWidget
{...libraryData}
onCommit={!readOnly ? commit : undefined}
onCommit={!readOnly && canPublish ? commit : undefined}
onCommitStatus={commitLibraryChanges.status}
onCommitLabel={intl.formatMessage(messages.publishLibraryButtonLabel)}
onRevert={!readOnly ? openConfirmModal : undefined}
onRevert={!readOnly && canPublish ? openConfirmModal : undefined}
/>
<DeleteModal
isOpen={isConfirmModalOpen}

View File

@@ -33,13 +33,13 @@ export const ROUTES = {
COLLECTION: '/collection/:collectionId/:selectedItemId?',
// LibrarySectionPage route:
// * with a selected containerId and an optionally selected subsection.
SECTION: '/section/:containerId/:selectedItemId?',
SECTION: '/section/:containerId/:selectedItemId?/:index?',
// LibrarySubsectionPage route:
// * with a selected containerId and an optionally selected unit.
SUBSECTION: '/subsection/:containerId/:selectedItemId?',
SUBSECTION: '/subsection/:containerId/:selectedItemId?/:index?',
// LibraryUnitPage route:
// * with a selected containerId and/or an optionally selected componentId.
UNIT: '/unit/:containerId/:selectedItemId?',
UNIT: '/unit/:containerId/:selectedItemId?/:index?',
// LibraryBackupPage route:
BACKUP: '/backup',
};
@@ -60,6 +60,7 @@ export type NavigateToData = {
collectionId?: string,
containerId?: string,
contentType?: ContentType,
index?: number,
};
export type LibraryRoutesData = {
@@ -122,6 +123,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
collectionId,
containerId,
contentType,
index,
}: NavigateToData = {}) => {
const routeParams = {
...params,
@@ -129,6 +131,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
...((selectedItemId !== undefined) && { selectedItemId }),
...((containerId !== undefined) && { containerId }),
...((collectionId !== undefined) && { collectionId }),
...((index !== undefined) && { index }),
};
let route: string;
@@ -230,6 +233,12 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
route = ROUTES.HOME;
}
// Since index is just the order number of the selectedItemId
// clear index if selectedItemId is undefined
if (routeParams.selectedItemId === undefined) {
routeParams.index = undefined;
}
// Also remove the `sa` (sidebar action) search param if it exists.
searchParams.delete('sa');

View File

@@ -39,9 +39,12 @@ interface LibraryContainerMetadataWithUniqueId extends Container {
interface ContainerRowProps extends LibraryContainerChildrenProps {
container: LibraryContainerMetadataWithUniqueId;
index?: number;
}
const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps) => {
const ContainerRow = ({
containerKey, container, readOnly, index,
}: ContainerRowProps) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const updateMutation = useUpdateContainer(container.originalId, containerKey);
@@ -112,6 +115,7 @@ const ContainerRow = ({ containerKey, container, readOnly }: ContainerRowProps)
<ContainerMenu
containerKey={container.originalId}
displayName={container.displayName}
index={index}
/>
)}
</Stack>
@@ -148,7 +152,7 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
isLoading,
isError,
error,
} = useContainerChildren(containerKey, showOnlyPublished);
} = useContainerChildren<Container>(containerKey, showOnlyPublished);
useEffect(() => {
// Create new ids which are unique using index.
@@ -164,14 +168,18 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
return setOrderedChildren(newChildren || []);
}, [children, setOrderedChildren]);
const handleChildClick = useCallback((child: LibraryContainerMetadataWithUniqueId, numberOfClicks: number) => {
const handleChildClick = useCallback((
child: LibraryContainerMetadataWithUniqueId,
numberOfClicks: number,
index: number,
) => {
if (readOnly) {
// don't allow interaction if rendered as preview
return;
}
const doubleClicked = numberOfClicks > 1;
if (!doubleClicked) {
openItemSidebar(child.originalId, SidebarBodyItemId.ContainerInfo);
openItemSidebar(child.originalId, SidebarBodyItemId.ContainerInfo, index);
} else {
navigateTo({ containerId: child.originalId });
}
@@ -215,7 +223,7 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
activeId={activeDraggingId}
setActiveId={setActiveDraggingId}
>
{orderedChildren?.map((child) => (
{orderedChildren?.map((child, index) => (
// A container can have multiple instances of the same block
// eslint-disable-next-line react/no-array-index-key
<SortableItem
@@ -229,19 +237,20 @@ export const LibraryContainerChildren = ({ containerKey, readOnly }: LibraryCont
borderLeft: '8px solid #E1DDDB',
}}
isClickable={!readOnly}
onClick={(e) => skipIfUnwantedTarget(e, (event) => handleChildClick(child, event.detail))}
onClick={(e) => skipIfUnwantedTarget(e, (event) => handleChildClick(child, event.detail, index))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleChildClick(child, 1);
handleChildClick(child, 1, index);
}
}}
disabled={readOnly || libReadOnly}
cardClassName={sidebarItemInfo?.id === child.originalId ? 'selected' : undefined}
cardClassName={sidebarItemInfo?.id === child.originalId && sidebarItemInfo?.index === index ? 'selected' : undefined}
actions={(
<ContainerRow
containerKey={containerKey}
container={child}
readOnly={readOnly || libReadOnly}
index={index}
/>
)}
/>

View File

@@ -46,13 +46,14 @@ interface LibraryBlockMetadataWithUniqueId extends LibraryBlockMetadata {
}
interface ComponentBlockProps {
index: number;
block: LibraryBlockMetadataWithUniqueId;
readOnly?: boolean;
isDragging?: boolean;
}
/** Component header */
const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
const BlockHeader = ({ block, index, readOnly }: ComponentBlockProps) => {
const intl = useIntl();
const { showOnlyPublished } = useLibraryContext();
const { showToast } = useContext(ToastContext);
@@ -118,17 +119,18 @@ const BlockHeader = ({ block, readOnly }: ComponentBlockProps) => {
</Badge>
)}
<TagCount size="sm" count={block.tagsCount} onClick={readOnly ? undefined : jumpToManageTags} />
{!readOnly && <ComponentMenu usageKey={block.originalId} />}
{!readOnly && <ComponentMenu index={index} usageKey={block.originalId} />}
</Stack>
</>
);
};
/** ComponentBlock to render preview of given component under Unit */
const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) => {
const { showOnlyPublished } = useLibraryContext();
const ComponentBlock = ({
block, readOnly, isDragging, index,
}: ComponentBlockProps) => {
const { showOnlyPublished, openComponentEditor } = useLibraryContext();
const { openComponentEditor } = useLibraryContext();
const { sidebarItemInfo, openItemSidebar } = useSidebarContext();
const handleComponentSelection = useCallback((numberOfClicks: number) => {
@@ -136,7 +138,11 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
// don't allow interaction if rendered as preview
return;
}
openItemSidebar(block.originalId, SidebarBodyItemId.ComponentInfo);
openItemSidebar(
block.originalId,
SidebarBodyItemId.ComponentInfo,
index,
);
const canEdit = canEditComponent(block.originalId);
if (numberOfClicks > 1 && canEdit) {
// Open editor on double click.
@@ -174,7 +180,7 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
<SortableItem
id={block.id}
componentStyle={getComponentStyle()}
actions={<BlockHeader block={block} readOnly={readOnly} />}
actions={<BlockHeader block={block} index={index} readOnly={readOnly} />}
actionStyle={{
borderRadius: '8px 8px 0px 0px',
padding: '0.5rem 1rem',
@@ -189,7 +195,7 @@ const ComponentBlock = ({ block, readOnly, isDragging }: ComponentBlockProps) =>
}
}}
disabled={readOnly}
cardClassName={sidebarItemInfo?.id === block.originalId ? 'selected' : undefined}
cardClassName={sidebarItemInfo?.id === block.originalId && sidebarItemInfo?.index === index ? 'selected' : undefined}
>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
@@ -236,7 +242,7 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
isLoading,
isError,
error,
} = useContainerChildren(unitId, showOnlyPublished);
} = useContainerChildren<LibraryBlockMetadata>(unitId, showOnlyPublished);
const handleReorder = useCallback(() => async (newOrder?: LibraryBlockMetadataWithUniqueId[]) => {
if (!newOrder) {
@@ -294,6 +300,7 @@ export const LibraryUnitBlocks = ({ unitId, readOnly: componentReadOnly }: Libra
// eslint-disable-next-line react/no-array-index-key
key={`${block.originalId}-${idx}-${block.modified}`}
block={block}
index={idx}
isDragging={hidePreviewFor === block.id}
readOnly={readOnly}
/>

View File

@@ -381,15 +381,44 @@ describe('<LibraryUnitPage />', () => {
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
axiosMock.onPost(restoreUrl).reply(200);
axiosMock.onPatch(restoreUrl).reply(200);
// restore collection
restoreFn();
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.patch.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Undo successful');
});
it('should remove only one instance of component even if it is present multiple times in this page', async () => {
const user = userEvent.setup();
const url = getLibraryContainerChildrenApiUrl(mockGetContainerChildren.unitIdWithDuplicate);
axiosMock.onPatch(url).reply(200);
renderLibraryUnitPage(mockGetContainerChildren.unitIdWithDuplicate);
expect((await screen.findAllByText('text block 0')).length).toEqual(2);
const menu = (await screen.findAllByRole('button', { name: /component actions menu/i }))[0];
await user.click(menu);
const removeButton = await screen.findByText('Remove from unit');
await user.click(removeButton);
const modal = await screen.findByRole('dialog', { name: 'Remove Component' });
expect(modal).toBeVisible();
const confirmButton = await within(modal).findByRole('button', { name: 'Remove' });
await user.click(confirmButton);
const result = await mockGetContainerChildren(mockGetContainerChildren.unitIdWithDuplicate);
const resultIds = result.map((obj) => obj.id);
await waitFor(() => {
expect(axiosMock.history.patch[0].url).toEqual(url);
});
// Only the first element is removed even though the last element has the same id.
expect(JSON.parse(axiosMock.history.patch[0].data).usage_keys).toEqual(resultIds.slice(1));
await waitFor(() => expect(mockShowToast).toHaveBeenCalled());
});
it('should show error on remove a component', async () => {
const user = userEvent.setup();
const url = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
@@ -444,11 +473,11 @@ describe('<LibraryUnitPage />', () => {
const restoreFn = mockShowToast.mock.calls[0][1].onClick;
const restoreUrl = getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.unitId);
axiosMock.onPost(restoreUrl).reply(404);
axiosMock.onPatch(restoreUrl).reply(404);
// restore collection
restoreFn();
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.patch.length).toEqual(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Failed to undo remove component operation');
});

View File

@@ -147,7 +147,7 @@ describe('DiscussionsSettings', () => {
// content has been loaded - prior to proceeding with our expectations.
await waitForElementToBeRemoved(screen.queryByRole('status'));
await user.click(queryByLabelText(container, 'Select edX'));
await user.click(queryByLabelText(container, 'Select Open edX (legacy)'));
await user.click(queryByText(container, messages.nextButton.defaultMessage));
expect(queryByTestId(container, 'appList')).not.toBeInTheDocument();

View File

@@ -107,13 +107,13 @@ const messages = defineMessages({
},
'appName-legacy': {
id: 'authoring.discussions.appConfigForm.appName-legacy',
defaultMessage: 'edX',
description: 'The name of the Legacy edX Discussions app.',
defaultMessage: 'Open edX (legacy)',
description: 'The name of the Legacy Open edX Discussions app.',
},
'appName-openedx': {
id: 'authoring.discussions.appConfigForm.appName-openedx',
defaultMessage: 'edX (new)',
description: 'The name of the new edX Discussions app.',
defaultMessage: 'Open edX',
description: 'The name of the new Open edX Discussions app.',
},
divisionByGroup: {
id: 'authoring.discussions.builtIn.divisionByGroup',

View File

@@ -117,7 +117,6 @@ const ScheduleAndDetails = ({ courseId }) => {
license,
language,
subtitle,
overview,
duration,
selfPaced,
startDate,
@@ -128,7 +127,6 @@ const ScheduleAndDetails = ({ courseId }) => {
instructorInfo,
enrollmentStart,
shortDescription,
aboutSidebarHtml,
preRequisiteCourses,
entranceExamEnabled,
courseImageAssetPath,
@@ -140,6 +138,12 @@ const ScheduleAndDetails = ({ courseId }) => {
} = editedValues;
useScrollToHashElement({ isLoading });
// No need to get overview and aboutSidebarHtml from editedValues
// As updating them re-renders TinyMCE
// Which causes issues with TinyMCE editor cursor position
// https://www.tiny.cloud/docs/tinymce/5/react/#initialvalue
const { overview: initialOverview } = courseDetails || {};
const { aboutSidebarHtml: initialAboutSidebarHtml } = courseDetails || {};
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
@@ -277,12 +281,12 @@ const ScheduleAndDetails = ({ courseId }) => {
)}
<IntroducingSection
title={title}
overview={overview}
overview={initialOverview}
duration={duration}
subtitle={subtitle}
introVideo={introVideo}
description={description}
aboutSidebarHtml={aboutSidebarHtml}
aboutSidebarHtml={initialAboutSidebarHtml}
shortDescription={shortDescription}
aboutPageEditable={aboutPageEditable}
sidebarHtmlEnabled={sidebarHtmlEnabled}

View File

@@ -23,6 +23,7 @@ import {
Routes,
} from 'react-router-dom';
import * as authzApi from '@src/authz/data/api';
import { ToastContext, type ToastContextData } from './generic/toast-context';
import initializeReduxStore, { type DeprecatedReduxState } from './store';
import { getApiWaffleFlagsUrl } from './data/api';
@@ -31,6 +32,7 @@ import { getApiWaffleFlagsUrl } from './data/api';
let reduxStore: Store;
let queryClient: QueryClient;
let axiosMock: MockAdapter;
let validateUserPermissionsMock: jest.SpiedFunction<typeof authzApi.validateUserPermissions>;
/** To use this: `const { mockShowToast } = initializeMocks()` and `expect(mockShowToast).toHaveBeenCalled()` */
let mockToastContext: ToastContextData = {
@@ -192,12 +194,17 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
// Clear the call counts etc. of all mocks. This doesn't remove the mock's effects; just clears their history.
jest.clearAllMocks();
// Mock user permissions to avoid breaking tests that monitor axios calls
// If needed, override the mockResolvedValue in your test
validateUserPermissionsMock = jest.spyOn(authzApi, 'validateUserPermissions').mockResolvedValue({});
return {
reduxStore,
axiosMock,
mockShowToast: mockToastContext.showToast,
mockToastAction: mockToastContext.toastAction,
queryClient,
validateUserPermissionsMock,
};
}