Skip to content

Commit

Permalink
feat: manage collections in component sidebar [FC-0062] (#1373)
Browse files Browse the repository at this point in the history
* feat: add to collection in sidebar

* feat: manage collections

* test: add tests for manage collections

* feat: remove from collection menu option
  • Loading branch information
navinkarkera authored Oct 15, 2024
1 parent 7fb4600 commit 8448760
Show file tree
Hide file tree
Showing 19 changed files with 621 additions and 39 deletions.
21 changes: 20 additions & 1 deletion src/library-authoring/__mocks__/collection-search.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down
4 changes: 2 additions & 2 deletions src/library-authoring/add-content/AddContentContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
37 changes: 35 additions & 2 deletions src/library-authoring/collections/LibraryCollectionPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import fetchMock from 'fetch-mock-jest';
import { cloneDeep } from 'lodash';
import MockAdapter from 'axios-mock-adapter/types';

import {
fireEvent,
initializeMocks,
Expand All @@ -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();
Expand All @@ -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.
Expand Down Expand Up @@ -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');
Expand All @@ -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());
});
});
1 change: 0 additions & 1 deletion src/library-authoring/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:

const closeLibrarySidebar = React.useCallback(() => {
resetSidebar();
setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
resetSidebar();
Expand Down
30 changes: 18 additions & 12 deletions src/library-authoring/component-info/ComponentManagement.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand All @@ -27,19 +28,26 @@ 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();
expect(screen.getByText(matchInnerText('SPAN', 'Draft saved on June 20, 2024 at 13:54.'))).toBeInTheDocument();
});

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();
Expand All @@ -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();
Expand All @@ -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();
Expand All @@ -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();
});
});
11 changes: 7 additions & 4 deletions src/library-authoring/component-info/ComponentManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>
);
Expand Down
121 changes: 121 additions & 0 deletions src/library-authoring/component-info/ManageCollections.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading

0 comments on commit 8448760

Please sign in to comment.