Skip to content

Commit 37370d2

Browse files
committed
feat: store selected tags in the URL search string
Related features: 1. We can filter on more than one tag, so the search field needs to store an Array of values which still need to be sanitised. This change adds useListHelpers to assist with the parsing and validating of an Array of Typed values. 2. SearchManager takes a skipUrlUpdate property which should disable using search params for state variables. Our "sort" parameters respected this, but none of the other search parameters did. So this change also adds a wrapper hook called useStateOrUrlSearchParam that handles this switch cleanly. This feature also revealed two bugs fixed in useStateWithUrlSearchParam: 1. When the returnSetter is called with a function instead of a simple value, we need to pass in previous returnValue to the function so it can generate the new value. 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. To fix this, we had to use the location.search string as the "latest" previous url search, not the prevParams passed into setSearchParams, because these params may not have the latest updates.
1 parent f6b46c4 commit 37370d2

File tree

2 files changed

+155
-34
lines changed

2 files changed

+155
-34
lines changed

src/hooks.ts

+95-14
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
type SetStateAction,
44
useCallback,
55
useEffect,
6+
useRef,
67
useState,
78
} from 'react';
89
import { history } from '@edx/frontend-platform';
@@ -85,10 +86,13 @@ export const useLoadOnScroll = (
8586
};
8687

8788
/**
88-
* Hook which stores state variables in the URL search parameters.
89-
*
90-
* It wraps useState with functions that get/set a query string
91-
* search parameter when returning/setting the state variable.
89+
* Types used by the useListHelpers and useStateWithUrlSearchParam hooks.
90+
*/
91+
export type FromStringFn<Type> = (value: string | null) => Type | undefined;
92+
export type ToStringFn<Type> = (value: Type | undefined) => string | undefined;
93+
94+
/**
95+
* Hook that stores/retrieves state variables using the URL search parameters.
9296
*
9397
* @param defaultValue: Type
9498
* Returned when no valid value is found in the url search parameter.
@@ -101,26 +105,103 @@ export const useLoadOnScroll = (
101105
export function useStateWithUrlSearchParam<Type>(
102106
defaultValue: Type,
103107
paramName: string,
104-
fromString: (value: string | null) => Type | undefined,
105-
toString: (value: Type) => string | undefined,
108+
fromString: FromStringFn<Type>,
109+
toString: ToStringFn<Type>,
106110
): [value: Type, setter: Dispatch<SetStateAction<Type>>] {
111+
// STATE WORKAROUND:
112+
// If we use this hook to control multiple state parameters on the same
113+
// page, we can run into state update issues. Because our state variables
114+
// are actually stored in setSearchParams, and not in separate variables like
115+
// useState would do, the searchParams "previous" state may not be updated
116+
// for sequential calls to returnSetter in the same render loop (like in
117+
// SearchManager's clearFilters).
118+
//
119+
// One workaround could be to use window.location.search as the "previous"
120+
// value when returnSetter constructs the new URLSearchParams. This works
121+
// fine with BrowserRouter, but our test suite uses MemoryRouter, and that
122+
// router doesn't store URL search params, cf
123+
// https://github.com/remix-run/react-router/issues/9757
124+
//
125+
// So instead, we maintain a reference to the current useLocation()
126+
// object, and use its search params as the "previous" value when
127+
// initializing URLSearchParams.
128+
const location = useLocation();
129+
const locationRef = useRef(location);
107130
const [searchParams, setSearchParams] = useSearchParams();
131+
108132
const returnValue: Type = fromString(searchParams.get(paramName)) ?? defaultValue;
109-
// Function to update the url search parameter
110-
const returnSetter: Dispatch<SetStateAction<Type>> = useCallback((value: Type) => {
111-
setSearchParams((prevParams) => {
112-
const paramValue: string = toString(value) ?? '';
113-
const newSearchParams = new URLSearchParams(prevParams);
114-
// If using the default paramValue, remove it from the search params.
115-
if (paramValue === defaultValue) {
133+
// Update the url search parameter using:
134+
type ReturnSetterParams = (
135+
// a Type value
136+
value?: Type
137+
// or a function that returns a Type from the previous returnValue
138+
| ((value: Type) => Type)
139+
) => void;
140+
const returnSetter: Dispatch<SetStateAction<Type>> = useCallback<ReturnSetterParams>((value) => {
141+
setSearchParams((/* prev */) => {
142+
const useValue = value instanceof Function ? value(returnValue) : value;
143+
const paramValue = toString(useValue);
144+
const newSearchParams = new URLSearchParams(locationRef.current.search);
145+
// If the provided value was invalid (toString returned undefined)
146+
// or the same as the defaultValue, remove it from the search params.
147+
if (paramValue === undefined || paramValue === defaultValue) {
116148
newSearchParams.delete(paramName);
117149
} else {
118150
newSearchParams.set(paramName, paramValue);
119151
}
152+
153+
// Update locationRef
154+
locationRef.current.search = newSearchParams.toString();
155+
120156
return newSearchParams;
121157
}, { replace: true });
122-
}, [setSearchParams]);
158+
}, [returnValue, setSearchParams]);
123159

