From fa8783d677904f025fdeb022582f017a9c3dd50b Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 19 Dec 2024 21:36:20 +1030 Subject: [PATCH] feat: store tags, block type, and problem type in the URL search string These search fields store an Array of values, and so this change adds another hook called useListHelpers to assist with the parsing and validating of an Array of Typed values. This feature also revealed two bugs fixed useStateWithUrlSearchParam: 1. when the returnSetter is called with a function, pass returnValue to the function to get the value to use. 2. when the returnSetter is called multiple times by a single callback (like with clearFilters), the latest changes to the UrlSearchParams weren't showing up. Essentially, we need to use the window.location.search string as the "latest" previous url search, not the "prevParams" passed into setSearchParams, because these params may not have the latest updates. --- src/hooks.ts | 91 ++++++++++++++++++++++++----- src/search-manager/SearchManager.ts | 51 ++++++++++++++-- 2 files changed, 123 insertions(+), 19 deletions(-) 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, );