/** * This is a search manager that provides search functionality similar to the * Instantsearch library. We use it because Instantsearch doesn't support * multiple selections of hierarchical tags. * https://github.com/algolia/instantsearch/issues/1658 */ import React from 'react'; import { MeiliSearch, type Filter } from 'meilisearch'; import { union } from 'lodash'; import { type HitType, SearchSortOption, forceArray, PublishStatus, } from './data/api'; import { TypesFilterData, useStateOrUrlSearchParam } from './hooks'; import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks'; import { getBlockType } from '../generic/key-utils'; export interface SearchContextData { client?: MeiliSearch; indexName?: string; searchKeywords: string; setSearchKeywords: React.Dispatch>; publishStatusFilter: PublishStatus[]; setPublishStatusFilter: React.Dispatch>; typesFilter: TypesFilterData; setTypesFilter: React.Dispatch>; tagsFilter: string[]; setTagsFilter: React.Dispatch>; blockTypes: Record; problemTypes: Record; publishStatus: Record; extraFilter?: Filter; canClearFilters: boolean; clearFilters: () => void; isFiltered: boolean; searchSortOrder: SearchSortOption; setSearchSortOrder: React.Dispatch>; defaultSearchSortOrder: SearchSortOption; hits: HitType[]; totalHits: number; isPending: boolean; hasNextPage: boolean | undefined; isFetchingNextPage: boolean; fetchNextPage: () => void; closeSearchModal: () => void; hasError: boolean; usageKey: string; } const SearchContext = React.createContext(undefined); export const SearchContextProvider: React.FC<{ extraFilter?: Filter, overrideTypesFilter?: TypesFilterData, overrideSearchSortOrder?: SearchSortOption children: React.ReactNode, closeSearchModal?: () => void, skipBlockTypeFetch?: boolean, skipUrlUpdate?: boolean, }> = ({ overrideTypesFilter, overrideSearchSortOrder, skipBlockTypeFetch, skipUrlUpdate, ...props }) => { // Search parameters can be set via the query string // E.g. ?q=draft+text // TODO -- how to sanitize search terms? const [searchKeywords, setSearchKeywords] = useStateOrUrlSearchParam( '', 'q', (value: string) => value || '', (value: string) => value || '', skipUrlUpdate, ); // Block + problem types use alphanumeric plus a few other characters. // E.g ?type=html&type=video&type=p.multiplechoiceresponse const [internalTypesFilter, setTypesFilter] = useStateOrUrlSearchParam( new TypesFilterData(), 'type', (value: string | null) => new TypesFilterData(value), (value: TypesFilterData | undefined) => (value ? value.toString() : undefined), skipUrlUpdate, ); // Callers can override the types filter when searching, but we still preserve the user's selected state. const typesFilter = overrideTypesFilter ?? internalTypesFilter; // Tags can be almost any string value (see openedx-learning's RESERVED_TAG_CHARS) // and multiple tags may be selected together. // E.g ?tag=Skills+>+Abilities&tag=Skills+>+Knowledge const sanitizeTag = (value: string | null | undefined): string | undefined => ( (value && /^[^\t;]+$/.test(value)) ? value : undefined ); const [tagsFilter, setTagsFilter] = useStateOrUrlSearchParam( [], 'tag', sanitizeTag, sanitizeTag, skipUrlUpdate, ); const [publishStatusFilter, setPublishStatusFilter] = useStateOrUrlSearchParam( [], 'published', (value: string) => Object.values(PublishStatus).find((enumValue) => value === enumValue), (value: PublishStatus) => value.toString(), skipUrlUpdate, ); // E.g ?usageKey=lb:OpenCraft:libA:problem:5714eb65-7c36-4eee-8ab9-a54ed5a95849 const sanitizeUsageKey = (value: string): string | undefined => { try { if (getBlockType(value)) { return value; } } catch { // Error thrown if value cannot be parsed into a library usage key. // Pass through to return below. } return undefined; }; const [usageKey, setUsageKey] = useStateOrUrlSearchParam( '', 'usageKey', sanitizeUsageKey, sanitizeUsageKey, skipUrlUpdate, ); let extraFilter: string[] = forceArray(props.extraFilter); if (usageKey) { extraFilter = union(extraFilter, [`usage_key = "${usageKey}"`]); } // Default sort by Most Relevant if there's search keyword(s), else by Recently Modified. const defaultSearchSortOrder = searchKeywords ? SearchSortOption.RELEVANCE : SearchSortOption.RECENTLY_MODIFIED; const [searchSortOrder, setSearchSortOrder] = useStateOrUrlSearchParam( defaultSearchSortOrder, 'sort', (value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue), (value: SearchSortOption) => value.toString(), skipUrlUpdate, ); // SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we // send it to useContentSearchResults as an empty array. const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder; let sort: SearchSortOption[] = (searchSortOrderToUse === SearchSortOption.RELEVANCE ? [] : [searchSortOrderToUse]); // Adding `SearchSortOption.RECENTLY_MODIFIED` as second sort when // selecting `SearchSortOption.RECENTLY_PUBLISHED`. // This is to sort the never published components by recently modified that // appears in the end after all published components. if (searchSortOrderToUse === SearchSortOption.RECENTLY_PUBLISHED) { sort = union(sort, [SearchSortOption.RECENTLY_MODIFIED]); } const canClearFilters = ( !typesFilter.isEmpty() || tagsFilter.length > 0 || publishStatusFilter.length > 0 || !!usageKey ); const isFiltered = canClearFilters || (searchKeywords !== ''); const clearFilters = React.useCallback(() => { setTypesFilter((types) => types.clear()); setTagsFilter([]); setPublishStatusFilter([]); if (usageKey !== '') { setUsageKey(''); } }, []); // Initialize a connection to Meilisearch: const { client, indexName, hasConnectionError } = useContentSearchConnection(); // Run the search const result = useContentSearchResults({ client, indexName, extraFilter, searchKeywords, publishStatusFilter, blockTypesFilter: [...typesFilter.blocks], problemTypesFilter: [...typesFilter.problems], tagsFilter, sort, skipBlockTypeFetch, }); return React.createElement(SearchContext.Provider, { value: { client, indexName, searchKeywords, setSearchKeywords, publishStatusFilter, setPublishStatusFilter, typesFilter, setTypesFilter, tagsFilter, setTagsFilter, extraFilter, isFiltered, canClearFilters, clearFilters, searchSortOrder, setSearchSortOrder, defaultSearchSortOrder, closeSearchModal: props.closeSearchModal ?? (() => { }), hasError: hasConnectionError || result.isError, usageKey, ...result, }, }, props.children); }; export const useSearchContext = () => { const ctx = React.useContext(SearchContext); if (ctx === undefined) { throw new Error('Cannot use search components outside of '); } return ctx; };