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