feat: improve collection sidebar (#1320)

* feat: improve collection sidebar

* feat: add comments to splice blockTypesArray code

Co-authored-by: Jillian <jill@opencraft.com>
---------

Co-authored-by: Jillian <jill@opencraft.com>
Co-authored-by: Chris Chávez <xnpiochv@gmail.com>
This commit is contained in:
Rômulo Penido
2024-09-27 23:24:12 -03:00
committed by GitHub
parent c80483c053
commit 4d67e8bda9
29 changed files with 1155 additions and 139 deletions

View File

@@ -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('<LibraryAuthoringPage />', () => {
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',

View File

@@ -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",

View File

@@ -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",

View File

@@ -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('<CollectionDetails />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
fetchMock.mockReset();
});
it('should render Collection Details', async () => {
render(<CollectionDetails library={library} collectionId={collectionId} />);
// 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(<CollectionDetails library={library} collectionId={collectionId} />);
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(<CollectionDetails library={library} collectionId={collectionId} />);
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(<CollectionDetails library={library} collectionId={collectionId} />);
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(<CollectionDetails library={library} collectionId={collectionId} />);
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(<CollectionDetails library={library} collectionId={collectionId} />);
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();
});
});
});

View File

@@ -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 (
<Stack className={classNames('text-center', className)}>
<span className="text-muted">{label}</span>
<Stack direction="horizontal" gap={1} className="justify-content-center">
{icon && <Icon src={icon} size="lg" />}
<span>{count}</span>
</Stack>
</Stack>
);
};
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 (
<div
className="text-center text-muted align-content-center"
style={{
height: '72px', // same height as the BlockCount component
}}
>
<FormattedMessage {...messages.detailsTabStatsNoComponents} />
</div>
);
}
return (
<Stack direction="horizontal" className="p-2 justify-content-between" gap={2}>
<BlockCount
label={<FormattedMessage {...messages.detailsTabStatsTotalComponents} />}
count={totalBlocksCount}
className="border-right"
/>
{blockTypesArray.map(({ blockType, count }) => (
<BlockCount
key={blockType}
label={<BlockTypeLabel type={blockType} />}
blockType={blockType}
count={count}
/>
))}
{otherBlocks.length > 0 && (
<BlockCount
label={<FormattedMessage {...messages.detailsTabStatsOtherComponents} />}
count={otherBlocksCount}
/>
)}
</Stack>
);
};
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<HTMLTextAreaElement>) => {
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 (
<Stack
gap={3}
>
<div>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabDescriptionTitle)}
</h3>
{library.canEditLibrary ? (
<textarea
className="form-control"
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={onSubmit}
/>
) : collection.description}
</div>
<div>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabStatsTitle)}
</h3>
<CollectionStatsWidget libraryId={library.id} collectionId={collectionId} />
</div>
<hr className="w-100" />
<div>
<h3 className="h5">
{intl.formatMessage(messages.detailsTabHistoryTitle)}
</h3>
<HistoryWidget
created={collection.created ? new Date(collection.created) : null}
modified={collection.modified ? new Date(collection.modified) : null}
/>
</div>
</Stack>
);
};
export default CollectionDetails;

View File

@@ -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 (
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey="manage"
>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
Manage tab placeholder
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
Details tab placeholder
</Tab>
</Tabs>
<Stack>
{!urlMatch && (
<div className="d-flex flex-wrap">
<Button
as={Link}
to={url}
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
disabled={!!urlMatch}
>
{intl.formatMessage(messages.openCollectionButton)}
</Button>
</div>
)}
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
defaultActiveKey="manage"
>
<Tab eventKey="manage" title={intl.formatMessage(messages.manageTabTitle)}>
Manage tab placeholder
</Tab>
<Tab eventKey="details" title={intl.formatMessage(messages.detailsTabTitle)}>
<CollectionDetails
library={library}
collectionId={collectionId}
/>
</Tab>
</Tabs>
</Stack>
);
};

View File

@@ -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('<CollectionInfoHeader />', () => {
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(<CollectionInfoHeader library={library} collectionId={collectionId} />);
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(<CollectionInfoHeader library={readOnlyLibrary} collectionId={collectionId} />);
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(<CollectionInfoHeader library={library} collectionId={collectionId} />);
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(<CollectionInfoHeader library={library} collectionId={collectionId} />);
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(<CollectionInfoHeader library={library} collectionId={collectionId} />);
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(<CollectionInfoHeader library={library} collectionId={collectionId} />);
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(<CollectionInfoHeader library={library} collectionId={collectionId} />);
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.');
});
});

View File

