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 ? (
+
+
+
+ {intl.formatMessage(messages.detailsTabStatsTitle)}
+
+
+
+
+
+
+ {intl.formatMessage(messages.detailsTabHistoryTitle)}
+
+
+
+
+ );
+};
+
+export default CollectionDetails;
diff --git a/src/library-authoring/collections/CollectionInfo.tsx b/src/library-authoring/collections/CollectionInfo.tsx
index 4c1f40e732..b13d5c5d3d 100644
--- a/src/library-authoring/collections/CollectionInfo.tsx
+++ b/src/library-authoring/collections/CollectionInfo.tsx
@@ -1,27 +1,57 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
+ Button,
+ Stack,
Tab,
Tabs,
} from '@openedx/paragon';
+import { Link, useMatch } from 'react-router-dom';
+import type { ContentLibrary } from '../data/api';
+import CollectionDetails from './CollectionDetails';
import messages from './messages';
-const CollectionInfo = () => {
+interface CollectionInfoProps {
+ library: ContentLibrary,
+ collectionId: string,
+}
+
+const CollectionInfo = ({ library, collectionId }: CollectionInfoProps) => {
const intl = useIntl();
+ const url = `/library/${library.id}/collection/${collectionId}/`;
+ const urlMatch = useMatch(url);
return (
-
-
- Manage tab placeholder
-
-
- Details tab placeholder
-
-
+
+ {!urlMatch && (
+
+
+
+ )}
+
+
+ Manage tab placeholder
+
+
+
+
+
+
);
};
diff --git a/src/library-authoring/collections/CollectionInfoHeader.test.tsx b/src/library-authoring/collections/CollectionInfoHeader.test.tsx
new file mode 100644
index 0000000000..47dfe07aa1
--- /dev/null
+++ b/src/library-authoring/collections/CollectionInfoHeader.test.tsx
@@ -0,0 +1,157 @@
+import type MockAdapter from 'axios-mock-adapter';
+import userEvent from '@testing-library/user-event';
+
+import {
+ initializeMocks,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from '../../testUtils';
+import { mockContentLibrary, mockGetCollectionMetadata } from '../data/api.mocks';
+import * as api from '../data/api';
+import CollectionInfoHeader from './CollectionInfoHeader';
+
+let axiosMock: MockAdapter;
+let mockShowToast: (message: string) => void;
+
+mockGetCollectionMetadata.applyMock();
+
+const { collectionId } = mockGetCollectionMetadata;
+
+describe('', () => {
+ beforeEach(() => {
+ const mocks = initializeMocks();
+ axiosMock = mocks.axiosMock;
+ mockShowToast = mocks.mockShowToast;
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ axiosMock.restore();
+ });
+
+ it('should render Collection info Header', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render();
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ expect(screen.getByRole('button', { name: /edit collection title/i })).toBeInTheDocument();
+ });
+
+ it('should not render edit title button without permission', async () => {
+ const readOnlyLibrary = await mockContentLibrary(mockContentLibrary.libraryIdReadOnly);
+ render();
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ expect(screen.queryByRole('button', { name: /edit collection title/i })).not.toBeInTheDocument();
+ });
+
+ it('should update collection title', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render();
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, 'New Collection Title{enter}');
+
+ await waitFor(() => {
+ expect(axiosMock.history.patch[0].url).toEqual(url);
+ expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ title: 'New Collection Title' }));
+ });
+
+ expect(textBox).not.toBeInTheDocument();
+ expect(mockShowToast).toHaveBeenCalledWith('Collection updated successfully.');
+ });
+
+ it('should not update collection title if title is the same', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render();
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, `${mockGetCollectionMetadata.collectionData.title}{enter}`);
+
+ await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
+
+ expect(textBox).not.toBeInTheDocument();
+ });
+
+ it('should not update collection title if title is empty', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render();
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, '{enter}');
+
+ await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
+
+ expect(textBox).not.toBeInTheDocument();
+ });
+
+ it('should close edit collection title on press Escape', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render();
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(200);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, 'New Collection Title{esc}');
+
+ await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
+
+ expect(textBox).not.toBeInTheDocument();
+ });
+
+ it('should show error on edit collection title', async () => {
+ const library = await mockContentLibrary(mockContentLibrary.libraryId);
+ render();
+ expect(await screen.findByText('Test Collection')).toBeInTheDocument();
+
+ const url = api.getLibraryCollectionApiUrl(library.id, collectionId);
+ axiosMock.onPatch(url).reply(500);
+
+ fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
+
+ const textBox = screen.getByRole('textbox', { name: /title input/i });
+
+ userEvent.clear(textBox);
+ userEvent.type(textBox, 'New Collection Title{enter}');
+
+ await waitFor(() => {
+ expect(axiosMock.history.patch[0].url).toEqual(url);
+ expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ title: 'New Collection Title' }));
+ });
+
+ expect(textBox).not.toBeInTheDocument();
+ expect(mockShowToast).toHaveBeenCalledWith('Failed to update collection.');
+ });
+});
diff --git a/src/library-authoring/collections/CollectionInfoHeader.tsx b/src/library-authoring/collections/CollectionInfoHeader.tsx
index fda3f42eb9..1d6d9cfbe5 100644
--- a/src/library-authoring/collections/CollectionInfoHeader.tsx
+++ b/src/library-authoring/collections/CollectionInfoHeader.tsx
@@ -1,13 +1,101 @@
-import { type CollectionHit } from '../../search-manager/data/api';
+import React, { useState, useContext, useCallback } from 'react';
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Icon,
+ IconButton,
+ Stack,
+ Form,
+} from '@openedx/paragon';
+import { Edit } from '@openedx/paragon/icons';
+
+import { ToastContext } from '../../generic/toast-context';
+import type { ContentLibrary } from '../data/api';
+import { useCollection, useUpdateCollection } from '../data/apiHooks';
+import messages from './messages';
interface CollectionInfoHeaderProps {
- collection?: CollectionHit;
+ library: ContentLibrary;
+ collectionId: string;
}
-const CollectionInfoHeader = ({ collection } : CollectionInfoHeaderProps) => (
-
- {collection?.displayName}
-
-);
+const CollectionInfoHeader = ({ library, collectionId }: CollectionInfoHeaderProps) => {
+ const intl = useIntl();
+ const [inputIsActive, setIsActive] = useState(false);
+
+ const { data: collection } = useCollection(library.id, collectionId);
+
+ const updateMutation = useUpdateCollection(library.id, collectionId);
+ const { showToast } = useContext(ToastContext);
+
+ const handleSaveDisplayName = useCallback(
+ (event) => {
+ const newTitle = event.target.value;
+ if (newTitle && newTitle !== collection?.title) {
+ updateMutation.mutateAsync({
+ title: newTitle,
+ }).then(() => {
+ showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
+ }).finally(() => {
+ setIsActive(false);
+ });
+ } else {
+ setIsActive(false);
+ }
+ },
+ [collection, showToast, intl],
+ );
+
+ if (!collection) {
+ return null;
+ }
+
+ const handleClick = () => {
+ setIsActive(true);
+ };
+
+ const handleOnKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter') {
+ handleSaveDisplayName(event);
+ } else if (event.key === 'Escape') {
+ setIsActive(false);
+ }
+ };
+
+ return (
+
+ {inputIsActive
+ ? (
+
+ )
+ : (
+ <>
+
+ {collection.title}
+
+ {library.canEditLibrary && (
+
+ )}
+ >
+ )}
+
+ );
+};
export default CollectionInfoHeader;
diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
index 129fd2fadb..af9f794a8d 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx
@@ -10,13 +10,19 @@ import {
} from '../../testUtils';
import mockResult from '../__mocks__/collection-search.json';
import {
- mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields,
+ mockContentLibrary,
+ mockLibraryBlockTypes,
+ mockXBlockFields,
+ mockGetCollectionMetadata,
} from '../data/api.mocks';
-import { mockContentSearchConfig } from '../../search-manager/data/api.mock';
-import { mockBroadcastChannel } from '../../generic/data/api.mock';
+import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
+import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { LibraryLayout } from '..';
+mockClipboardEmpty.applyMock();
+mockGetCollectionMetadata.applyMock();
mockContentSearchConfig.applyMock();
+mockGetBlockTypes.applyMock();
mockContentLibrary.applyMock();
mockLibraryBlockTypes.applyMock();
mockXBlockFields.applyMock();
@@ -28,14 +34,16 @@ 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,
+ collectionEmpty: mockGetCollectionMetadata.collectionIdError,
};
+const { title } = mockGetCollectionMetadata.collectionData;
+
describe('', () => {
beforeEach(() => {
initializeMocks();
+ fetchMock.mockReset();
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
@@ -50,7 +58,7 @@ describe('', () => {
// 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];
+ const collectionQueryId = requestData?.queries[0]?.filter?.[3]?.split('collections.key = "')[1].split('"')[0];
switch (collectionQueryId) {
case mockCollection.collectionNeverLoads:
return new Promise(() => {});
@@ -73,7 +81,6 @@ describe('', () => {
afterEach(() => {
jest.clearAllMocks();
- fetchMock.mockReset();
});
const renderLibraryCollectionPage = async (collectionId?: string, libraryId?: string) => {
@@ -86,7 +93,7 @@ describe('', () => {
},
});
- if (colId !== mockCollection.collectionNeverLoads) {
+ if (![mockCollection.collectionNeverLoads, mockCollection.collectionEmpty].includes(colId)) {
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
}
};
@@ -101,20 +108,18 @@ describe('', () => {
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();
+ expect(await screen.findByText(/Mocked request failed with status code 400./)).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();
+ expect((await screen.findAllByText(title))[0]).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();
@@ -125,9 +130,9 @@ describe('', () => {
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(title))[0]).toBeInTheDocument();
- expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
+ expect(screen.getAllByText('This collection is currently empty.').length).toEqual(2);
const addComponentButton = screen.getAllByRole('button', { name: /new/i })[1];
fireEvent.click(addComponentButton);
@@ -150,7 +155,10 @@ describe('', () => {
await renderLibraryCollectionPage(mockCollection.collectionNoComponents, libraryId);
expect(await screen.findByText('All Collections')).toBeInTheDocument();
- expect(screen.getByText('This collection is currently empty.')).toBeInTheDocument();
+
+ // Show in the collection page and in the sidebar
+ expect(screen.getAllByText('This collection is currently empty.').length).toEqual(2);
+
expect(screen.queryByRole('button', { name: /new/i })).not.toBeInTheDocument();
expect(screen.getByText('Read Only')).toBeInTheDocument();
});
@@ -161,7 +169,7 @@ describe('', () => {
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(title))[0]).toBeInTheDocument();
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'noresults' } });
@@ -194,8 +202,8 @@ describe('', () => {
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((await screen.findAllByText(title))[0]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[1]).toBeInTheDocument();
expect(screen.getByText('Manage')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
@@ -206,8 +214,8 @@ describe('', () => {
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((await screen.findAllByText(title))[0]).toBeInTheDocument();
+ expect((await screen.findAllByText(title))[1]).toBeInTheDocument();
// Open by default; close the library info sidebar
const closeButton = screen.getByRole('button', { name: /close/i });
diff --git a/src/library-authoring/collections/LibraryCollectionPage.tsx b/src/library-authoring/collections/LibraryCollectionPage.tsx
index b2344a9b1f..efcf749999 100644
--- a/src/library-authoring/collections/LibraryCollectionPage.tsx
+++ b/src/library-authoring/collections/LibraryCollectionPage.tsx
@@ -13,8 +13,8 @@ import {
import { Add, InfoOutline } from '@openedx/paragon/icons';
import { Link, useParams } from 'react-router-dom';
-import { SearchParams } from 'meilisearch';
import Loading from '../../generic/Loading';
+import ErrorAlert from '../../generic/alert-error';
import SubHeader from '../../generic/sub-header/SubHeader';
import Header from '../../header';
import NotFoundAlert from '../../generic/NotFoundAlert';
@@ -25,9 +25,8 @@ import {
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
- useSearchContext,
} from '../../search-manager';
-import { useContentLibrary } from '../data/apiHooks';
+import { useCollection, useContentLibrary } from '../data/apiHooks';
import { LibraryContext } from '../common/context';
import messages from './messages';
import { LibrarySidebar } from '../library-sidebar';
@@ -92,31 +91,48 @@ const SubHeaderTitle = ({
);
};
-const LibraryCollectionPageInner = ({ libraryId }: { libraryId: string }) => {
+const LibraryCollectionPage = () => {
const intl = useIntl();
+ 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 {
sidebarBodyComponent,
openCollectionInfoSidebar,
} = useContext(LibraryContext);
- const { collectionHits: [collectionData], isFetching } = useSearchContext();
+
+ const {
+ data: collectionData,
+ isLoading,
+ isError,
+ error,
+ } = useCollection(libraryId, collectionId);
useEffect(() => {
- openCollectionInfoSidebar();
- }, []);
+ openCollectionInfoSidebar(collectionId);
+ }, [collectionData]);
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)) {
+ if (isLibLoading || isLoading) {
return ;
}
- if (!libraryData || !collectionData) {
+ if (!libraryData) {
return ;
}
+ if (isError) {
+ return ;
+ }
+
const breadcrumbs = [
{
label: libraryData.title,
@@ -144,65 +160,47 @@ const LibraryCollectionPageInner = ({ libraryId }: { libraryId: string }) => {
isLibrary
/>
-
- )}
- breadcrumbs={(
-
- )}
- headerActions={}
- />
-
-
-
+
+ openCollectionInfoSidebar(collectionId)}
+ />
+ )}
+ 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/collections/messages.ts b/src/library-authoring/collections/messages.ts
index 0f260f7033..03ddff1af0 100644
--- a/src/library-authoring/collections/messages.ts
+++ b/src/library-authoring/collections/messages.ts
@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
+ openCollectionButton: {
+ id: 'course-authoring.library-authoring.collections-sidebbar.open-button',
+ defaultMessage: 'Open',
+ description: 'Button text to open collection',
+ },
manageTabTitle: {
id: 'course-authoring.library-authoring.collections-sidebar.manage-tab.title',
defaultMessage: 'Manage',
@@ -11,6 +16,41 @@ const messages = defineMessages({
defaultMessage: 'Details',
description: 'Title for details tab',
},
+ detailsTabDescriptionTitle: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.description-title',
+ defaultMessage: 'Description / Card Preview Text',
+ description: 'Title for the Description container in the details tab',
+ },
+ detailsTabDescriptionPlaceholder: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.description-placeholder',
+ defaultMessage: 'Add description',
+ description: 'Placeholder for the Description container in the details tab',
+ },
+ detailsTabStatsTitle: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-title',
+ defaultMessage: 'Collection Stats',
+ description: 'Title for the Collection Stats container in the details tab',
+ },
+ detailsTabStatsNoComponents: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-no-components',
+ defaultMessage: 'This collection is currently empty.',
+ description: 'Message displayed when no components are found in the Collection Stats container',
+ },
+ detailsTabStatsTotalComponents: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-total-components',
+ defaultMessage: 'Total ',
+ description: 'Label for total components in the Collection Stats container',
+ },
+ detailsTabStatsOtherComponents: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.stats-other-components',
+ defaultMessage: 'Other',
+ description: 'Label for other components in the Collection Stats container',
+ },
+ detailsTabHistoryTitle: {
+ id: 'course-authoring.library-authoring.collections-sidebar.details-tab.history-title',
+ defaultMessage: 'Collection History',
+ description: 'Title for the Collection History container in the details tab',
+ },
noComponentsInCollection: {
id: 'course-authoring.library-authoring.collections-pag.no-components.text',
defaultMessage: 'This collection is currently empty.',
@@ -71,6 +111,21 @@ const messages = defineMessages({
defaultMessage: 'Add collection',
description: 'Button text to add a new collection',
},
+ updateCollectionSuccessMsg: {
+ id: 'course-authoring.library-authoring.update-collection-success-msg',
+ defaultMessage: 'Collection updated successfully.',
+ description: 'Message displayed when collection is updated successfully',
+ },
+ updateCollectionErrorMsg: {
+ id: 'course-authoring.library-authoring.update-collection-error-msg',
+ defaultMessage: 'Failed to update collection.',
+ description: 'Message displayed when collection update fails',
+ },
+ editTitleButtonAlt: {
+ id: 'course-authoring.library-authoring.collection.sidebar.edit-name.alt',
+ defaultMessage: 'Edit collection title',
+ description: 'Alt text for edit collection title icon button',
+ },
});
export default messages;
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index cd82a2d84a..86b862e96a 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -18,7 +18,8 @@ export interface LibraryContextData {
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
closeCreateCollectionModal: () => void;
- openCollectionInfoSidebar: () => void;
+ openCollectionInfoSidebar: (collectionId: string) => void;
+ currentCollectionId?: string;
}
export const LibraryContext = React.createContext({
@@ -30,7 +31,8 @@ export const LibraryContext = React.createContext({
isCreateCollectionModalOpen: false,
openCreateCollectionModal: () => {},
closeCreateCollectionModal: () => {},
- openCollectionInfoSidebar: () => {},
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ openCollectionInfoSidebar: (_collectionId: string) => {},
} as LibraryContextData);
/**
@@ -39,29 +41,38 @@ export const LibraryContext = React.createContext({
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState();
+ const [currentCollectionId, setcurrentCollectionId] = React.useState();
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
- const closeLibrarySidebar = React.useCallback(() => {
+ const resetSidebar = React.useCallback(() => {
+ setCurrentComponentUsageKey(undefined);
+ setcurrentCollectionId(undefined);
setSidebarBodyComponent(null);
+ }, []);
+
+ const closeLibrarySidebar = React.useCallback(() => {
+ resetSidebar();
setCurrentComponentUsageKey(undefined);
}, []);
const openAddContentSidebar = React.useCallback(() => {
- setCurrentComponentUsageKey(undefined);
+ resetSidebar();
setSidebarBodyComponent(SidebarBodyComponentId.AddContent);
}, []);
const openInfoSidebar = React.useCallback(() => {
- setCurrentComponentUsageKey(undefined);
+ resetSidebar();
setSidebarBodyComponent(SidebarBodyComponentId.Info);
}, []);
const openComponentInfoSidebar = React.useCallback(
(usageKey: string) => {
+ resetSidebar();
setCurrentComponentUsageKey(usageKey);
setSidebarBodyComponent(SidebarBodyComponentId.ComponentInfo);
},
[],
);
- const openCollectionInfoSidebar = React.useCallback(() => {
- setCurrentComponentUsageKey(undefined);
+ const openCollectionInfoSidebar = React.useCallback((collectionId: string) => {
+ resetSidebar();
+ setcurrentCollectionId(collectionId);
setSidebarBodyComponent(SidebarBodyComponentId.CollectionInfo);
}, []);
@@ -76,6 +87,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
+ currentCollectionId,
}), [
sidebarBodyComponent,
closeLibrarySidebar,
@@ -87,6 +99,7 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
openCreateCollectionModal,
closeCreateCollectionModal,
openCollectionInfoSidebar,
+ currentCollectionId,
]);
return (
diff --git a/src/library-authoring/components/CollectionCard.test.tsx b/src/library-authoring/components/CollectionCard.test.tsx
index a3be58151f..92f2c8d981 100644
--- a/src/library-authoring/components/CollectionCard.test.tsx
+++ b/src/library-authoring/components/CollectionCard.test.tsx
@@ -1,4 +1,9 @@
-import { initializeMocks, render, screen } from '../../testUtils';
+import {
+ initializeMocks,
+ fireEvent,
+ render,
+ screen,
+} from '../../testUtils';
import { type CollectionHit } from '../../search-manager';
import CollectionCard from './CollectionCard';
@@ -7,6 +12,8 @@ const CollectionHitSample: CollectionHit = {
id: '1',
type: 'collection',
contextKey: 'lb:org1:Demo_Course',
+ usageKey: 'lb:org1:Demo_Course:collection1',
+ blockId: 'collection1',
org: 'org1',
breadcrumbs: [{ displayName: 'Demo Lib' }],
displayName: 'Collection Display Name',
@@ -37,4 +44,18 @@ describe('', () => {
expect(screen.queryByText('Collection description')).toBeInTheDocument();
expect(screen.queryByText('Collection (2)')).toBeInTheDocument();
});
+
+ it('should navigate to the collection if the open menu clicked', async () => {
+ render();
+
+ // Open menu
+ expect(screen.getByTestId('collection-card-menu-toggle')).toBeInTheDocument();
+ fireEvent.click(screen.getByTestId('collection-card-menu-toggle'));
+
+ // Open menu item
+ const openMenuItem = screen.getByRole('link', { name: 'Open' });
+ expect(openMenuItem).toBeInTheDocument();
+
+ expect(openMenuItem).toHaveAttribute('href', '/library/lb:org1:Demo_Course/collection/collection1/');
+ });
});
diff --git a/src/library-authoring/components/CollectionCard.tsx b/src/library-authoring/components/CollectionCard.tsx
index 3968a7d681..c8114ec7e7 100644
--- a/src/library-authoring/components/CollectionCard.tsx
+++ b/src/library-authoring/components/CollectionCard.tsx
@@ -1,21 +1,54 @@
-import { useIntl } from '@edx/frontend-platform/i18n';
+import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import {
ActionRow,
+ Dropdown,
Icon,
IconButton,
} from '@openedx/paragon';
import { MoreVert } from '@openedx/paragon/icons';
+import { useContext } from 'react';
+import { Link } from 'react-router-dom';
import { type CollectionHit } from '../../search-manager';
-import messages from './messages';
+import { LibraryContext } from '../common/context';
import BaseComponentCard from './BaseComponentCard';
+import messages from './messages';
+
+export const CollectionMenu = ({ collectionHit }: { collectionHit: CollectionHit }) => {
+ const intl = useIntl();
+
+ return (
+ e.stopPropagation()}>
+
+
+
+
+
+
+
+ );
+};
type CollectionCardProps = {
collectionHit: CollectionHit,
};
-const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
+const CollectionCard = ({ collectionHit }: CollectionCardProps) => {
const intl = useIntl();
+ const {
+ openCollectionInfoSidebar,
+ } = useContext(LibraryContext);
const {
type,
@@ -37,16 +70,11 @@ const CollectionCard = ({ collectionHit } : CollectionCardProps) => {
tags={tags}
actions={(
-
+
)}
blockTypeDisplayName={blockTypeDisplayName}
- openInfoSidebar={() => {}}
+ openInfoSidebar={() => openCollectionInfoSidebar(collectionHit.blockId)}
/>
);
};
diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts
index e801f7ec0b..c826bae09d 100644
--- a/src/library-authoring/components/messages.ts
+++ b/src/library-authoring/components/messages.ts
@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Collection ({numChildren})',
description: 'Collection type text with children count',
},
+ menuOpen: {
+ id: 'course-authoring.library-authoring.collection.menu.open',
+ defaultMessage: 'Open',
+ description: 'Menu item for open a collection.',
+ },
menuEdit: {
id: 'course-authoring.library-authoring.component.menu.edit',
defaultMessage: 'Edit',
diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index 505a9d1d16..177464d06e 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -271,3 +271,32 @@ mockLibraryBlockMetadata.dataPublished = {
} satisfies api.LibraryBlockMetadata;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockLibraryBlockMetadata.applyMock = () => jest.spyOn(api, 'getLibraryBlockMetadata').mockImplementation(mockLibraryBlockMetadata);
+
+/**
+ * Mock for `getCollectionMetadata()`
+ *
+ * This mock returns a fixed response for the collection ID *collection_1*.
+ */
+export async function mockGetCollectionMetadata(libraryId: string, collectionId: string): Promise {
+ if (collectionId === mockGetCollectionMetadata.collectionIdError) {
+ throw createAxiosError({ code: 400, message: 'Not found.', path: api.getLibraryCollectionApiUrl(libraryId, collectionId) });
+ }
+ return Promise.resolve(mockGetCollectionMetadata.collectionData);
+}
+mockGetCollectionMetadata.collectionId = 'collection_1';
+mockGetCollectionMetadata.collectionIdError = 'collection_error';
+mockGetCollectionMetadata.collectionData = {
+ id: 1,
+ key: 'collection_1',
+ title: 'Test Collection',
+ description: 'A collection for testing',
+ created: '2024-09-19T10:00:00Z',
+ createdBy: 'test_author',
+ modified: '2024-09-20T11:00:00Z',
+ learningPackage: 11,
+ enabled: true,
+} satisfies api.Collection;
+/** Apply this mock. Returns a spy object that can tell you if it's been called. */
+mockGetCollectionMetadata.applyMock = () => {
+ jest.spyOn(api, 'getCollectionMetadata').mockImplementation(mockGetCollectionMetadata);
+};
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index 970a79a96e..5c96176763 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -180,6 +180,8 @@ export interface CreateLibraryCollectionDataRequest {
description: string | null;
}
+export type UpdateCollectionComponentsRequest = Partial;
+
/**
* Fetch the list of XBlock types that can be added to this library
*/
@@ -316,10 +318,30 @@ export async function getXBlockOLX(usageKey: string): Promise {
return data.olx;
}
+/**
+ * Get the collection metadata.
+ */
+export async function getCollectionMetadata(libraryId: string, collectionId: string): Promise {
+ const { data } = await getAuthenticatedHttpClient().get(getLibraryCollectionApiUrl(libraryId, collectionId));
+ return camelCaseObject(data);
+}
+
+/**
+ * Update collection metadata.
+ */
+export async function updateCollectionMetadata(
+ libraryId: string,
+ collectionId: string,
+ collectionData: UpdateCollectionComponentsRequest,
+) {
+ const client = getAuthenticatedHttpClient();
+ await client.patch(getLibraryCollectionApiUrl(libraryId, collectionId), collectionData);
+}
+
/**
* Update collection components.
*/
-export async function updateCollectionComponents(libraryId:string, collectionId: string, usageKeys: string[]) {
+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 70ba691635..c21f5f5a67 100644
--- a/src/library-authoring/data/apiHooks.test.tsx
+++ b/src/library-authoring/data/apiHooks.test.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import { waitFor } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
@@ -10,6 +11,7 @@ import {
getCreateLibraryBlockUrl,
getLibraryCollectionComponentApiUrl,
getLibraryCollectionsApiUrl,
+ getLibraryCollectionApiUrl,
} from './api';
import {
useCommitLibraryChanges,
@@ -17,6 +19,7 @@ import {
useCreateLibraryCollection,
useRevertLibraryChanges,
useUpdateCollectionComponents,
+ useCollection,
} from './apiHooks';
let axiosMock;
@@ -106,4 +109,18 @@ describe('library api hooks', () => {
expect(axiosMock.history.patch[0].url).toEqual(url);
});
+
+ it('should get collection metadata', async () => {
+ const libraryId = 'lib:org:1';
+ const collectionId = 'my-first-collection';
+ const url = getLibraryCollectionApiUrl(libraryId, collectionId);
+
+ axiosMock.onGet(url).reply(200, { 'test-data': 'test-value' });
+ const { result } = renderHook(() => useCollection(libraryId, collectionId), { wrapper });
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ });
+ expect(result.current.data).toEqual({ testData: 'test-value' });
+ expect(axiosMock.history.get[0].url).toEqual(url);
+ });
});
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index 96b7122af8..13ece775bc 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -26,8 +26,11 @@ import {
updateXBlockFields,
createCollection,
getXBlockOLX,
+ updateCollectionMetadata,
+ type UpdateCollectionComponentsRequest,
updateCollectionComponents,
type CreateLibraryCollectionDataRequest,
+ getCollectionMetadata,
} from './api';
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
@@ -278,6 +281,33 @@ export const useXBlockOLX = (usageKey: string) => (
})
);
+/**
+ * Get the metadata for a collection in a library
+ */
+export const useCollection = (libraryId: string, collectionId: string) => (
+ useQuery({
+ enabled: !!libraryId && !!collectionId,
+ queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId),
+ queryFn: () => getCollectionMetadata(libraryId!, collectionId!),
+ })
+);
+
+/**
+ * Use this mutation to update the fields of a collection in a library
+ */
+export const useUpdateCollection = (libraryId: string, collectionId: string) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: (data: UpdateCollectionComponentsRequest) => updateCollectionMetadata(libraryId, collectionId, data),
+ onSettled: () => {
+ // NOTE: We invalidate the library query here because we need to update the library's
+ // collection list.
+ queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
+ queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.collection(libraryId, collectionId) });
+ },
+ });
+};
+
/**
* Use this mutation to add components to a collection in a library
*/
diff --git a/src/library-authoring/generic/history-widget/index.tsx b/src/library-authoring/generic/history-widget/index.tsx
index 615a570504..8b49d03d77 100644
--- a/src/library-authoring/generic/history-widget/index.tsx
+++ b/src/library-authoring/generic/history-widget/index.tsx
@@ -3,7 +3,7 @@ import { Stack } from '@openedx/paragon';
import messages from './messages';
-const CustomFormattedDate = ({ date }: { date: string }) => (
+const CustomFormattedDate = ({ date }: { date: string | Date }) => (
(
);
type HistoryWidgedProps = {
- modified: string | null;
- created: string | null;
+ modified: string | Date | null;
+ created: string | Date | null;
};
/**
diff --git a/src/library-authoring/generic/index.scss b/src/library-authoring/generic/index.scss
index b7c9c75447..aa5bf5cc50 100644
--- a/src/library-authoring/generic/index.scss
+++ b/src/library-authoring/generic/index.scss
@@ -1,2 +1,2 @@
-@import "./status-widget/StatusWidget";
@import "./history-widget/HistoryWidget";
+@import "./status-widget/StatusWidget";
diff --git a/src/library-authoring/library-sidebar/LibrarySidebar.tsx b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
index d1ac43de22..a7ce2b5b5b 100644
--- a/src/library-authoring/library-sidebar/LibrarySidebar.tsx
+++ b/src/library-authoring/library-sidebar/LibrarySidebar.tsx
@@ -6,18 +6,17 @@ import {
} from '@openedx/paragon';
import { Close } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
-import messages from '../messages';
+
import { AddContentContainer, AddContentHeader } from '../add-content';
+import { CollectionInfo, CollectionInfoHeader } from '../collections';
+import { ContentLibrary } from '../data/api';
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';
+import { LibraryInfo, LibraryInfoHeader } from '../library-info';
+import messages from '../messages';
type LibrarySidebarProps = {
library: ContentLibrary,
- collection?: CollectionHit,
};
/**
@@ -29,12 +28,13 @@ type LibrarySidebarProps = {
* You can add more components in `bodyComponentMap`.
* Use the returned actions to open and close this sidebar.
*/
-const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
+const LibrarySidebar = ({ library }: LibrarySidebarProps) => {
const intl = useIntl();
const {
sidebarBodyComponent,
closeLibrarySidebar,
currentComponentUsageKey,
+ currentCollectionId,
} = useContext(LibraryContext);
const bodyComponentMap = {
@@ -43,7 +43,9 @@ const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey &&
),
- [SidebarBodyComponentId.CollectionInfo]: ,
+ [SidebarBodyComponentId.CollectionInfo]: (
+ currentCollectionId &&
+ ),
unknown: null,
};
@@ -53,7 +55,9 @@ const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey &&
),
- [SidebarBodyComponentId.CollectionInfo]: ,
+ [SidebarBodyComponentId.CollectionInfo]: (
+ currentCollectionId &&
+ ),
unknown: null,
};
diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts
index bcce0779e7..cb1314b6b3 100644
--- a/src/search-manager/SearchManager.ts
+++ b/src/search-manager/SearchManager.ts
@@ -166,7 +166,7 @@ export const SearchContextProvider: React.FC<{
searchSortOrder,
setSearchSortOrder,
defaultSearchSortOrder,
- closeSearchModal: props.closeSearchModal ?? (() => {}),
+ closeSearchModal: props.closeSearchModal ?? (() => { }),
hasError: hasConnectionError || result.isError,
...result,
},
diff --git a/src/search-manager/data/__mocks__/block-types.json b/src/search-manager/data/__mocks__/block-types.json
new file mode 100644
index 0000000000..9d812df91b
--- /dev/null
+++ b/src/search-manager/data/__mocks__/block-types.json
@@ -0,0 +1,24 @@
+{
+ "comment": "This is a mock of the response from Meilisearch, based on an actual search in Studio.",
+ "results": [
+ {
+ "indexUid": "studio",
+ "hits": [],
+ "query": "",
+ "processingTimeMs": 1,
+ "limit": 0,
+ "offset": 0,
+ "estimatedTotalHits": 0,
+ "facetDistribution": {
+ "block_type": {
+ "chapter": 1,
+ "html": 2,
+ "problem": 16,
+ "vertical": 2,
+ "video": 1
+ }
+ },
+ "facetStats": {}
+ }
+ ]
+}
diff --git a/src/search-manager/data/api.mock.ts b/src/search-manager/data/api.mock.ts
index dfcc9584ae..bfed5a3694 100644
--- a/src/search-manager/data/api.mock.ts
+++ b/src/search-manager/data/api.mock.ts
@@ -40,3 +40,27 @@ export function mockSearchResult(mockResponse: MultiSearchResponse) {
return newMockResponse;
}, { overwriteRoutes: true });
}
+
+/**
+ * Mock the block types returned by the API.
+ */
+export async function mockGetBlockTypes(
+ mockResponse: 'noBlocks' | 'someBlocks' | 'moreBlocks',
+) {
+ const mockResponseMap = {
+ noBlocks: {},
+ someBlocks: { problem: 1, html: 2 },
+ moreBlocks: {
+ advanced: 1,
+ discussion: 2,
+ library: 3,
+ drag_and_drop_v2: 4,
+ openassessment: 5,
+ html: 6,
+ problem: 7,
+ video: 8,
+ },
+ };
+ jest.spyOn(api, 'fetchBlockTypes').mockResolvedValue(mockResponseMap[mockResponse]);
+}
+mockGetBlockTypes.applyMock = () => jest.spyOn(api, 'fetchBlockTypes').mockResolvedValue({});
diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts
index d5d524a81e..5feeb2456a 100644
--- a/src/search-manager/data/api.ts
+++ b/src/search-manager/data/api.ts
@@ -101,6 +101,8 @@ interface BaseContentHit {
id: string;
type: 'course_block' | 'library_block' | 'collection';
displayName: string;
+ usageKey: string;
+ blockId: string;
/** The course or library ID */
contextKey: string;
org: string;
@@ -117,8 +119,6 @@ interface BaseContentHit {
* Defined in edx-platform/openedx/core/djangoapps/content/search/documents.py
*/
export interface ContentHit extends BaseContentHit {
- usageKey: string;
- blockId: string;
/** The block_type part of the usage key. What type of XBlock this is. */
blockType: string;
/**
@@ -144,7 +144,7 @@ export interface CollectionHit extends BaseContentHit {
* Convert search hits to camelCase
* @param hit A search result directly from Meilisearch
*/
-function formatSearchHit(hit: Record): ContentHit | CollectionHit {
+export function formatSearchHit(hit: Record): ContentHit | CollectionHit {
// eslint-disable-next-line @typescript-eslint/naming-convention
const { _formatted, ...newHit } = hit;
newHit.formatted = {
@@ -303,6 +303,29 @@ export async function fetchSearchResults({
};
}
+/**
+ * Fetch the block types facet distribution for the search results.
+ */
+export const fetchBlockTypes = async (
+ client: MeiliSearch,
+ indexName: string,
+ extraFilter?: Filter,
+): Promise> => {
+ // Convert 'extraFilter' into an array
+ const extraFilterFormatted = forceArray(extraFilter);
+
+ const { results } = await client.multiSearch({
+ queries: [{
+ indexUid: indexName,
+ facets: ['block_type'],
+ filter: extraFilterFormatted,
+ limit: 0, // We don't need any "hits" for this - just the facetDistribution
+ }],
+ });
+
+ return results[0].facetDistribution?.block_type ?? {};
+};
+
/** Information about a single tag in the tag tree, as returned by fetchAvailableTagOptions() */
export interface TagEntry {
tagName: string;
diff --git a/src/search-manager/data/apiHooks.test.tsx b/src/search-manager/data/apiHooks.test.tsx
new file mode 100644
index 0000000000..b6fc63f49a
--- /dev/null
+++ b/src/search-manager/data/apiHooks.test.tsx
@@ -0,0 +1,57 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { waitFor } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import fetchMock from 'fetch-mock-jest';
+
+import mockResult from './__mocks__/block-types.json';
+import { mockContentSearchConfig } from './api.mock';
+import {
+ useGetBlockTypes,
+} from './apiHooks';
+
+mockContentSearchConfig.applyMock();
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+});
+
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+const fetchMockResponse = () => {
+ fetchMock.post(
+ mockContentSearchConfig.searchEndpointUrl,
+ () => mockResult,
+ { overwriteRoutes: true },
+ );
+};
+
+describe('search manager api hooks', () => {
+ afterEach(() => {
+ fetchMock.reset();
+ });
+
+ it('it should return block types facet', async () => {
+ fetchMockResponse();
+ const { result } = renderHook(() => useGetBlockTypes('filter'), { wrapper });
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ });
+ const expectedData = {
+ chapter: 1,
+ html: 2,
+ problem: 16,
+ vertical: 2,
+ video: 1,
+ };
+ expect(result.current.data).toEqual(expectedData);
+ expect(fetchMock.calls().length).toEqual(1);
+ });
+});
diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts
index c2a330c1e0..c22a004269 100644
--- a/src/search-manager/data/apiHooks.ts
+++ b/src/search-manager/data/apiHooks.ts
@@ -10,6 +10,7 @@ import {
fetchTagsThatMatchKeyword,
getContentSearchConfig,
fetchDocumentById,
+ fetchBlockTypes,
OverrideQueries,
} from './api';
@@ -243,6 +244,22 @@ export const useTagFilterOptions = (args: {
return { ...mainQuery, data };
};
+export const useGetBlockTypes = (extraFilters: Filter) => {
+ const { client, indexName } = useContentSearchConnection();
+ return useQuery({
+ enabled: client !== undefined && indexName !== undefined,
+ queryKey: [
+ 'content_search',
+ client?.config.apiKey,
+ client?.config.host,
+ indexName,
+ extraFilters,
+ 'block_types',
+ ],
+ queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
+ });
+};
+
/* istanbul ignore next */
export const useGetSingleDocument = ({ client, indexName, id }: {
client?: MeiliSearch;
diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts
index 0f716a9c3e..e2d4188be1 100644
--- a/src/search-manager/index.ts
+++ b/src/search-manager/index.ts
@@ -1,4 +1,5 @@
export { SearchContextProvider, useSearchContext } from './SearchManager';
+export { default as BlockTypeLabel } from './BlockTypeLabel';
export { default as ClearFiltersButton } from './ClearFiltersButton';
export { default as FilterByBlockType } from './FilterByBlockType';
export { default as FilterByTags } from './FilterByTags';
@@ -7,5 +8,6 @@ export { default as SearchKeywordsField } from './SearchKeywordsField';
export { default as SearchSortWidget } from './SearchSortWidget';
export { default as Stats } from './Stats';
export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api';
+export { useGetBlockTypes } from './data/apiHooks';
export type { CollectionHit, ContentHit, ContentHitTags } from './data/api';