@pastable/react
Version:
200 lines (162 loc) • 8.03 kB
text/typescript
import { SetStateAction, useEffect } from "react";
import { ObjectLiteral } from "@pastable/typings";
import { Formater, format, isBrowser, isDefined, isType } from "@pastable/utils";
import { useEvent } from "./useEvent";
import { useForceUpdate } from "./useForceUpdate";
/** Get/set page history with query params */
export const useQueryParams = <QP = ObjectLiteral>(props: UseQueryParamsProps<QP> = {}) => {
const { getterFormater, setterFormater, toPath } = props;
const state = useCurrentQueryParams<QP>(getterFormater as any);
const setter = useSetQueryParams<QP>({ formater: setterFormater as any, toPath });
const reset = (keys?: Array<keyof QP>) =>
setter(Object.fromEntries((keys || Object.keys(state)).map((key: any) => [key, undefined])));
// Set default values for keys not yet in query params
useEffect(() => {
if (!props.defaultValues) return;
setter({ ...props.defaultValues, ...state });
}, []);
return [state, setter, reset] as const;
};
export interface UseQueryParamsProps<QP = ObjectLiteral> extends Pick<UseSetQueryParamsProps, "toPath"> {
/** Getter used to parse query params as object from query string */
getterFormater?: Formater<QP[keyof QP], any, keyof QP>;
/** Setter used to serialize query string from query param object */
setterFormater?: Formater<QP[keyof QP], any, keyof QP>;
/** Set default values for keys not yet in query params */
defaultValues?: QP;
}
export const getLocation = () => (isBrowser() ? window.location : ({ search: {} } as Location));
/** Get parsed query params, might be formated using given method */
export const useCurrentQueryParams = <QP = ObjectLiteral, F extends Formater = Formater>(formater?: F) => {
const location = getLocation();
const params = new URLSearchParams(location.search);
const parsed = decodeValues(Object.fromEntries(params.entries()));
const forceUpdate = useForceUpdate();
useEvent("pushstate" as any, forceUpdate);
const formated = formater ? format(parsed, formater) : parsed;
return formated as Partial<QP>;
};
/** Control a queryParam from its key like a useState */
export const useQueryParamsState = <Value, Key extends string = any, QP = any>(
key: Key,
props: UseQueryParamsStateProps<Value, Key, QP> = {}
) => {
const { getterFormater, setterFormater, toPath, defaultValue } = props;
const state = useCurrentQueryParams<QP>(getterFormater as any);
const setter = useSetQueryParams({ formater: setterFormater as any, toPath });
const queryParam = state[(key as any) as keyof QP] || defaultValue;
const setQueryParam = (action: SetStateAction<Value>) => {
const current = isType<Function>(action, typeof action === "function") ? action(queryParam as any) : action;
setter({ [key]: current });
};
return [queryParam as Value, setQueryParam] as undefined extends QP
? [Value, UseQueryParamsSetState<Value>]
: [QP[Key extends keyof QP ? Key : never], UseQueryParamsSetState<QP[Key extends keyof QP ? Key : never]>];
// as UseQueryParamsState<QP[keyof QP]>
};
export interface UseQueryParamsStateProps<Value, K, QP = ObjectLiteral> extends Pick<UseSetQueryParamsProps, "toPath"> {
/** Getter used to parse query params as object from query string */
getterFormater?: Formater<QP[K extends keyof QP ? K : never], any, K>;
/** Setter used to serialize query string from query param object */
setterFormater?: Formater<QP[K extends keyof QP ? K : never], any, K>;
/** Set default values for keys not yet in query params */
defaultValue?: Value | QP[K extends keyof QP ? K : never];
}
export type UseQueryParamsSetState<T = any> = (action: SetStateAction<T>) => void;
export type UseQueryParamsState<T = any> = [T, UseQueryParamsSetState<T>];
const noop = () => {};
export const getHistory = () =>
isBrowser() ? window.history : (({ pushState: noop, replaceState: noop } as any) as History);
export type HistoryMode = "push" | "replace";
/** Update page history by merging current queryParams with values */
export const useSetQueryParams = <QP = ObjectLiteral>({
toPath: toPathProp,
formater,
}: UseSetQueryParamsProps = {}) => {
const merger = useQueryParamsMerger<typeof formater, QP>(formater);
const setter = (values: Partial<QP>, mode: HistoryMode = "push", toPath?: UseSetQueryParamsProps["toPath"]) => {
const history = getHistory();
const location = getLocation();
const pathname = location.pathname;
const basePath = pathname.endsWith("/") ? pathname.slice(0, -1) : pathname;
const path = toPath || toPathProp;
// Either pass a static toPath or a function that will be given basePath as argument
const to = path ? (typeof path === "string" ? path : path(basePath)) : basePath;
const url = to + merger(values);
if (mode === "push") {
history.pushState(values, "", url);
} else {
history.replaceState(values, "", url);
}
};
return setter;
};
export type UseSetQueryParamsReturn<QP = ObjectLiteral> = ({
toPath: toPathProp,
formater,
}?: UseSetQueryParamsProps) => (
values: Partial<QP>,
mode?: HistoryMode,
toPath?: UseSetQueryParamsProps["toPath"]
) => void;
export type UseSetQueryParamsToPathFn = (currentPathname: string) => string;
export interface UseSetQueryParamsProps {
/**
* Allow overriding the pathname on which the history will be pushed/replaced,
* defaults to current history.location.pathname
* Either pass a static toPath or a function that will be given basePath as argument
*/
toPath?: string | UseSetQueryParamsToPathFn;
/** Custom formater fn to be passed to useQueryParamsMerger */
formater?: Formater;
}
/** Merge current queryParams with values and return the resulting query string */
export const useQueryParamsMerger = <F extends Function = Formater, QP = ObjectLiteral>(customFormater?: F) => {
const params = useCurrentQueryParams();
return (values: Partial<QP>) => formatObjToQueryString({ ...params, ...values }, customFormater);
};
const decodeValues = <T = ObjectLiteral>(obj: T) => format(obj, decodeURIComponent);
/** Remove object keys if not valid as query params */
export const formatObjToQueryString = <Value = ObjectLiteral, F extends Function = Formater>(
obj: Value,
customFormater?: F
) =>
"?" +
Object.entries(obj)
.map(([key, value]) => [key, formatValueToQueryParam(value, customFormater, key)])
.filter(([key, value]) => isValidQueryParam(value, key))
.map(([key, value]) => `${key}=${decodeURIComponent(value)}`)
.join("&");
function isValidQueryParam<Value = any>(value: Value, _key: string) {
if (Array.isArray(value)) return value.length;
if (typeof value === "object") return value instanceof Date;
return isDefined(value);
}
function formatValueToQueryParam<Value = any, F extends Function = Formater>(
rawValue: Value,
customFormater: F | null,
key: string
) {
const value = customFormater?.(rawValue, key) || rawValue;
return value;
}
// Taken from https://github.com/streamich/react-use/blob/8ceb4c0f0c5625124f487b435a2fd0d3b3bc2a4f/src/useLocation.this
// Props to him
function patchHistoryMethod(method: "pushState" | "replaceState") {
const history = window.history;
const original = history[method];
history[method] = function (state) {
const result = original.apply(this, arguments);
const event = new Event(method.toLowerCase());
(event as any).state = state;
window.dispatchEvent(event);
return result;
};
}
if (isBrowser()) {
// if (isBrowser() && (import.meta as any).hot.data?.patchState !== "patched") {
// console.log("patching");
patchHistoryMethod("pushState");
patchHistoryMethod("replaceState");
// (import.meta as any).hot.data.patchState = "patched";
}