Skip to content

Commit 259a50c

Browse files
authored
UI fixes for Sort Library Component [FC-0059] (#1222)
fix: use "Recently Modified" as the default sort option When search keyword(s) are entered, use "Most Relevant" as default. Also * Hides "Most Relevant" option if no keyword is entered. * Re-orders the sort menu options * Ensures the default sort option is not stored in the query string * Shows the selected sort option on the drop-down toggle button * Shows "Sort By" as a header inside the drop-down menu
1 parent 3e0f7b5 commit 259a50c

File tree

4 files changed

+129
-76
lines changed

4 files changed

+129
-76
lines changed

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 66 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -189,33 +189,33 @@ describe('<LibraryAuthoringPage />', () => {
189189
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
190190

191191
const {
192-
getByRole, getByText, queryByText, findByText, findAllByText,
192+
getByRole, getAllByText, getByText, queryByText, findByText, findAllByText,
193193
} = render(<RootWrapper />);
194194

195-
// Ensure the search endpoint is called:
196-
// Call 1: To fetch searchable/filterable/sortable library data
197-
// Call 2: To fetch the recently modified components only
198-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
195+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
199196

200197
expect(await findByText('Content library')).toBeInTheDocument();
201198
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
202199

203200
expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument();
204201

205-
expect(getByText('Recently Modified')).toBeInTheDocument();
202+
// "Recently Modified" header + sort shown
203+
expect(getAllByText('Recently Modified').length).toEqual(2);
206204
expect(getByText('Collections (0)')).toBeInTheDocument();
207205
expect(getByText('Components (6)')).toBeInTheDocument();
208206
expect((await findAllByText('Test HTML Block'))[0]).toBeInTheDocument();
209207

210208
// Navigate to the components tab
211209
fireEvent.click(getByRole('tab', { name: 'Components' }));
212-
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
210+
// "Recently Modified" default sort shown
211+
expect(getAllByText('Recently Modified').length).toEqual(1);
213212
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
214213
expect(queryByText('Components (6)')).not.toBeInTheDocument();
215214

216215
// Navigate to the collections tab
217216
fireEvent.click(getByRole('tab', { name: 'Collections' }));
218-
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
217+
// "Recently Modified" default sort shown
218+
expect(getAllByText('Recently Modified').length).toEqual(1);
219219
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
220220
expect(queryByText('Components (6)')).not.toBeInTheDocument();
221221
expect(queryByText('There are 6 components in this library')).not.toBeInTheDocument();
@@ -224,7 +224,8 @@ describe('<LibraryAuthoringPage />', () => {
224224
// Go back to Home tab
225225
// This step is necessary to avoid the url change leak to other tests
226226
fireEvent.click(getByRole('tab', { name: 'Home' }));
227-
expect(getByText('Recently Modified')).toBeInTheDocument();
227+
// "Recently Modified" header + sort shown
228+
expect(getAllByText('Recently Modified').length).toEqual(2);
228229
expect(getByText('Collections (0)')).toBeInTheDocument();
229230
expect(getByText('Components (6)')).toBeInTheDocument();
230231
});
@@ -239,10 +240,7 @@ describe('<LibraryAuthoringPage />', () => {
239240
expect(await findByText('Content library')).toBeInTheDocument();
240241
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
241242

242-
// Ensure the search endpoint is called:
243-
// Call 1: To fetch searchable/filterable/sortable library data
244-
// Call 2: To fetch the recently modified components only
245-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
243+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
246244

247245
expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument();
248246
});
@@ -304,16 +302,13 @@ describe('<LibraryAuthoringPage />', () => {
304302
expect(await findByText('Content library')).toBeInTheDocument();
305303
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
306304

307-
// Ensure the search endpoint is called:
308-
// Call 1: To fetch searchable/filterable/sortable library data
309-
// Call 2: To fetch the recently modified components only
310-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
305+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
311306

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

314309
// Ensure the search endpoint is called again, only once more since the recently modified call
315310
// should not be impacted by the search
316-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); });
311+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
317312

318313
expect(getByText('No matching components found in this library.')).toBeInTheDocument();
319314

@@ -396,15 +391,13 @@ describe('<LibraryAuthoringPage />', () => {
396391
getByRole, getByText, queryByText, getAllByText, findAllByText,
397392
} = render(<RootWrapper />);
398393

399-
// Ensure the search endpoint is called:
400-
// Call 1: To fetch searchable/filterable/sortable library data
401-
// Call 2: To fetch the recently modified components only
402-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
394+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
403395

404396
expect(getByText('Content library')).toBeInTheDocument();
405397
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
406398

407-
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
399+
// "Recently Modified" header + sort shown
400+
await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); });
408401
expect(getByText('Collections (0)')).toBeInTheDocument();
409402
expect(getByText('Components (6)')).toBeInTheDocument();
410403
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
@@ -416,15 +409,17 @@ describe('<LibraryAuthoringPage />', () => {
416409

417410
// Clicking on "View All" button should navigate to the Components tab
418411
fireEvent.click(getByText('View All'));
419-
expect(queryByText('Recently Modified')).not.toBeInTheDocument();
412+
// "Recently Modified" default sort shown
413+
expect(getAllByText('Recently Modified').length).toEqual(1);
420414
expect(queryByText('Collections (0)')).not.toBeInTheDocument();
421415
expect(queryByText('Components (6)')).not.toBeInTheDocument();
422416
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
423417

424418
// Go back to Home tab
425419
// This step is necessary to avoid the url change leak to other tests
426420
fireEvent.click(getByRole('tab', { name: 'Home' }));
427-
expect(getByText('Recently Modified')).toBeInTheDocument();
421+
// "Recently Modified" header + sort shown
422+
expect(getAllByText('Recently Modified').length).toEqual(2);
428423
expect(getByText('Collections (0)')).toBeInTheDocument();
429424
expect(getByText('Components (6)')).toBeInTheDocument();
430425
});
@@ -438,15 +433,13 @@ describe('<LibraryAuthoringPage />', () => {
438433
getByText, queryByText, getAllByText, findAllByText,
439434
} = render(<RootWrapper />);
440435

441-
// Ensure the search endpoint is called:
442-
// Call 1: To fetch searchable/filterable/sortable library data
443-
// Call 2: To fetch the recently modified components only
444-
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); });
436+
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
445437

