diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 4bd1542856..b2b044429a 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -11,12 +11,18 @@ import { } from '../testUtils'; import mockResult from './__mocks__/library-search.json'; import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; -import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './data/api.mocks'; +import { + mockContentLibrary, + mockGetCollectionMetadata, + mockLibraryBlockTypes, + mockXBlockFields, +} from './data/api.mocks'; import { mockContentSearchConfig } from '../search-manager/data/api.mock'; import { mockBroadcastChannel } from '../generic/data/api.mock'; import { LibraryLayout } from '.'; import { getLibraryCollectionsApiUrl } from './data/api'; +mockGetCollectionMetadata.applyMock(); mockContentSearchConfig.applyMock(); mockContentLibrary.applyMock(); mockLibraryBlockTypes.applyMock(); @@ -458,6 +464,25 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); + it('should open and close the collection sidebar', async () => { + await renderLibraryPage(); + + // Click on the first component. It could appear twice, in both "Recently Modified" and "Collections" + fireEvent.click((await screen.findAllByText('Collection 1'))[0]); + + const sidebar = screen.getByTestId('library-sidebar'); + + const { getByRole, getByText } = within(sidebar); + + // The mock data for the sidebar has a title of "Test Collection" + await waitFor(() => expect(getByText('Test Collection')).toBeInTheDocument()); + + const closeButton = getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); + }); + it('can filter by capa problem type', async () => { const problemTypes = { 'Multiple Choice': 'choiceresponse', diff --git a/src/library-authoring/__mocks__/collection-search.json b/src/library-authoring/__mocks__/collection-search.json index 3033e3c36a..9785489dbb 100644 --- a/src/library-authoring/__mocks__/collection-search.json +++ b/src/library-authoring/__mocks__/collection-search.json @@ -200,7 +200,7 @@ } ], "created": 1726740779.564664, - "modified": 1726740811.684142, + "modified": 1726840811.684142, "usage_key": "lib-collection:OpenedX:CSPROB2:collection-from-meilisearch", "context_key": "lib:OpenedX:CSPROB2", "org": "OpenedX", diff --git a/src/library-authoring/__mocks__/library-search.json b/src/library-authoring/__mocks__/library-search.json index f84d16c611..72ea474fdd 100644 --- a/src/library-authoring/__mocks__/library-search.json +++ b/src/library-authoring/__mocks__/library-search.json @@ -284,6 +284,7 @@ "hits": [ { "display_name": "Collection 1", + "block_id": "col1", "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque et mi ac nisi accumsan imperdiet vitae at odio. Vivamus tempor nec lorem eget lacinia. Vivamus efficitur lacus non dapibus porta. Nulla venenatis luctus nisi id posuere. Sed sollicitudin magna a sem ultrices accumsan. Praesent volutpat tortor vitae luctus rutrum. Integer.", "id": 1, "type": "collection", diff --git a/src/library-authoring/collections/CollectionDetails.test.tsx b/src/library-authoring/collections/CollectionDetails.test.tsx new file mode 100644 index 0000000000..5c4e69064e --- /dev/null +++ b/src/library-authoring/collections/CollectionDetails.test.tsx @@ -0,0 +1,161 @@ +import type MockAdapter from 'axios-mock-adapter'; +import fetchMock from 'fetch-mock-jest'; + +import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock'; +import { + initializeMocks, + fireEvent, + render, + screen, + waitFor, + within, +} from '../../testUtils'; +import * as api from '../data/api'; +import { mockContentLibrary, mockGetCollectionMetadata } from '../data/api.mocks'; +import CollectionDetails from './CollectionDetails'; + +let axiosMock: MockAdapter; +let mockShowToast: (message: string) => void; + +mockGetCollectionMetadata.applyMock(); +mockContentSearchConfig.applyMock(); +mockGetBlockTypes.applyMock(); + +const { collectionId } = mockGetCollectionMetadata; +const { description: originalDescription } = mockGetCollectionMetadata.collectionData; + +const library = mockContentLibrary.libraryData; + +describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + fetchMock.mockReset(); + }); + + it('should render Collection Details', async () => { + render(); + + // Collection Description + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + // Collection History + expect(screen.getByText('Collection History')).toBeInTheDocument(); + // Modified date + expect(screen.getByText('September 20, 2024')).toBeInTheDocument(); + // Created date + expect(screen.getByText('September 19, 2024')).toBeInTheDocument(); + }); + + it('should allow modifying the description', async () => { + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + const url = api.getLibraryCollectionApiUrl(library.id, collectionId); + axiosMock.onPatch(url).reply(200); + + const textArea = screen.getByRole('textbox'); + + // Change the description to the same value + fireEvent.focus(textArea); + fireEvent.change(textArea, { target: { value: originalDescription } }); + fireEvent.blur(textArea); + + await waitFor(() => { + expect(axiosMock.history.patch).toHaveLength(0); + expect(mockShowToast).not.toHaveBeenCalled(); + }); + + // Change the description to a new value + fireEvent.focus(textArea); + fireEvent.change(textArea, { target: { value: 'New description' } }); + fireEvent.blur(textArea); + + await waitFor(() => { + expect(axiosMock.history.patch).toHaveLength(1); + expect(axiosMock.history.patch[0].url).toEqual(url); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' })); + expect(mockShowToast).toHaveBeenCalledWith('Collection updated successfully.'); + }); + }); + + it('should show error while modifing the description', async () => { + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + const url = api.getLibraryCollectionApiUrl(library.id, collectionId); + axiosMock.onPatch(url).reply(500); + + const textArea = screen.getByRole('textbox'); + + // Change the description to a new value + fireEvent.focus(textArea); + fireEvent.change(textArea, { target: { value: 'New description' } }); + fireEvent.blur(textArea); + + await waitFor(() => { + expect(axiosMock.history.patch).toHaveLength(1); + expect(axiosMock.history.patch[0].url).toEqual(url); + expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' })); + expect(mockShowToast).toHaveBeenCalledWith('Failed to update collection.'); + }); + }); + + it('should render Collection stats', async () => { + mockGetBlockTypes('someBlocks'); + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('Total')).toBeInTheDocument(); + + [ + { blockType: 'Total', count: 3 }, + { blockType: 'Text', count: 2 }, + { blockType: 'Problem', count: 1 }, + ].forEach(({ blockType, count }) => { + const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement; + expect(within(blockCount).getByText(count.toString())).toBeInTheDocument(); + }); + }); + + it('should render Collection stats for empty collection', async () => { + mockGetBlockTypes('noBlocks'); + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('This collection is currently empty.')).toBeInTheDocument(); + }); + + it('should render Collection stats for big collection', async () => { + mockGetBlockTypes('moreBlocks'); + render(); + expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('36')).toBeInTheDocument(); + + [ + { blockType: 'Total', count: 36 }, + { blockType: 'Video', count: 8 }, + { blockType: 'Problem', count: 7 }, + { blockType: 'Text', count: 6 }, + { blockType: 'Other', count: 15 }, + ].forEach(({ blockType, count }) => { + const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement; + expect(within(blockCount).getByText(count.toString())).toBeInTheDocument(); + }); + }); +}); diff --git a/src/library-authoring/collections/CollectionDetails.tsx b/src/library-authoring/collections/CollectionDetails.tsx new file mode 100644 index 0000000000..9936177902 --- /dev/null +++ b/src/library-authoring/collections/CollectionDetails.tsx @@ -0,0 +1,177 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, Stack } from '@openedx/paragon'; +import { useContext, useEffect, useState } from 'react'; +import classNames from 'classnames'; + +import { getItemIcon } from '../../generic/block-type-utils'; +import { ToastContext } from '../../generic/toast-context'; +import { BlockTypeLabel, useGetBlockTypes } from '../../search-manager'; +import type { ContentLibrary } from '../data/api'; +import { useCollection, useUpdateCollection } from '../data/apiHooks'; +import HistoryWidget from '../generic/history-widget'; +import messages from './messages'; + +interface BlockCountProps { + count: number, + blockType?: string, + label: React.ReactNode, + className?: string, +} + +const BlockCount = ({ + count, + blockType, + label, + className, +}: BlockCountProps) => { + const icon = blockType && getItemIcon(blockType); + return ( + + {label} + + {icon && } + {count} + + + ); +}; + +interface CollectionStatsWidgetProps { + libraryId: string, + collectionId: string, +} + +const CollectionStatsWidget = ({ libraryId, collectionId }: CollectionStatsWidgetProps) => { + const { data: blockTypes } = useGetBlockTypes([ + `context_key = "${libraryId}"`, + `collections.key = "${collectionId}"`, + ]); + + if (!blockTypes) { + return null; + } + + const blockTypesArray = Object.entries(blockTypes) + .map(([blockType, count]) => ({ blockType, count })) + .sort((a, b) => b.count - a.count); + + const totalBlocksCount = blockTypesArray.reduce((acc, { count }) => acc + count, 0); + // Show the top 3 block type counts individually, and splice the remaining block types together under "Other". + const numBlockTypesShown = 3; + const otherBlocks = blockTypesArray.splice(numBlockTypesShown); + const otherBlocksCount = otherBlocks.reduce((acc, { count }) => acc + count, 0); + + if (totalBlocksCount === 0) { + return ( +
+ +
+ ); + } + + return ( + + } + count={totalBlocksCount} + className="border-right" + /> + {blockTypesArray.map(({ blockType, count }) => ( + } + blockType={blockType} + count={count} + /> + ))} + {otherBlocks.length > 0 && ( + } + count={otherBlocksCount} + /> + )} + + ); +}; + +interface CollectionDetailsProps { + library: ContentLibrary, + collectionId: string, +} + +const CollectionDetails = ({ library, collectionId }: CollectionDetailsProps) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + + const updateMutation = useUpdateCollection(library.id, collectionId); + const { data: collection } = useCollection(library.id, collectionId); + + const [description, setDescription] = useState(collection?.description || ''); + + useEffect(() => { + if (collection) { + setDescription(collection.description); + } + }, [collection]); + + if (!collection) { + return null; + } + + const onSubmit = (e: React.FocusEvent) => { + const newDescription = e.target.value; + if (newDescription === collection.description) { + return; + } + updateMutation.mutateAsync({ + description: newDescription, + }).then(() => { + showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); + }); + }; + + return ( + +
+

+ {intl.formatMessage(messages.detailsTabDescriptionTitle)} +

+ {library.canEditLibrary ? ( +