Skip to content

Commit a1e2730

Browse files
committed
fixup! feat: improve collection sidebar
1 parent 4a8a638 commit a1e2730

File tree

4 files changed

+198
-6
lines changed

4 files changed

+198
-6
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: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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+
render,
11+
screen,
12+
waitFor,
13+
within,
14+
} from '../../testUtils';
15+
import mockResult from '../__mocks__/collection-search.json';
16+
import { mockContentLibrary } from '../data/api.mocks';
17+
import * as api from '../data/api';
18+
import CollectionDetails from './CollectionDetails';
19+
20+
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
21+
const mockCollection = {
22+
collectionId: mockResult.results[2].hits[0].block_id,
23+
collectionNoComponents: 'collection-no-components',
24+
collectionMultipleComponents: 'collection-multiple-components',
25+
title: mockResult.results[2].hits[0].display_name,
26+
description: mockResult.results[2].hits[0].description,
27+
};
28+
29+
let axiosMock: MockAdapter;
30+
let mockShowToast: (message: string) => void;
31+
32+
mockContentSearchConfig.applyMock();
33+
34+
describe('<CollectionDetails />', () => {
35+
beforeEach(() => {
36+
const mocks = initializeMocks();
37+
axiosMock = mocks.axiosMock;
38+
mockShowToast = mocks.mockShowToast;
39+
return;
40+
41+
// The Meilisearch client-side API uses fetch, not Axios.
42+
fetchMock.post(searchEndpoint, (_url, req) => {
43+
const requestData = JSON.parse(req.body?.toString() ?? '');
44+
const query = requestData?.queries[0]?.q ?? '';
45+
const mockResultCopy = cloneDeep(mockResult);
46+
// We have to replace the query (search keywords) in the mock results with the actual query,
47+
// because otherwise Instantsearch will update the UI and change the query,
48+
// leading to unexpected results in the test cases.
49+
mockResultCopy.results[0].query = query;
50+
mockResultCopy.results[1].query = query;
51+
mockResultCopy.results[2].query = query;
52+
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
53+
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
54+
mockResultCopy.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
55+
const collectionQueryId = requestData?.queries[2]?.filter[2]?.split('block_id = "')[1].split('"')[0];
56+
mockResultCopy.results[0].description = requestData?.queries[2]?.filter[2];
57+
switch (collectionQueryId) {
58+
case mockCollection.collectionNoComponents:
59+
mockResultCopy.results[1].facetDistribution.block_type = {};
60+
break;
61+
case mockCollection.collectionMultipleComponents:
62+
mockResultCopy.results[1].facetDistribution.block_type = {
63+
annotatable: 1,
64+
chapter: 2,
65+
discussion: 3,
66+
drag_and_drop_v2: 4,
67+
html: 5,
68+
library_content: 6,
69+
openassessment: 7,
70+
problem: 8,
71+
sequential: 9,
72+
vertical: 10,
73+
video: 11,
74+
choiceresponse: 12,
75+
};
76+
break;
77+
default:
78+
break;
79+
}
80+
return mockResultCopy;
81+
});
82+
});
83+
84+
afterEach(() => {
85+
jest.clearAllMocks();
86+
axiosMock.restore();
87+
fetchMock.mockReset();
88+
});
89+
90+
const renderCollectionDetails = async (collectionId?: string) => {
91+
const colId = collectionId || mockCollection.collectionId;
92+
const collectionData: CollectionHit = formatSearchHit(mockResult.results[2].hits[0]) as CollectionHit;
93+
const library = mockContentLibrary.libraryData;
94+
95+
render((
96+
<SearchContextProvider>
97+
<CollectionDetails library={library} collection={collectionData} />
98+
</SearchContextProvider>
99+
));
100+
101+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
102+
};
103+
104+
it('should render Collection Detail', async () => {
105+
mockSearchResult(mockResult);
106+
await renderCollectionDetails();
107+
108+
// Collection Description
109+
expect(screen.getByText('Description / Card Preview Text')).toBeInTheDocument();
110+
expect(screen.getByText(mockCollection.description)).toBeInTheDocument();
111+
112+
// Collection History
113+
expect(screen.getByText('Collection History')).toBeInTheDocument();
114+
// Modified date
115+
expect(screen.getByText('September 20, 2024')).toBeInTheDocument();
116+
// Created date
117+
expect(screen.getByText('September 19, 2024')).toBeInTheDocument();
118+
});
119+
120+
it('should render Collection stats', async () => {
121+
mockSearchResult(mockResult);
122+
await renderCollectionDetails();
123+
124+
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
125+
expect(await screen.findByText('Total')).toBeInTheDocument();
126+
127+
[
128+
{ blockType: 'Total', count: 5 },
129+
{ blockType: 'Text', count: 4 },
130+
{ blockType: 'Problem', count: 1 },
131+
].forEach(({ blockType, count }) => {
132+
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement;
133+
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument();
134+
});
135+
});
136+
137+
it('should render Collection stats for empty collection', async () => {
138+
const mockResultCopy = cloneDeep(mockResult);
139+
mockResultCopy.results[1].facetDistribution.block_type = {};
140+
mockSearchResult(mockResultCopy);
141+
await renderCollectionDetails(mockCollection.collectionNoComponents);
142+
143+
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
144+
expect(await screen.findByText('This collection is currently empty.')).toBeInTheDocument();
145+
});
146+
147+
it('should render Collection stats for big collection', async () => {
148+
const mockResultCopy = cloneDeep(mockResult);
149+
mockResultCopy.results[1].facetDistribution.block_type = {
150+
annotatable: 1,
151+
chapter: 2,
152+
discussion: 3,
153+
drag_and_drop_v2: 4,
154+
html: 5,
155+
library_content: 6,
156+
openassessment: 7,
157+
problem: 8,
158+
sequential: 9,
159+
vertical: 10,
160+
video: 11,
161+
choiceresponse: 12,
162+
};
163+
mockSearchResult(mockResultCopy);
164+
await renderCollectionDetails(mockCollection.collectionMultipleComponents);
165+
166+
expect(screen.getByText('Collection Stats')).toBeInTheDocument();
167+
expect(await screen.findByText('78')).toBeInTheDocument();
168+
169+
[
170+
{ blockType: 'Total', count: 78 },
171+
{ blockType: 'Multiple Choice', count: 12 },
172+
{ blockType: 'Video', count: 11 },
173+
{ blockType: 'Unit', count: 10 },
174+
{ blockType: 'Other', count: 45 },
175+
].forEach(({ blockType, count }) => {
176+
const blockCount = screen.getByText(blockType).closest('div') as HTMLDivElement;
177+
expect(within(blockCount).getByText(count.toString())).toBeInTheDocument();
178+
});
179+
});
180+
});

