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:
Navin Karkera
2024-10-15 20:50:23 +05:30
committed by GitHub
parent 7fb460019e
commit 84487602cc
19 changed files with 621 additions and 39 deletions

View File

@@ -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": "",

View File

@@ -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);

View File

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

View File

@@ -61,7 +61,6 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
const closeLibrarySidebar = React.useCallback(() => {
resetSidebar();
setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
resetSidebar();

View File

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

View File

@@ -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>
);

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

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

View File

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

View File

@@ -32,6 +32,7 @@ const contentHit: ContentHit = {
created: 1722434322294,
modified: 1722434322294,
lastPublished: null,
collections: {},
};
const clipboardBroadcastChannelMock = {

View File

@@ -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>

View File

@@ -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',

View File

@@ -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);

View File

@@ -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,
});
}

View File

@@ -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);

View File

@@ -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) });
},
});
};

View File

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

View File

@@ -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>

View File

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