@@ -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) => (
<div className="d-flex flex-wrap">
{collection?.displayName}
</div>
);
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<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSaveDisplayName(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
return (
<Stack direction="horizontal">
{inputIsActive
? (
<Form.Control
autoFocus
name="title"
id="title"
type="text"
aria-label="Title input"
defaultValue={collection.title}
onBlur={handleSaveDisplayName}
onKeyDown={handleOnKeyDown}
/>
)
: (
<>
<span className="font-weight-bold m-1.5">
{collection.title}
</span>
{library.canEditLibrary && (
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editTitleButtonAlt)}
onClick={handleClick}
size="inline"
/>
)}
</>
)}
</Stack>
);
};
export default CollectionInfoHeader;

View File

@@ -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('<LibraryCollectionPage />', () => {
beforeEach(() => {
initializeMocks();
fetchMock.mockReset();
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
@@ -50,7 +58,7 @@ describe('<LibraryCollectionPage />', () => {
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
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<any>(() => {});
@@ -73,7 +81,6 @@ describe('<LibraryCollectionPage />', () => {
afterEach(() => {
jest.clearAllMocks();
fetchMock.mockReset();
});
const renderLibraryCollectionPage = async (collectionId?: string, libraryId?: string) => {
@@ -86,7 +93,7 @@ describe('<LibraryCollectionPage />', () => {
},
});
if (colId !== mockCollection.collectionNeverLoads) {
if (![mockCollection.collectionNeverLoads, mockCollection.collectionEmpty].includes(colId)) {
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
}
};
@@ -101,20 +108,18 @@ describe('<LibraryCollectionPage />', () => {
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('<LibraryCollectionPage />', () => {
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('<LibraryCollectionPage />', () => {
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('<LibraryCollectionPage />', () => {
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('<LibraryCollectionPage />', () => {
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('<LibraryCollectionPage />', () => {
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 });

View File

@@ -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 <Loading />;
}
if (!libraryData || !collectionData) {
if (!libraryData) {
return <NotFoundAlert />;
}
if (isError) {
return <ErrorAlert error={error} />;
}
const breadcrumbs = [
{
label: libraryData.title,
@@ -144,65 +160,47 @@ const LibraryCollectionPageInner = ({ libraryId }: { libraryId: string }) => {
isLibrary
/>
<Container size="xl" className="px-4 mt-4 mb-5 library-authoring-page">
<SubHeader
title={(
<SubHeaderTitle
title={collectionData.displayName}
canEditLibrary={libraryData.canEditLibrary}
infoClickHandler={openCollectionInfoSidebar}
/>
)}
breadcrumbs={(
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={breadcrumbs}
linkAs={Link}
/>
)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
/>
<SearchKeywordsField className="w-50" placeholder={intl.formatMessage(messages.searchPlaceholder)} />
<div className="d-flex mt-3 mb-4 align-items-center">
<FilterByTags />
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<LibraryCollectionComponents libraryId={libraryId} />
<SearchContextProvider
extraFilter={[`context_key = "${libraryId}"`, `collections.key = "${collectionId}"`]}
overrideQueries={{ collections: { limit: 0 } }}
>
<SubHeader
title={(
<SubHeaderTitle
title={collectionData.title}
canEditLibrary={libraryData.canEditLibrary}
infoClickHandler={() => openCollectionInfoSidebar(collectionId)}
/>
)}
breadcrumbs={(
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbsAriaLabel)}
links={breadcrumbs}
linkAs={Link}
/>
)}
headerActions={<HeaderActions canEditLibrary={libraryData.canEditLibrary} />}
/>
<SearchKeywordsField className="w-50" placeholder={intl.formatMessage(messages.searchPlaceholder)} />
<div className="d-flex mt-3 mb-4 align-items-center">
<FilterByTags />
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<LibraryCollectionComponents libraryId={libraryId} />
</SearchContextProvider>
</Container>
<StudioFooter />
</div>
{ !!sidebarBodyComponent && (
<div className="library-authoring-sidebar box-shadow-left-1 bg-white" data-testid="library-sidebar">
<LibrarySidebar library={libraryData} collection={collectionData} />
<LibrarySidebar library={libraryData} />
</div>
)}
</div>
);
};
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 (
<SearchContextProvider
extraFilter={[`context_key = "${libraryId}"`, `collections.key = "${collectionId}"`]}
overrideQueries={{ collections: collectionQuery }}
>
<LibraryCollectionPageInner libraryId={libraryId} />
</SearchContextProvider>
);
};
export default LibraryCollectionPage;

View File

@@ -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;

View File

@@ -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<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
const [currentCollectionId, setcurrentCollectionId] = React.useState<string>();
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 (

View File

@@ -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('<CollectionCard />', () => {
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(<CollectionCard collectionHit={CollectionHitSample} />);
// 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/');
});
});

View File

@@ -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 (
<Dropdown id="collection-card-dropdown" onClick={(e) => e.stopPropagation()}>
<Dropdown.Toggle
id="collection-card-menu-toggle"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
data-testid="collection-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item
as={Link}
to={`/library/${collectionHit.contextKey}/collection/${collectionHit.blockId}/`}
>
<FormattedMessage {...messages.menuOpen} />
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
);
};
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={(
<ActionRow>
<IconButton
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
/>
<CollectionMenu collectionHit={collectionHit} />
</ActionRow>
)}
blockTypeDisplayName={blockTypeDisplayName}
openInfoSidebar={() => {}}
openInfoSidebar={() => openCollectionInfoSidebar(collectionHit.blockId)}
/>
);
};

View File

@@ -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',

View File

@@ -274,3 +274,32 @@ mockLibraryBlockMetadata.dataPublished = {
mockLibraryBlockMetadata.usageKeyForTags = mockContentTaxonomyTagsData.largeTagsId;
/** 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<api.Collection> {
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);
};

View File

@@ -180,6 +180,8 @@ export interface CreateLibraryCollectionDataRequest {
description: string | null;
}
export type UpdateCollectionComponentsRequest = Partial<CreateLibraryCollectionDataRequest>;
/**
* Fetch the list of XBlock types that can be added to this library
*/
@@ -316,10 +318,30 @@ export async function getXBlockOLX(usageKey: string): Promise<string> {
return data.olx;
}
/**
* Get the collection metadata.
*/
export async function getCollectionMetadata(libraryId: string, collectionId: string): Promise<Collection> {
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,
});

View File

@@ -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);
});
});

View File

@@ -26,8 +26,11 @@ import {
updateXBlockFields,
createCollection,
getXBlockOLX,
updateCollectionMetadata,
type UpdateCollectionComponentsRequest,
updateCollectionComponents,
type CreateLibraryCollectionDataRequest,
getCollectionMetadata,
} from './api';
export 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
*/

View File

@@ -3,7 +3,7 @@ import { Stack } from '@openedx/paragon';
import messages from './messages';
const CustomFormattedDate = ({ date }: { date: string }) => (
const CustomFormattedDate = ({ date }: { date: string | Date }) => (
<FormattedDate
value={date}
year="numeric"
@@ -13,8 +13,8 @@ const CustomFormattedDate = ({ date }: { date: string }) => (
);
type HistoryWidgedProps = {
modified: string | null;
created: string | null;
modified: string | Date | null;
created: string | Date | null;
};
/**

View File

@@ -1,2 +1,2 @@
@import "./status-widget/StatusWidget";
@import "./history-widget/HistoryWidget";
@import "./status-widget/StatusWidget";

View File

@@ -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 { 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 { ContentLibrary } from '../data/api';
import { LibraryContext, SidebarBodyComponentId } from '../common/context';
import { ComponentInfo, ComponentInfoHeader } from '../component-info';
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 && <ComponentInfo usageKey={currentComponentUsageKey} />
),
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfo />,
[SidebarBodyComponentId.CollectionInfo]: (
currentCollectionId && <CollectionInfo library={library} collectionId={currentCollectionId} />
),
unknown: null,
};
@@ -53,7 +55,9 @@ const LibrarySidebar = ({ library, collection }: LibrarySidebarProps) => {
[SidebarBodyComponentId.ComponentInfo]: (
currentComponentUsageKey && <ComponentInfoHeader library={library} usageKey={currentComponentUsageKey} />
),
[SidebarBodyComponentId.CollectionInfo]: <CollectionInfoHeader collection={collection} />,
[SidebarBodyComponentId.CollectionInfo]: (
currentCollectionId && <CollectionInfoHeader library={library} collectionId={currentCollectionId} />
),
unknown: null,
};

View File

@@ -166,7 +166,7 @@ export const SearchContextProvider: React.FC<{
searchSortOrder,
setSearchSortOrder,
defaultSearchSortOrder,
closeSearchModal: props.closeSearchModal ?? (() => {}),
closeSearchModal: props.closeSearchModal ?? (() => { }),
hasError: hasConnectionError || result.isError,
...result,
},

View File

@@ -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": {}
}
]
}

View File

@@ -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({});

View File

@@ -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<string, any>): ContentHit | CollectionHit {
export function formatSearchHit(hit: Record<string, any>): 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<Record<string, number>> => {
// 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;

View File

@@ -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 }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
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);
});
});

View File

@@ -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;

View File

@@ -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';