diff --git a/src/library-authoring/__mocks__/collection-search.json b/src/library-authoring/__mocks__/collection-search.json
index 9785489db..7b4ddd3a4 100644
--- a/src/library-authoring/__mocks__/collection-search.json
+++ b/src/library-authoring/__mocks__/collection-search.json
@@ -201,11 +201,30 @@
],
"created": 1726740779.564664,
"modified": 1726840811.684142,
- "usage_key": "lib-collection:OpenedX:CSPROB2:collection-from-meilisearch",
+ "usage_key": "lib-collection:OpenedX:CSPROB2:my-first-collection",
"context_key": "lib:OpenedX:CSPROB2",
"org": "OpenedX",
"access_id": 16,
"num_children": 5
+ },
+ {
+ "display_name": "My second collection",
+ "block_id": "my-second-collection",
+ "description": "A collection for testing",
+ "id": 2,
+ "type": "collection",
+ "breadcrumbs": [
+ {
+ "display_name": "CS problems 2"
+ }
+ ],
+ "created": 1726740779.564664,
+ "modified": 1726840811.684142,
+ "usage_key": "lib-collection:OpenedX:CSPROB2:my-second-collection",
+ "context_key": "lib:OpenedX:CSPROB2",
+ "org": "OpenedX",
+ "access_id": 16,
+ "num_children": 1
}
],
"query": "",
diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx
index 0bf942e5e..989c44d09 100644
--- a/src/library-authoring/add-content/AddContentContainer.tsx
+++ b/src/library-authoring/add-content/AddContentContainer.tsx
@@ -22,7 +22,7 @@ import { useParams } from 'react-router-dom';
import { ToastContext } from '../../generic/toast-context';
import { useCopyToClipboard } from '../../generic/clipboard';
import { getCanEdit } from '../../course-unit/data/selectors';
-import { useCreateLibraryBlock, useLibraryPasteClipboard, useUpdateCollectionComponents } from '../data/apiHooks';
+import { useCreateLibraryBlock, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks';
import { useLibraryContext } from '../common/context';
import { canEditComponent } from '../components/ComponentEditorModal';
@@ -69,7 +69,7 @@ const AddContentContainer = () => {
openComponentEditor,
} = useLibraryContext();
const createBlockMutation = useCreateLibraryBlock();
- const updateComponentsMutation = useUpdateCollectionComponents(libraryId, collectionId);
+ const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
index b24260147..aa9083631 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
@@ -1,5 +1,7 @@
import fetchMock from 'fetch-mock-jest';
import { cloneDeep } from 'lodash';
+import MockAdapter from 'axios-mock-adapter/types';
+
import {
fireEvent,
initializeMocks,
@@ -17,6 +19,10 @@ import {
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { LibraryLayout } from '..';
+import { getLibraryCollectionComponentApiUrl } from '../data/api';
+
+let axiosMock: MockAdapter;
+let mockShowToast;
mockClipboardEmpty.applyMock();
mockGetCollectionMetadata.applyMock();
@@ -40,7 +46,9 @@ const { title } = mockGetCollectionMetadata.collectionData;
describe('', () => {
beforeEach(() => {
- initializeMocks();
+ const mocks = initializeMocks();
+ axiosMock = mocks.axiosMock;
+ mockShowToast = mocks.mockShowToast;
fetchMock.mockReset();
// The Meilisearch client-side API uses fetch, not Axios.
@@ -301,7 +309,6 @@ describe('', () => {
expect(mockResult0.display_name).toStrictEqual(displayName);
await renderLibraryCollectionPage();
- // Click on the first component. It should appear twice, in both "Recently Modified" and "Components"
fireEvent.click((await screen.findAllByText(displayName))[0]);
const sidebar = screen.getByTestId('library-sidebar');
@@ -324,4 +331,30 @@ describe('', () => {
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});
+
+ it('should remove component from collection and hides sidebar', async () => {
+ const url = getLibraryCollectionComponentApiUrl(
+ mockContentLibrary.libraryId,
+ mockCollection.collectionId,
+ );
+ axiosMock.onDelete(url).reply(204);
+ const displayName = 'Introduction to Testing';
+ await renderLibraryCollectionPage();
+
+ // open sidebar
+ fireEvent.click(await screen.findByText(displayName));
+ await waitFor(() => expect(screen.queryByTestId('library-sidebar')).toBeInTheDocument());
+
+ const menuBtns = await screen.findAllByRole('button', { name: 'Component actions menu' });
+ // open menu
+ fireEvent.click(menuBtns[0]);
+
+ fireEvent.click(await screen.findByText('Remove from collection'));
+ await waitFor(() => {
+ expect(axiosMock.history.delete.length).toEqual(1);
+ expect(mockShowToast).toHaveBeenCalledWith('Component successfully removed');
+ });
+ // Should close sidebar as component was removed
+ await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
+ });
});
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index 5c4b1938d..aea78f54f 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -61,7 +61,6 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
const closeLibrarySidebar = React.useCallback(() => {
resetSidebar();
- setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
resetSidebar();
diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx
index 96a12ec5d..3ce44cc34 100644
--- a/src/library-authoring/component-info/ComponentManagement.test.tsx
+++ b/src/library-authoring/component-info/ComponentManagement.test.tsx
@@ -2,12 +2,13 @@ import { setConfig, getConfig } from '@edx/frontend-platform';
import {
initializeMocks,
- render,
+ render as baseRender,
screen,
} from '../../testUtils';
import { mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentManagement from './ComponentManagement';
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
+import { LibraryProvider } from '../common/context';
jest.mock('../../content-tags-drawer', () => ({
ContentTagsDrawer: () =>
Mocked ContentTagsDrawer
,
@@ -27,10 +28,19 @@ const matchInnerText = (nodeName: string, textToMatch: string) => (_: string, el
element.nodeName === nodeName && getInnerText(element) === textToMatch
);
+const render = (ui: React.ReactElement) => baseRender(ui, {
+ extraWrapper: ({ children }) => { children },
+});
+
+mockLibraryBlockMetadata.applyMock();
+mockContentTaxonomyTagsData.applyMock();
+
describe('', () => {
- it('should render draft status', async () => {
+ beforeEach(() => {
initializeMocks();
- mockLibraryBlockMetadata.applyMock();
+ });
+
+ it('should render draft status', async () => {
render();
expect(await screen.findByText('Draft')).toBeInTheDocument();
expect(await screen.findByText('(Never Published)')).toBeInTheDocument();
@@ -38,8 +48,6 @@ describe('', () => {
});
it('should render published status', async () => {
- initializeMocks();
- mockLibraryBlockMetadata.applyMock();
render();
expect(await screen.findByText('Published')).toBeInTheDocument();
expect(screen.getByText('Published')).toBeInTheDocument();
@@ -53,8 +61,6 @@ describe('', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
- initializeMocks();
- mockLibraryBlockMetadata.applyMock();
render();
expect(await screen.findByText('Tags (0)')).toBeInTheDocument();
expect(screen.queryByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
@@ -65,8 +71,6 @@ describe('', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
});
- initializeMocks();
- mockLibraryBlockMetadata.applyMock();
render();
expect(await screen.findByText('Draft')).toBeInTheDocument();
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
@@ -77,10 +81,12 @@ describe('', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
- initializeMocks();
- mockLibraryBlockMetadata.applyMock();
- mockContentTaxonomyTagsData.applyMock();
render();
expect(await screen.findByText('Tags (6)')).toBeInTheDocument();
});
+
+ it('should render collection count in collection info section', async () => {
+ render();
+ expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
+ });
});
diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx
index 92adb3310..ec0414597 100644
--- a/src/library-authoring/component-info/ComponentManagement.tsx
+++ b/src/library-authoring/component-info/ComponentManagement.tsx
@@ -2,22 +2,25 @@ import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Collapsible, Icon, Stack } from '@openedx/paragon';
-import { Tag } from '@openedx/paragon/icons';
+import { BookOpen, Tag } from '@openedx/paragon/icons';
import { useLibraryBlockMetadata } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import messages from './messages';
import { ContentTagsDrawer } from '../../content-tags-drawer';
import { useContentTaxonomyTagsData } from '../../content-tags-drawer/data/apiHooks';
+import ManageCollections from './ManageCollections';
interface ComponentManagementProps {
usageKey: string;
}
+
const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
const intl = useIntl();
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
const { data: componentTags } = useContentTaxonomyTagsData(usageKey);
+ const collectionsCount = React.useMemo(() => componentMetadata?.collections?.length || 0, [componentMetadata]);
const tagsCount = React.useMemo(() => {
if (!componentTags) {
return 0;
@@ -69,13 +72,13 @@ const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
defaultOpen
title={(
-
- {intl.formatMessage(messages.manageTabCollectionsTitle)}
+
+ {intl.formatMessage(messages.manageTabCollectionsTitle, { count: collectionsCount })}
)}
className="border-0"
>
- Collections placeholder
+
);
diff --git a/src/library-authoring/component-info/ManageCollections.test.tsx b/src/library-authoring/component-info/ManageCollections.test.tsx
new file mode 100644
index 000000000..b6aa5af61
--- /dev/null
+++ b/src/library-authoring/component-info/ManageCollections.test.tsx
@@ -0,0 +1,121 @@
+import fetchMock from 'fetch-mock-jest';
+
+import userEvent from '@testing-library/user-event';
+import MockAdapter from 'axios-mock-adapter/types';
+import {
+ initializeMocks,
+ render as baseRender,
+ screen,
+ waitFor,
+} from '../../testUtils';
+import mockCollectionsResults from '../__mocks__/collection-search.json';
+import { mockContentSearchConfig } from '../../search-manager/data/api.mock';
+import { mockLibraryBlockMetadata } from '../data/api.mocks';
+import ManageCollections from './ManageCollections';
+import { LibraryProvider } from '../common/context';
+import { getLibraryBlockCollectionsUrl } from '../data/api';
+
+const render = (ui: React.ReactElement) => baseRender(ui, {
+ extraWrapper: ({ children }) => { children },
+});
+
+let axiosMock: MockAdapter;
+let mockShowToast;
+
+mockLibraryBlockMetadata.applyMock();
+mockContentSearchConfig.applyMock();
+const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
+
+describe('', () => {
+ beforeEach(() => {
+ const mocks = initializeMocks();
+ axiosMock = mocks.axiosMock;
+ mockShowToast = mocks.mockShowToast;
+ // The Meilisearch client-side API uses fetch, not Axios.
+ fetchMock.mockReset();
+ fetchMock.post(searchEndpoint, (_url, req) => {
+ const requestData = JSON.parse(req.body?.toString() ?? '');
+ const query = requestData?.queries[2]?.q ?? '';
+ // We have to replace the query (search keywords) in the mock results with the actual query,
+ // because otherwise Instantsearch will update the UI and change the query,
+ // leading to unexpected results in the test cases.
+ mockCollectionsResults.results[2].query = query;
+ // And fake the required '_formatted' fields; it contains the highlighting ... around matched words
+ // eslint-disable-next-line no-underscore-dangle, no-param-reassign
+ mockCollectionsResults.results[2]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
+ return mockCollectionsResults;
+ });
+ });
+
+ it('should show all collections in library and allow users to select for the current component ', async () => {
+ const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
+ axiosMock.onPatch(url).reply(200);
+ render();
+ const manageBtn = await screen.findByRole('button', { name: 'Manage Collections' });
+ userEvent.click(manageBtn);
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
+ expect(screen.queryByRole('search')).toBeInTheDocument();
+ const secondCollection = await screen.findByRole('button', { name: 'My second collection' });
+ userEvent.click(secondCollection);
+ const confirmBtn = await screen.findByRole('button', { name: 'Confirm' });
+ userEvent.click(confirmBtn);
+ await waitFor(() => {
+ expect(axiosMock.history.patch.length).toEqual(1);
+ expect(mockShowToast).toHaveBeenCalledWith('Component collections updated');
+ expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
+ collection_keys: ['my-first-collection', 'my-second-collection'],
+ });
+ });
+ expect(screen.queryByRole('search')).not.toBeInTheDocument();
+ });
+
+ it('should show toast and close manage collections selection on failure', async () => {
+ const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
+ axiosMock.onPatch(url).reply(400);
+ render();
+ const manageBtn = await screen.findByRole('button', { name: 'Add to Collection' });
+ userEvent.click(manageBtn);
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
+ expect(screen.queryByRole('search')).toBeInTheDocument();
+ const secondCollection = await screen.findByRole('button', { name: 'My second collection' });
+ userEvent.click(secondCollection);
+ const confirmBtn = await screen.findByRole('button', { name: 'Confirm' });
+ userEvent.click(confirmBtn);
+ await waitFor(() => {
+ expect(axiosMock.history.patch.length).toEqual(1);
+ expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
+ collection_keys: ['my-second-collection'],
+ });
+ expect(mockShowToast).toHaveBeenCalledWith('Failed to update Component collections');
+ });
+ expect(screen.queryByRole('search')).not.toBeInTheDocument();
+ });
+
+ it('should close manage collections selection on cancel', async () => {
+ const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
+ axiosMock.onPatch(url).reply(400);
+ render();
+ const manageBtn = await screen.findByRole('button', { name: 'Add to Collection' });
+ userEvent.click(manageBtn);
+ await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
+ expect(screen.queryByRole('search')).toBeInTheDocument();
+ const secondCollection = await screen.findByRole('button', { name: 'My second collection' });
+ userEvent.click(secondCollection);
+ const cancelBtn = await screen.findByRole('button', { name: 'Cancel' });
+ userEvent.click(cancelBtn);
+ await waitFor(() => {
+ expect(axiosMock.history.patch.length).toEqual(0);
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ expect(screen.queryByRole('search')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/library-authoring/component-info/ManageCollections.tsx b/src/library-authoring/component-info/ManageCollections.tsx
new file mode 100644
index 000000000..d43e65111
--- /dev/null
+++ b/src/library-authoring/component-info/ManageCollections.tsx
@@ -0,0 +1,211 @@
+import { useContext, useState } from 'react';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Button, Icon, Scrollable, SelectableBox, Stack, StatefulButton, useCheckboxSetValues,
+} from '@openedx/paragon';
+import { Folder } from '@openedx/paragon/icons';
+
+import {
+ SearchContextProvider,
+ SearchKeywordsField,
+ SearchSortWidget,
+ useSearchContext,
+} from '../../search-manager';
+import messages from './messages';
+import { useUpdateComponentCollections } from '../data/apiHooks';
+import { ToastContext } from '../../generic/toast-context';
+import { CollectionMetadata } from '../data/api';
+import { useLibraryContext } from '../common/context';
+
+interface ManageCollectionsProps {
+ usageKey: string;
+ collections: CollectionMetadata[],
+}
+
+interface CollectionsDrawerProps extends ManageCollectionsProps {
+ onClose: () => void;
+}
+
+const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
+ const type = 'checkbox';
+ const intl = useIntl();
+ const { collectionHits } = useSearchContext();
+ const { showToast } = useContext(ToastContext);
+ const collectionKeys = collections.map((collection) => collection.key);
+ const [selectedCollections, {
+ add,
+ remove,
+ }] = useCheckboxSetValues(collectionKeys);
+ const [btnState, setBtnState] = useState('default');
+
+ const { libraryId } = useLibraryContext();
+
+ const updateCollectionsMutation = useUpdateComponentCollections(libraryId, usageKey);
+
+ const handleConfirmation = () => {
+ setBtnState('pending');
+ updateCollectionsMutation.mutateAsync(selectedCollections).then(() => {
+ showToast(intl.formatMessage(messages.manageCollectionsToComponentSuccess));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.manageCollectionsToComponentFailed));
+ }).finally(() => {
+ setBtnState('default');
+ onClose();
+ });
+ };
+
+ const handleChange = (e) => (e.target.checked ? add(e.target.value) : remove(e.target.value));
+
+ return (
+
+
+
+ {collectionHits.map((collectionHit) => (
+
+
+
+ {collectionHit.displayName}
+
+
+ ))}
+
+
+
+
+
+
+
+ );
+};
+
+const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
+ const intl = useIntl();
+ const { libraryId } = useLibraryContext();
+
+ return (
+
+
+
+
+
+
+
+ {/* Set key to update selection when component usageKey changes */}
+
+
+
+ );
+};
+
+const ComponentCollections = ({ collections, onManageClick }: {
+ collections?: string[];
+ onManageClick: () => void;
+}) => {
+ const intl = useIntl();
+
+ if (!collections?.length) {
+ return (
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {collections.map((collection) => (
+
+
+ {collection}
+
+ ))}
+
+
+ );
+};
+
+const ManageCollections = ({ usageKey, collections }: ManageCollectionsProps) => {
+ const [editing, setEditing] = useState(false);
+ const collectionNames = collections.map((collection) => collection.title);
+
+ if (editing) {
+ return (
+ setEditing(false)}
+ />
+ );
+ }
+ return (
+ setEditing(true)}
+ />
+ );
+};
+
+export default ManageCollections;
diff --git a/src/library-authoring/component-info/messages.ts b/src/library-authoring/component-info/messages.ts
index 8f52cdfef..7ae394b4c 100644
--- a/src/library-authoring/component-info/messages.ts
+++ b/src/library-authoring/component-info/messages.ts
@@ -93,7 +93,7 @@ const messages = defineMessages({
},
manageTabCollectionsTitle: {
id: 'course-authoring.library-authoring.component.manage-tab.collections-title',
- defaultMessage: 'Collections',
+ defaultMessage: 'Collections ({count})',
description: 'Title for the Collections container in the management tab',
},
detailsTabTitle: {
@@ -121,6 +121,51 @@ const messages = defineMessages({
defaultMessage: 'Component Preview',
description: 'Title for preview modal',
},
+ manageCollectionsText: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.text',
+ defaultMessage: 'Manage Collections',
+ description: 'Header and button text for collection section in manage tab',
+ },
+ manageCollectionsAddBtnText: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.btn-text',
+ defaultMessage: 'Add to Collection',
+ description: 'Button text for collection section in manage tab',
+ },
+ manageCollectionsSearchPlaceholder: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.search-placeholder',
+ defaultMessage: 'Search',
+ description: 'Placeholder text for collection search in manage tab',
+ },
+ manageCollectionsSelectionLabel: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.selection-aria-label',
+ defaultMessage: 'Collection selection',
+ description: 'Aria label text for collection selection box',
+ },
+ manageCollectionsToComponentSuccess: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.add-success',
+ defaultMessage: 'Component collections updated',
+ description: 'Message to display on updating component collections',
+ },
+ manageCollectionsToComponentFailed: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.add-failed',
+ defaultMessage: 'Failed to update Component collections',
+ description: 'Message to display on failure of updating component collections',
+ },
+ manageCollectionsToComponentConfirmBtn: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.add-confirm-btn',
+ defaultMessage: 'Confirm',
+ description: 'Button text to confirm collections for a component',
+ },
+ manageCollectionsToComponentCancelBtn: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.add-cancel-btn',
+ defaultMessage: 'Cancel',
+ description: 'Button text to cancel collections selection for a component',
+ },
+ componentNotOrganizedIntoCollection: {
+ id: 'course-authoring.library-authoring.component.manage-tab.collections.no-collections',
+ defaultMessage: 'This component is not organized into any collection.',
+ description: 'Message to display in manage collections section when component is not part of any collection.',
+ },
});
export default messages;
diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx
index b22c6b028..f5a826645 100644
--- a/src/library-authoring/components/ComponentCard.test.tsx
+++ b/src/library-authoring/components/ComponentCard.test.tsx
@@ -32,6 +32,7 @@ const contentHit: ContentHit = {
created: 1722434322294,
modified: 1722434322294,
lastPublished: null,
+ collections: {},
};
const clipboardBroadcastChannelMock = {
diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx
index 095512d2d..662344247 100644
--- a/src/library-authoring/components/ComponentCard.tsx
+++ b/src/library-authoring/components/ComponentCard.tsx
@@ -8,6 +8,7 @@ import {
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
+import { useParams } from 'react-router';
import { updateClipboard } from '../../generic/data/api';
import { ToastContext } from '../../generic/toast-context';
import { type ContentHit } from '../../search-manager';
@@ -16,6 +17,7 @@ import messages from './messages';
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
import BaseComponentCard from './BaseComponentCard';
import { canEditComponent } from './ComponentEditorModal';
+import { useRemoveComponentsFromCollection } from '../data/apiHooks';
type ComponentCardProps = {
contentHit: ContentHit,
@@ -23,10 +25,17 @@ type ComponentCardProps = {
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
const intl = useIntl();
- const { openComponentEditor } = useLibraryContext();
+ const {
+ libraryId,
+ openComponentEditor,
+ closeLibrarySidebar,
+ currentComponentUsageKey,
+ } = useLibraryContext();
+ const { collectionId } = useParams();
const canEdit = usageKey && canEditComponent(usageKey);
const { showToast } = useContext(ToastContext);
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
+ const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
const updateClipboardClick = () => {
updateClipboard(usageKey)
.then((clipboardData) => {
@@ -36,6 +45,18 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
};
+ const removeFromCollection = () => {
+ removeComponentsMutation.mutateAsync([usageKey]).then(() => {
+ if (currentComponentUsageKey === usageKey) {
+ // Close sidebar if current component is open
+ closeLibrarySidebar();
+ }
+ showToast(intl.formatMessage(messages.removeComponentSucess));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.removeComponentFailure));
+ });
+ };
+
return (
e.stopPropagation()}>
{
+ {collectionId && (
+
+
+
+ )}
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
index 3bac3ad17..b230b5b21 100644
--- a/src/library-authoring/components/messages.ts
+++ b/src/library-authoring/components/messages.ts
@@ -31,6 +31,21 @@ const messages = defineMessages({
defaultMessage: 'Add to collection',
description: 'Menu item for add a component to collection.',
},
+ menuRemoveFromCollection: {
+ id: 'course-authoring.library-authoring.component.menu.remove',
+ defaultMessage: 'Remove from collection',
+ description: 'Menu item for remove a component from collection.',
+ },
+ removeComponentSucess: {
+ id: 'course-authoring.library-authoring.component.remove-from-collection-success',
+ defaultMessage: 'Component successfully removed',
+ description: 'Message for successful removal of component from collection.',
+ },
+ removeComponentFailure: {
+ id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
+ defaultMessage: 'Failed to remove Component',
+ description: 'Message for failure of removal of component from collection.',
+ },
copyToClipboardSuccess: {
id: 'course-authoring.library-authoring.component.copyToClipboardSuccess',
defaultMessage: 'Component copied to clipboard',
diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index 804b22405..86c124afc 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -95,6 +95,7 @@ mockCreateLibraryBlock.newHtmlData = {
created: '2024-07-22T21:37:49Z',
modified: '2024-07-22T21:37:49Z',
tagsCount: 0,
+ collections: [],
} satisfies api.LibraryBlockMetadata;
mockCreateLibraryBlock.newProblemData = {
id: 'lb:Axim:TEST:problem:prob1',
@@ -109,6 +110,7 @@ mockCreateLibraryBlock.newProblemData = {
created: '2024-07-22T21:37:49Z',
modified: '2024-07-22T21:37:49Z',
tagsCount: 0,
+ collections: [],
} satisfies api.LibraryBlockMetadata;
mockCreateLibraryBlock.newVideoData = {
id: 'lb:Axim:TEST:video:vid1',
@@ -123,6 +125,7 @@ mockCreateLibraryBlock.newVideoData = {
created: '2024-07-22T21:37:49Z',
modified: '2024-07-22T21:37:49Z',
tagsCount: 0,
+ collections: [],
} satisfies api.LibraryBlockMetadata;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockCreateLibraryBlock.applyMock = () => (
@@ -200,6 +203,7 @@ export async function mockLibraryBlockMetadata(usageKey: string): Promise jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index 1c609722b..528ccfa45 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -18,6 +18,11 @@ export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl(
*/
export const getLibraryBlockMetadataUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/`;
+/**
+ * Get the URL for library block metadata.
+ */
+export const getLibraryBlockCollectionsUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}collections/`;
+
/**
* Get the URL for content library list API.
*/
@@ -40,7 +45,7 @@ export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/a
/**
* Get the URL for the xblock OLX API
*/
-export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/olx/`;
+export const getXBlockOLXApiUrl = (usageKey: string) => `${getLibraryBlockMetadataUrl(usageKey)}olx/`;
/**
* Get the URL for the xblock Assets List API
*/
@@ -54,7 +59,7 @@ export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseU
*/
export const getLibraryCollectionApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionsApiUrl(libraryId)}${collectionId}/`;
/**
- * Get the URL for the collection API.
+ * Get the URL for the collection components API.
*/
export const getLibraryCollectionComponentApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}components/`;
/**
@@ -141,6 +146,11 @@ export interface CreateBlockDataRequest {
definitionId: string;
}
+export interface CollectionMetadata {
+ key: string;
+ title: string;
+}
+
export interface LibraryBlockMetadata {
id: string;
blockType: string;
@@ -154,6 +164,7 @@ export interface LibraryBlockMetadata {
created: string | null,
modified: string | null,
tagsCount: number;
+ collections: CollectionMetadata[];
}
export interface UpdateLibraryDataRequest {
@@ -354,14 +365,23 @@ export async function updateCollectionMetadata(
}
/**
- * Update collection components.
+ * Add components to collection.
*/
-export async function updateCollectionComponents(libraryId: string, collectionId: string, usageKeys: string[]) {
+export async function addComponentsToCollection(libraryId: string, collectionId: string, usageKeys: string[]) {
await getAuthenticatedHttpClient().patch(getLibraryCollectionComponentApiUrl(libraryId, collectionId), {
usage_keys: usageKeys,
});
}
+/**
+ * Remove components from collection.
+ */
+export async function removeComponentsFromCollection(libraryId: string, collectionId: string, usageKeys: string[]) {
+ await getAuthenticatedHttpClient().delete(getLibraryCollectionComponentApiUrl(libraryId, collectionId), {
+ data: { usage_keys: usageKeys },
+ });
+}
+
/**
* Soft-Delete collection.
*/
@@ -377,3 +397,12 @@ export async function restoreCollection(libraryId: string, collectionId: string)
const client = getAuthenticatedHttpClient();
await client.post(getLibraryCollectionRestoreApiUrl(libraryId, collectionId));
}
+
+/**
+ * Update component collections.
+ */
+export async function updateComponentCollections(usageKey: string, collectionKeys: string[]) {
+ await getAuthenticatedHttpClient().patch(getLibraryBlockCollectionsUrl(usageKey), {
+ collection_keys: collectionKeys,
+ });
+}
diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx
index c21f5f5a6..62fadf31e 100644
--- a/src/library-authoring/data/apiHooks.test.tsx
+++ b/src/library-authoring/data/apiHooks.test.tsx
@@ -18,7 +18,7 @@ import {
useCreateLibraryBlock,
useCreateLibraryCollection,
useRevertLibraryChanges,
- useUpdateCollectionComponents,
+ useAddComponentsToCollection,
useCollection,
} from './apiHooks';
@@ -104,7 +104,7 @@ describe('library api hooks', () => {
const collectionId = 'my-first-collection';
const url = getLibraryCollectionComponentApiUrl(libraryId, collectionId);
axiosMock.onPatch(url).reply(200);
- const { result } = renderHook(() => useUpdateCollectionComponents(libraryId, collectionId), { wrapper });
+ const { result } = renderHook(() => useAddComponentsToCollection(libraryId, collectionId), { wrapper });
await result.current.mutateAsync(['some-usage-key']);
expect(axiosMock.history.patch[0].url).toEqual(url);
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 42a1f53a3..f405497ef 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -27,13 +27,15 @@ import {
getXBlockOLX,
updateCollectionMetadata,
type UpdateCollectionComponentsRequest,
- updateCollectionComponents,
+ addComponentsToCollection,
type CreateLibraryCollectionDataRequest,
getCollectionMetadata,
deleteCollection,
restoreCollection,
setXBlockOLX,
getXBlockAssets,
+ updateComponentCollections,
+ removeComponentsFromCollection,
} from './api';
export const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
@@ -328,12 +330,32 @@ export const useUpdateCollection = (libraryId: string, collectionId: string) =>
/**
* Use this mutation to add components to a collection in a library
*/
-export const useUpdateCollectionComponents = (libraryId?: string, collectionId?: string) => {
+export const useAddComponentsToCollection = (libraryId?: string, collectionId?: string) => {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: async (usage_keys: string[]) => {
+ mutationFn: async (usageKeys: string[]) => {
if (libraryId !== undefined && collectionId !== undefined) {
- return updateCollectionComponents(libraryId, collectionId, usage_keys);
+ return addComponentsToCollection(libraryId, collectionId, usageKeys);
+ }
+ return undefined;
+ },
+ onSettled: () => {
+ if (libraryId !== undefined && collectionId !== undefined) {
+ queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
+ }
+ },
+ });
+};
+
+/**
+ * Use this mutation to remove components from a collection in a library
+ */
+export const useRemoveComponentsFromCollection = (libraryId?: string, collectionId?: string) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (usageKeys: string[]) => {
+ if (libraryId !== undefined && collectionId !== undefined) {
+ return removeComponentsFromCollection(libraryId, collectionId, usageKeys);
}
return undefined;
},
@@ -372,3 +394,17 @@ export const useRestoreCollection = (libraryId: string, collectionId: string) =>
},
});
};
+
+/**
+ * Use this mutation to update collections related a component in a library
+ */
+export const useUpdateComponentCollections = (libraryId: string, usageKey: string) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (collectionKeys: string[]) => updateComponentCollections(usageKey, collectionKeys),
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(usageKey) });
+ queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
+ },
+ });
+};
diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts
index 1726e10d9..413e4ff76 100644
--- a/src/search-manager/SearchManager.ts
+++ b/src/search-manager/SearchManager.ts
@@ -93,7 +93,10 @@ export const SearchContextProvider: React.FC<{
children: React.ReactNode,
closeSearchModal?: () => void,
overrideQueries?: OverrideQueries,
-}> = ({ overrideSearchSortOrder, overrideQueries, ...props }) => {
+ skipUrlUpdate?: boolean,
+}> = ({
+ overrideSearchSortOrder, overrideQueries, skipUrlUpdate, ...props
+}) => {
const [searchKeywords, setSearchKeywords] = React.useState('');
const [blockTypesFilter, setBlockTypesFilter] = React.useState([]);
const [problemTypesFilter, setProblemTypesFilter] = React.useState([]);
@@ -104,12 +107,17 @@ export const SearchContextProvider: React.FC<{
// E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA.
// Default sort by Most Relevant if there's search keyword(s), else by Recently Modified.
const defaultSearchSortOrder = searchKeywords ? SearchSortOption.RELEVANCE : SearchSortOption.RECENTLY_MODIFIED;
- const [searchSortOrder, setSearchSortOrder] = useStateWithUrlSearchParam(
+ let sortStateManager = React.useState(defaultSearchSortOrder);
+ const sortUrlStateManager = useStateWithUrlSearchParam(
defaultSearchSortOrder,
'sort',
(value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue),
(value: SearchSortOption) => value.toString(),
);
+ if (!skipUrlUpdate) {
+ sortStateManager = sortUrlStateManager;
+ }
+ const [searchSortOrder, setSearchSortOrder] = sortStateManager;
// SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we
// send it to useContentSearchResults as an empty array.
const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder;
diff --git a/src/search-manager/SearchSortWidget.tsx b/src/search-manager/SearchSortWidget.tsx
index 01309845d..6f2d78272 100644
--- a/src/search-manager/SearchSortWidget.tsx
+++ b/src/search-manager/SearchSortWidget.tsx
@@ -3,11 +3,12 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Dropdown } from '@openedx/paragon';
import { Check, SwapVert } from '@openedx/paragon/icons';
+import classNames from 'classnames';
import messages from './messages';
import { SearchSortOption } from './data/api';
import { useSearchContext } from './SearchManager';
-export const SearchSortWidget: React.FC> = () => {
+export const SearchSortWidget = ({ iconOnly = false }: { iconOnly?: boolean }) => {
const intl = useIntl();
const {
searchSortOrder,
@@ -82,11 +83,13 @@ export const SearchSortWidget: React.FC> = () => {
title={intl.formatMessage(messages.searchSortWidgetAltTitle)}
alt={intl.formatMessage(messages.searchSortWidgetAltTitle)}
variant="outline-primary"
- className="dropdown-toggle-menu-items d-flex"
+ className={classNames('dropdown-toggle-menu-items d-flex', {
+ 'border-0': iconOnly,
+ })}
size="sm"
>
- {toggleLabel}
+ { !iconOnly && {toggleLabel}
}
{menuHeader}
diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts
index fe73a7d3f..b9ede51d5 100644
--- a/src/search-manager/data/api.ts
+++ b/src/search-manager/data/api.ts
@@ -129,6 +129,7 @@ export interface ContentHit extends BaseContentHit {
breadcrumbs: [{ displayName: string }, ...Array<{ displayName: string, usageKey: string }>];
content?: ContentDetails;
lastPublished: number | null;
+ collections: { displayName?: string[], key?: string[] },
}
/**
@@ -157,6 +158,7 @@ export function formatSearchHit(hit: Record): ContentHit | Collecti
export interface OverrideQueries {
components?: SearchParams,
+ blockTypes?: SearchParams,
collections?: SearchParams,
}
@@ -168,6 +170,9 @@ function applyOverrideQueries(
if (overrideQueries?.components) {
newQueries[0] = { ...overrideQueries.components, indexUid: queries[0].indexUid };
}
+ if (overrideQueries?.blockTypes) {
+ newQueries[1] = { ...overrideQueries.blockTypes, indexUid: queries[1].indexUid };
+ }
if (overrideQueries?.collections) {
newQueries[2] = { ...overrideQueries.collections, indexUid: queries[2].indexUid };
}