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:
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
161
src/library-authoring/collections/CollectionDetails.test.tsx
Normal file
161
src/library-authoring/collections/CollectionDetails.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
177
src/library-authoring/collections/CollectionDetails.tsx
Normal file
177
src/library-authoring/collections/CollectionDetails.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
157
src/library-authoring/collections/CollectionInfoHeader.test.tsx
Normal file
157
src/library-authoring/collections/CollectionInfoHeader.test.tsx
Normal 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.');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
@import "./status-widget/StatusWidget";
|
||||
@import "./history-widget/HistoryWidget";
|
||||
@import "./status-widget/StatusWidget";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export const SearchContextProvider: React.FC<{
|
||||
searchSortOrder,
|
||||
setSearchSortOrder,
|
||||
defaultSearchSortOrder,
|
||||
closeSearchModal: props.closeSearchModal ?? (() => {}),
|
||||
closeSearchModal: props.closeSearchModal ?? (() => { }),
|
||||
hasError: hasConnectionError || result.isError,
|
||||
...result,
|
||||
},
|
||||
|
||||
24
src/search-manager/data/__mocks__/block-types.json
Normal file
24
src/search-manager/data/__mocks__/block-types.json
Normal 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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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({});
|
||||
|
||||
@@ -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;
|
||||
|
||||
57
src/search-manager/data/apiHooks.test.tsx
Normal file
57
src/search-manager/data/apiHooks.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user