From f9fa28e8d6342cab901482fff5d5cd9bfe19ee76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Tue, 24 Sep 2024 12:53:30 -0300 Subject: [PATCH 01/21] feat: improve collection sidebar --- .../__mocks__/collection-search.json | 2 +- .../collections/CollectionDetails.test.tsx | 201 ++++++++++++++++++ .../collections/CollectionDetails.tsx | 154 ++++++++++++++ .../collections/CollectionInfo.tsx | 53 +++-- .../collections/CollectionInfoHeader.test.tsx | 105 +++++++++ .../collections/CollectionInfoHeader.tsx | 97 ++++++++- .../LibraryCollectionPage.test.tsx | 7 +- .../collections/LibraryCollectionPage.tsx | 8 +- src/library-authoring/collections/messages.ts | 55 +++++ src/library-authoring/common/context.tsx | 28 ++- .../components/CollectionCard.test.tsx | 2 + .../components/CollectionCard.tsx | 48 ++++- src/library-authoring/components/messages.ts | 5 + src/library-authoring/data/api.ts | 16 +- src/library-authoring/data/apiHooks.ts | 17 ++ .../generic/history-widget/index.tsx | 6 +- src/library-authoring/generic/index.scss | 2 +- .../library-sidebar/LibrarySidebar.tsx | 69 +++--- src/search-manager/data/api.mock.ts | 23 ++ src/search-manager/data/api.ts | 6 +- src/search-manager/index.ts | 1 + 21 files changed, 828 insertions(+), 77 deletions(-) create mode 100644 src/library-authoring/collections/CollectionDetails.test.tsx create mode 100644 src/library-authoring/collections/CollectionDetails.tsx create mode 100644 src/library-authoring/collections/CollectionInfoHeader.test.tsx 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/collections/CollectionDetails.test.tsx b/src/library-authoring/collections/CollectionDetails.test.tsx new file mode 100644 index 0000000000..32d2c37179 --- /dev/null +++ b/src/library-authoring/collections/CollectionDetails.test.tsx @@ -0,0 +1,201 @@ +import type MockAdapter from 'axios-mock-adapter'; +import fetchMock from 'fetch-mock-jest'; +import { cloneDeep } from 'lodash'; + +import { SearchContextProvider } from '../../search-manager'; +import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock'; +import { type CollectionHit, formatSearchHit } from '../../search-manager/data/api'; +import { + initializeMocks, + fireEvent, + render, + screen, + waitFor, + within, +} from '../../testUtils'; +import mockResult from '../__mocks__/collection-search.json'; +import * as api from '../data/api'; +import { mockContentLibrary } from '../data/api.mocks'; +import CollectionDetails from './CollectionDetails'; + +const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; + +let axiosMock: MockAdapter; +let mockShowToast: (message: string) => void; + +mockContentSearchConfig.applyMock(); +const library = mockContentLibrary.libraryData; + +describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + fetchMock.mockReset(); + }); + + const renderCollectionDetails = async () => { + const collectionData: CollectionHit = formatSearchHit(mockResult.results[2].hits[0]) as CollectionHit; + + render(( + + + + )); + + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + }; + + it('should render Collection Details', async () => { + mockSearchResult(mockResult); + await renderCollectionDetails(); + + // Collection Description + expect(screen.getByText('Description / Card Preview Text')).toBeInTheDocument(); + const { description } = mockResult.results[2].hits[0]; + expect(screen.getByText(description)).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 () => { + mockSearchResult(mockResult); + await renderCollectionDetails(); + + const { + description: originalDescription, + block_id: blockId, + context_key: contextKey, + } = mockResult.results[2].hits[0]; + + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + const url = api.getLibraryCollectionApiUrl(contextKey, blockId); + 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 () => { + mockSearchResult(mockResult); + await renderCollectionDetails(); + + const { + description: originalDescription, + block_id: blockId, + context_key: contextKey, + } = mockResult.results[2].hits[0]; + + expect(screen.getByText(originalDescription)).toBeInTheDocument(); + + const url = api.getLibraryCollectionApiUrl(contextKey, blockId); + 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 () => { + mockSearchResult(mockResult); + await renderCollectionDetails(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('Total')).toBeInTheDocument(); + + [ + { blockType: 'Total', count: 5 }, + { blockType: 'Text', count: 4 }, + { 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 () => { + const mockResultCopy = cloneDeep(mockResult); + mockResultCopy.results[1].facetDistribution.block_type = {}; + mockSearchResult(mockResultCopy); + await renderCollectionDetails(); + + 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 () => { + const mockResultCopy = cloneDeep(mockResult); + mockResultCopy.results[1].facetDistribution.block_type = { + annotatable: 1, + chapter: 2, + discussion: 3, + drag_and_drop_v2: 4, + html: 5, + library_content: 6, + openassessment: 7, + problem: 8, + sequential: 9, + vertical: 10, + video: 11, + choiceresponse: 12, + }; + mockSearchResult(mockResultCopy); + await renderCollectionDetails(); + + expect(screen.getByText('Collection Stats')).toBeInTheDocument(); + expect(await screen.findByText('78')).toBeInTheDocument(); + + [ + { blockType: 'Total', count: 78 }, + { blockType: 'Multiple Choice', count: 12 }, + { blockType: 'Video', count: 11 }, + { blockType: 'Unit', count: 10 }, + { blockType: 'Other', count: 45 }, + ].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..07076c334d --- /dev/null +++ b/src/library-authoring/collections/CollectionDetails.tsx @@ -0,0 +1,154 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Icon, Stack } from '@openedx/paragon'; +import { useContext, useState } from 'react'; +import classNames from 'classnames'; + +import { getItemIcon } from '../../generic/block-type-utils'; +import { ToastContext } from '../../generic/toast-context'; +import { BlockTypeLabel, type CollectionHit, useSearchContext } from '../../search-manager'; +import type { ContentLibrary } from '../data/api'; +import { 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} + + + ); +}; + +const CollectionStatsWidget = () => { + const { + blockTypes, + } = useSearchContext(); + + 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); + const otherBlocks = blockTypesArray.splice(3); + 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, + collection: CollectionHit, +} + +const CollectionDetails = ({ library, collection }: CollectionDetailsProps) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + + const [description, setDescription] = useState(collection.description); + + const updateMutation = useUpdateCollection(collection.contextKey, collection.blockId); + + // istanbul ignore if: this should never happen + if (!collection) { + throw new Error('A collection must be provided to CollectionDetails'); + } + + 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 ? ( +