Skip to content

Commit 4f88948

Browse files
authored
feat: adds sort widget to search manager and library component page (#1147)
1 parent 699cbea commit 4f88948

16 files changed

+531
-62
lines changed

src/library-authoring/EmptyStates.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
Button, Stack,
55
} from '@openedx/paragon';
66
import { Add } from '@openedx/paragon/icons';
7+
import { ClearFiltersButton } from '../search-manager';
78
import messages from './messages';
89
import { LibraryContext } from './common/context';
910

@@ -21,7 +22,8 @@ export const NoComponents = () => {
2122
};
2223

2324
export const NoSearchResults = () => (
24-
<div className="d-flex mt-6 justify-content-center">
25+
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
2526
<FormattedMessage {...messages.noSearchResults} />
26-
</div>
27+
<ClearFiltersButton variant="primary" size="md" />
28+
</Stack>
2729
);

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 158 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const queryClient = new QueryClient({
3838
},
3939
});
4040

41+
/**
42+
* Returns 0 components from the search query.
43+
*/
4144
const returnEmptyResult = (_url, req) => {
4245
const requestData = JSON.parse(req.body?.toString() ?? '');
4346
const query = requestData?.queries[0]?.q ?? '';
@@ -50,6 +53,26 @@ const returnEmptyResult = (_url, req) => {
5053
return mockEmptyResult;
5154
};
5255

56+
/**
57+
* Returns 2 components from the search query.
58+
* This lets us test that the StudioHome "View All" button is hidden when a
59+
* low number of search results are shown (<=4 by default).
60+
*/
61+
const returnLowNumberResults = (_url, req) => {
62+
const requestData = JSON.parse(req.body?.toString() ?? '');
63+
const query = requestData?.queries[0]?.q ?? '';
64+
// We have to replace the query (search keywords) in the mock results with the actual query,
65+
// because otherwise we may have an inconsistent state that causes more queries and unexpected results.
66+
mockResult.results[0].query = query;
67+
// Limit number of results to just 2
68+
mockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2);
69+
mockResult.results[0].estimatedTotalHits = 2;
70+
// And fake the required '_formatted' fields; it contains the highlighting <mark>...</mark> around matched words
71+
// eslint-disable-next-line no-underscore-dangle, no-param-reassign
72+
mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; });
73+
return mockResult;
74+
};
75+
5376
const libraryData: ContentLibrary = {
5477
id: 'lib:org1:lib1',
5578
type: 'complex',
@@ -154,11 +177,13 @@ describe('<LibraryAuthoringPage />', () => {
154177
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
155178

156179
const {
157-
getByRole, getByText, queryByText, findByText,
180+
getByRole, getByText, getAllByText, queryByText,
158181
} = render(<RootWrapper />);
159182

160-
// Ensure the search endpoint is called
161-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
183+
// Ensure the search endpoint is called:
184+
// Call 1: To fetch searchable/filterable/sortable library data
185+
// Call 2: To fetch the recently modified components only
186+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
162187

163188
expect(getByText('Content library')).toBeInTheDocument();
164189
expect(getByText(libraryData.title)).toBeInTheDocument();
@@ -168,7 +193,7 @@ describe('<LibraryAuthoringPage />', () => {
168193
expect(getByText('Recently Modified')).toBeInTheDocument();
169194
expect(getByText('Collections (0)')).toBeInTheDocument();
170195
expect(getByText('Components (6)')).toBeInTheDocument();
171-
expect(await findByText('Test HTML Block')).toBeInTheDocument();
196+
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
172197

173198
// Navigate to the components tab
174199
fireEvent.click(getByRole('tab', { name: 'Components' }));
@@ -202,8 +227,10 @@ describe('<LibraryAuthoringPage />', () => {
202227
expect(await findByText('Content library')).toBeInTheDocument();
203228
expect(await findByText(libraryData.title)).toBeInTheDocument();
204229

205-
// Ensure the search endpoint is called
206-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
230+
// Ensure the search endpoint is called:
231+
// Call 1: To fetch searchable/filterable/sortable library data
232+
// Call 2: To fetch the recently modified components only
233+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
207234

208235
expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument();
209236
});
@@ -228,13 +255,16 @@ describe('<LibraryAuthoringPage />', () => {
228255
expect(await findByText('Content library')).toBeInTheDocument();
229256
expect(await findByText(libraryData.title)).toBeInTheDocument();
230257

231-
// Ensure the search endpoint is called
232-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
258+
// Ensure the search endpoint is called:
259+
// Call 1: To fetch searchable/filterable/sortable library data
260+
// Call 2: To fetch the recently modified components only
261+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
233262

234263
fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } });
235264

236-
// Ensure the search endpoint is called again
237-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
265+
// Ensure the search endpoint is called again, only once more since the recently modified call
266+
// should not be impacted by the search
267+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
238268

239269
expect(getByText('No matching components found in this library.')).toBeInTheDocument();
240270

@@ -266,4 +296,122 @@ describe('<LibraryAuthoringPage />', () => {
266296

267297
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
268298
});
299+
300+
it('show the "View All" button when viewing library with many components', async () => {
301+
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
302+
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
303+
304+
const {
305+
getByRole, getByText, queryByText, getAllByText,
306+
} = render(<RootWrapper />);
307+
308+
// Ensure the search endpoint is called:
309+
// Call 1: To fetch searchable/filterable/sortable library data
310+
// Call 2: To fetch the recently modified components only
311+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
312+
313+
expect(getByText('Content library')).toBeInTheDocument();
314+
expect(getByText(libraryData.title)).toBeInTheDocument();
315+
316+
expect(getByText('Recently Modified')).toBeInTheDocument();
317+
expect(getByText('Collections (0)')).toBeInTheDocument();
318+
expect(getByText('Components (6)')).toBeInTheDocument();
319+
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
320+
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
321+
322+
// There should only be one "View All" button, since the Components count
323+
// are above the preview limit (4)
324+
expect(getByText('View All')).toBeInTheDocument();
325+
326+
// Clicking on "View All" button should navigate to the Components tab
327+
fireEvent.click(getByText('View All'));
328+
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
329+
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
330+
expect(queryByText('Components (6)')).not.toBeInTheDocument();
331+
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
332+
333+
// Go back to Home tab
334+
// This step is necessary to avoid the url change leak to other tests
335+
fireEvent.click(getByRole('tab', { name: 'Home' }));
336+
expect(getByText('Recently Modified')).toBeInTheDocument();
337+
expect(getByText('Collections (0)')).toBeInTheDocument();
338+
expect(getByText('Components (6)')).toBeInTheDocument();
339+
});
340+
341+
it('should not show the "View All" button when viewing library with low number of components', async () => {
342+
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
343+
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
344+
fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true });
345+
346+
const {
347+
getByText, queryByText, getAllByText,
348+
} = render(<RootWrapper />);
349+
350+
// Ensure the search endpoint is called:
351+
// Call 1: To fetch searchable/filterable/sortable library data
352+
// Call 2: To fetch the recently modified components only
353+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
354+
355+
expect(getByText('Content library')).toBeInTheDocument();
356+
expect(getByText(libraryData.title)).toBeInTheDocument();
357+
358+
expect(getByText('Recently Modified')).toBeInTheDocument();
359+
expect(getByText('Collections (0)')).toBeInTheDocument();
360+
expect(getByText('Components (2)')).toBeInTheDocument();
361+
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
362+
363+
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
364+
365+
// There should not be any "View All" button on page since Components count
366+
// is less than the preview limit (4)
367+
expect(queryByText('View All')).not.toBeInTheDocument();
368+
});
369+
370+
it('sort library components', async () => {
371+
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
372+
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
373+
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
374+
375+
const {
376+
findByTitle, getAllByText, getByText, getByTitle,
377+
} = render(<RootWrapper />);
378+
379+
expect(await findByTitle('Sort search results')).toBeInTheDocument();
380+
381+
const testSortOption = (async (optionText, sortBy) => {
382+
if (optionText) {
383+
fireEvent.click(getByTitle('Sort search results'));
384+
fireEvent.click(getByText(optionText));
385+
}
386+
const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]';
387+
const searchText = sortBy ? `?sort=${encodeURIComponent(sortBy)}` : '';
388+
await waitFor(() => {
389+
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
390+
body: expect.stringContaining(bodyText),
391+
method: 'POST',
392+
headers: expect.anything(),
393+
});
394+
});
395+
expect(window.location.search).toEqual(searchText);
396+
});
397+
398+
await testSortOption('Title, A-Z', 'display_name:asc');
399+
await testSortOption('Title, Z-A', 'display_name:desc');
400+
await testSortOption('Newest', 'created:desc');
401+
await testSortOption('Oldest', 'created:asc');
402+
403+
// Sorting by Recently Published also excludes unpublished components
404+
await testSortOption('Recently Published', 'last_published:desc');
405+
await waitFor(() => {
406+
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
407+
body: expect.stringContaining('last_published IS NOT NULL'),
408+
method: 'POST',
409+
headers: expect.anything(),
410+
});
411+
});
412+
413+
// Clearing filters clears the url search param and uses default sort
414+
fireEvent.click(getAllByText('Clear Filters')[0]);
415+
await testSortOption('', '');
416+
});
269417
});

