|
import { createContext, useMemo, useRef, useContext } from "react"; |
|
|
|
import { useRefCallback } from "./useRefCallback"; |
|
|
|
export type UrlUpdateType = "pushIn" | "replaceIn"; |
|
|
|
export type QueryParamConfig<T> = { |
|
encode(value?: undefined | T): undefined | string; |
|
decode(str?: undefined | string): undefined | T; |
|
}; |
|
|
|
export type QueryParamConfigMap<D> = { |
|
[K in keyof D]: QueryParamConfig<D[K]>; |
|
}; |
|
|
|
export type HistoryLike = { |
|
push: (newLocation: LocationLike) => void; |
|
replace: (newLocation: LocationLike) => void; |
|
}; |
|
|
|
export type LocationLike = { |
|
pathname: string; |
|
search: string; |
|
}; |
|
|
|
export type HistoryLocationLike = { |
|
history: HistoryLike; |
|
location: LocationLike; |
|
}; |
|
|
|
export const QueryParamContext = createContext<HistoryLocationLike>({ |
|
history: { |
|
push() { |
|
throw new Error("First wrap the app with a <QueryParamContext.Provider>"); |
|
}, |
|
replace() { |
|
throw new Error("First wrap the app with a <QueryParamContext.Provider>"); |
|
}, |
|
}, |
|
get location() { |
|
return window?.location; |
|
}, |
|
}); |
|
|
|
export const BooleanParam: QueryParamConfig<boolean> = { |
|
encode(value) { |
|
return value ? "" : undefined; |
|
}, |
|
decode(str) { |
|
return typeof str === "string"; |
|
}, |
|
}; |
|
|
|
export const StringParam: QueryParamConfig<string> = { |
|
encode(value) { |
|
return value; |
|
}, |
|
decode(str) { |
|
return str; |
|
}, |
|
}; |
|
|
|
export const NumberParam: QueryParamConfig<number> = { |
|
encode(value) { |
|
return value === undefined ? undefined : String(value); |
|
}, |
|
decode(str) { |
|
return str === undefined ? undefined : Number(str); |
|
}, |
|
}; |
|
|
|
export function FixedNumberParam( |
|
fractionDigits: number = 5 |
|
): QueryParamConfig<number> { |
|
return { |
|
encode(num) { |
|
return num?.toFixed(fractionDigits); |
|
}, |
|
decode(str) { |
|
return str === undefined ? undefined : Number(str); |
|
}, |
|
}; |
|
} |
|
|
|
export const IntParam: QueryParamConfig<number> = { |
|
encode(value) { |
|
return value === undefined ? undefined : String(value); |
|
}, |
|
decode(str) { |
|
if (!str) return; |
|
if (str.match(/^[0-9]+$/)) { |
|
return Number(str); |
|
} |
|
}, |
|
}; |
|
|
|
export function EnumParam<K extends string>(values: K[]): QueryParamConfig<K> { |
|
return { |
|
encode(value) { |
|
return value; |
|
}, |
|
decode(value) { |
|
if (values.includes(value as any)) { |
|
return value as any; |
|
} |
|
}, |
|
}; |
|
} |
|
|
|
export const GeoPointParam: QueryParamConfig<[number, number]> = { |
|
encode(p) { |
|
return p ? [p[0].toFixed(5), p[1].toFixed(5)].join("_") : undefined; |
|
}, |
|
decode(value) { |
|
if (!value) return; |
|
|
|
const [_lng, _lat] = String(value).split("_"); |
|
if (!_lng || !_lat) { |
|
return; |
|
} |
|
const lat = Number(_lat); |
|
const lng = Number(_lng); |
|
if (isNaN(lng) || isNaN(lat)) { |
|
return; |
|
} |
|
|
|
return [lng, lat]; |
|
}, |
|
}; |
|
|
|
export function StringArrayParam(separator = "_"): QueryParamConfig<string[]> { |
|
return { |
|
encode(arr) { |
|
if (!arr || arr.length === 0) { |
|
return undefined; |
|
} |
|
return arr.join(separator); |
|
}, |
|
decode(value) { |
|
if (!value) return []; |
|
return value.split(separator); |
|
}, |
|
}; |
|
} |
|
|
|
export const UnderscoreSepStringArrayParam = StringArrayParam("_"); |
|
|
|
export type UseQueryParamResult<T> = [ |
|
undefined | T, |
|
(value?: undefined | T, updateType?: UrlUpdateType) => void, |
|
(value?: undefined | T) => string |
|
]; |
|
|
|
export function useQueryParam<T>( |
|
name: string, |
|
config: QueryParamConfig<T> |
|
): UseQueryParamResult<T> { |
|
const [_data, _setData, _updateSearch] = useQueryParams({ |
|
[name]: config, |
|
}); |
|
|
|
const value = _data[name]; |
|
|
|
const updateSearch = useRefCallback((value?: undefined | T) => { |
|
return _updateSearch({ [name]: value }); |
|
}); |
|
|
|
const setValue = useRefCallback( |
|
(value?: undefined | T, updateType?: UrlUpdateType) => { |
|
_setData({ [name]: value }, updateType); |
|
} |
|
); |
|
|
|
return [value, setValue, updateSearch]; |
|
} |
|
|
|
export type UseQueryParamsResult<D> = [ |
|
{ |
|
[K in keyof D]: undefined | D[K]; |
|
}, |
|
( |
|
updates: { |
|
[K in keyof D]?: undefined | D[K]; |
|
}, |
|
updateType?: UrlUpdateType |
|
) => void, |
|
( |
|
updates: { |
|
[K in keyof D]?: undefined | D[K]; |
|
} |
|
) => string |
|
]; |
|
|
|
export function useQueryParams<D>( |
|
configMap: QueryParamConfigMap<D> |
|
): UseQueryParamsResult<D> { |
|
const { history, location } = useContext(QueryParamContext); |
|
const configMapRef = useRef(configMap); |
|
|
|
const data: { |
|
[K in keyof D]: undefined | D[K]; |
|
} = useMemo(() => { |
|
const data: any = {}; |
|
const keys = Object.keys(configMapRef.current); |
|
for (const key of keys) { |
|
const config = (configMapRef.current as any)[ |
|
key |
|
] as QueryParamConfig<any>; |
|
data[key] = config.decode(find(location.search, key)); |
|
} |
|
return data; |
|
}, [location.search]); |
|
|
|
const updateSearch = useRefCallback( |
|
( |
|
updates: { |
|
[K in keyof D]?: undefined | D[K]; |
|
} |
|
) => { |
|
let search = location.search; |
|
|
|
const updateKeys = Object.keys(updates); |
|
for (const key of updateKeys) { |
|
const config = (configMapRef.current as any)[ |
|
key |
|
] as QueryParamConfig<any>; |
|
search = update(search, key, config.encode((updates as any)[key])); |
|
} |
|
|
|
return search; |
|
} |
|
); |
|
|
|
const setData = useRefCallback( |
|
( |
|
updates: { |
|
[K in keyof D]?: undefined | D[K]; |
|
}, |
|
updateType?: UrlUpdateType |
|
) => { |
|
const newLocation = { |
|
pathname: location.pathname, |
|
search: updateSearch(updates), |
|
}; |
|
if (updateType === "replaceIn") { |
|
history.replace(newLocation); |
|
} else { |
|
history.push(newLocation); |
|
} |
|
} |
|
); |
|
|
|
return [data, setData, updateSearch]; |
|
} |
|
|
|
/** |
|
* Removes or updates the query param value within the search string. |
|
* Pass `undefined` to remove, an empty string to set w/o value, |
|
* and any other string to set the value to that value. Will |
|
* automatically uri-encode the value. Will also remove duplicate |
|
* query params in the process. |
|
*/ |
|
function update( |
|
search: string, |
|
name: string, |
|
value?: string // undefined means: remove |
|
) { |
|
if (search[0] === "?") { |
|
search = search.slice(1); |
|
} |
|
|
|
const pieces = search.split("&").filter((p) => { |
|
return p !== name && !p.startsWith(name + "="); |
|
}); |
|
|
|
if (typeof value === "string") { |
|
pieces.push(name + (value ? "=" + encodeURIComponent(value) : "")); |
|
} |
|
|
|
search = pieces.join("&"); |
|
|
|
if (search.length > 0 && search[0] !== "?") { |
|
search = "?" + search; |
|
} |
|
|
|
return search; |
|
} |
|
|
|
/** |
|
* Returns the uri-decoded single string value of the given query param, |
|
* if any. Returns an empty string if in the search string but |
|
* without associated value. |
|
*/ |
|
function find(search: string, name: string): string | undefined { |
|
if (search[0] === "?") { |
|
search = search.slice(1); |
|
} |
|
|
|
const piece = search.split("&").find((p) => { |
|
return p === name || p.startsWith(name + "="); |
|
}); |
|
|
|
if (piece) { |
|
return decodeURIComponent( |
|
piece === name ? "" : piece.slice(name.length + 1) |
|
); |
|
} |
|
} |