From 6924591720747f78d890f9eff70b5410dfc17e2c Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Fri, 26 Jul 2024 05:07:55 +0930 Subject: [PATCH] feat: sort library components --- .../LibraryAuthoringPage.test.tsx | 46 +++++++++++ .../LibraryAuthoringPage.tsx | 12 ++- src/search-manager/SearchManager.ts | 78 +++++++++++++++++- src/search-manager/SearchSortWidget.tsx | 82 +++++++++++++++++++ src/search-manager/data/api.ts | 24 +++++- src/search-manager/data/apiHooks.ts | 6 ++ src/search-manager/index.ts | 1 + src/search-manager/messages.ts | 40 +++++++++ 8 files changed, 279 insertions(+), 10 deletions(-) create mode 100644 src/search-manager/SearchSortWidget.tsx diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index f08555b023..cee647311a 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -266,4 +266,50 @@ describe('', () => { 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(); + + 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('', ''); + }); }); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index f3afb5555f..2cdb67d48b 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -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'; @@ -26,6 +26,7 @@ import { FilterByTags, SearchContextProvider, SearchKeywordsField, + SearchSortWidget, } from '../search-manager'; import LibraryComponents from './components/LibraryComponents'; import LibraryCollections from './LibraryCollections'; @@ -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 ; } @@ -78,7 +80,10 @@ const LibraryAuthoringPage = () => { } const handleTabChange = (key: string) => { - navigate(key); + navigate({ + pathname: key, + search: searchParams.toString(), + }); }; return ( @@ -116,6 +121,7 @@ const LibraryAuthoringPage = () => {
+
void; + searchSortOrder: SearchSortOption; + setSearchSortOrder: React.Dispatch>; hits: ContentHit[]; totalHits: number; isFetching: boolean; @@ -36,19 +39,85 @@ export interface SearchContextData { const SearchContext = React.createContext(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( + 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>] { + const [searchParams, setSearchParams] = useSearchParams(); + const [stateValue, stateSetter] = React.useState(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> = ((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([]); const [tagsFilter, setTagsFilter] = React.useState([]); + 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( + 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: @@ -69,6 +138,7 @@ export const SearchContextProvider: React.FC<{ searchKeywords, blockTypesFilter, tagsFilter, + sort, }); return React.createElement(SearchContext.Provider, { @@ -84,6 +154,8 @@ export const SearchContextProvider: React.FC<{ extraFilter, canClearFilters, clearFilters, + searchSortOrder, + setSearchSortOrder, closeSearchModal: props.closeSearchModal ?? (() => {}), hasError: hasConnectionError || result.isError, ...result, diff --git a/src/search-manager/SearchSortWidget.tsx b/src/search-manager/SearchSortWidget.tsx new file mode 100644 index 0000000000..6885859b3c --- /dev/null +++ b/src/search-manager/SearchSortWidget.tsx @@ -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> = () => { + 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 ( + + + + {searchSortLabel} + + + {menuItems.map(({ id, name, value }) => ( + setSearchSortOrder(value)} + > + {name} + {(value === searchSortOrder) && } + + ))} + + + ); +}; + +export default SearchSortWidget; diff --git a/src/search-manager/data/api.ts b/src/search-manager/data/api.ts index d13ef2641b..a16055df62 100644 --- a/src/search-manager/data/api.ts +++ b/src/search-manager/data/api.ts @@ -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. */ @@ -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 []; } /** @@ -95,6 +105,9 @@ export interface ContentHit { content?: ContentDetails; /** Same fields with ... highlights */ formatted: { displayName: string, content?: ContentDetails }; + created: number; + modified: number; + last_published: number; } /** @@ -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, } @@ -130,6 +144,7 @@ export async function fetchSearchResults({ blockTypesFilter, tagsFilter, extraFilter, + sort, offset = 0, }: FetchSearchParams): Promise<{ hits: ContentHit[], @@ -164,6 +179,7 @@ export async function fetchSearchResults({ highlightPostTag: HIGHLIGHT_POST_TAG, attributesToCrop: ['content'], cropLength: 20, + sort, offset, limit, }); diff --git a/src/search-manager/data/apiHooks.ts b/src/search-manager/data/apiHooks.ts index fe77482285..2b0b2e3227 100644 --- a/src/search-manager/data/apiHooks.ts +++ b/src/search-manager/data/apiHooks.ts @@ -3,6 +3,7 @@ import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; import type { Filter, MeiliSearch } from 'meilisearch'; import { + SearchSortOption, TAG_SEP, fetchAvailableTagOptions, fetchSearchResults, @@ -37,6 +38,7 @@ export const useContentSearchResults = ({ searchKeywords, blockTypesFilter = [], tagsFilter = [], + sort = [], }: { /** The Meilisearch API client */ client?: MeiliSearch; @@ -50,6 +52,8 @@ export const useContentSearchResults = ({ blockTypesFilter?: string[]; /** Required tags (all must match), e.g. `["Difficulty > Hard", "Subject > Math"]` */ tagsFilter?: string[]; + /** Sort search results using these options */ + sort?: SearchSortOption[]; }) => { const query = useInfiniteQuery({ enabled: client !== undefined && indexName !== undefined, @@ -63,6 +67,7 @@ export const useContentSearchResults = ({ searchKeywords, blockTypesFilter, tagsFilter, + sort, ], queryFn: ({ pageParam = 0 }) => { if (client === undefined || indexName === undefined) { @@ -75,6 +80,7 @@ export const useContentSearchResults = ({ searchKeywords, blockTypesFilter, tagsFilter, + sort, // For infinite pagination of results, we can retrieve additional pages if requested. // Note that if there are 20 results per page, the "second page" has offset=20, not 2. offset: pageParam, diff --git a/src/search-manager/index.ts b/src/search-manager/index.ts index f78d9a4ba7..267a333ecb 100644 --- a/src/search-manager/index.ts +++ b/src/search-manager/index.ts @@ -4,6 +4,7 @@ export { default as FilterByBlockType } from './FilterByBlockType'; export { default as FilterByTags } from './FilterByTags'; export { default as Highlight } from './Highlight'; export { default as SearchKeywordsField } from './SearchKeywordsField'; +export { default as SearchSortWidget } from './SearchSortWidget'; export { default as Stats } from './Stats'; export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api'; diff --git a/src/search-manager/messages.ts b/src/search-manager/messages.ts index 1ff48ea6f0..8cd2e506ef 100644 --- a/src/search-manager/messages.ts +++ b/src/search-manager/messages.ts @@ -130,6 +130,46 @@ const messages = defineMessages({ defaultMessage: 'Clear Filter', description: 'Label for the button that removes applied search filters in a specific widget', }, + searchSortWidgetLabel: { + id: 'course-authoring.course-search.searchSortWidget.label', + defaultMessage: 'Sort', + description: 'Label displayed to users when default sorting is used by the content search drop-down menu', + }, + searchSortWidgetAltTitle: { + id: 'course-authoring.course-search.searchSortWidget.title', + defaultMessage: 'Sort search results', + description: 'Alt/title text for the content search sort drop-down menu', + }, + searchSortTitleAZ: { + id: 'course-authoring.course-search.searchSort.titleAZ', + defaultMessage: 'Title, A-Z', + description: 'Label for the content search sort drop-down which sorts by content title, ascending', + }, + searchSortTitleZA: { + id: 'course-authoring.course-search.searchSort.titleZA', + defaultMessage: 'Title, Z-A', + description: 'Label for the content search sort drop-down which sorts by content title, descending', + }, + searchSortNewest: { + id: 'course-authoring.course-search.searchSort.newest', + defaultMessage: 'Newest', + description: 'Label for the content search sort drop-down which sorts by creation date, descending', + }, + searchSortOldest: { + id: 'course-authoring.course-search.searchSort.oldest', + defaultMessage: 'Oldest', + description: 'Label for the content search sort drop-down which sorts by creation date, ascending', + }, + searchSortRecentlyPublished: { + id: 'course-authoring.course-search.searchSort.recentlyPublished', + defaultMessage: 'Recently Published', + description: 'Label for the content search sort drop-down which sorts by published date, descending', + }, + searchSortRecentlyModified: { + id: 'course-authoring.course-search.searchSort.recentlyModified', + defaultMessage: 'Recently Modified', + description: 'Label for the content search sort drop-down which sorts by modified date, descending', + }, }); export default messages;