src/library-authoring/LibraryAuthoringPage.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
} from '@openedx/paragon';
1414
import { Add, InfoOutline } from '@openedx/paragon/icons';
1515
import {
16-
Routes, Route, useLocation, useNavigate, useParams,
16+
Routes, Route, useLocation, useNavigate, useParams, useSearchParams,
1717
} from 'react-router-dom';
1818

1919
import Loading from '../generic/Loading';
@@ -26,6 +26,7 @@ import {
2626
FilterByTags,
2727
SearchContextProvider,
2828
SearchKeywordsField,
29+
SearchSortWidget,
2930
} from '../search-manager';
3031
import LibraryComponents from './components/LibraryComponents';
3132
import LibraryCollections from './LibraryCollections';
@@ -62,13 +63,14 @@ const LibraryAuthoringPage = () => {
6263
const navigate = useNavigate();
6364

6465
const { libraryId } = useParams();
65-
6666
const { data: libraryData, isLoading } = useContentLibrary(libraryId);
6767

6868
const currentPath = location.pathname.split('/').pop();
6969
const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home;
7070
const { sidebarBodyComponent, openAddContentSidebar } = useContext(LibraryContext);
7171

72+
const [searchParams] = useSearchParams();
73+
7274
if (isLoading) {
7375
return <Loading />;
7476
}
@@ -78,7 +80,10 @@ const LibraryAuthoringPage = () => {
7880
}
7981

8082
const handleTabChange = (key: string) => {
81-
navigate(key);
83+
navigate({
84+
pathname: key,
85+
search: searchParams.toString(),
86+
});
8287
};
8388

8489
return (
@@ -116,6 +121,7 @@ const LibraryAuthoringPage = () => {
116121
<FilterByBlockType />
117122
<ClearFiltersButton />
118123
<div className="flex-grow-1" />
124+
<SearchSortWidget />
119125
</div>
120126
<Tabs
121127
variant="tabs"
@@ -130,7 +136,13 @@ const LibraryAuthoringPage = () => {
130136
<Routes>
131137
<Route
132138
path={TabList.home}
133-
element={<LibraryHome libraryId={libraryId} />}
139+
element={(
140+
<LibraryHome
141+
libraryId={libraryId}
142+
tabList={TabList}
143+
handleTabChange={handleTabChange}
144+
/>
145+
)}
134146
/>
135147
<Route
136148
path={TabList.components}

src/library-authoring/LibraryHome.tsx

Lines changed: 35 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,61 @@
11
import React from 'react';
2+
import { Stack } from '@openedx/paragon';
23
import { useIntl } from '@edx/frontend-platform/i18n';
3-
import {
4-
Card, Stack,
5-
} from '@openedx/paragon';
64

75
import { useSearchContext } from '../search-manager';
86
import { NoComponents, NoSearchResults } from './EmptyStates';
97
import LibraryCollections from './LibraryCollections';
108
import { LibraryComponents } from './components';
9+
import LibrarySection from './components/LibrarySection';
10+
import LibraryRecentlyModified from './LibraryRecentlyModified';
1111
import messages from './messages';
1212

13-
const Section = ({ title, children } : { title: string, children: React.ReactNode }) => (
14-
<Card>
15-
<Card.Header
16-
title={title}
17-
/>
18-
<Card.Section>
19-
{children}
20-
</Card.Section>
21-
</Card>
22-
);
23-
2413
type LibraryHomeProps = {
2514
libraryId: string,
15+
tabList: { home: string, components: string, collections: string },
16+
handleTabChange: (key: string) => void,
2617
};
2718

28-
const LibraryHome = ({ libraryId } : LibraryHomeProps) => {
19+
const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) => {
2920
const intl = useIntl();
30-
3121
const {
3222
totalHits: componentCount,
33-
searchKeywords,
23+
isFiltered,
3424
} = useSearchContext();
3525

3626
const collectionCount = 0;
3727

38-
if (componentCount === 0) {
39-
return searchKeywords === '' ? <NoComponents /> : <NoSearchResults />;
40-
}
28+
const renderEmptyState = () => {
29+
if (componentCount === 0) {
30+
return isFiltered ? <NoSearchResults /> : <NoComponents />;
31+
}
32+
return null;
33+
};
4134

4235
return (
4336
<Stack gap={3}>
44-
<Section title={intl.formatMessage(messages.recentlyModifiedTitle)}>
45-
{ intl.formatMessage(messages.recentComponentsTempPlaceholder) }
46-
</Section>
47-
<Section title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}>
48-
<LibraryCollections />
49-
</Section>
50-
<Section title={`Components (${componentCount})`}>
51-
<LibraryComponents libraryId={libraryId} variant="preview" />
52-
</Section>
37+
<LibraryRecentlyModified libraryId={libraryId} />
38+
{
39+
renderEmptyState()
40+
|| (
41+
<>
42+
<LibrarySection
43+
title={intl.formatMessage(messages.collectionsTitle, { collectionCount })}
44+
contentCount={collectionCount}
45+
// TODO: add viewAllAction here once collections implemented
46+
>
47+
<LibraryCollections />
48+
</LibrarySection>
49+
<LibrarySection
50+
title={intl.formatMessage(messages.componentsTitle, { componentCount })}
51+
contentCount={componentCount}
52+
viewAllAction={() => handleTabChange(tabList.components)}
53+
>
54+
<LibraryComponents libraryId={libraryId} variant="preview" />
55+
</LibrarySection>
56+
</>
57+
)
58+
}
5359
</Stack>
5460
);
5561
};

0 commit comments

Comments
 (0)