diff --git a/apps/playground/src/app/array-values/page.tsx b/apps/playground/src/app/array-values/page.tsx index 6bdacf1..a6b311e 100644 --- a/apps/playground/src/app/array-values/page.tsx +++ b/apps/playground/src/app/array-values/page.tsx @@ -1,12 +1,16 @@ "use client"; import { useState } from "react"; +import { useSearchParams } from "next/navigation"; import { SetKeyValueArrayInputs } from "@/components/set-key-value-inputs"; import { Alert } from "@/components/ui/alert"; -import { useObserveAndStore } from "@sp-hooks/next"; + +import { searchParamsToObject, useObserveAndStore } from "@sp-hooks/next"; export default function Page() { - const [state, setState] = useState({}); + const sp = useSearchParams(); + + const [state, setState] = useState(searchParamsToObject(sp)); useObserveAndStore(state); @@ -16,7 +20,11 @@ export default function Page() { {"This demonstration shows how to use the library with arrays."} - + { + setState((s) => ({ ...s, [key]: values })); + }} + />
{JSON.stringify(state, null, 2)}
diff --git a/apps/playground/src/app/basic/page.tsx b/apps/playground/src/app/basic/page.tsx index e13fc35..639c33b 100644 --- a/apps/playground/src/app/basic/page.tsx +++ b/apps/playground/src/app/basic/page.tsx @@ -1,11 +1,10 @@ "use client"; +import { useState } from "react"; +import { useSearchParams } from "next/navigation"; import { SetKeyValueInputs } from "@/components/set-key-value-inputs"; -import { useObserveAndStore, } from "@sp-hooks/next"; -import { searchParamsToObject } from "@sp-hooks/react" -import { useSearchParams } from "next/navigation"; -import { useState } from "react"; +import { searchParamsToObject, useObserveAndStore } from "@sp-hooks/next"; export default function Page() { const sp = useSearchParams(); diff --git a/apps/playground/src/app/with-default-values/page.tsx b/apps/playground/src/app/with-default-values/page.tsx index 2f49035..bf95937 100644 --- a/apps/playground/src/app/with-default-values/page.tsx +++ b/apps/playground/src/app/with-default-values/page.tsx @@ -1,21 +1,31 @@ "use client"; import { useState } from "react"; +import { useSearchParams } from "next/navigation"; import { SetKeyValueInputs } from "@/components/set-key-value-inputs"; import { Alert } from "@/components/ui/alert"; -import { useObserveAndStore } from "@sp-hooks/next"; + +import { searchParamsToObject, useObserveAndStore } from "@sp-hooks/next"; + +const defaultValues = { + hello: "world", +}; export default function Page() { - const [state, setState] = useState>({ - hello: "world", - }); + const sp = useSearchParams(); + + const [state, setState] = useState( + searchParamsToObject(sp, { defaultValues }), + ); - useObserveAndStore(state); + useObserveAndStore(state, { defaultValues }); return (
- {'This page has a default value of "world" for the key "hello".'} + { + 'This page has a default value of "world" for the key "hello". The key/value pair is removed from URL if its set to the default value.' + } diff --git a/apps/playground/src/app/with-weak-typesafety/page.tsx b/apps/playground/src/app/with-weak-typesafety/page.tsx index ee5b802..cd5fa5f 100644 --- a/apps/playground/src/app/with-weak-typesafety/page.tsx +++ b/apps/playground/src/app/with-weak-typesafety/page.tsx @@ -3,9 +3,10 @@ import { useState } from "react"; import { SetKeyValueInputs } from "@/components/set-key-value-inputs"; import { Alert } from "@/components/ui/alert"; -import { useObserveAndStore } from "@sp-hooks/next"; -interface SearchParamsType extends Record { +import { SPHooksStateType, useObserveAndStore } from "@sp-hooks/next"; + +interface SearchParamsType extends SPHooksStateType { page: string; search: string; testArray: string[]; diff --git a/packages/next/src/index.ts b/packages/next/src/index.ts index 6de0340..6f2de44 100644 --- a/packages/next/src/index.ts +++ b/packages/next/src/index.ts @@ -1,36 +1,23 @@ -import { usePathname, useRouter, useSearchParams } from "next/navigation"; -import { - useObserveAndStore as useObserveAndStoreReact, - type SupportedValues, -} from "@sp-hooks/react"; +import { usePathname, useRouter } from "next/navigation"; -// export const useSearchParamsState = < -// S extends Partial>, -// >( -// opts?: UseSearchParamsStateOptions, -// ) => { -// const searchParams = useSearchParams(); -// const router = useRouter(); -// const pathname = usePathname(); +import { useObserveAndStore as useObserveAndStoreReact } from "@sp-hooks/react"; +import type { SPHooksStateType } from "@sp-hooks/react"; -// const setSearchParams = (newSearchParams: URLSearchParams) => { -// router.push(pathname + "?" + newSearchParams.toString()); -// }; +export * from "@sp-hooks/react"; +export type * from "@sp-hooks/react"; -// return useSearchParamsStateReact({ -// searchParams, -// setSearchParams, -// ...opts, -// }); -// }; - -export function useObserveAndStore>( +export function useObserveAndStore( state: S, + options?: { defaultValues?: Partial }, ) { const router = useRouter(); const pathname = usePathname(); - useObserveAndStoreReact(state, (newSearchParams) => { - router.push(pathname + "?" + newSearchParams.toString()); - }); + useObserveAndStoreReact( + state, + (newSearchParams) => { + router.push(pathname + "?" + newSearchParams.toString()); + }, + options, + ); } diff --git a/packages/react/src/hooks.ts b/packages/react/src/hooks.ts deleted file mode 100644 index 861ee06..0000000 --- a/packages/react/src/hooks.ts +++ /dev/null @@ -1,326 +0,0 @@ -import { useState } from "react"; - -import type { SupportedValueTypes, UseSearchParamsStateParams } from "./types"; -import { searchParamsToObject } from "./utils"; - -export * from "./types"; - -// --------------- Number Getter --------------- - -type NumberGetterResult = Readonly<{ - catch: (cval: number) => number; - value: () => number; -}>; - -const createNumberGetter = ({ - value, -}: { - value?: SupportedValueTypes; -}): NumberGetterResult => { - const v = Number(value); - - return Object.freeze({ - catch: (cval) => (isNaN(v) || typeof value === "undefined" ? cval : v), - value: () => v, - }); -}; - -type AllNumberGetterResult = Readonly<{ - catch: (cval: number[]) => number[]; - value: () => number[]; -}>; - -const createAllNumberGetter = ({ - values, -}: { - values: SupportedValueTypes[]; -}): AllNumberGetterResult => { - const v = values.map((v) => Number(v)); - - return Object.freeze({ - catch: (cval) => (v.every((v) => isNaN(v)) ? cval : v), - value: () => v, - }); -}; - -// ---------------- Boolean Getter -------------- - -type BooleanGetterResult = Readonly<{ - catch: (cval: boolean) => boolean; - value: () => boolean; -}>; - -const createBooleanGetter = ({ - value, -}: { - value?: SupportedValueTypes; -}): BooleanGetterResult => { - const v = Boolean(value); - - return Object.freeze({ - catch: (cval) => { - const isBoolean = - typeof value === "boolean" || value === "true" || value === "false"; - - return isBoolean ? v : cval; - }, - value: () => v, - }); -}; - -type AllBooleanGetterResult = Readonly<{ - catch: (cval: boolean[]) => boolean[]; - value: () => boolean[]; -}>; - -const createAllBooleanGetter = ({ - values, -}: { - values: SupportedValueTypes[]; -}): AllBooleanGetterResult => { - const v = values.map((v) => Boolean(v)); - - const isBoolean = values.every( - (vb) => typeof vb === "boolean" || vb === "true" || vb === "false", - ); - - return Object.freeze({ - catch: (cval) => (isBoolean ? v : cval), - value: () => v, - }); -}; - -// ----------------- Date Getter ------------- - -type DateGetterResult = Readonly<{ - catch: (cval: Date) => Date; - value: () => Date | undefined; -}>; - -const createDateGetter = ({ - value, -}: { - value?: SupportedValueTypes; -}): DateGetterResult => { - const vn = Date.parse(value as string); - const v = new Date(value as string); - - return Object.freeze({ - catch: (cval: Date) => (isNaN(vn) ? cval : v), - value: () => (typeof value !== "undefined" ? v : value), - }); -}; - -type AllDateGetterResult = Readonly<{ - catch: (cval: Date[]) => Date[]; - value: () => Date[]; -}>; - -const createAllDateGetter = ({ - values, -}: { - values: SupportedValueTypes[]; -}): AllDateGetterResult => { - const vn = values.map((v) => Date.parse(v as string)); - const v = values.map((v) => new Date(v as string)); - - return Object.freeze({ - catch: (cval: Date[]) => (vn.every((v) => isNaN(v)) ? cval : v), - value: () => v, - }); -}; - -// ---------------- General Getter ---------------- - -type GetterResult< - R, - O extends "asNumber" | "asBoolean" | "asDate" | "value" = never, -> = Omit< - { - asNumber: () => NumberGetterResult; - asBoolean: () => BooleanGetterResult; - asDate: () => DateGetterResult; - value: () => R | undefined; - }, - O ->; - -type AllGetterResult< - R, - O extends "asNumber" | "asBoolean" | "asDate" | "value" = never, -> = Readonly< - Omit< - { - asNumber: () => AllNumberGetterResult; - asBoolean: () => AllBooleanGetterResult; - asDate: () => AllDateGetterResult; - value: () => R[]; - }, - O - > ->; - -// ----------------- createGetter ----------------- - -const createGetter = ({ - value, -}: { - value?: R; -}): GetterResult => { - return Object.freeze({ - asNumber: () => createNumberGetter({ value }), - asBoolean: () => createBooleanGetter({ value }), - asDate: () => createDateGetter({ value }), - value: () => value, - }); -}; - -// ----------------- createAllGetter ----------------- - -const createAllGetter = ({ - values, -}: { - values: R[]; -}): AllGetterResult => { - return Object.freeze({ - asNumber: () => createAllNumberGetter({ values }), - asBoolean: () => createAllBooleanGetter({ values }), - asDate: () => createAllDateGetter({ values }), - value: () => values, - }); -}; - -// ----------------- createSetter ----------------- - -type SetterResult = Readonly<{ - number: (value: number | number[]) => void; - boolean: (value: boolean | boolean[]) => void; - date: (value: Date | Date[]) => void; - string: (value: string | string[]) => void; -}>; - -const createSetter = ({ key }: { key: string }): SetterResult => { - return Object.freeze({ - string: (value: string | string[]) => { - const sp = new URLSearchParams(); - - if (Array.isArray(value)) { - // setStringArray; - // sp.set(key, firstValue); - // sp.append(key, values[i+1]); - } else { - // setString; - // sp.set(key, value); - } - - // setSearchParams(sp); - }, - number: (value: number | number[]) => { - if (Array.isArray(value)) { - // setNumberArray; - } else { - // setNumber; - } - }, - boolean: (value: boolean | boolean[]) => { - if (Array.isArray(value)) { - // setBooleanArray; - } else { - // setBoolean; - } - }, - date: (value: Date | Date[]) => { - if (Array.isArray(value)) { - // setDateArray; - } else { - // setDate; - } - }, - }); -}; - -// ----------------- useSearchParamsState ----------------- - -export const useSearchParamsState = < - Params extends UseSearchParamsStateParams = UseSearchParamsStateParams, ->( - p: Params, -): Readonly<{ - get: (key: string) => ReturnType; - getAll: (key: string) => ReturnType; - set: (key: string) => ReturnType; -}> => { - // Configure default values. - const opts: Params = { - removeDefaultValues: true, - removeFalsyValues: true, - sortKeys: true, - instant: false, - ...p, - }; - // return [spObject as unknown as S, setState]; - - return Object.freeze({ - get: (key) => { - // Extract default value, taking first item if it's an array. - const defaultValue = ((): SupportedValueTypes | undefined => { - if (!p.defaultValues || !(key in p.defaultValues)) return undefined; - - const val = p.defaultValues[key]; - if (!val) return undefined; - - if (Array.isArray(val)) { - return val.at(0); - } - - return val; - })(); - - const value = p.searchParams.get(key) ?? defaultValue ?? undefined; - - return createGetter({ - value, - }); - }, - getAll: (key) => { - // Extract default value as an array, even if it's a single value. - const defaultValue = ((): SupportedValueTypes[] | undefined => { - if (!p.defaultValues || !(key in p.defaultValues)) return undefined; - - const val = p.defaultValues[key]; - if (!val) return undefined; - - if (Array.isArray(val)) { - return val; - } - - return [val]; - })(); - - const value = p.searchParams.getAll(key); - - return createAllGetter({ - values: - value.length > 0 - ? value - : defaultValue && defaultValue.length > 0 - ? defaultValue - : [], - }); - }, - set: (key) => { - // TODO: Implement - console.log("WIP: Setting is not yet implemented."); - - return createSetter({ key }); - }, - }); -}; - -// const s = useSearchParamsState({ -// searchParams: new URLSearchParams(), -// setSearchParams: () => {}, -// }); - -// s.get("page").asNumber().value(); -// s.get("perPage").asNumber().value(); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 8c9f61c..9e03490 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,3 +1,2 @@ export * from "./types"; -export * from "./hooks"; export * from "./utils"; diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 4aad705..c9465ae 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,17 +1,11 @@ import type { ReadonlyURLSearchParams } from "next/navigation"; -export type SupportedValueTypes = number | string | boolean | Date | BigInt; -export type SupportedArrayValueTypes = - | number[] - | string[] - | boolean[] - | Date[] - | BigInt[]; -export type SupportedJointValueArrayTypes = SupportedValueTypes[]; -export type SupportedValues = - | SupportedValueTypes - | SupportedArrayValueTypes - | SupportedJointValueArrayTypes; +export type SupportedValueTypes = number | string | boolean | Date | bigint; +export type SupportedValueArrayTypes = SupportedValueTypes[]; +export type SupportedValues = SupportedValueTypes | SupportedValueArrayTypes; + +// TODO: Replace string | string[] with SupportedValues +export type SPHooksStateType = Record; export interface UseSearchParamsStateBase { searchParams: URLSearchParams | ReadonlyURLSearchParams; diff --git a/packages/react/src/utils.ts b/packages/react/src/utils.ts index 0748672..462995f 100644 --- a/packages/react/src/utils.ts +++ b/packages/react/src/utils.ts @@ -1,17 +1,18 @@ -import { useEffect, useState, type DependencyList } from "react"; +import { useEffect } from "react"; import type { ReadonlyURLSearchParams } from "next/navigation"; -import { SupportedValues } from "./types"; +import { SPHooksStateType, SupportedValues } from "./types"; /* * Conerts a URLSearchParams object to a plain object, with * the values being either a string or an array of strings, * depending on the number of values for a given key. */ -export const searchParamsToObject = ( +export const searchParamsToObject = ( sp: URLSearchParams | ReadonlyURLSearchParams, -): Record => { - const newObj: Record = {}; + options?: { defaultValues?: Partial }, +): SPHooksStateType => { + const newObj: SPHooksStateType = {}; for (const key of Array.from(sp.keys())) { const values = sp.getAll(key); @@ -25,19 +26,30 @@ export const searchParamsToObject = ( } } + if (options?.defaultValues) { + Object.entries(options.defaultValues).forEach( + ([key, value]: [string, string | string[]]) => { + if (typeof newObj[key] === "undefined") { + newObj[key] = value; + } + }, + ); + } + return newObj; }; /* * Converts a plain object to a URLSearchParams object. */ -export function stateToSearchParams>( +export function stateToSearchParams( state: S, + options?: { defaultValues?: Partial }, ) { const searchParams = new URLSearchParams(); Object.entries(state).forEach(([key, value]) => - mutSetValueToSearchParams(searchParams, key, value), + mutSetValueToSearchParams(searchParams, key, value, options), ); return searchParams; @@ -46,29 +58,35 @@ export function stateToSearchParams>( /* * Observe a state object and call setSearchParams when the state changes. */ -export function useObserveAndStore>( +export function useObserveAndStore( state: S, setSearchParams: (newSearchParams: URLSearchParams) => void, + options?: { defaultValues?: Partial }, ) { useEffect(() => { - setSearchParams(stateToSearchParams(state)); - }, Object.entries(state)); + setSearchParams(stateToSearchParams(state, options)); + }, [state]); } /* * Accepts URLSearchParams as first parameter and a generic serialisable type as a second. * Converts type to a string or an array of strings depending on the provided type and sets it in the URLSearchParams. */ -export function mutSetValueToSearchParams( +export function mutSetValueToSearchParams( sp: URLSearchParams, key: string, value: SupportedValues, + options?: { defaultValues?: Partial }, ) { if (Array.isArray(value)) { value.forEach((v) => { sp.append(key, encodeURIComponent(v.toString())); }); } else { + if (options?.defaultValues && options.defaultValues[key] === value) { + return; + } + sp.set(key, encodeURIComponent(value.toString())); } }