diff --git a/src/hooks.ts b/src/hooks.ts index facaf65ef..efc4f6c32 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -85,10 +85,13 @@ export const useLoadOnScroll = ( }; /** - * Hook which stores 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. + * Types used by the useListHelpers and useStateWithUrlSearchParam hooks. + */ +export type FromStringFn = (value: string | null) => Type | undefined; +export type ToStringFn = (value: Type | undefined) => string | undefined; + +/** + * Hook that stores/retrieves state variables using the URL search parameters. * * @param defaultValue: Type * Returned when no valid value is found in the url search parameter. @@ -101,26 +104,86 @@ export const useLoadOnScroll = ( export function useStateWithUrlSearchParam( defaultValue: Type, paramName: string, - fromString: (value: string | null) => Type | undefined, - toString: (value: Type) => string | undefined, + fromString: FromStringFn, + toString: ToStringFn, ): [value: Type, setter: Dispatch>] { const [searchParams, setSearchParams] = useSearchParams(); const returnValue: Type = fromString(searchParams.get(paramName)) ?? defaultValue; - // Function to update the url search parameter - const returnSetter: Dispatch> = useCallback((value: Type) => { - setSearchParams((prevParams) => { - const paramValue: string = toString(value) ?? ''; - const newSearchParams = new URLSearchParams(prevParams); - // If using the default paramValue, remove it from the search params. - if (paramValue === defaultValue) { + + // Update the url search parameter using: + type ReturnSetterParams = ( + // a Type value + value?: Type + // or a function that returns a Type from the previous returnValue + | ((value: Type) => Type) + ) => void; + const returnSetter: Dispatch> = useCallback((value) => { + setSearchParams((/* prev */) => { + const useValue = value instanceof Function ? value(returnValue) : value; + const paramValue = toString(useValue); + + // We have to parse the current location.search instead of using prev + // in case we call returnSetter multiple times in the same hook + // (like clearFilters does). + // cf https://github.com/remix-run/react-router/issues/9757 + const newSearchParams = new URLSearchParams(window.location.search); + + // If the provided value was invalid (toString returned undefined) + // or the same as the defaultValue, remove it from the search params. + if (paramValue === undefined || paramValue === defaultValue) { newSearchParams.delete(paramName); } else { newSearchParams.set(paramName, paramValue); } return newSearchParams; }, { replace: true }); - }, [setSearchParams]); + }, [returnValue, setSearchParams]); // Return the computed value and wrapped set state function return [returnValue, returnSetter]; } + +/** + * Helper hook for useStateWithUrlSearchParam. + * + * useListHelpers provides toString and fromString handlers that can: + * - split/join a list of values using a separator string, and + * - validate each value using the provided functions, omitting any invalid values. + * + * @param fromString + * Serialize a string to a Type, or undefined if not valid. + * @param toString + * Deserialize a Type to a string. + * @param separator : string to use when splitting/joining the types. + * Defaults value is ','. + */ +export function useListHelpers({ + fromString, + toString, + separator = ',', +}: { + fromString: FromStringFn, + toString: ToStringFn, + separator?: string; +}): [ FromStringFn, ToStringFn ] { + const isType = (item: Type | undefined): item is Type => item !== undefined; + + // Split the given string with separator, + // and convert the parts to a list of Types, omiting any invalid Types. + const fromStringToList : FromStringFn = (value: string) => ( + value + ? value.split(separator).map(fromString).filter(isType) + : [] + ); + // Convert an array of Types to strings and join with separator. + // Returns undefined if the given list contains no valid Types. + const fromListToString : ToStringFn = (value: Type[]) => { + const stringValues = value.map(toString).filter((val) => val !== undefined); + return ( + stringValues && stringValues.length + ? stringValues.join(separator) + : undefined + ); + }; + return [fromStringToList, fromListToString]; +} diff --git a/src/search-manager/SearchManager.ts b/src/search-manager/SearchManager.ts index acf5a0519..b56e8aae7 100644 --- a/src/search-manager/SearchManager.ts +++ b/src/search-manager/SearchManager.ts @@ -12,7 +12,7 @@ import { CollectionHit, ContentHit, SearchSortOption, forceArray, } from './data/api'; import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks'; -import { useStateWithUrlSearchParam } from '../hooks'; +import { useListHelpers, useStateWithUrlSearchParam } from '../hooks'; export interface SearchContextData { client?: MeiliSearch; @@ -59,7 +59,7 @@ export const SearchContextProvider: React.FC<{ }) => { // Search parameters can be set via the query string // E.g. q=draft+text - // TODO -- how to scrub search terms? + // TODO -- how to sanitize search terms? const keywordStateManager = React.useState(''); const keywordUrlStateManager = useStateWithUrlSearchParam( '', @@ -73,12 +73,53 @@ export const SearchContextProvider: React.FC<{ : keywordUrlStateManager ); - const [blockTypesFilter, setBlockTypesFilter] = React.useState([]); - const [problemTypesFilter, setProblemTypesFilter] = React.useState([]); - const [tagsFilter, setTagsFilter] = React.useState([]); + // Block/problem types can be alphanumeric with underscores or dashes + const sanitizeType = (value: string | null | undefined): string | undefined => ( + (value && /^[a-z0-9_-]+$/.test(value)) + ? value + : undefined + ); + const [typeToList, listToType] = useListHelpers({ + toString: sanitizeType, + fromString: sanitizeType, + separator: '|', + }); + const [blockTypesFilter, setBlockTypesFilter] = useStateWithUrlSearchParam( + [], + 'bt', + typeToList, + listToType, + ); + const [problemTypesFilter, setProblemTypesFilter] = useStateWithUrlSearchParam( + [], + 'pt', + typeToList, + listToType, + ); + + // Tags can be almost any string value, except our separator (|) + // TODO how to sanitize tags? + const sanitizeTag = (value: string | null | undefined): string | undefined => ( + (value && /^[^|]+$/.test(value)) + ? value + : undefined + ); + const [tagToList, listToTag] = useListHelpers({ + toString: sanitizeTag, + fromString: sanitizeTag, + separator: '|', + }); + const [tagsFilter, setTagsFilter] = useStateWithUrlSearchParam( + [], + 'tg', + tagToList, + listToTag, + ); + const [usageKey, setUsageKey] = useStateWithUrlSearchParam( '', 'usageKey', + // TODO should sanitize usageKeys too. (value: string) => value, (value: string) => value, );