Files
frontend-app-authoring/src/search-manager/SearchManager.ts
Rômulo Penido 98ae74e78c feat: unit cards in library [FC-0083] (#1742)
Unit cards in library and rename BaseComponentCard -> BaseCard
2025-03-31 10:54:07 -05:00

227 lines
7.4 KiB
TypeScript

/**
* 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<React.SetStateAction<string>>;
publishStatusFilter: PublishStatus[];
setPublishStatusFilter: React.Dispatch<React.SetStateAction<PublishStatus[]>>;
typesFilter: TypesFilterData;
setTypesFilter: React.Dispatch<React.SetStateAction<TypesFilterData>>;
tagsFilter: string[];
setTagsFilter: React.Dispatch<React.SetStateAction<string[]>>;
blockTypes: Record<string, number>;
problemTypes: Record<string, number>;
publishStatus: Record<string, number>;
extraFilter?: Filter;
canClearFilters: boolean;
clearFilters: () => void;
isFiltered: boolean;
searchSortOrder: SearchSortOption;
setSearchSortOrder: React.Dispatch<React.SetStateAction<SearchSortOption>>;
defaultSearchSortOrder: SearchSortOption;
hits: HitType[];
totalHits: number;
isLoading: boolean;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
fetchNextPage: () => void;
closeSearchModal: () => void;
hasError: boolean;
usageKey: string;
}
const SearchContext = React.createContext<SearchContextData | undefined>(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<string>(
'',
'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<TypesFilterData>(
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<string>(
[],
'tag',
sanitizeTag,
sanitizeTag,
skipUrlUpdate,
);
const [publishStatusFilter, setPublishStatusFilter] = useStateOrUrlSearchParam<PublishStatus>(
[],
'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) {
// Error thrown if value cannot be parsed into a library usage key.
// Pass through to return below.
}
return undefined;
};
const [usageKey, setUsageKey] = useStateOrUrlSearchParam<string>(
'',
'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<SearchSortOption>(
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 <SearchContextProvider>');
}
return ctx;
};