Skip to content

Commit f9fa28e

Browse files
committed
feat: improve collection sidebar
1 parent ff67c9a commit f9fa28e

21 files changed

+828
-77
lines changed

src/library-authoring/__mocks__/collection-search.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@
200200
}
201201
],
202202
"created": 1726740779.564664,
203-
"modified": 1726740811.684142,
203+
"modified": 1726840811.684142,
204204
"usage_key": "lib-collection:OpenedX:CSPROB2:collection-from-meilisearch",
205205
"context_key": "lib:OpenedX:CSPROB2",
206206
"org": "OpenedX",
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import type MockAdapter from 'axios-mock-adapter';
2+
import fetchMock from 'fetch-mock-jest';
3+
import { cloneDeep } from 'lodash';
4+
5+
import { SearchContextProvider } from '../../search-manager';
6+
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
7+
import { type CollectionHit, formatSearchHit } from '../../search-manager/data/api';
8+
import {
9+
initializeMocks,
10+
fireEvent,
11+
render,
12+
screen,
13+
waitFor,
14+
within,
15+
} from '../../testUtils';
16+
import mockResult from '../__mocks__/collection-search.json';
17+
import * as api from '../data/api';
18+
import { mockContentLibrary } from '../data/api.mocks';
19+
import CollectionDetails from './CollectionDetails';
20+
21+
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
22+
23+
let axiosMock: MockAdapter;
24+
let mockShowToast: (message: string) => void;
25+
26+
mockContentSearchConfig.applyMock();
27+
const library = mockContentLibrary.libraryData;
28+
29+
describe('<CollectionDetails />', () => {
30+
beforeEach(() => {
31+
const mocks = initializeMocks();
32+
axiosMock = mocks.axiosMock;
33+
mockShowToast = mocks.mockShowToast;
34+
});
35+
36+
afterEach(() => {
37+
jest.clearAllMocks();
38+
axiosMock.restore();
39+
fetchMock.mockReset();
40+
});
41+
42+
const renderCollectionDetails = async () => {
43+
const collectionData: CollectionHit = formatSearchHit(mockResult.results[2].hits[0]) as CollectionHit;
44+
45+
render((
46+
<SearchContextProvider>
47+
<CollectionDetails library={library} collection={collectionData} />
48+
</SearchContextProvider>
49+
));
50+
51+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
52+
};
53+
54+
it('should render Collection Details', async () => {
55+
mockSearchResult(mockResult);
56+
await renderCollectionDetails();
57+
58+
// Collection Description
59+
expect(screen.getByText('Description / Card Preview Text')).toBeInTheDocument();
60+
const { description } = mockResult.results[2].hits[0];
61+
expect(screen.getByText(description)).toBeInTheDocument();
62+
63+
// Collection History
64+
expect(screen.getByText('Collection History')).toBeInTheDocument();
65+
// Modified date
66+
expect(screen.getByText('September 20, 2024')).toBeInTheDocument();
67+
// Created date
68+
expect(screen.getByText('September 19, 2024')).toBeInTheDocument();
69+
});
70+
71+
it('should allow modifying the description', async () => {
72+
mockSearchResult(mockResult);
73+
await renderCollectionDetails();
74+
75+
const {
76+
description: originalDescription,
77+
block_id: blockId,
78+
context_key: contextKey,
79+
} = mockResult.results[2].hits[0];
80+
81+
expect(screen.getByText(originalDescription)).toBeInTheDocument();
82+
83+
const url = api.getLibraryCollectionApiUrl(contextKey, blockId);
84+
axiosMock.onPatch(url).reply(200);
85+
86+
const textArea = screen.getByRole('textbox');
87+
88+
// Change the description to the same value
89+
fireEvent.focus(textArea);
90+
fireEvent.change(textArea, { target: { value: originalDescription } });
91+
fireEvent.blur(textArea);
92+
93+
await waitFor(() => {
94+
expect(axiosMock.history.patch).toHaveLength(0);
95+
expect(mockShowToast).not.toHaveBeenCalled();
96+
});
97+
98+
// Change the description to a new value
99+
fireEvent.focus(textArea);
100+
fireEvent.change(textArea, { target: { value: 'New description' } });
101+
fireEvent.blur(textArea);
102+
103+
await waitFor(() => {
104+
expect(axiosMock.history.patch).toHaveLength(1);
105+
expect(axiosMock.history.patch[0].url).toEqual(url);
106+
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' }));
107+
expect(mockShowToast).toHaveBeenCalledWith('Collection updated successfully.');
108+
});
109+
});
110+
111+
it('should show error while modifing the description', async () => {
112+
mockSearchResult(mockResult);
113+
await renderCollectionDetails();
114+
115+
const {
116+
description: originalDescription,
117+
block_id: blockId,
118+
context_key: contextKey,
119+
} = mockResult.results[2].hits[0];
120+
121+
expect(screen.getByText(originalDescription)).toBeInTheDocument();
122+
123+
const url = api.getLibraryCollectionApiUrl(contextKey, blockId);
124+
axiosMock.onPatch(url).reply(500);
125+
126+
const textArea = screen.getByRole('textbox');
127+
128+
// Change the description to a new value
129+
fireEvent.focus(textArea);
130+
fireEvent.change(textArea, { target: { value: 'New description' } });
131+
fireEvent.blur(textArea);
132+
133+
await waitFor(() => {
134+
expect(axiosMock.history.patch).toHaveLength(1);
135+
expect(axiosMock.history.patch[0].url).toEqual(url);
136+
expect(axiosMock.history.patch[0].data).toEqual(JSON.stringify({ description: 'New description' }));
137+
expect(mockShowToast).toHaveBeenCalledWith('Failed to update collection.');
138+
});
139+
});
140+
141+
it('should render Collection stats', async () => {
142+
mockSearchResult(mockResult);
143+
await renderCollectionDetails();
144+
145+
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
146+
expect(await screen.findByText('Total')).toBeInTheDocument();
147+
148+
[
149+
{ blockType: 'Total', count: 5 },
150+
{ blockType: 'Text', count: 4 },
151+
{ blockType: 'Problem', count: 1 },
152+
].forEach(({ blockType, count }) => {
153+
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement;
154+
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument();
155+
});
156+
});
157+
158+
it('should render Collection stats for empty collection', async () => {
159+
const mockResultCopy = cloneDeep(mockResult);
160+
mockResultCopy.results[1].facetDistribution.block_type = {};
161+
mockSearchResult(mockResultCopy);
162+
await renderCollectionDetails();
163+
164+
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
165+
expect(await screen.findByText('This collection is currently empty.')).toBeInTheDocument();
166+
});
167+
168+
it('should render Collection stats for big collection', async () => {
169+
const mockResultCopy = cloneDeep(mockResult);
170+
mockResultCopy.results[1].facetDistribution.block_type = {
171+
annotatable: 1,
172+
chapter: 2,
173+
discussion: 3,
174+
drag_and_drop_v2: 4,
175+
html: 5,
176+
library_content: 6,
177+
openassessment: 7,
178+
problem: 8,
179+
sequential: 9,
180+
vertical: 10,
181+
video: 11,
182+
choiceresponse: 12,
183+
};
184+
mockSearchResult(mockResultCopy);
185+
await renderCollectionDetails();
186+
187+
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
188+
expect(await screen.findByText('78')).toBeInTheDocument();
189+
190+
[
191+
{ blockType: 'Total', count: 78 },
192+
{ blockType: 'Multiple Choice', count: 12 },
193+
{ blockType: 'Video', count: 11 },
194+
{ blockType: 'Unit', count: 10 },
195+
{ blockType: 'Other', count: 45 },
196+
].forEach(({ blockType, count }) => {
197+
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement;
198+
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument();
199+
});
200+
});
201+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
2+
import { Icon, Stack } from '@openedx/paragon';
3+
import { useContext, useState } from 'react';
4+
import classNames from 'classnames';
5+
6+
import { getItemIcon } from '../../generic/block-type-utils';
7+
import { ToastContext } from '../../generic/toast-context';
8+
import { BlockTypeLabel, type CollectionHit, useSearchContext } from '../../search-manager';
9+
import type { ContentLibrary } from '../data/api';
10+
import { useUpdateCollection } from '../data/apiHooks';
11+
import HistoryWidget from '../generic/history-widget';
12+
import messages from './messages';
13+
14+
interface BlockCountProps {
15+
count: number,
16+
blockType?: string,
17+
label: React.ReactNode,
18+
className?: string,
19+
}
20+
21+
const BlockCount = ({
22+
count,
23+
blockType,
24+
label,
25+
className,
26+
}: BlockCountProps) => {
27+
const icon = blockType && getItemIcon(blockType);
28+
return (
29+
<Stack className={classNames('text-center', className)}>
30+
<span className="text-muted">{label}</span>
31+
<Stack direction="horizontal" gap={1} className="justify-content-center">
32+
{icon && <Icon src={icon} size="lg" />}
33+
<span>{count}</span>
34+
</Stack>
35+
</Stack>
36+
);
37+
};
38+
39+
const CollectionStatsWidget = () => {
40+
const {
41+
blockTypes,
42+
} = useSearchContext();
43+
44+
const blockTypesArray = Object.entries(blockTypes)
45+
.map(([blockType, count]) => ({ blockType, count }))
46+
.sort((a, b) => b.count - a.count);
47+
48+
const totalBlocksCount = blockTypesArray.reduce((acc, { count }) => acc + count, 0);
49+
const otherBlocks = blockTypesArray.splice(3);
50+
const otherBlocksCount = otherBlocks.reduce((acc, { count }) => acc + count, 0);
51+
52+
if (totalBlocksCount === 0) {
53+
return (
54+
<div className="text-center text-muted">
55+
<FormattedMessage {...messages.detailsTabStatsNoComponents} />
56+
</div>
57+
);
58+
}
59+
60+
return (
61+
<Stack direction="horizontal" className="p-2 justify-content-between" gap={2}>
62+
<BlockCount
63+
label={<FormattedMessage {...messages.detailsTabStatsTotalComponents} />}
64+
count={totalBlocksCount}
65+
className="border-right"
66+
/>
67+
{blockTypesArray.map(({ blockType, count }) => (
68+
<BlockCount
69+
key={blockType}
70+
label={<BlockTypeLabel type={blockType} />}
71+
blockType={blockType}
72+
count={count}
73+
/>
74+
))}
75+
{otherBlocks.length > 0 && (
76+
<BlockCount
77+
label={<FormattedMessage {...messages.detailsTabStatsOtherComponents} />}
78+
count={otherBlocksCount}
79+
/>
80+
)}
81+
</Stack>
82+
);
83+
};
84+
85+
interface CollectionDetailsProps {
86+
library: ContentLibrary,
87+
collection: CollectionHit,
88+
}
89+
90+
const CollectionDetails = ({ library, collection }: CollectionDetailsProps) => {
91+
const intl = useIntl();
92+
const { showToast } = useContext(ToastContext);
93+
94+
const [description, setDescription] = useState(collection.description);
95+
96+
const updateMutation = useUpdateCollection(collection.contextKey, collection.blockId);
97+
98+
// istanbul ignore if: this should never happen
99+
if (!collection) {
100+
throw new Error('A collection must be provided to CollectionDetails');
101+
}
102+
103+
const onSubmit = (e: React.FocusEvent<HTMLTextAreaElement>) => {
104+
const newDescription = e.target.value;
105+
if (newDescription === collection.description) {
106+
return;
107+
}
108+
updateMutation.mutateAsync({
109+
description: newDescription,
110+
}).then(() => {
111+
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
112+
}).catch(() => {
113+
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
114+
});
115+
};
116+
117+
return (
118+
<Stack
119+
gap={3}
120+
>
121+
<div>
122+
<h3 className="h5">
123+
{intl.formatMessage(messages.detailsTabDescriptionTitle)}
124+
</h3>
125+
{library.canEditLibrary ? (
126+
<textarea
127+
className="form-control"
128+
value={description}
129+
onChange={(e) => setDescription(e.target.value)}
130+
onBlur={onSubmit}
131+
/>
132+
) : collection.description}
133+
</div>
134+
<div>
135+
<h3 className="h5">
136+
{intl.formatMessage(messages.detailsTabStatsTitle)}
137+
</h3>
138+
<CollectionStatsWidget />
139+
</div>
140+
<hr className="w-100" />
141+
<div>
142+
<h3 className="h5">
143+
{intl.formatMessage(messages.detailsTabHistoryTitle)}
144+
</h3>
145+
<HistoryWidget
146+
created={collection.created ? new Date(collection.created * 1000) : null}
147+
modified={collection.modified ? new Date(collection.modified * 1000) : null}
148+
/>
149+
</div>
150+
</Stack>
151+
);
152+
};
153+
154+
export default CollectionDetails;

0 commit comments

Comments
 (0)