124160
// Return the computed value and wrapped set state function
125161
return [returnValue, returnSetter];
126162
}
163+
164+
/**
165+
* Helper hook for useStateWithUrlSearchParam<Type[]>.
166+
*
167+
* useListHelpers provides toString and fromString handlers that can:
168+
* - split/join a list of values using a separator string, and
169+
* - validate each value using the provided functions, omitting any invalid values.
170+
*
171+
* @param fromString
172+
* Serialize a string to a Type, or undefined if not valid.
173+
* @param toString
174+
* Deserialize a Type to a string.
175+
* @param separator : string to use when splitting/joining the types.
176+
* Defaults value is ','.
177+
*/
178+
export function useListHelpers<Type>({
179+
fromString,
180+
toString,
181+
separator = ',',
182+
}: {
183+
fromString: FromStringFn<Type>,
184+
toString: ToStringFn<Type>,
185+
separator?: string;
186+
}): [ FromStringFn<Type[]>, ToStringFn<Type[]> ] {
187+
const isType = (item: Type | undefined): item is Type => item !== undefined;
188+
189+
// Split the given string with separator,
190+
// and convert the parts to a list of Types, omiting any invalid Types.
191+
const fromStringToList : FromStringFn<Type[]> = (value: string) => (
192+
value
193+
? value.split(separator).map(fromString).filter(isType)
194+
: []
195+
);
196+
// Convert an array of Types to strings and join with separator.
197+
// Returns undefined if the given list contains no valid Types.
198+
const fromListToString : ToStringFn<Type[]> = (value: Type[]) => {
199+
const stringValues = value.map(toString).filter((val) => val !== undefined);
200+
return (
201+
stringValues && stringValues.length
202+
? stringValues.join(separator)
203+
: undefined
204+
);
205+
};
206+
return [fromStringToList, fromListToString];
207+
}

src/search-manager/SearchManager.ts

+60-20
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,35 @@ import {
1212
CollectionHit, ContentHit, SearchSortOption, forceArray,
1313
} from './data/api';
1414
import { useContentSearchConnection, useContentSearchResults } from './data/apiHooks';
15-
import { useStateWithUrlSearchParam } from '../hooks';
15+
import {
16+
type FromStringFn,
17+
type ToStringFn,
18+
useListHelpers,
19+
useStateWithUrlSearchParam,
20+
} from '../hooks';
21+
22+
/**
23+
* Typed hook that returns useState if skipUrlUpdate,
24+
* or useStateWithUrlSearchParam if it's not.
25+
*
26+
* Provided here to reduce some code overhead in SearchManager.
27+
*/
28+
function useStateOrUrlSearchParam<Type>(
29+
defaultValue: Type,
30+
paramName: string,
31+
fromString: FromStringFn<Type>,
32+
toString: ToStringFn<Type>,
33+
skipUrlUpdate?: boolean,
34+
): [value: Type, setter: React.Dispatch<React.SetStateAction<Type>>] {
35+
const useStateManager = React.useState<Type>(defaultValue);
36+
const urlStateManager = useStateWithUrlSearchParam<Type>(
37+
defaultValue,
38+
paramName,
39+
fromString,
40+
toString,
41+
);
42+
return skipUrlUpdate ? useStateManager : urlStateManager;
43+
}
1644