446438
expect(getByText('Content library')).toBeInTheDocument();
447439
expect((await findAllByText(libraryData.title))[0]).toBeInTheDocument();
448440

449-
await waitFor(() => { expect(getByText('Recently Modified')).toBeInTheDocument(); });
441+
// "Recently Modified" header + sort shown
442+
await waitFor(() => { expect(getAllByText('Recently Modified').length).toEqual(2); });
450443
expect(getByText('Collections (0)')).toBeInTheDocument();
451444
expect(getByText('Components (2)')).toBeInTheDocument();
452445
expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument();
@@ -463,35 +456,49 @@ describe('<LibraryAuthoringPage />', () => {
463456
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });
464457

465458
const {
466-
findByTitle, getAllByText, getByText, getByTitle,
459+
findByTitle, getAllByText, getByRole, getByTitle,
467460
} = render(<RootWrapper />);
468461

469462
expect(await findByTitle('Sort search results')).toBeInTheDocument();
470463

471-
const testSortOption = (async (optionText, sortBy) => {
472-
if (optionText) {
473-
fireEvent.click(getByTitle('Sort search results'));
474-
fireEvent.click(getByText(optionText));
475-
}
464+
const testSortOption = (async (optionText, sortBy, isDefault) => {
465+
// Open the drop-down menu
466+
fireEvent.click(getByTitle('Sort search results'));
467+
468+
// Click the option with the given text
469+
// Since the sort drop-down also shows the selected sort
470+
// option in its toggle button, we need to make sure we're
471+
// clicking on the last one found.
472+
const options = getAllByText(optionText);
473+
expect(options.length).toBeGreaterThan(0);
474+
fireEvent.click(options[options.length - 1]);
475+
476+
// Did the search happen with the expected sort option?
476477
const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]';
477-
const searchText = sortBy ? `?sort=${encodeURIComponent(sortBy)}` : '';
478478
await waitFor(() => {
479479
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
480480
body: expect.stringContaining(bodyText),
481481
method: 'POST',
482482
headers: expect.anything(),
483483
});
484484
});
485+
486+
// Is the sort option stored in the query string?
487+
const searchText = isDefault ? '' : `?sort=${encodeURIComponent(sortBy)}`;
485488
expect(window.location.search).toEqual(searchText);
489+
490+
// Is the selected sort option shown in the toggle button (if not default)
491+
// as well as in the drop-down menu?
492+
expect(getAllByText(optionText).length).toEqual(isDefault ? 1 : 2);
486493
});
487494

488-
await testSortOption('Title, A-Z', 'display_name:asc');
489-
await testSortOption('Title, Z-A', 'display_name:desc');
490-
await testSortOption('Newest', 'created:desc');
491-
await testSortOption('Oldest', 'created:asc');
495+
await testSortOption('Title, A-Z', 'display_name:asc', false);
496+
await testSortOption('Title, Z-A', 'display_name:desc', false);
497+
await testSortOption('Newest', 'created:desc', false);
498+
await testSortOption('Oldest', 'created:asc', false);
492499

