diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index 343b5b947d..a968bcaa09 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -4,6 +4,7 @@ import { Button, Stack, } from '@openedx/paragon'; import { Add } from '@openedx/paragon/icons'; +import { ClearFiltersButton } from '../search-manager'; import messages from './messages'; import { LibraryContext } from './common/context'; @@ -21,7 +22,8 @@ export const NoComponents = () => { }; export const NoSearchResults = () => ( -
+ -
+ + ); diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index f08555b023..07afaff667 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -38,6 +38,9 @@ const queryClient = new QueryClient({ }, }); +/** + * Returns 0 components from the search query. +*/ const returnEmptyResult = (_url, req) => { const requestData = JSON.parse(req.body?.toString() ?? ''); const query = requestData?.queries[0]?.q ?? ''; @@ -50,6 +53,26 @@ const returnEmptyResult = (_url, req) => { return mockEmptyResult; }; +/** + * Returns 2 components from the search query. + * This lets us test that the StudioHome "View All" button is hidden when a + * low number of search results are shown (<=4 by default). +*/ +const returnLowNumberResults = (_url, req) => { + const requestData = JSON.parse(req.body?.toString() ?? ''); + const query = requestData?.queries[0]?.q ?? ''; + // We have to replace the query (search keywords) in the mock results with the actual query, + // because otherwise we may have an inconsistent state that causes more queries and unexpected results. + mockResult.results[0].query = query; + // Limit number of results to just 2 + mockResult.results[0].hits = mockResult.results[0]?.hits.slice(0, 2); + mockResult.results[0].estimatedTotalHits = 2; + // And fake the required '_formatted' fields; it contains the highlighting ... around matched words + // eslint-disable-next-line no-underscore-dangle, no-param-reassign + mockResult.results[0]?.hits.forEach((hit) => { hit._formatted = { ...hit }; }); + return mockResult; +}; + const libraryData: ContentLibrary = { id: 'lib:org1:lib1', type: 'complex', @@ -154,11 +177,13 @@ describe('', () => { axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); const { - getByRole, getByText, queryByText, findByText, + getByRole, getByText, getAllByText, queryByText, } = render(); - // Ensure the search endpoint is called - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + // Ensure the search endpoint is called: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); expect(getByText('Content library')).toBeInTheDocument(); expect(getByText(libraryData.title)).toBeInTheDocument(); @@ -168,7 +193,7 @@ describe('', () => { expect(getByText('Recently Modified')).toBeInTheDocument(); expect(getByText('Collections (0)')).toBeInTheDocument(); expect(getByText('Components (6)')).toBeInTheDocument(); - expect(await findByText('Test HTML Block')).toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); // Navigate to the components tab fireEvent.click(getByRole('tab', { name: 'Components' })); @@ -202,8 +227,10 @@ describe('', () => { expect(await findByText('Content library')).toBeInTheDocument(); expect(await findByText(libraryData.title)).toBeInTheDocument(); - // Ensure the search endpoint is called - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + // Ensure the search endpoint is called: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); expect(getByText('You have not added any content to this library yet.')).toBeInTheDocument(); }); @@ -228,13 +255,16 @@ describe('', () => { expect(await findByText('Content library')).toBeInTheDocument(); expect(await findByText(libraryData.title)).toBeInTheDocument(); - // Ensure the search endpoint is called - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); }); + // Ensure the search endpoint is called: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); fireEvent.change(getByRole('searchbox'), { target: { value: 'noresults' } }); - // Ensure the search endpoint is called again - await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + // Ensure the search endpoint is called again, only once more since the recently modified call + // should not be impacted by the search + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(3, searchEndpoint, 'post'); }); expect(getByText('No matching components found in this library.')).toBeInTheDocument(); @@ -266,4 +296,122 @@ describe('', () => { expect(screen.queryByText(/add content/i)).not.toBeInTheDocument(); }); + + it('show the "View All" button when viewing library with many components', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + + const { + getByRole, getByText, queryByText, getAllByText, + } = render(); + + // Ensure the search endpoint is called: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + + expect(getByText('Content library')).toBeInTheDocument(); + expect(getByText(libraryData.title)).toBeInTheDocument(); + + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (6)')).toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); + expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + + // There should only be one "View All" button, since the Components count + // are above the preview limit (4) + expect(getByText('View All')).toBeInTheDocument(); + + // Clicking on "View All" button should navigate to the Components tab + fireEvent.click(getByText('View All')); + expect(queryByText('Recently Modified')).not.toBeInTheDocument(); + expect(queryByText('Collections (0)')).not.toBeInTheDocument(); + expect(queryByText('Components (6)')).not.toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); + + // Go back to Home tab + // This step is necessary to avoid the url change leak to other tests + fireEvent.click(getByRole('tab', { name: 'Home' })); + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (6)')).toBeInTheDocument(); + }); + + it('should not show the "View All" button when viewing library with low number of components', async () => { + mockUseParams.mockReturnValue({ libraryId: libraryData.id }); + axiosMock.onGet(getContentLibraryApiUrl(libraryData.id)).reply(200, libraryData); + fetchMock.post(searchEndpoint, returnLowNumberResults, { overwriteRoutes: true }); + + const { + getByText, queryByText, getAllByText, + } = render(); + + // Ensure the search endpoint is called: + // Call 1: To fetch searchable/filterable/sortable library data + // Call 2: To fetch the recently modified components only + await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(2, searchEndpoint, 'post'); }); + + expect(getByText('Content library')).toBeInTheDocument(); + expect(getByText(libraryData.title)).toBeInTheDocument(); + + expect(getByText('Recently Modified')).toBeInTheDocument(); + expect(getByText('Collections (0)')).toBeInTheDocument(); + expect(getByText('Components (2)')).toBeInTheDocument(); + expect(getAllByText('Test HTML Block')[0]).toBeInTheDocument(); + + expect(queryByText('You have not added any content to this library yet.')).not.toBeInTheDocument(); + + // There should not be any "View All" button on page since Components count + // is less than the preview limit (4) + expect(queryByText('View All')).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, getAllByText, 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 NULL'), + method: 'POST', + headers: expect.anything(), + }); + }); + + // Clearing filters clears the url search param and uses default sort + fireEvent.click(getAllByText('Clear Filters')[0]); + await testSortOption('', ''); + }); }); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index f3afb5555f..02e7d93260 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 = () => {
+
{ } + element={( + + )} /> ( - - - - {children} - - -); - type LibraryHomeProps = { libraryId: string, + tabList: { home: string, components: string, collections: string }, + handleTabChange: (key: string) => void, }; -const LibraryHome = ({ libraryId } : LibraryHomeProps) => { +const LibraryHome = ({ libraryId, tabList, handleTabChange } : LibraryHomeProps) => { const intl = useIntl(); - const { totalHits: componentCount, - searchKeywords, + isFiltered, } = useSearchContext(); const collectionCount = 0; - if (componentCount === 0) { - return searchKeywords === '' ? : ; - } + const renderEmptyState = () => { + if (componentCount === 0) { + return isFiltered ? : ; + } + return null; + }; return ( -
- { intl.formatMessage(messages.recentComponentsTempPlaceholder) } -
-
- -
-
- -
+ + { + renderEmptyState() + || ( + <> + + + + handleTabChange(tabList.components)} + > + + + + ) + }
); }; diff --git a/src/library-authoring/LibraryRecentlyModified.tsx b/src/library-authoring/LibraryRecentlyModified.tsx new file mode 100644 index 0000000000..7708f47ac4 --- /dev/null +++ b/src/library-authoring/LibraryRecentlyModified.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import { SearchContextProvider, useSearchContext } from '../search-manager'; +import { SearchSortOption } from '../search-manager/data/api'; +import LibraryComponents from './components/LibraryComponents'; +import LibrarySection from './components/LibrarySection'; +import messages from './messages'; + +const RecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => { + const intl = useIntl(); + const { totalHits: componentCount } = useSearchContext(); + + return componentCount > 0 + ? ( + + + + ) + : null; +}; + +const LibraryRecentlyModified: React.FC<{ libraryId: string }> = ({ libraryId }) => ( + + + +); + +export default LibraryRecentlyModified; diff --git a/src/library-authoring/components/LibraryComponents.test.tsx b/src/library-authoring/components/LibraryComponents.test.tsx index 716f07ec83..a68309812d 100644 --- a/src/library-authoring/components/LibraryComponents.test.tsx +++ b/src/library-authoring/components/LibraryComponents.test.tsx @@ -30,6 +30,7 @@ const data = { hasNextPage: false, fetchNextPage: mockFetchNextPage, searchKeywords: '', + isFiltered: false, }; let store: Store; diff --git a/src/library-authoring/components/LibraryComponents.tsx b/src/library-authoring/components/LibraryComponents.tsx index a4742194c0..0e4d978722 100644 --- a/src/library-authoring/components/LibraryComponents.tsx +++ b/src/library-authoring/components/LibraryComponents.tsx @@ -5,6 +5,7 @@ import { useSearchContext } from '../../search-manager'; import { NoComponents, NoSearchResults } from '../EmptyStates'; import { useLibraryBlockTypes } from '../data/apiHooks'; import ComponentCard from './ComponentCard'; +import { LIBRARY_SECTION_PREVIEW_LIMIT } from './LibrarySection'; type LibraryComponentsProps = { libraryId: string, @@ -28,10 +29,10 @@ const LibraryComponents = ({ isFetchingNextPage, hasNextPage, fetchNextPage, - searchKeywords, + isFiltered, } = useSearchContext(); - const componentList = variant === 'preview' ? hits.slice(0, 4) : hits; + const componentList = variant === 'preview' ? hits.slice(0, LIBRARY_SECTION_PREVIEW_LIMIT) : hits; // TODO add this to LibraryContext const { data: blockTypesData } = useLibraryBlockTypes(libraryId); @@ -67,7 +68,7 @@ const LibraryComponents = ({ }, [hasNextPage, isFetchingNextPage, fetchNextPage]); if (componentCount === 0) { - return searchKeywords === '' ? : ; + return isFiltered ? : ; } return ( diff --git a/src/library-authoring/components/LibrarySection.tsx b/src/library-authoring/components/LibrarySection.tsx new file mode 100644 index 0000000000..66fe604ac6 --- /dev/null +++ b/src/library-authoring/components/LibrarySection.tsx @@ -0,0 +1,39 @@ +/* eslint-disable react/require-default-props */ +import React from 'react'; +import { Card, ActionRow, Button } from '@openedx/paragon'; + +export const LIBRARY_SECTION_PREVIEW_LIMIT = 4; + +const LibrarySection: React.FC<{ + title: string, + viewAllAction?: () => void, + contentCount: number, + previewLimit?: number, + children: React.ReactNode, +}> = ({ + title, + viewAllAction, + contentCount, + previewLimit = LIBRARY_SECTION_PREVIEW_LIMIT, + children, +}) => ( + + previewLimit + && ( + + + + ) + } + /> + + {children} + + +); + +export default LibrarySection; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index 88116c620b..eb9a9f21fc 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -85,11 +85,6 @@ const messages = defineMessages({ defaultMessage: 'Components ({componentCount})', description: 'Title for the components container', }, - recentComponentsTempPlaceholder: { - id: 'course-authoring.library-authoring.recent-components-temp-placeholder', - defaultMessage: 'Recently modified components and collections will be displayed here.', - description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.', - }, addContentTitle: { id: 'course-authoring.library-authoring.drawer.title.add-content', defaultMessage: 'Add Content', diff --git a/src/search-manager/ClearFiltersButton.tsx b/src/search-manager/ClearFiltersButton.tsx index eeae127381..0aca013741 100644 --- a/src/search-manager/ClearFiltersButton.tsx +++ b/src/search-manager/ClearFiltersButton.tsx @@ -1,17 +1,26 @@ +/* eslint-disable react/require-default-props */ import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { Button } from '@openedx/paragon'; import messages from './messages'; import { useSearchContext } from './SearchManager'; +type ClearFiltersButtonProps = { + variant?: 'link' | 'primary', + size?: 'sm' | 'md' | 'lg' | 'inline', +}; + /** * A button that appears when at least one filter is active, and will clear the filters when clicked. */ -const ClearFiltersButton: React.FC> = () => { +const ClearFiltersButton = ({ + variant = 'link', + size = 'sm', +}: ClearFiltersButtonProps) => { const { canClearFilters, clearFilters } = useSearchContext(); if (canClearFilters) { return ( - ); diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index 6db1d6031f..4db9e6f21b 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -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 { @@ -24,6 +25,9 @@ export interface SearchContextData { extraFilter?: Filter; canClearFilters: boolean; clearFilters: () => void; + isFiltered: boolean; + searchSortOrder: SearchSortOption; + setSearchSortOrder: React.Dispatch>; hits: ContentHit[]; totalHits: number; isFetching: boolean; @@ -36,19 +40,87 @@ 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(); + // The converted search parameter value takes precedence over the state value. + const returnValue: Type = fromString(searchParams.get(paramName)) ?? defaultValue; + // Function to update the url search parameter + const returnSetter: React.Dispatch> = React.useCallback((value: Type) => { + setSearchParams((prevParams) => { + const paramValue: string = toString(value) ?? ''; + const newSearchParams = new URLSearchParams(prevParams); + if (paramValue) { + newSearchParams.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. + newSearchParams.delete(paramName); + } + return newSearchParams; + }, { replace: true }); + }, [setSearchParams]); + + // Return the computed value and wrapped set state function + return [returnValue, returnSetter]; +} + export const SearchContextProvider: React.FC<{ extraFilter?: Filter; + overrideSearchSortOrder?: SearchSortOption children: React.ReactNode, closeSearchModal?: () => void, -}> = ({ extraFilter, ...props }) => { +}> = ({ overrideSearchSortOrder, ...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 searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder; + const sort: SearchSortOption[] = (searchSortOrderToUse === defaultSortOption ? [] : [searchSortOrderToUse]); + // Selecting SearchSortOption.RECENTLY_PUBLISHED also excludes unpublished components. + if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) { + extraFilter.push('last_published IS NOT NULL'); + } - const canClearFilters = blockTypesFilter.length > 0 || tagsFilter.length > 0; + const canClearFilters = ( + blockTypesFilter.length > 0 + || tagsFilter.length > 0 + || searchSortOrderToUse !== defaultSortOption + ); + const isFiltered = canClearFilters || (searchKeywords !== ''); const clearFilters = React.useCallback(() => { setBlockTypesFilter([]); setTagsFilter([]); + setSearchSortOrder(defaultSortOption); }, []); // Initialize a connection to Meilisearch: @@ -69,6 +141,7 @@ export const SearchContextProvider: React.FC<{ searchKeywords, blockTypesFilter, tagsFilter, + sort, }); return React.createElement(SearchContext.Provider, { @@ -82,8 +155,11 @@ export const SearchContextProvider: React.FC<{ tagsFilter, setTagsFilter, extraFilter, + isFiltered, 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;