1745
export interface SearchContextData {
1846
client?: MeiliSearch;
@@ -58,51 +86,63 @@ export const SearchContextProvider: React.FC<{
5886
overrideSearchSortOrder, skipBlockTypeFetch, skipUrlUpdate, ...props
5987
}) => {
6088
// Search parameters can be set via the query string
61-
// E.g. q=draft+text
62-
// TODO -- how to scrub search terms?
63-
const keywordStateManager = React.useState('');
64-
const keywordUrlStateManager = useStateWithUrlSearchParam<string>(
89+
// E.g. ?q=draft+text
90+
// TODO -- how to sanitize search terms?
91+
const [searchKeywords, setSearchKeywords] = useStateOrUrlSearchParam<string>(
6592
'',
6693
'q',
6794
(value: string) => value || '',
6895
(value: string) => value || '',
69-
);
70-
const [searchKeywords, setSearchKeywords] = (
71-
skipUrlUpdate
72-
? keywordStateManager
73-
: keywordUrlStateManager
96+
skipUrlUpdate,
7497
);
7598

7699
const [blockTypesFilter, setBlockTypesFilter] = React.useState<string[]>([]);
77100
const [problemTypesFilter, setProblemTypesFilter] = React.useState<string[]>([]);
78-
const [tagsFilter, setTagsFilter] = React.useState<string[]>([]);
79-
const [usageKey, setUsageKey] = useStateWithUrlSearchParam(
101+
// Tags can be almost any string value, except our separator (|)
102+
// TODO how to sanitize tags?
103+
// E.g ?tags=Skills+>+Abilities|Skills+>+Knowledge
104+
const sanitizeTag = (value: string | null | undefined): string | undefined => (
105+
(value && /^[^|]+$/.test(value))
106+
? value
107+
: undefined
108+
);
109+
const [tagToList, listToTag] = useListHelpers<string>({
110+
toString: sanitizeTag,
111+
fromString: sanitizeTag,
112+
separator: '|',
113+
});
114+
const [tagsFilter, setTagsFilter] = useStateOrUrlSearchParam<string[]>(
115+
[],
116+
'tags',
117+
tagToList,
118+
listToTag,
119+
skipUrlUpdate,
120+
);
121+
122+
// E.g ?usageKey=lb:OpenCraft:libA:problem:5714eb65-7c36-4eee-8ab9-a54ed5a95849
123+
const [usageKey, setUsageKey] = useStateOrUrlSearchParam<string>(
80124
'',
81125
'usageKey',
126+
// TODO should sanitize usageKeys too.
82127
(value: string) => value,
83128
(value: string) => value,
129+
skipUrlUpdate,
84130
);
85131

86132
let extraFilter: string[] = forceArray(props.extraFilter);
87133
if (usageKey) {
88134
extraFilter = union(extraFilter, [`usage_key = "${usageKey}"`]);
89135
}
90136

91-
// The search sort order can be set via the query string
92-
// E.g. ?sort=display_name:desc maps to SearchSortOption.TITLE_ZA.
93137
// Default sort by Most Relevant if there's search keyword(s), else by Recently Modified.
94138
const defaultSearchSortOrder = searchKeywords ? SearchSortOption.RELEVANCE : SearchSortOption.RECENTLY_MODIFIED;
95-
let sortStateManager = React.useState<SearchSortOption>(defaultSearchSortOrder);
96-
const sortUrlStateManager = useStateWithUrlSearchParam<SearchSortOption>(
139+
const [searchSortOrder, setSearchSortOrder] = useStateOrUrlSearchParam<SearchSortOption>(
97140
defaultSearchSortOrder,
98141
'sort',
99142
(value: string) => Object.values(SearchSortOption).find((enumValue) => value === enumValue),
100143
(value: SearchSortOption) => value.toString(),
144+
skipUrlUpdate,
101145
);
102-
if (!skipUrlUpdate) {
103-
sortStateManager = sortUrlStateManager;
104-
}
105-
const [searchSortOrder, setSearchSortOrder] = sortStateManager;
106146
// SearchSortOption.RELEVANCE is special, it means "no custom sorting", so we
107147
// send it to useContentSearchResults as an empty array.
108148
const searchSortOrderToUse = overrideSearchSortOrder ?? searchSortOrder;

0 commit comments

Comments
 (0)