diff --git a/src/editors/EditorContainer.test.jsx b/src/editors/EditorContainer.test.jsx index d57d14c6b1..ea5ec4a8b4 100644 --- a/src/editors/EditorContainer.test.jsx +++ b/src/editors/EditorContainer.test.jsx @@ -8,6 +8,7 @@ jest.mock('react-router', () => ({ blockId: 'company-id1', blockType: 'html', }), + useLocation: () => {}, })); const props = { learningContextId: 'cOuRsEId' }; diff --git a/src/editors/EditorContainer.tsx b/src/editors/EditorContainer.tsx index c5b2d5ec89..90bd248766 100644 --- a/src/editors/EditorContainer.tsx +++ b/src/editors/EditorContainer.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { useParams } from 'react-router-dom'; +import { useLocation, useParams } from 'react-router-dom'; import { getConfig } from '@edx/frontend-platform'; import EditorPage from './EditorPage'; @@ -8,7 +8,7 @@ interface Props { /** Course ID or Library ID */ learningContextId: string; /** Event handler sometimes called when user cancels out of the editor page */ - onClose?: () => void; + onClose?: (prevPath?: string) => void; /** * Event handler called after when user saves their changes using an editor * and sometimes called when user cancels the editor, instead of onClose. @@ -17,7 +17,7 @@ interface Props { * TODO: clean this up so there are separate onCancel and onSave callbacks, * and they are used consistently instead of this mess. */ - returnFunction?: () => (newData: Record | undefined) => void; + returnFunction?: (prevPath?: string) => (newData: Record | undefined) => void; } const EditorContainer: React.FC = ({ @@ -26,6 +26,8 @@ const EditorContainer: React.FC = ({ returnFunction, }) => { const { blockType, blockId } = useParams(); + const location = useLocation(); + if (blockType === undefined || blockId === undefined) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. return
Error: missing URL parameters
; @@ -38,8 +40,8 @@ const EditorContainer: React.FC = ({ blockId={blockId} studioEndpointUrl={getConfig().STUDIO_BASE_URL} lmsEndpointUrl={getConfig().LMS_BASE_URL} - onClose={onClose} - returnFunction={returnFunction} + onClose={onClose ? () => onClose(location.state?.from) : null} + returnFunction={returnFunction ? () => returnFunction(location.state?.from) : null} /> ); diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss index a546d8ca6b..c68dfbee49 100644 --- a/src/generic/block-type-utils/index.scss +++ b/src/generic/block-type-utils/index.scss @@ -4,6 +4,12 @@ .pgn__icon { color: white; } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#005C9E, 15%); + } + } } .component-style-html { @@ -12,6 +18,12 @@ .pgn__icon { color: white; } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#9747FF, 15%); + } + } } .component-style-collection { @@ -20,6 +32,12 @@ .pgn__icon { color: black; } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#FFCD29, 15%); + } + } } .component-style-video { @@ -28,6 +46,12 @@ .pgn__icon { color: white; } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#358F0A, 15%); + } + } } .component-style-vertical { @@ -36,6 +60,12 @@ .pgn__icon { color: white; } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#0B8E77, 15%); + } + } } .component-style-other { @@ -44,4 +74,10 @@ .pgn__icon { color: white; } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#646464, 15%); + } + } } diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index 39e43ed1ed..eea5ed732a 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -1,54 +1,46 @@ -import React, { useContext, useCallback } from 'react'; import { useParams } from 'react-router'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import type { MessageDescriptor } from 'react-intl'; import { Button, Stack, } from '@openedx/paragon'; import { Add } from '@openedx/paragon/icons'; import { ClearFiltersButton } from '../search-manager'; import messages from './messages'; -import { LibraryContext } from './common/context'; import { useContentLibrary } from './data/apiHooks'; -type NoSearchResultsProps = { - searchType?: 'collection' | 'component', -}; - -export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => { - const { openAddContentSidebar, openCreateCollectionModal } = useContext(LibraryContext); +export const NoComponents = ({ + infoText = messages.noComponents, + addBtnText = messages.addComponent, + handleBtnClick, +}: { + infoText?: MessageDescriptor; + addBtnText?: MessageDescriptor; + handleBtnClick: () => void; +}) => { const { libraryId } = useParams(); const { data: libraryData } = useContentLibrary(libraryId); const canEditLibrary = libraryData?.canEditLibrary ?? false; - const handleOnClickButton = useCallback(() => { - if (searchType === 'collection') { - openCreateCollectionModal(); - } else { - openAddContentSidebar(); - } - }, [searchType]); - return ( - {searchType === 'collection' - ? - : } + {canEditLibrary && ( - )} ); }; -export const NoSearchResults = ({ searchType = 'component' }: NoSearchResultsProps) => ( +export const NoSearchResults = ({ + infoText = messages.noSearchResults, +}: { + infoText?: MessageDescriptor; +}) => ( - {searchType === 'collection' - ? - : } + ); diff --git a/src/library-authoring/LibraryAuthoringPage.scss b/src/library-authoring/LibraryAuthoringPage.scss index 9680b8062b..6e422a802f 100644 --- a/src/library-authoring/LibraryAuthoringPage.scss +++ b/src/library-authoring/LibraryAuthoringPage.scss @@ -20,3 +20,8 @@ height: 100vh; overflow-y: auto; } + +// Reduce breadcrumb bottom margin +ol.list-inline { + margin-bottom: 0; +} diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 3f0a680a3b..343bb94f62 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -29,7 +29,7 @@ import { SearchSortWidget, } from '../search-manager'; import LibraryComponents from './components/LibraryComponents'; -import LibraryCollections from './LibraryCollections'; +import LibraryCollections from './collections/LibraryCollections'; import LibraryHome from './LibraryHome'; import { useContentLibrary } from './data/apiHooks'; import { LibrarySidebar } from './library-sidebar'; diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx index 73d0e44ad5..2ee1e5cb66 100644 --- a/src/library-authoring/LibraryHome.tsx +++ b/src/library-authoring/LibraryHome.tsx @@ -1,14 +1,15 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { Stack } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useSearchContext } from '../search-manager'; import { NoComponents, NoSearchResults } from './EmptyStates'; -import LibraryCollections from './LibraryCollections'; +import LibraryCollections from './collections/LibraryCollections'; import { LibraryComponents } from './components'; import LibrarySection from './components/LibrarySection'; import LibraryRecentlyModified from './LibraryRecentlyModified'; import messages from './messages'; +import { LibraryContext } from './common/context'; type LibraryHomeProps = { libraryId: string, @@ -23,10 +24,11 @@ const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) totalCollectionHits: collectionCount, isFiltered, } = useSearchContext(); + const { openAddContentSidebar } = useContext(LibraryContext); const renderEmptyState = () => { if (componentCount === 0 && collectionCount === 0) { - return isFiltered ? : ; + return isFiltered ? : ; } return null; }; diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 59ee11797c..abd2cf8ee6 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -13,6 +13,7 @@ import LibraryAuthoringPage from './LibraryAuthoringPage'; import { LibraryProvider } from './common/context'; import { CreateCollectionModal } from './create-collection'; import { invalidateComponentData } from './data/apiHooks'; +import LibraryCollectionPage from './collections/LibraryCollectionPage'; const LibraryLayout = () => { const { libraryId } = useParams(); @@ -24,14 +25,20 @@ const LibraryLayout = () => { } const navigate = useNavigate(); - const goBack = React.useCallback(() => { - // Go back to the library - navigate(`/library/${libraryId}`); + const goBack = React.useCallback((prevPath?: string) => { + if (prevPath) { + // Redirects back to the previous route like collection page or library page + navigate(prevPath); + } else { + // Go back to the library + navigate(`/library/${libraryId}`); + } }, []); - const returnFunction = React.useCallback(() => { + + const returnFunction = React.useCallback((prevPath?: string) => { // When changes are cancelled, either onClose (goBack) or this returnFunction will be called. // When changes are saved, this returnFunction is called. - goBack(); + goBack(prevPath); return (args) => { if (args === undefined) { return; // Do nothing - the user cancelled the changes @@ -58,6 +65,10 @@ const LibraryLayout = () => { )} /> + } + /> } diff --git a/src/library-authoring/__mocks__/collection-search.json b/src/library-authoring/__mocks__/collection-search.json new file mode 100644 index 0000000000..3033e3c36a --- /dev/null +++ b/src/library-authoring/__mocks__/collection-search.json @@ -0,0 +1,218 @@ +{ + "comment": "This mock is captured from a real search result and roughly edited to match the mocks in src/library-authoring/data/api.mocks.ts", + "note": "The _formatted fields have been removed from this result and should be re-added programatically when mocking.", + "results": [ + { + "indexUid": "studio_content", + "hits": [ + { + "id": "lbaximtesthtml571fe018-f3ce-45c9-8f53-5dafcb422fdd-273ebd90", + "display_name": "Introduction to Testing", + "block_id": "571fe018-f3ce-45c9-8f53-5dafcb422fdd", + "content": { + "html_content": "This is a text component which uses HTML." + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1721857069.042984, + "modified": 1725878053.420395, + "last_published": 1725035862.450613, + "usage_key": "lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15, + "collections": { + "display_name": [ + "My first collection" + ], + "key": [ + "my-first-collection" + ] + } + }, + { + "id": "lbaximtesthtml73a22298-bcd9-4f4c-ae34-0bc2b0612480-46b4a7f2", + "display_name": "Second Text Component", + "block_id": "73a22298-bcd9-4f4c-ae34-0bc2b0612480", + "content": { + "html_content": "Preview of the second text component here" + }, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1724879593.066427, + "modified": 1725034981.663482, + "last_published": 1725035862.450613, + "usage_key": "lb:Axim:TEST:html:73a22298-bcd9-4f4c-ae34-0bc2b0612480", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15, + "collections": { + "display_name": [ + "My first collection" + ], + "key": [ + "my-first-collection" + ] + } + }, + { + "id": "lbaximtesthtmlbe5b5db9-26ba-4fac-86af-654538c70b5e-73dbaa95", + "display_name": "Third Text component", + "block_id": "be5b5db9-26ba-4fac-86af-654538c70b5e", + "content": { + "html_content": "This is a text component that I've edited within the library. " + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1721857034.455737, + "modified": 1722551300.377488, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:html:be5b5db9-26ba-4fac-86af-654538c70b5e", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15, + "collections": { + "display_name": [ + "My first collection", + "My second collection" + ], + "key": [ + "my-first-collection", + "my-second-collection" + ] + } + }, + { + "id": "lbaximtesthtmle59e8c73-4056-4894-bca4-062781fb3f68-46a404b2", + "display_name": "Text 4", + "block_id": "e59e8c73-4056-4894-bca4-062781fb3f68", + "content": { + "html_content": "" + }, + "tags": {}, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1720774228.49832, + "modified": 1720774228.49832, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:html:e59e8c73-4056-4894-bca4-062781fb3f68", + "block_type": "html", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15, + "collections": { + "display_name": [ + "My first collection" + ], + "key": [ + "my-first-collection" + ] + } + }, + { + "id": "lbaximtestproblemf16116c9-516e-4bb9-b99e-103599f62417-f2798115", + "display_name": "Blank Problem", + "block_id": "f16116c9-516e-4bb9-b99e-103599f62417", + "content": { + "problem_types": [], + "capa_content": " " + }, + "type": "library_block", + "breadcrumbs": [ + { + "display_name": "Test Library" + } + ], + "created": 1724725821.973896, + "modified": 1724725821.973896, + "last_published": 1724879092.002222, + "usage_key": "lb:Axim:TEST:problem:f16116c9-516e-4bb9-b99e-103599f62417", + "block_type": "problem", + "context_key": "lib:Axim:TEST", + "org": "Axim", + "access_id": 15, + "collections": { + "display_name": [ + "My first collection" + ], + "key": [ + "my-first-collection" + ] + } + } + ], + "query": "", + "processingTimeMs": 1, + "limit": 20, + "offset": 0, + "estimatedTotalHits": 5 + }, + { + "indexUid": "studio_content", + "hits": [], + "query": "", + "processingTimeMs": 0, + "limit": 0, + "offset": 0, + "estimatedTotalHits": 5, + "facetDistribution": { + "block_type": { + "html": 4, + "problem": 1 + }, + "content.problem_types": {} + }, + "facetStats": {} + }, + { + "indexUid": "studio_content", + "hits": [ + { + "display_name": "My first collection", + "block_id": "my-first-collection", + "description": "A collection for testing", + "id": 1, + "type": "collection", + "breadcrumbs": [ + { + "display_name": "CS problems 2" + } + ], + "created": 1726740779.564664, + "modified": 1726740811.684142, + "usage_key": "lib-collection:OpenedX:CSPROB2:collection-from-meilisearch", + "context_key": "lib:OpenedX:CSPROB2", + "org": "OpenedX", + "access_id": 16, + "num_children": 5 + } + ], + "query": "", + "processingTimeMs": 0, + "limit": 1, + "offset": 0, + "estimatedTotalHits": 1 + } + ] +} diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 0ba42b2d04..1d46aaacbb 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -16,12 +16,12 @@ import { ContentPaste, } from '@openedx/paragon/icons'; import { v4 as uuid4 } from 'uuid'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useLocation, useNavigate, 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 } from '../data/apiHooks'; +import { useCreateLibraryBlock, useLibraryPasteClipboard, useUpdateCollectionComponents } from '../data/apiHooks'; import { getEditUrl } from '../components/utils'; import messages from './messages'; @@ -62,8 +62,11 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro const AddContentContainer = () => { const intl = useIntl(); const navigate = useNavigate(); - const { libraryId } = useParams(); + const location = useLocation(); + const currentPath = location.pathname; + const { libraryId, collectionId } = useParams(); const createBlockMutation = useCreateLibraryBlock(); + const updateComponentsMutation = useUpdateCollectionComponents(libraryId, collectionId); const pasteClipboardMutation = useLibraryPasteClipboard(); const { showToast } = useContext(ToastContext); const canEdit = useSelector(getCanEdit); @@ -149,8 +152,13 @@ const AddContentContainer = () => { definitionId: `${uuid4()}`, }).then((data) => { const editUrl = getEditUrl(data.id); + updateComponentsMutation.mutateAsync([data.id]).catch(() => { + showToast(intl.formatMessage(messages.errorAssociateComponentMessage)); + }); if (editUrl) { - navigate(editUrl); + // Pass currentPath in state so that we can come back to + // current page on save or cancel + navigate(editUrl, { state: { from: currentPath } }); } else { // We can't start editing this right away so just show a toast message: showToast(intl.formatMessage(messages.successCreateMessage)); @@ -168,7 +176,7 @@ const AddContentContainer = () => { return ( - + {!collectionId && }
{contentTypes.map((contentType) => ( { + const intl = useIntl(); + + return ( + + + Manage tab placeholder + + + Details tab placeholder + + + ); +}; + +export default CollectionInfo; diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx new file mode 100644 index 0000000000..fda3f42eb9 --- /dev/null +++ b/src/library-authoring/collections/CollectionInfoHeader.tsx @@ -0,0 +1,13 @@ +import { type CollectionHit } from '../../search-manager/data/api'; + +interface CollectionInfoHeaderProps { + collection?: CollectionHit; +} + +const CollectionInfoHeader = ({ collection } : CollectionInfoHeaderProps) => ( +
+ {collection?.displayName} +
+); + +export default CollectionInfoHeader; diff --git a/src/library-authoring/collections/LibraryCollectionComponents.tsx b/src/library-authoring/collections/LibraryCollectionComponents.tsx new file mode 100644 index 0000000000..5d870645c9 --- /dev/null +++ b/src/library-authoring/collections/LibraryCollectionComponents.tsx @@ -0,0 +1,33 @@ +import { useContext } from 'react'; +import { Stack } from '@openedx/paragon'; +import { NoComponents, NoSearchResults } from '../EmptyStates'; +import { useSearchContext } from '../../search-manager'; +import { LibraryComponents } from '../components'; +import messages from './messages'; +import { LibraryContext } from '../common/context'; + +const LibraryCollectionComponents = ({ libraryId }: { libraryId: string }) => { + const { totalHits: componentCount, isFiltered } = useSearchContext(); + const { openAddContentSidebar } = useContext(LibraryContext); + + if (componentCount === 0) { + return isFiltered + ? + : ( + + ); + } + + return ( + +

Content ({componentCount})

+ +
+ ); +}; + +export default LibraryCollectionComponents; diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx new file mode 100644 index 0000000000..129fd2fadb --- /dev/null +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -0,0 +1,325 @@ +import fetchMock from 'fetch-mock-jest'; +import { cloneDeep } from 'lodash'; +import { + fireEvent, + initializeMocks, + render, + screen, + waitFor, + within, +} from '../../testUtils'; +import mockResult from '../__mocks__/collection-search.json'; +import { + mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields, +} from '../data/api.mocks'; +import { mockContentSearchConfig } from '../../search-manager/data/api.mock'; +import { mockBroadcastChannel } from '../../generic/data/api.mock'; +import { LibraryLayout } from '..'; + +mockContentSearchConfig.applyMock(); +mockContentLibrary.applyMock(); +mockLibraryBlockTypes.applyMock(); +mockXBlockFields.applyMock(); +mockBroadcastChannel(); + +const searchEndpoint = 'http://mock.meilisearch.local/multi-search'; +const path = '/library/:libraryId/*'; +const libraryTitle = mockContentLibrary.libraryData.title; +const mockCollection = { + collectionId: mockResult.results[2].hits[0].block_id, + collectionNeverLoads: 'collection-always-loading', + collectionEmpty: 'collection-no-data', + collectionNoComponents: 'collection-no-components', + title: mockResult.results[2].hits[0].display_name, +}; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + + // The Meilisearch client-side API uses fetch, not Axios. + fetchMock.post(searchEndpoint, (_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + const mockResultCopy = cloneDeep(mockResult); + // 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. + mockResultCopy.results[0].query = query; + mockResultCopy.results[2].query = query; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + const collectionQueryId = requestData?.queries[2]?.filter[2]?.split('block_id = "')[1].split('"')[0]; + switch (collectionQueryId) { + case mockCollection.collectionNeverLoads: + return new Promise(() => {}); + case mockCollection.collectionEmpty: + mockResultCopy.results[2].hits = []; + mockResultCopy.results[2].estimatedTotalHits = 0; + break; + case mockCollection.collectionNoComponents: + mockResultCopy.results[0].hits = []; + mockResultCopy.results[0].estimatedTotalHits = 0; + mockResultCopy.results[1].facetDistribution.block_type = {}; + mockResultCopy.results[2].hits[0].num_children = 0; + break; + default: + break; + } + return mockResultCopy; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + fetchMock.mockReset(); + }); + + const renderLibraryCollectionPage = async (collectionId?: string, libraryId?: string) => { + const libId = libraryId || mockContentLibrary.libraryId; + const colId = collectionId || mockCollection.collectionId; + render(, { + path, + routerProps: { + initialEntries: [`/library/${libId}/collection/${colId}`], + }, + }); + + if (colId !== mockCollection.collectionNeverLoads) { + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + } + }; + + it('shows the spinner before the query is complete', async () => { + // This mock will never return data about the collection (it loads forever): + await renderLibraryCollectionPage(mockCollection.collectionNeverLoads); + const spinner = screen.getByRole('status'); + expect(spinner.textContent).toEqual('Loading...'); + }); + + it('shows an error component if no collection returned', async () => { + // This mock will simulate incorrect collection id + await renderLibraryCollectionPage(mockCollection.collectionEmpty); + screen.debug(); + expect(await screen.findByTestId('notFoundAlert')).toBeInTheDocument(); + }); + + it('shows collection data', async () => { + await renderLibraryCollectionPage(); + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument(); + + expect(screen.queryByText('This collection is currently empty.')).not.toBeInTheDocument(); + + // "Recently Modified" sort shown + expect(screen.getAllByText('Recently Modified').length).toEqual(1); + expect((await screen.findAllByText('Introduction to Testing'))[0]).toBeInTheDocument(); + // Content header with count + expect(await screen.findByText('Content (5)')).toBeInTheDocument(); + }); + + it('shows a collection without associated components', async () => { + await renderLibraryCollectionPage(mockCollection.collectionNoComponents); + + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument(); + + expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument(); + + const addComponentButton = screen.getAllByRole('button', { name: /new/i })[1]; + fireEvent.click(addComponentButton); + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + }); + + it('shows the new content button', async () => { + await renderLibraryCollectionPage(); + + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect(await screen.findByText('Content (5)')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new/i })).toBeInTheDocument(); + expect(screen.queryByText('Read Only')).not.toBeInTheDocument(); + }); + + it('shows an empty read-only library collection, without a new button', async () => { + // Use a library mock that is read-only: + const libraryId = mockContentLibrary.libraryIdReadOnly; + // Update search mock so it returns no results: + await renderLibraryCollectionPage(mockCollection.collectionNoComponents, libraryId); + + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument(); + expect(screen.getByText('Read Only')).toBeInTheDocument(); + }); + + it('show a collection without search results', async () => { + // Update search mock so it returns no results: + await renderLibraryCollectionPage(mockCollection.collectionNoComponents); + + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument(); + + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'noresults' } }); + + // Ensure the search endpoint is called again, only once more since the recently modified call + // should not be impacted by the search + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + + expect(screen.queryByText('No matching components found in this collections.')).toBeInTheDocument(); + }); + + it('should open and close new content sidebar', async () => { + await renderLibraryCollectionPage(); + + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + + const newButton = screen.getByRole('button', { name: /new/i }); + fireEvent.click(newButton); + + expect(screen.getByText(/add content/i)).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); + }); + + it('should open collection Info by default', async () => { + await renderLibraryCollectionPage(); + + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(mockCollection.title))[1]).toBeInTheDocument(); + + expect(screen.getByText('Manage')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + + it('should close and open Collection Info', async () => { + await renderLibraryCollectionPage(); + + expect(await screen.findByText('All Collections')).toBeInTheDocument(); + expect((await screen.findAllByText(libraryTitle))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(mockCollection.title))[0]).toBeInTheDocument(); + expect((await screen.findAllByText(mockCollection.title))[1]).toBeInTheDocument(); + + // Open by default; close the library info sidebar + const closeButton = screen.getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + expect(screen.queryByText('Draft')).not.toBeInTheDocument(); + expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument(); + + // Open library info sidebar with 'Library info' button + const collectionInfoBtn = screen.getByRole('button', { name: /collection info/i }); + fireEvent.click(collectionInfoBtn); + expect(screen.getByText('Manage')).toBeInTheDocument(); + expect(screen.getByText('Details')).toBeInTheDocument(); + }); + + it('sorts collection components', async () => { + await renderLibraryCollectionPage(); + + expect(await screen.findByTitle('Sort search results')).toBeInTheDocument(); + + const testSortOption = (async (optionText, sortBy, isDefault) => { + // Open the drop-down menu + fireEvent.click(screen.getByTitle('Sort search results')); + + // Click the option with the given text + // Since the sort drop-down also shows the selected sort + // option in its toggle button, we need to make sure we're + // clicking on the last one found. + const options = screen.getAllByText(optionText); + expect(options.length).toBeGreaterThan(0); + fireEvent.click(options[options.length - 1]); + + // Did the search happen with the expected sort option? + const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]'; + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining(bodyText), + method: 'POST', + headers: expect.anything(), + }); + }); + + // Is the sort option stored in the query string? + // Note: we can't easily check this at the moment with + // const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`; + // expect(window.location.href).toEqual(searchText); + + // Is the selected sort option shown in the toggle button (if not default) + // as well as in the drop-down menu? + expect(screen.getAllByText(optionText).length).toEqual(isDefault ? 1 : 2); + }); + + await testSortOption('Title, A-Z', 'display_name:asc', false); + await testSortOption('Title, Z-A', 'display_name:desc', false); + await testSortOption('Newest', 'created:desc', false); + await testSortOption('Oldest', 'created:asc', false); + + // Sorting by Recently Published also excludes unpublished components + await testSortOption('Recently Published', 'last_published:desc', false); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining('last_published IS NOT NULL'), + method: 'POST', + headers: expect.anything(), + }); + }); + + // Re-selecting the previous sort option resets sort to default "Recently Modified" + await testSortOption('Recently Published', 'modified:desc', true); + expect(screen.getAllByText('Recently Modified').length).toEqual(2); + + // Enter a keyword into the search box + const searchBox = await screen.findByRole('searchbox'); + fireEvent.change(searchBox, { target: { value: 'words to find' } }); + + // Default sort option changes to "Most Relevant" + expect(screen.getAllByText('Most Relevant').length).toEqual(2); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining('"sort":[]'), + method: 'POST', + headers: expect.anything(), + }); + }); + }); + + it('should open and close the component sidebar', async () => { + const mockResult0 = mockResult.results[0].hits[0]; + const displayName = 'Introduction to Testing'; + 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'); + + const { getByRole, getByText } = within(sidebar); + + await waitFor(() => expect(getByText(displayName)).toBeInTheDocument()); + + const closeButton = getByRole('button', { name: /close/i }); + fireEvent.click(closeButton); + + await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); + }); + + it('has an empty type filter when there are no results', async () => { + await renderLibraryCollectionPage(mockCollection.collectionNoComponents); + + const filterButton = screen.getByRole('button', { name: /type/i }); + fireEvent.click(filterButton); + + expect(screen.getByText(/no matching components/i)).toBeInTheDocument(); + }); +}); diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx new file mode 100644 index 0000000000..b2344a9b1f --- /dev/null +++ b/src/library-authoring/collections/LibraryCollectionPage.tsx @@ -0,0 +1,208 @@ +import { useContext, useEffect } from 'react'; +import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Badge, + Button, + Breadcrumb, + Container, + Icon, + IconButton, + Stack, +} from '@openedx/paragon'; +import { Add, InfoOutline } from '@openedx/paragon/icons'; +import { Link, useParams } from 'react-router-dom'; + +import { SearchParams } from 'meilisearch'; +import Loading from '../../generic/Loading'; +import SubHeader from '../../generic/sub-header/SubHeader'; +import Header from '../../header'; +import NotFoundAlert from '../../generic/NotFoundAlert'; +import { + ClearFiltersButton, + FilterByBlockType, + FilterByTags, + SearchContextProvider, + SearchKeywordsField, + SearchSortWidget, + useSearchContext, +} from '../../search-manager'; +import { useContentLibrary } from '../data/apiHooks'; +import { LibraryContext } from '../common/context'; +import messages from './messages'; +import { LibrarySidebar } from '../library-sidebar'; +import LibraryCollectionComponents from './LibraryCollectionComponents'; + +const HeaderActions = ({ canEditLibrary }: { canEditLibrary: boolean; }) => { + const intl = useIntl(); + const { + openAddContentSidebar, + } = useContext(LibraryContext); + + if (!canEditLibrary) { + return null; + } + + return ( +
+ +
+ ); +}; + +const SubHeaderTitle = ({ + title, + canEditLibrary, + infoClickHandler, +}: { + title: string; + canEditLibrary: boolean; + infoClickHandler: () => void; +}) => { + const intl = useIntl(); + + return ( + + + {title} + + + { !canEditLibrary && ( +
+ + {intl.formatMessage(messages.readOnlyBadge)} + +
+ )} +
+ ); +}; + +const LibraryCollectionPageInner = ({ libraryId }: { libraryId: string }) => { + const intl = useIntl(); + + const { + sidebarBodyComponent, + openCollectionInfoSidebar, + } = useContext(LibraryContext); + const { collectionHits: [collectionData], isFetching } = useSearchContext(); + + useEffect(() => { + openCollectionInfoSidebar(); + }, []); + + const { data: libraryData, isLoading: isLibLoading } = useContentLibrary(libraryId); + + // Only show loading if collection data is not fetched from index yet + // Loading info for search results will be handled by LibraryCollectionComponents component. + if (isLibLoading || (!collectionData && isFetching)) { + return ; + } + + if (!libraryData || !collectionData) { + return ; + } + + const breadcrumbs = [ + { + label: libraryData.title, + to: `/library/${libraryId}`, + }, + { + label: intl.formatMessage(messages.allCollections), + to: `/library/${libraryId}/collections`, + }, + // Adding empty breadcrumb to add the last `>` spacer. + { + label: '', + to: '', + }, + ]; + + return ( +
+
+
+ + + )} + breadcrumbs={( + + )} + headerActions={} + /> + +
+ + + +
+ +
+ + + +
+ { !!sidebarBodyComponent && ( +
+ +
+ )} +
+ ); +}; + +const LibraryCollectionPage = () => { + const { libraryId, collectionId } = useParams(); + + if (!collectionId || !libraryId) { + // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. + throw new Error('Rendered without collectionId or libraryId URL parameter'); + } + + const collectionQuery: SearchParams = { + filter: ['type = "collection"', `context_key = "${libraryId}"`, `block_id = "${collectionId}"`], + limit: 1, + }; + + return ( + + + + ); +}; + +export default LibraryCollectionPage; diff --git a/src/library-authoring/LibraryCollections.tsx b/src/library-authoring/collections/LibraryCollections.tsx similarity index 53% rename from src/library-authoring/LibraryCollections.tsx rename to src/library-authoring/collections/LibraryCollections.tsx index f8e1bf56b2..97d194f4a3 100644 --- a/src/library-authoring/LibraryCollections.tsx +++ b/src/library-authoring/collections/LibraryCollections.tsx @@ -1,8 +1,12 @@ -import { useLoadOnScroll } from '../hooks'; -import { useSearchContext } from '../search-manager'; -import { NoComponents, NoSearchResults } from './EmptyStates'; -import CollectionCard from './components/CollectionCard'; -import { LIBRARY_SECTION_PREVIEW_LIMIT } from './components/LibrarySection'; +import { useContext } from 'react'; + +import { useLoadOnScroll } from '../../hooks'; +import { useSearchContext } from '../../search-manager'; +import { NoComponents, NoSearchResults } from '../EmptyStates'; +import CollectionCard from '../components/CollectionCard'; +import { LIBRARY_SECTION_PREVIEW_LIMIT } from '../components/LibrarySection'; +import messages from './messages'; +import { LibraryContext } from '../common/context'; type LibraryCollectionsProps = { variant: 'full' | 'preview', @@ -25,6 +29,8 @@ const LibraryCollections = ({ variant }: LibraryCollectionsProps) => { isFiltered, } = useSearchContext(); + const { openCreateCollectionModal } = useContext(LibraryContext); + const collectionList = variant === 'preview' ? collectionHits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : collectionHits; useLoadOnScroll( @@ -35,17 +41,25 @@ const LibraryCollections = ({ variant }: LibraryCollectionsProps) => { ); if (totalCollectionHits === 0) { - return isFiltered ? : ; + return isFiltered + ? + : ( + + ); } return (
- { collectionList.map((collectionHit) => ( + {collectionList.map((collectionHit) => ( - )) } + ))}
); }; diff --git a/src/library-authoring/collections/index.tsx b/src/library-authoring/collections/index.tsx new file mode 100644 index 0000000000..dc3ec3a47d --- /dev/null +++ b/src/library-authoring/collections/index.tsx @@ -0,0 +1,2 @@ +export { default as CollectionInfo } from './CollectionInfo'; +export { default as CollectionInfoHeader } from './CollectionInfoHeader'; diff --git a/src/library-authoring/collections/messages.ts b/src/library-authoring/collections/messages.ts new file mode 100644 index 0000000000..0f260f7033 --- /dev/null +++ b/src/library-authoring/collections/messages.ts @@ -0,0 +1,76 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + manageTabTitle: { + id: 'course-authoring.library-authoring.collections-sidebar.manage-tab.title', + defaultMessage: 'Manage', + description: 'Title for manage tab', + }, + detailsTabTitle: { + id: 'course-authoring.library-authoring.collections-sidebar.details-tab.title', + defaultMessage: 'Details', + description: 'Title for details tab', + }, + noComponentsInCollection: { + id: 'course-authoring.library-authoring.collections-pag.no-components.text', + defaultMessage: 'This collection is currently empty.', + description: 'Text to display when collection has no associated components', + }, + addComponentsInCollection: { + id: 'course-authoring.library-authoring.collections-pag.add-components.btn-text', + defaultMessage: 'New', + description: 'Text to display in new button if no components in collection is found', + }, + noSearchResultsInCollection: { + id: 'course-authoring.library-authoring.collections-pag.no-search-results.text', + defaultMessage: 'No matching components found in this collections.', + description: 'Message displayed when no matching components are found in collection', + }, + newContentButton: { + id: 'course-authoring.library-authoring.collections.buttons.new-content.text', + defaultMessage: 'New', + description: 'Text of button to open "Add content drawer" in collections page', + }, + collectionInfoButton: { + id: 'course-authoring.library-authoring.buttons.collection-info.alt-text', + defaultMessage: 'Collection Info', + description: 'Alt text for collection info button besides the collection title', + }, + readOnlyBadge: { + id: 'course-authoring.library-authoring.collections.badge.read-only', + defaultMessage: 'Read Only', + description: 'Text in badge when the user has read only access in collections page', + }, + allCollections: { + id: 'course-authoring.library-authoring.all-collections.text', + defaultMessage: 'All Collections', + description: 'Breadcrumbs text to navigate back to all collections', + }, + breadcrumbsAriaLabel: { + id: 'course-authoring.library-authoring.breadcrumbs.label.text', + defaultMessage: 'Navigation breadcrumbs', + description: 'Aria label for navigation breadcrumbs', + }, + searchPlaceholder: { + id: 'course-authoring.library-authoring.search.placeholder.text', + defaultMessage: 'Search Collection', + description: 'Search placeholder text in collections page.', + }, + noSearchResultsCollections: { + id: 'course-authoring.library-authoring.no-search-results-collections', + defaultMessage: 'No matching collections found in this library.', + description: 'Message displayed when no matching collections are found', + }, + noCollections: { + id: 'course-authoring.library-authoring.no-collections', + defaultMessage: 'You have not added any collection to this library yet.', + description: 'Message displayed when the library has no collections', + }, + addCollection: { + id: 'course-authoring.library-authoring.add-collection', + defaultMessage: 'Add collection', + description: 'Button text to add a new collection', + }, +}); + +export default messages; diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index 6ad0f29b58..cd82a2d84a 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -5,6 +5,7 @@ export enum SidebarBodyComponentId { AddContent = 'add-content', Info = 'info', ComponentInfo = 'component-info', + CollectionInfo = 'collection-info', } export interface LibraryContextData { @@ -17,6 +18,7 @@ export interface LibraryContextData { isCreateCollectionModalOpen: boolean; openCreateCollectionModal: () => void; closeCreateCollectionModal: () => void; + openCollectionInfoSidebar: () => void; } export const LibraryContext = React.createContext({ @@ -28,6 +30,7 @@ export const LibraryContext = React.createContext({ isCreateCollectionModalOpen: false, openCreateCollectionModal: () => {}, closeCreateCollectionModal: () => {}, + openCollectionInfoSidebar: () => {}, } as LibraryContextData); /** @@ -57,6 +60,10 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { }, [], ); + const openCollectionInfoSidebar = React.useCallback(() => { + setCurrentComponentUsageKey(undefined); + setSidebarBodyComponent(SidebarBodyComponentId.CollectionInfo); + }, []); const context = React.useMemo(() => ({ sidebarBodyComponent, @@ -68,6 +75,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, + openCollectionInfoSidebar, }), [ sidebarBodyComponent, closeLibrarySidebar, @@ -78,6 +86,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, + openCollectionInfoSidebar, ]); return ( diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index af02dff1f9..c91dbad55a 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useContext, useMemo } from 'react'; import { useLoadOnScroll } from '../../hooks'; import { useSearchContext } from '../../search-manager'; @@ -6,6 +6,7 @@ import { NoComponents, NoSearchResults } from '../EmptyStates'; import { useLibraryBlockTypes } from '../data/apiHooks'; import ComponentCard from './ComponentCard'; import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection'; +import { LibraryContext } from '../common/context'; type LibraryComponentsProps = { libraryId: string, @@ -28,6 +29,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => { fetchNextPage, isFiltered, } = useSearchContext(); + const { openAddContentSidebar } = useContext(LibraryContext); const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits; @@ -51,7 +53,7 @@ const LibraryComponents = ({ libraryId, variant }: LibraryComponentsProps) => { ); if (componentCount === 0) { - return isFiltered ? : ; + return isFiltered ? : ; } return ( diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx index 905272b181..cc611b3a96 100644 --- a/src/library-authoring/create-collection/CreateCollectionModal.tsx +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -5,7 +5,7 @@ import { Form, ModalDialog, } from '@openedx/paragon'; -import { useParams } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Formik } from 'formik'; import * as Yup from 'yup'; @@ -17,6 +17,7 @@ import { ToastContext } from '../../generic/toast-context'; const CreateCollectionModal = () => { const intl = useIntl(); + const navigate = useNavigate(); const { libraryId } = useParams(); if (!libraryId) { throw new Error('Rendered without libraryId URL parameter'); @@ -29,8 +30,9 @@ const CreateCollectionModal = () => { const { showToast } = React.useContext(ToastContext); const handleCreate = React.useCallback((values) => { - create.mutateAsync(values).then(() => { + create.mutateAsync(values).then((data) => { closeCreateCollectionModal(); + navigate(`/library/${libraryId}/collection/${data.key}`); showToast(intl.formatMessage(messages.createCollectionSuccess)); }).catch(() => { showToast(intl.formatMessage(messages.createCollectionError)); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 76388b1641..894151e903 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -50,6 +50,14 @@ export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/ * Get the URL for the Library Collections API. */ export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/collections/`; +/** + * Get the URL for the collection API. + */ +export const getLibraryCollectionApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionsApiUrl(libraryId)}${collectionId}/`; +/** + * Get the URL for the collection API. + */ +export const getLibraryCollectionComponentApiUrl = (libraryId: string, collectionId: string) => `${getLibraryCollectionApiUrl(libraryId, collectionId)}components/`; export interface ContentLibrary { id: string; @@ -75,6 +83,18 @@ export interface ContentLibrary { updated: string | null; } +export interface Collection { + id: number; + key: string; + title: string; + description: string; + enabled: boolean; + createdBy: string | null; + created: string; + modified: string; + learningPackage: number; +} + export interface LibraryBlockType { blockType: string; displayName: string; @@ -294,3 +314,12 @@ export async function getXBlockOLX(usageKey: string): Promise { const { data } = await getAuthenticatedHttpClient().get(getXBlockOLXApiUrl(usageKey)); return data.olx; } + +/** + * Update collection components. + */ +export async function updateCollectionComponents(libraryId:string, collectionId: string, usageKeys: string[]) { + await getAuthenticatedHttpClient().patch(getLibraryCollectionComponentApiUrl(libraryId, collectionId), { + usage_keys: usageKeys, + }); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index a92546c10f..70ba691635 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -5,12 +5,18 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { renderHook } from '@testing-library/react-hooks'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; -import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl, getLibraryCollectionsApiUrl } from './api'; +import { + getCommitLibraryChangesUrl, + getCreateLibraryBlockUrl, + getLibraryCollectionComponentApiUrl, + getLibraryCollectionsApiUrl, +} from './api'; import { useCommitLibraryChanges, useCreateLibraryBlock, useCreateLibraryCollection, useRevertLibraryChanges, + useUpdateCollectionComponents, } from './apiHooks'; let axiosMock; @@ -89,4 +95,15 @@ describe('library api hooks', () => { expect(axiosMock.history.post[0].url).toEqual(url); }); + + it('should add components to collection', async () => { + const libraryId = 'lib:org:1'; + const collectionId = 'my-first-collection'; + const url = getLibraryCollectionComponentApiUrl(libraryId, collectionId); + axiosMock.onPatch(url).reply(200); + const { result } = renderHook(() => useUpdateCollectionComponents(libraryId, collectionId), { wrapper }); + await result.current.mutateAsync(['some-usage-key']); + + expect(axiosMock.history.patch[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index efbd59efc0..ef48443c3c 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -26,6 +26,7 @@ import { updateXBlockFields, createCollection, getXBlockOLX, + updateCollectionComponents, type CreateLibraryCollectionDataRequest, } from './api'; @@ -61,6 +62,11 @@ export const libraryAuthoringQueryKeys = { 'content', 'libraryBlockTypes', ], + collection: (libraryId?: string, collectionId?: string) => [ + ...libraryAuthoringQueryKeys.all, + libraryId, + collectionId, + ], }; export const xblockQueryKeys = { @@ -122,7 +128,7 @@ export const useCreateLibraryBlock = () => { mutationFn: createLibraryBlock, onSettled: (_data, _error, variables) => { queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) }); - queryClient.invalidateQueries({ queryKey: ['content_search'] }); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, variables.libraryId) }); }, }); }; @@ -270,3 +276,24 @@ export const useXBlockOLX = (usageKey: string) => ( enabled: !!usageKey, }) ); + +/** + * Use this mutation to add components to a collection in a library + */ +export const useUpdateCollectionComponents = (libraryId?: string, collectionId?: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (usage_keys: string[]) => { + if (libraryId !== undefined && collectionId !== undefined) { + return updateCollectionComponents(libraryId, collectionId, usage_keys); + } + return undefined; + }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onSettled: (_data, _error, _variables) => { + if (libraryId !== undefined && collectionId !== undefined) { + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + } + }, + }); +}; diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx index 6ced6ba50e..d1ac43de22 100644 --- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx +++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx @@ -12,9 +12,12 @@ import { LibraryContext, SidebarBodyComponentId } from '../common/context'; import { LibraryInfo, LibraryInfoHeader } from '../library-info'; import { ComponentInfo, ComponentInfoHeader } from '../component-info'; import { ContentLibrary } from '../data/api'; +import { CollectionInfo, CollectionInfoHeader } from '../collections'; +import { type CollectionHit } from '../../search-manager/data/api'; type LibrarySidebarProps = { library: ContentLibrary, + collection?: CollectionHit, }; /** @@ -26,7 +29,7 @@ type LibrarySidebarProps = { * You can add more components in `bodyComponentMap`. * Use the returned actions to open and close this sidebar. */ -const LibrarySidebar = ({ library }: LibrarySidebarProps) => { +const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => { const intl = useIntl(); const { sidebarBodyComponent, @@ -40,6 +43,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => { [SidebarBodyComponentId.ComponentInfo]: ( currentComponentUsageKey && ), + [SidebarBodyComponentId.CollectionInfo]: , unknown: null, }; @@ -49,6 +53,7 @@ const LibrarySidebar = ({ library }: LibrarySidebarProps) => { [SidebarBodyComponentId.ComponentInfo]: ( currentComponentUsageKey && ), + [SidebarBodyComponentId.CollectionInfo]: , unknown: null, }; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index d086d6b565..0f9bdfbd5b 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -21,31 +21,16 @@ const messages = defineMessages({ defaultMessage: 'No matching components found in this library.', description: 'Message displayed when no search results are found', }, - noSearchResultsCollections: { - id: 'course-authoring.library-authoring.no-search-results-collections', - defaultMessage: 'No matching collections found in this library.', - description: 'Message displayed when no matching collections are found', - }, noComponents: { id: 'course-authoring.library-authoring.no-components', defaultMessage: 'You have not added any content to this library yet.', description: 'Message displayed when the library is empty', }, - noCollections: { - id: 'course-authoring.library-authoring.no-collections', - defaultMessage: 'You have not added any collection to this library yet.', - description: 'Message displayed when the library has no collections', - }, addComponent: { id: 'course-authoring.library-authoring.add-component', defaultMessage: 'Add component', description: 'Button text to add a new component', }, - addCollection: { - id: 'course-authoring.library-authoring.add-collection', - defaultMessage: 'Add collection', - description: 'Button text to add a new collection', - }, homeTab: { id: 'course-authoring.library-authoring.home-tab', defaultMessage: 'Home', diff --git a/src/search-manager/SearchKeywordsField.tsx b/src/search-manager/SearchKeywordsField.tsx index 953cd7799d..a60a54cd02 100644 --- a/src/search-manager/SearchKeywordsField.tsx +++ b/src/search-manager/SearchKeywordsField.tsx @@ -7,7 +7,7 @@ import { useSearchContext } from './SearchManager'; /** * The "main" input field where users type in search keywords. The search happens as they type (no need to press enter). */ -const SearchKeywordsField: React.FC<{ className?: string }> = (props) => { +const SearchKeywordsField: React.FC<{ className?: string, placeholder?: string }> = (props) => { const intl = useIntl(); const { searchKeywords, setSearchKeywords } = useSearchContext(); @@ -22,7 +22,9 @@ const SearchKeywordsField: React.FC<{ className?: string }> = (props) => { diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index d1e925a91a..bcce0779e7 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -7,9 +7,10 @@ import React from 'react'; import { useSearchParams } from 'react-router-dom'; import { MeiliSearch, type Filter } from 'meilisearch'; +import { union } from 'lodash'; import { - CollectionHit, ContentHit, SearchSortOption, forceArray, + CollectionHit, ContentHit, SearchSortOption, forceArray, OverrideQueries, } from './data/api'; import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks'; @@ -91,12 +92,13 @@ export const SearchContextProvider: React.FC<{ overrideSearchSortOrder?: SearchSortOption children: React.ReactNode, closeSearchModal?: () => void, -}> = ({ overrideSearchSortOrder, ...props }) => { + overrideQueries?: OverrideQueries, +}> = ({ overrideSearchSortOrder, overrideQueries, ...props }) => { const [searchKeywords, setSearchKeywords] = React.useState(''); const [blockTypesFilter, setBlockTypesFilter] = React.useState([]); const [problemTypesFilter, setProblemTypesFilter] = React.useState([]); const [tagsFilter, setTagsFilter] = React.useState([]); - const extraFilter: string[] = forceArray(props.extraFilter); + let extraFilter: string[] = forceArray(props.extraFilter); // The search sort order can be set via the query string // E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA. @@ -114,7 +116,7 @@ export const SearchContextProvider: React.FC<{ const sort: SearchSortOption[] = (searchSortOrderToUse === SearchSortOption.RELEVANCE ? [] : [searchSortOrderToUse]); // Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components. if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) { - extraFilter.push('last_published IS NOT NULL'); + extraFilter = union(extraFilter, ['last_published IS NOT NULL']); } const canClearFilters = ( @@ -130,14 +132,7 @@ export const SearchContextProvider: React.FC<{ }, []); // Initialize a connection to Meilisearch: - const { data: connectionDetails, isError: hasConnectionError } = useContentSearchConnection(); - const indexName = connectionDetails?.indexName; - const client = React.useMemo(() => { - if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { - return undefined; - } - return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); - }, [connectionDetails?.apiKey, connectionDetails?.url]); + const { client, indexName, hasConnectionError } = useContentSearchConnection(); // Run the search const result = useContentSearchResults({ @@ -149,6 +144,7 @@ export const SearchContextProvider: React.FC<{ problemTypesFilter, tagsFilter, sort, + overrideQueries, }); return React.createElement(SearchContext.Provider, { diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index 6dbb805e74..d5d524a81e 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -1,6 +1,8 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import type { Filter, MeiliSearch, MultiSearchQuery } from 'meilisearch'; +import type { + Filter, MeiliSearch, MultiSearchQuery, SearchParams, +} from 'meilisearch'; export const getContentSearchConfigUrl = () => new URL( 'api/content_search/v2/studio/', @@ -146,13 +148,32 @@ function formatSearchHit(hit: Record): ContentHit | CollectionHit { // eslint-disable-next-line @typescript-eslint/naming-convention const { _formatted, ...newHit } = hit; newHit.formatted = { - displayName: _formatted.display_name, - content: _formatted.content ?? {}, - description: _formatted.description, + displayName: _formatted?.display_name, + content: _formatted?.content ?? {}, + description: _formatted?.description, }; return camelCaseObject(newHit); } +export interface OverrideQueries { + components?: SearchParams, + collections?: SearchParams, +} + +function applyOverrideQueries( + queries: MultiSearchQuery[], + overrideQueries?: OverrideQueries, +): MultiSearchQuery[] { + const newQueries = [...queries]; + if (overrideQueries?.components) { + newQueries[0] = { ...overrideQueries.components, indexUid: queries[0].indexUid }; + } + if (overrideQueries?.collections) { + newQueries[2] = { ...overrideQueries.collections, indexUid: queries[2].indexUid }; + } + return newQueries; +} + interface FetchSearchParams { client: MeiliSearch, indexName: string, @@ -165,6 +186,7 @@ interface FetchSearchParams { sort?: SearchSortOption[], /** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */ offset?: number, + overrideQueries?: OverrideQueries, } export async function fetchSearchResults({ @@ -176,6 +198,7 @@ export async function fetchSearchResults({ tagsFilter, extraFilter, sort, + overrideQueries, offset = 0, }: FetchSearchParams): Promise<{ hits: ContentHit[], @@ -186,7 +209,7 @@ export async function fetchSearchResults({ collectionHits: CollectionHit[], totalCollectionHits: number, }> { - const queries: MultiSearchQuery[] = []; + let queries: MultiSearchQuery[] = []; // Convert 'extraFilter' into an array const extraFilterFormatted = forceArray(extraFilter); @@ -264,15 +287,19 @@ export async function fetchSearchResults({ limit, }); + queries = applyOverrideQueries(queries, overrideQueries); + const { results } = await client.multiSearch(({ queries })); + const componentHitLength = results[0].hits.length; + const collectionHitLength = results[2].hits.length; return { hits: results[0].hits.map(formatSearchHit) as ContentHit[], - totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? results[0].hits.length, + totalHits: results[0].totalHits ?? results[0].estimatedTotalHits ?? componentHitLength, blockTypes: results[1].facetDistribution?.block_type ?? {}, problemTypes: results[1].facetDistribution?.['content.problem_types'] ?? {}, - nextOffset: results[0].hits.length === limit || results[2].hits.length === limit ? offset + limit : undefined, + nextOffset: componentHitLength === limit || collectionHitLength === limit ? offset + limit : undefined, collectionHits: results[2].hits.map(formatSearchHit) as CollectionHit[], - totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? results[2].hits.length, + totalCollectionHits: results[2].totalHits ?? results[2].estimatedTotalHits ?? collectionHitLength, }; } @@ -487,3 +514,19 @@ export async function fetchTagsThatMatchKeyword({ return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit }; } + +/** + * Fetch single document by its id + */ +/* istanbul ignore next */ +export async function fetchDocumentById({ client, indexName, id } : { + /** The Meilisearch client instance */ + client: MeiliSearch; + /** Which index to search */ + indexName: string; + /** document id */ + id: string | number; +}): Promise { + const doc = await client.index(indexName).getDocument(id); + return formatSearchHit(doc); +} diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index 703251e6ee..c2a330c1e0 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -1,6 +1,6 @@ import React from 'react'; import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; -import type { Filter, MeiliSearch } from 'meilisearch'; +import { type Filter, MeiliSearch } from 'meilisearch'; import { SearchSortOption, @@ -9,6 +9,8 @@ import { fetchSearchResults, fetchTagsThatMatchKeyword, getContentSearchConfig, + fetchDocumentById, + OverrideQueries, } from './api'; /** @@ -16,8 +18,12 @@ import { * to the current user that allows it to search all content he have permission to view. * */ -export const useContentSearchConnection = () => ( - useQuery({ +export const useContentSearchConnection = (): { + client?: MeiliSearch, + indexName?: string, + hasConnectionError: boolean; +} => { + const { data: connectionDetails, isError: hasConnectionError } = useQuery({ queryKey: ['content_search'], queryFn: getContentSearchConfig, cacheTime: 60 * 60_000, // Even if we're not actively using the search modal, keep it in memory up to an hour @@ -25,8 +31,18 @@ export const useContentSearchConnection = () => ( refetchInterval: 60 * 60_000, refetchOnWindowFocus: false, // This doesn't need to be refreshed when the user switches back to this tab. refetchOnMount: false, - }) -); + }); + + const indexName = connectionDetails?.indexName; + const client = React.useMemo(() => { + if (connectionDetails?.apiKey === undefined || connectionDetails?.url === undefined) { + return undefined; + } + return new MeiliSearch({ host: connectionDetails.url, apiKey: connectionDetails.apiKey }); + }, [connectionDetails?.apiKey, connectionDetails?.url]); + + return { client, indexName, hasConnectionError }; +}; /** * Get the results of a search @@ -40,6 +56,7 @@ export const useContentSearchResults = ({ problemTypesFilter = [], tagsFilter = [], sort = [], + overrideQueries, }: { /** The Meilisearch API client */ client?: MeiliSearch; @@ -57,6 +74,8 @@ export const useContentSearchResults = ({ tagsFilter?: string[]; /** Sort search results using these options */ sort?: SearchSortOption[]; + /** Set true to fetch collections along with components */ + overrideQueries?: OverrideQueries, }) => { const query = useInfiniteQuery({ enabled: client !== undefined && indexName !== undefined, @@ -72,6 +91,7 @@ export const useContentSearchResults = ({ problemTypesFilter, tagsFilter, sort, + overrideQueries, ], queryFn: ({ pageParam = 0 }) => { if (client === undefined || indexName === undefined) { @@ -89,6 +109,7 @@ export const useContentSearchResults = ({ // For infinite pagination of results, we can retrieve additional pages if requested. // Note that if there are 20 results per page, the "second page" has offset=20, not 2. offset: pageParam, + overrideQueries, }); }, getNextPageParam: (lastPage) => lastPage.nextOffset, @@ -221,3 +242,27 @@ export const useTagFilterOptions = (args: { return { ...mainQuery, data }; }; + +/* istanbul ignore next */ +export const useGetSingleDocument = ({ client, indexName, id }: { + client?: MeiliSearch; + indexName?: string; + id: string | number; +}) => ( + useQuery({ + enabled: client !== undefined && indexName !== undefined, + queryKey: [ + 'content_search', + client?.config.apiKey, + client?.config.host, + indexName, + id, + ], + queryFn: () => { + if (client === undefined || indexName === undefined) { + throw new Error('Required data unexpectedly undefined. Check "enable" condition of useQuery.'); + } + return fetchDocumentById({ client, indexName, id }); + }, + }) +);