Skip to content

Commit

Permalink
feat: sort library components
Browse files Browse the repository at this point in the history
  • Loading branch information
pomegranited committed Jul 25, 2024
1 parent 649863d commit 6924591
Show file tree
Hide file tree
Showing 8 changed files with 279 additions and 10 deletions.
46 changes: 46 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,4 +266,50 @@ describe('<LibraryAuthoringPage />', () => {

expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
});

it('sort library components', async () => {
mockUseParams.mockReturnValue({ libraryId: libraryData.id });
axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData);
fetchMock.post(searchEndpoint, returnEmptyResult, { overwriteRoutes: true });

const { findByTitle, getByText, getByTitle } = render(<RootWrapper />);

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

const testSortOption = (async (optionText, sortBy) => {
if (optionText) {
fireEvent.click(getByTitle('Sort search results'));
fireEvent.click(getByText(optionText));
}
const bodyText = sortBy ? `"sort":["${sortBy}"]` : '"sort":[]';
const searchText = sortBy ? `?sort=${encodeURIComponent(sortBy)}` : '';
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining(bodyText),
method: 'POST',
headers: expect.anything(),
});
});
expect(window.location.search).toEqual(searchText);
});

await testSortOption('Title, A-Z', 'display_name:asc');
await testSortOption('Title, Z-A', 'display_name:desc');
await testSortOption('Newest', 'created:desc');
await testSortOption('Oldest', 'created:asc');

// Sorting by Recently Published also excludes unpublished components
await testSortOption('Recently Published', 'last_published:desc');
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('last_published IS NOT EMPTY'),
method: 'POST',
headers: expect.anything(),
});
});

// Clearing filters clears the url search param and uses default sort
fireEvent.click(getByText('Clear Filters'));
await testSortOption('', '');
});
});
12 changes: 9 additions & 3 deletions src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
} from '@openedx/paragon';
import { Add, InfoOutline } from '@openedx/paragon/icons';
import {
Routes, Route, useLocation, useNavigate, useParams,
Routes, Route, useLocation, useNavigate, useParams, useSearchParams,
} from 'react-router-dom';

