Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: manage collections in component sidebar [FC-0062] #1373

Merged
merged 14 commits into from
Oct 15, 2024
Merged
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 @@ -21,7 +21,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 @@ -63,7 +63,7 @@ const AddContentContainer = () => {
const intl = useIntl();
const { libraryId, collectionId } = useParams();
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
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