493500
// Sorting by Recently Published also excludes unpublished components
494-
await testSortOption('Recently Published', 'last_published:desc');
501+
await testSortOption('Recently Published', 'last_published:desc', false);
495502
await waitFor(() => {
496503
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
497504
body: expect.stringContaining('last_published IS NOT NULL'),
@@ -500,8 +507,22 @@ describe('<LibraryAuthoringPage />', () => {
500507
});
501508
});
502509

503-
// Clearing filters clears the url search param and uses default sort
504-
fireEvent.click(getAllByText('Clear Filters')[0]);
505-
await testSortOption('', '');
510+
// Re-selecting the previous sort option resets sort to default "Recently Modified"
511+
await testSortOption('Recently Published', 'modified:desc', true);
512+
expect(getAllByText('Recently Modified').length).toEqual(2);
513+
514+
// Enter a keyword into the search box
515+
const searchBox = getByRole('searchbox');
516+
fireEvent.change(searchBox, { target: { value: 'words to find' } });
517+
518+
// Default sort option changes to "Most Relevant"
519+
expect(getAllByText('Most Relevant').length).toEqual(2);
520+
await waitFor(() => {
521+
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
522+
body: expect.stringContaining('"sort":[]'),
523+
method: 'POST',
524+
headers: expect.anything(),
525+
});
526+
});
506527
});
507528
});

src/search-manager/SearchManager.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export interface SearchContextData {
2828
isFiltered: boolean;
2929
searchSortOrder: SearchSortOption;
3030
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
31+
defaultSearchSortOrder: SearchSortOption;
3132
hits: ContentHit[];
3233
totalHits: number;
3334
isFetching: boolean;
@@ -65,14 +66,11 @@ function useStateWithUrlSearchParam<Type>(
6566
setSearchParams((prevParams) => {
6667
const paramValue: string = toString(value) ?? '';
6768
const newSearchParams = new URLSearchParams(prevParams);
68-
if (paramValue) {
69-
newSearchParams.set(paramName, paramValue);
70-
} else {
71-
// If no paramValue, remove it from the search params, so
72-
// we don't get dangling parameter values like ?paramName=
73-
// Another way to decide this would be to check value === defaultValue,
74-
// and ensure that default values are never stored in the search string.
69+
// If using the default paramValue, remove it from the search params.
70+
if (paramValue === defaultValue) {
7571
newSearchParams.delete(paramName);
72+
} else {
73+
newSearchParams.set(paramName, paramValue);
7674
}
7775
return newSearchParams;
7876
}, { replace: true });
@@ -95,17 +93,18 @@ export const SearchContextProvider: React.FC<{
9593

9694
// The search sort order can be set via the query string
9795
// E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA.
98-
const defaultSortOption = SearchSortOption.RELEVANCE;
96+
// Default sort by Most Relevant if there's search keyword(s), else by Recently Modified.
97+
const defaultSearchSortOrder = searchKeywords ? SearchSortOption.RELEVANCE : SearchSortOption.RECENTLY_MODIFIED;
9998
const [searchSortOrder, setSearchSortOrder] = useStateWithUrlSearchParam<SearchSortOption>(
100-
defaultSortOption,
99+
defaultSearchSortOrder,
101100
'sort',
102101
(value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue),
103102
(value: SearchSortOption) => value.toString(),
104103
);
105104
// SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we
106105
// send it to useContentSearchResults as an empty array.
107106
const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder;
108-
const sort: SearchSortOption[] = (searchSortOrderToUse === defaultSortOption ? [] : [searchSortOrderToUse]);
107+
const sort: SearchSortOption[] = (searchSortOrderToUse === SearchSortOption.RELEVANCE ? [] : [searchSortOrderToUse]);
109108
// Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components.
110109
if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) {
111110
extraFilter.push('last_published IS NOT NULL');
@@ -114,13 +113,11 @@ export const SearchContextProvider: React.FC<{
114113
const canClearFilters = (
115114
blockTypesFilter.length > 0
116115
|| tagsFilter.length > 0
117-
|| searchSortOrderToUse !== defaultSortOption
118116
);
119117
const isFiltered = canClearFilters || (searchKeywords !== '');
120118
const clearFilters = React.useCallback(() => {
121119
setBlockTypesFilter([]);
122120
setTagsFilter([]);
123-
setSearchSortOrder(defaultSortOption);
124121
}, []);
125122

126123
// Initialize a connection to Meilisearch:
@@ -160,6 +157,7 @@ export const SearchContextProvider: React.FC<{
160157
clearFilters,
161158
searchSortOrder,
162159
setSearchSortOrder,
160+
defaultSearchSortOrder,
163161
closeSearchModal: props.closeSearchModal ?? (() => {}),
164162
hasError: hasConnectionError || result.isError,
165163
...result,

0 commit comments

Comments
 (0)