import Loading from '../generic/Loading';
Expand All @@ -26,6 +26,7 @@ import {
FilterByTags,
SearchContextProvider,
SearchKeywordsField,
SearchSortWidget,
} from '../search-manager';
import LibraryComponents from './components/LibraryComponents';
import LibraryCollections from './LibraryCollections';
Expand Down Expand Up @@ -62,13 +63,14 @@ const LibraryAuthoringPage = () => {
const navigate = useNavigate();

const { libraryId } = useParams();

const { data: libraryData, isLoading } = useContentLibrary(libraryId);

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

const [searchParams] = useSearchParams();

if (isLoading) {
return <Loading />;
}
Expand All @@ -78,7 +80,10 @@ const LibraryAuthoringPage = () => {
}

const handleTabChange = (key: string) => {
navigate(key);
navigate({
pathname: key,
search: searchParams.toString(),
});
};

return (
Expand Down Expand Up @@ -116,6 +121,7 @@ const LibraryAuthoringPage = () => {
<FilterByBlockType />
<ClearFiltersButton />
<div className="flex-grow-1" />
<SearchSortWidget />
</div>
<Tabs
variant="tabs"
Expand Down
78 changes: 75 additions & 3 deletions src/search-manager/SearchManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
* https://github.com/algolia/instantsearch/issues/1658
*/
import React from 'react';
import { useSearchParams } from 'react-router-dom';
import { MeiliSearch, type Filter } from 'meilisearch';

import { ContentHit } from './data/api';
import { ContentHit, SearchSortOption, forceArray } from './data/api';
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';

export interface SearchContextData {
Expand All @@ -24,6 +25,8 @@ export interface SearchContextData {
extraFilter?: Filter;
canClearFilters: boolean;
clearFilters: () => void;
searchSortOrder: SearchSortOption;
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
hits: ContentHit[];
totalHits: number;
isFetching: boolean;
Expand All @@ -36,19 +39,85 @@ export interface SearchContextData {

const SearchContext = React.createContext<SearchContextData | undefined>(undefined);

/**
* Hook which lets you store state variables in the URL search parameters.
*
* It wraps useState with functions that get/set a query string
* search parameter when returning/setting the state variable.
*
*/
function useStateWithUrlSearchParam<Type>(
defaultValue: Type,
paramName: string,
// Returns the Type equivalent of the given string value, or
// undefined if value is invalid.
fromString: (value: string | null) => Type | undefined,
// Returns the string equivalent of the given Type value.
// Returning empty string/undefined will clear the url search paramName.
toString: (value: Type) => string | undefined,
): [value: Type, setter: React.Dispatch<React.SetStateAction<Type>>] {
const [searchParams, setSearchParams] = useSearchParams();
const [stateValue, stateSetter] = React.useState<Type>(defaultValue);

// The converted search parameter value takes precedence over the state value.
const returnValue: Type = fromString(searchParams.get(paramName)) ?? stateValue ?? defaultValue;

// Before updating the state value, update the url search parameter
const returnSetter: React.Dispatch<React.SetStateAction<Type>> = ((value: Type) => {
const paramValue: string = toString(value) ?? '';
if (paramValue) {
searchParams.set(paramName, paramValue);
} else {
// If no paramValue, remove it from the search params, so
// we don't get dangling parameter values like ?paramName=
// Another way to decide this would be to check value === defaultValue,
// and ensure that default values are never stored in the search string.
searchParams.delete(paramName);
}
setSearchParams(searchParams, { replace: true });
return stateSetter(value);
});

// Return the computed value and wrapped set state function
return [returnValue, returnSetter];
}

export const SearchContextProvider: React.FC<{
extraFilter?: Filter;
children: React.ReactNode,
closeSearchModal?: () => void,
}> = ({ extraFilter, ...props }) => {
}> = ({ ...props }) => {
const [searchKeywords, setSearchKeywords] = React.useState('');
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
const extraFilter: string[] = forceArray(props.extraFilter);

// The search sort order can be set via the query string
// E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA.
const defaultSortOption = SearchSortOption.RELEVANCE;
const [searchSortOrder, setSearchSortOrder] = useStateWithUrlSearchParam<SearchSortOption>(
defaultSortOption,
'sort',
(value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue),
(value: SearchSortOption) => value.toString(),
);
// SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we
// send it to useContentSearchResults as an empty array.
const sort: SearchSortOption[] = (searchSortOrder === defaultSortOption ? [] : [searchSortOrder]);
// Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components.
if (searchSortOrder === SearchSortOption.RECENTLY_PUBLISHED) {
extraFilter.push('last_published IS NOT EMPTY');
}

const canClearFilters = blockTypesFilter.length > 0 || tagsFilter.length > 0;
const canClearFilters = (
blockTypesFilter.length > 0
|| tagsFilter.length > 0
|| searchSortOrder !== defaultSortOption
);
const clearFilters = React.useCallback(() => {
setBlockTypesFilter([]);
setTagsFilter([]);
setSearchSortOrder(defaultSortOption);
}, []);

// Initialize a connection to Meilisearch:
Expand All @@ -69,6 +138,7 @@ export const SearchContextProvider: React.FC<{
searchKeywords,
blockTypesFilter,
tagsFilter,
sort,
});

return React.createElement(SearchContext.Provider, {
Expand All @@ -84,6 +154,8 @@ export const SearchContextProvider: React.FC<{
extraFilter,
canClearFilters,
clearFilters,
searchSortOrder,
setSearchSortOrder,
closeSearchModal: props.closeSearchModal ?? (() => {}),
hasError: hasConnectionError || result.isError,
...result,
Expand Down
82 changes: 82 additions & 0 deletions src/search-manager/SearchSortWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import React, { useMemo } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Dropdown } from '@openedx/paragon';
import { Check, SwapVert } from '@openedx/paragon/icons';

import messages from './messages';
import { SearchSortOption } from './data/api';
import { useSearchContext } from './SearchManager';

export const SearchSortWidget: React.FC<Record<never, never>> = () => {
const intl = useIntl();
const menuItems = useMemo(
() => [
{
id: 'search-sort-option-title-az',
name: intl.formatMessage(messages.searchSortTitleAZ),
value: SearchSortOption.TITLE_AZ,
},
{
id: 'search-sort-option-title-za',
name: intl.formatMessage(messages.searchSortTitleZA),
value: SearchSortOption.TITLE_ZA,
},
{
id: 'search-sort-option-newest',
name: intl.formatMessage(messages.searchSortNewest),
value: SearchSortOption.NEWEST,
},
{
id: 'search-sort-option-oldest',
name: intl.formatMessage(messages.searchSortOldest),
value: SearchSortOption.OLDEST,
},
{
id: 'search-sort-option-recently-published',
name: intl.formatMessage(messages.searchSortRecentlyPublished),
value: SearchSortOption.RECENTLY_PUBLISHED,
},
{
id: 'search-sort-option-recently-modified',
name: intl.formatMessage(messages.searchSortRecentlyModified),
value: SearchSortOption.RECENTLY_MODIFIED,
},
],
[intl],
);

const { searchSortOrder, setSearchSortOrder } = useSearchContext();
const selectedSortOption = menuItems.find((menuItem) => menuItem.value === searchSortOrder);
const searchSortLabel = (
selectedSortOption ? selectedSortOption.name : intl.formatMessage(messages.searchSortWidgetLabel)
);

return (
<Dropdown id="search-sort-dropdown">
<Dropdown.Toggle
id="search-sort-toggle"
title={intl.formatMessage(messages.searchSortWidgetAltTitle)}
alt={intl.formatMessage(messages.searchSortWidgetAltTitle)}
variant="outline-primary"
className="dropdown-toggle-menu-items d-flex"
size="sm"
>
<Icon src={SwapVert} className="d-inline" />
{searchSortLabel}
</Dropdown.Toggle>
<Dropdown.Menu>
{menuItems.map(({ id, name, value }) => (
<Dropdown.Item
key={id}
onClick={() => setSearchSortOrder(value)}
>
{name}
{(value === searchSortOrder) && <Icon src={Check} className="ml-2" />}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};

export default SearchSortWidget;
24 changes: 20 additions & 4 deletions src/search-manager/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,16 @@ export const HIGHLIGHT_POST_TAG = '__/meili-highlight__'; // Indicate the end of
/** The separator used for hierarchical tags in the search index, e.g. tags.level1 = "Subject > Math > Calculus" */
export const TAG_SEP = ' > ';

export enum SearchSortOption {
RELEVANCE = '', // Default; sorts results by keyword search ranking
TITLE_AZ = 'display_name:asc',
TITLE_ZA = 'display_name:desc',
NEWEST = 'created:desc',
OLDEST = 'created:asc',
RECENTLY_PUBLISHED = 'last_published:desc',
RECENTLY_MODIFIED = 'modified:desc',
}

/**
* Get the content search configuration from the CMS.
*/
Expand Down Expand Up @@ -40,14 +50,14 @@ export interface ContentDetails {
* This helper method converts from any supported input format to an array, for consistency.
* @param filter A filter expression, e.g. `'foo = bar'` or `[['a = b', 'a = c'], 'd = e']`
*/
function forceArray(filter?: Filter): (string | string[])[] {
export function forceArray(filter?: Filter): string[] {
if (typeof filter === 'string') {
return [filter];
}
if (filter === undefined) {
return [];
if (Array.isArray(filter)) {
return filter as string[];
}
return filter;
return [];
}

/**
Expand Down Expand Up @@ -95,6 +105,9 @@ export interface ContentHit {
content?: ContentDetails;
/** Same fields with <mark>...</mark> highlights */
formatted: { displayName: string, content?: ContentDetails };
created: number;
modified: number;
last_published: number;
}

/**
Expand All @@ -119,6 +132,7 @@ interface FetchSearchParams {
/** The full path of tags that each result MUST have, e.g. ["Difficulty > Hard", "Subject > Math"] */
tagsFilter?: string[],
extraFilter?: Filter,
sort?: SearchSortOption[],
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
offset?: number,
}
Expand All @@ -130,6 +144,7 @@ export async function fetchSearchResults({
blockTypesFilter,
tagsFilter,
extraFilter,
sort,
offset = 0,
}: FetchSearchParams): Promise<{
hits: ContentHit[],
Expand Down Expand Up @@ -164,6 +179,7 @@ export async function fetchSearchResults({
highlightPostTag: HIGHLIGHT_POST_TAG,
attributesToCrop: ['content'],
cropLength: 20,
sort,
offset,
limit,
});
Expand Down
Loading

0 comments on commit 6924591

Please sign in to comment.