src/library-authoring/collections/CollectionInfoHeader.test.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type MockAdapter from 'axios-mock-adapter';
2+
13
import { mockCollectionHit } from '../../search-manager/data/api.mock';
24
import {
35
initializeMocks,
@@ -10,9 +12,22 @@ import { mockContentLibrary } from '../data/api.mocks';
1012
import * as api from '../data/api';
1113
import CollectionInfoHeader from './CollectionInfoHeader';
1214

15+
let axiosMock: MockAdapter;
16+
let mockShowToast: (message: string) => void;
17+
1318
describe('<CollectionInfoHeader />', () => {
19+
beforeEach(() => {
20+
const mocks = initializeMocks();
21+
axiosMock = mocks.axiosMock;
22+
mockShowToast = mocks.mockShowToast;
23+
});
24+
25+
afterEach(() => {
26+
jest.clearAllMocks();
27+
axiosMock.restore();
28+
});
29+
1430
it('should render Collection info Header', async () => {
15-
initializeMocks();
1631
const library = await mockContentLibrary(mockContentLibrary.libraryId);
1732
render(<CollectionInfoHeader library={library} collection={mockCollectionHit} />);
1833

@@ -27,7 +42,6 @@ describe('<CollectionInfoHeader />', () => {
2742
});
2843

2944
it('should update collection title', async () => {
30-
const { axiosMock, mockShowToast } = initializeMocks();
3145
const library = await mockContentLibrary(mockContentLibrary.libraryId);
3246
render(<CollectionInfoHeader library={library} collection={mockCollectionHit} />);
3347
const url = api.getLibraryCollectionApiUrl(library.id, mockCollectionHit.blockId);
@@ -50,7 +64,6 @@ describe('<CollectionInfoHeader />', () => {
5064
});
5165

5266
it('should close edit collection title on press Escape', async () => {
53-
const { axiosMock, mockShowToast } = initializeMocks();
5467
const library = await mockContentLibrary(mockContentLibrary.libraryId);
5568
render(<CollectionInfoHeader library={library} collection={mockCollectionHit} />);
5669
const url = api.getLibraryCollectionApiUrl(library.id, mockCollectionHit.blockId);
@@ -69,7 +82,6 @@ describe('<CollectionInfoHeader />', () => {
6982
});
7083

7184
it('should show error on edit collection tittle', async () => {
72-
const { axiosMock, mockShowToast } = initializeMocks();
7385
const library = await mockContentLibrary(mockContentLibrary.libraryId);
7486
render(<CollectionInfoHeader library={library} collection={mockCollectionHit} />);
7587
const url = api.getLibraryCollectionApiUrl(library.id, mockCollectionHit.blockId);

src/search-manager/data/api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ export interface CollectionHit extends BaseContentHit {
144144
* Convert search hits to camelCase
145145
* @param hit A search result directly from Meilisearch
146146
*/
147-
function formatSearchHit(hit: Record<string, any>): ContentHit | CollectionHit {
147+
export function formatSearchHit(hit: Record<string, any>): ContentHit | CollectionHit {
148148
// eslint-disable-next-line @typescript-eslint/naming-convention
149149
const { _formatted, ...newHit } = hit;
150150
newHit.formatted = {

0 commit comments

Comments
 (0)