feat: manage collections in component sidebar [FC-0062] (#1373)
* feat: add to collection in sidebar * feat: manage collections * test: add tests for manage collections * feat: remove from collection menu option
This commit is contained in:
@@ -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": "",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('<LibraryCollectionPage />', () => {
|
||||
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('<LibraryCollectionPage />', () => {
|
||||
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('<LibraryCollectionPage />', () => {
|
||||
|
||||
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());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,7 +61,6 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
|
||||
|
||||
const closeLibrarySidebar = React.useCallback(() => {
|
||||
resetSidebar();
|
||||
setCurrentComponentUsageKey(undefined);
|
||||
}, []);
|
||||
const openAddContentSidebar = React.useCallback(() => {
|
||||
resetSidebar();
|
||||
|
||||
@@ -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: () => <div>Mocked ContentTagsDrawer</div>,
|
||||
@@ -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 }) => <LibraryProvider libraryId="lib:OpenedX:CSPROB2">{ children }</LibraryProvider>,
|
||||
});
|
||||
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
mockContentTaxonomyTagsData.applyMock();
|
||||
|
||||
describe('<ComponentManagement />', () => {
|
||||
it('should render draft status', async () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
});
|
||||
|
||||
it('should render draft status', async () => {
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
expect(await screen.findByText('Draft')).toBeInTheDocument();
|
||||
expect(await screen.findByText('(Never Published)')).toBeInTheDocument();
|
||||
@@ -38,8 +48,6 @@ describe('<ComponentManagement />', () => {
|
||||
});
|
||||
|
||||
it('should render published status', async () => {
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyPublished} />);
|
||||
expect(await screen.findByText('Published')).toBeInTheDocument();
|
||||
expect(screen.getByText('Published')).toBeInTheDocument();
|
||||
@@ -53,8 +61,6 @@ describe('<ComponentManagement />', () => {
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
expect(await screen.findByText('Tags (0)')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
|
||||
@@ -65,8 +71,6 @@ describe('<ComponentManagement />', () => {
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
|
||||
});
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyNeverPublished} />);
|
||||
expect(await screen.findByText('Draft')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
@@ -77,10 +81,12 @@ describe('<ComponentManagement />', () => {
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
initializeMocks();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
mockContentTaxonomyTagsData.applyMock();
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyForTags} />);
|
||||
expect(await screen.findByText('Tags (6)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render collection count in collection info section', async () => {
|
||||
render(<ComponentManagement usageKey={mockLibraryBlockMetadata.usageKeyWithCollections} />);
|
||||
expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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={(
|
||||
<Stack gap={1} direction="horizontal">
|
||||
<Icon src={Tag} />
|
||||
{intl.formatMessage(messages.manageTabCollectionsTitle)}
|
||||
<Icon src={BookOpen} />
|
||||
{intl.formatMessage(messages.manageTabCollectionsTitle, { count: collectionsCount })}
|
||||
</Stack>
|
||||
)}
|
||||
className="border-0"
|
||||
>
|
||||
Collections placeholder
|
||||
<ManageCollections usageKey={usageKey} collections={componentMetadata.collections} />
|
||||
</Collapsible>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
121
src/library-authoring/component-info/ManageCollections.test.tsx
Normal file
121
src/library-authoring/component-info/ManageCollections.test.tsx
Normal file
@@ -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 }) => <LibraryProvider libraryId="lib:OpenedX:CSPROB2">{ children }</LibraryProvider>,
|
||||
});
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast;
|
||||
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
mockContentSearchConfig.applyMock();
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
|
||||
describe('<ManageCollections />', () => {
|
||||
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 <mark>...</mark> 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(<ManageCollections
|
||||
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
collections={[{ title: 'My first collection', key: 'my-first-collection' }]}
|
||||
/>);
|
||||
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(<ManageCollections
|
||||
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
collections={[]}
|
||||
/>);
|
||||
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(<ManageCollections
|
||||
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
collections={[]}
|
||||
/>);
|
||||
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();
|
||||
});
|
||||
});
|
||||
211
src/library-authoring/component-info/ManageCollections.tsx
Normal file
211
src/library-authoring/component-info/ManageCollections.tsx
Normal file
@@ -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 (
|
||||
<Stack gap={4}>
|
||||
<Scrollable className="mt-3 p-1 border-bottom border-gray-100" style={{ height: '25vh' }}>
|
||||
<SelectableBox.Set
|
||||
value={selectedCollections}
|
||||
type={type}
|
||||
onChange={handleChange}
|
||||
name="selectedCollections"
|
||||
columns={1}
|
||||
ariaLabelledby={intl.formatMessage(messages.manageCollectionsSelectionLabel)}
|
||||
>
|
||||
{collectionHits.map((collectionHit) => (
|
||||
<SelectableBox
|
||||
className="d-inline-flex align-items-center shadow-none border border-gray-100"
|
||||
value={collectionHit.blockId}
|
||||
key={collectionHit.blockId}
|
||||
inputHidden={false}
|
||||
type={type}
|
||||
aria-label={collectionHit.displayName}
|
||||
>
|
||||
<Stack className="ml-2" direction="horizontal" gap={2}>
|
||||
<Icon src={Folder} />
|
||||
<span>{collectionHit.displayName}</span>
|
||||
</Stack>
|
||||
</SelectableBox>
|
||||
))}
|
||||
</SelectableBox.Set>
|
||||
</Scrollable>
|
||||
<Stack direction="horizontal" gap={2}>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="font-weight-bold"
|
||||
variant="tertiary"
|
||||
>
|
||||
{intl.formatMessage(messages.manageCollectionsToComponentCancelBtn)}
|
||||
</Button>
|
||||
<StatefulButton
|
||||
onClick={handleConfirmation}
|
||||
className="flex-grow-1 rounded-0"
|
||||
variant="primary"
|
||||
state={btnState}
|
||||
labels={{
|
||||
default: intl.formatMessage(messages.manageCollectionsToComponentConfirmBtn),
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
|
||||
const intl = useIntl();
|
||||
const { libraryId } = useLibraryContext();
|
||||
|
||||
return (
|
||||
<SearchContextProvider
|
||||
overrideQueries={{
|
||||
components: { limit: 0 },
|
||||
blockTypes: { limit: 0 },
|
||||
}}
|
||||
extraFilter={`context_key = "${libraryId}"`}
|
||||
skipUrlUpdate
|
||||
>
|
||||
<Stack className="mt-2" gap={3}>
|
||||
<FormattedMessage
|
||||
{...messages.manageCollectionsText}
|
||||
/>
|
||||
<Stack gap={1} direction="horizontal">
|
||||
<SearchKeywordsField
|
||||
className="flex-grow-1"
|
||||
placeholder={intl.formatMessage(messages.manageCollectionsSearchPlaceholder)}
|
||||
/>
|
||||
<SearchSortWidget iconOnly />
|
||||
</Stack>
|
||||
{/* Set key to update selection when component usageKey changes */}
|
||||
<CollectionsSelectableBox
|
||||
usageKey={usageKey}
|
||||
collections={collections}
|
||||
onClose={onClose}
|
||||
key={usageKey}
|
||||
/>
|
||||
</Stack>
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const ComponentCollections = ({ collections, onManageClick }: {
|
||||
collections?: string[];
|
||||
onManageClick: () => void;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (!collections?.length) {
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
<span className="border-bottom pb-3 border-gray-100">
|
||||
<FormattedMessage {...messages.componentNotOrganizedIntoCollection} />
|
||||
</span>
|
||||
<Button
|
||||
onClick={onManageClick}
|
||||
variant="primary"
|
||||
>
|
||||
{intl.formatMessage(messages.manageCollectionsAddBtnText)}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={4} className="mt-2">
|
||||
{collections.map((collection) => (
|
||||
<Stack
|
||||
className="border-bottom pb-4 border-gray-100"
|
||||
gap={2}
|
||||
direction="horizontal"
|
||||
key={collection}
|
||||
>
|
||||
<Icon src={Folder} />
|
||||
<span>{collection}</span>
|
||||
</Stack>
|
||||
))}
|
||||
<Button
|
||||
onClick={onManageClick}
|
||||
variant="outline-primary"
|
||||
>
|
||||
{intl.formatMessage(messages.manageCollectionsText)}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ManageCollections = ({ usageKey, collections }: ManageCollectionsProps) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const collectionNames = collections.map((collection) => collection.title);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<AddToCollectionsDrawer
|
||||
usageKey={usageKey}
|
||||
collections={collections}
|
||||
onClose={() => setEditing(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ComponentCollections
|
||||
collections={collectionNames}
|
||||
onManageClick={() => setEditing(true)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageCollections;
|
||||
@@ -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;
|
||||
|
||||
@@ -32,6 +32,7 @@ const contentHit: ContentHit = {
|
||||
created: 1722434322294,
|
||||
modified: 1722434322294,
|
||||
lastPublished: null,
|
||||
collections: {},
|
||||
};
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
|
||||
@@ -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 (
|
||||
<Dropdown id="component-card-dropdown" onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown.Toggle
|
||||
@@ -54,6 +75,11 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
<Dropdown.Item onClick={updateClipboardClick}>
|
||||
<FormattedMessage {...messages.menuCopyToClipboard} />
|
||||
</Dropdown.Item>
|
||||
{collectionId && (
|
||||
<Dropdown.Item onClick={removeFromCollection}>
|
||||
<FormattedMessage {...messages.menuRemoveFromCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item disabled>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<api.Li
|
||||
throw createAxiosError({ code: 404, message: 'Not found.', path: api.getLibraryBlockMetadataUrl(usageKey) });
|
||||
case thisMock.usageKeyNeverPublished: return thisMock.dataNeverPublished;
|
||||
case thisMock.usageKeyPublished: return thisMock.dataPublished;
|
||||
case thisMock.usageKeyWithCollections: return thisMock.dataWithCollections;
|
||||
case thisMock.usageKeyThirdPartyXBlock: return thisMock.dataThirdPartyXBlock;
|
||||
case thisMock.usageKeyForTags: return thisMock.dataPublished;
|
||||
default: throw new Error(`No mock has been set up for usageKey "${usageKey}"`);
|
||||
@@ -221,6 +225,7 @@ mockLibraryBlockMetadata.dataNeverPublished = {
|
||||
created: '2024-06-20T13:54:21Z',
|
||||
modified: '2024-06-21T13:54:21Z',
|
||||
tagsCount: 0,
|
||||
collections: [],
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyPublished = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
|
||||
mockLibraryBlockMetadata.dataPublished = {
|
||||
@@ -236,6 +241,7 @@ mockLibraryBlockMetadata.dataPublished = {
|
||||
created: '2024-06-20T13:54:21Z',
|
||||
modified: '2024-06-21T13:54:21Z',
|
||||
tagsCount: 0,
|
||||
collections: [],
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyThirdPartyXBlock = mockXBlockFields.usageKeyThirdParty;
|
||||
mockLibraryBlockMetadata.dataThirdPartyXBlock = {
|
||||
@@ -244,6 +250,22 @@ mockLibraryBlockMetadata.dataThirdPartyXBlock = {
|
||||
blockType: 'third_party',
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
mockLibraryBlockMetadata.usageKeyForTags = mockContentTaxonomyTagsData.largeTagsId;
|
||||
mockLibraryBlockMetadata.usageKeyWithCollections = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
|
||||
mockLibraryBlockMetadata.dataWithCollections = {
|
||||
id: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
|
||||
defKey: null,
|
||||
blockType: 'html',
|
||||
displayName: 'Introduction to Testing 2',
|
||||
lastPublished: '2024-06-21T00:00:00',
|
||||
publishedBy: 'Luke',
|
||||
lastDraftCreated: null,
|
||||
lastDraftCreatedBy: '2024-06-20T20:00:00Z',
|
||||
hasUnpublishedChanges: false,
|
||||
created: '2024-06-20T13:54:21Z',
|
||||
modified: '2024-06-21T13:54:21Z',
|
||||
tagsCount: 0,
|
||||
collections: [{ title: 'My first collection', key: 'my-first-collection' }],
|
||||
} satisfies api.LibraryBlockMetadata;
|
||||
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
|
||||
mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
|
||||
@@ -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<SearchSortOption>(
|
||||
let sortStateManager = React.useState<SearchSortOption>(defaultSearchSortOrder);
|
||||
const sortUrlStateManager = useStateWithUrlSearchParam<SearchSortOption>(
|
||||
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;
|
||||
|
||||
@@ -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<Record<never, never>> = () => {
|
||||
export const SearchSortWidget = ({ iconOnly = false }: { iconOnly?: boolean }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
searchSortOrder,
|
||||
@@ -82,11 +83,13 @@ export const SearchSortWidget: React.FC<Record<never, never>> = () => {
|
||||
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"
|
||||
>
|
||||
<Icon src={SwapVert} className="d-inline" />
|
||||
<div className="py-0 px-1">{toggleLabel}</div>
|
||||
{ !iconOnly && <div className="py-0 px-1">{toggleLabel}</div>}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Header>{menuHeader}</Dropdown.Header>
|
||||
|
||||
@@ -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<string, any>): 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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user