Files
web/apps/scandic-web/utils/searchParams.ts
Anton Gunnarsson bff34b034e Merged in feat/sw-2857-refactor-booking-flow-url-updates (pull request #2302)
feat(SW-2857): Refactor booking flow url updates

* Add support for removing parameters when using initial values in serializeSearchParams

* Don't manually write search params in rate store

* Booking is already from live search params so no need

* Fix input type in serializeBookingSearchParams


Approved-by: Linus Flood
2025-06-09 09:16:22 +00:00

231 lines
6.9 KiB
TypeScript

import type { z } from "zod"
import type { NextSearchParams } from "@/types/params"
type ParseOptions<T extends z.ZodRawShape> = {
keyRenameMap?: Record<string, string>
typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
schema?: z.ZodObject<T>
}
type ParseOptionsWithSchema<T extends z.ZodRawShape> = ParseOptions<T> & {
schema: z.ZodObject<T>
}
// This ensures that the return type is correct when a schema is provided
export function parseSearchParams<T extends z.ZodRawShape>(
searchParams: NextSearchParams,
options: ParseOptionsWithSchema<T>
): z.infer<typeof options.schema>
export function parseSearchParams<T extends z.ZodRawShape>(
searchParams: NextSearchParams,
options?: ParseOptions<T>
): Record<string, any>
/**
* Parses URL search parameters into a structured object.
* This function can handle nested objects, arrays, and type validation/transformation using Zod schema.
*
* @param searchParams - The object to parse
* @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
* @param options.typeHints - Optional type hints to force certain keys to be treated as arrays
* @param options.schema - Pass a Zod schema to validate and transform the parsed search parameters and get a typed return value
*
* Supported formats:
* - Objects: `user.name=John&user.age=30`
* - Arrays: `tags[0]=javascript&tags[1]=typescript`
* - Arrays of objects: `tags[0].name=javascript&tags[0].age=30`
* - Nested arrays: `tags[0].languages[0]=javascript&tags[0].languages[1]=typescript`
* - Comma-separated arrays: `tags=javascript,typescript`
*
* For comma-separated arrays you must use the `typeHints`
* option to inform the parser that the key should be treated as an array.
*/
export function parseSearchParams<T extends z.ZodRawShape>(
searchParams: NextSearchParams,
options?: ParseOptions<T>
) {
const entries = Object.entries(searchParams)
const buildObject = getBuilder(options || {})
const resultObject: Record<string, any> = {}
for (const [key, value] of entries) {
const paths = key.split(".")
if (Array.isArray(value)) {
throw new Error(
`Arrays from duplicate keys (?a=1&a=2) are not yet supported.`
)
}
if (!value) {
continue
}
buildObject(resultObject, paths, value)
}
if (options?.schema) {
return options.schema.parse(resultObject)
}
return resultObject
}
// Use a higher-order function to avoid passing the options
// object every time we recursively call the builder
function getBuilder<T extends z.ZodRawShape>(options: ParseOptions<T>) {
const keyRenameMap = options.keyRenameMap || {}
const typeHints = options.typeHints || {}
return function buildNestedObject(
obj: Record<string, any>,
paths: string[],
value: string
) {
if (paths.length === 0) return
const path = paths[0]
const remainingPaths = paths.slice(1)
// Extract the key name and optional array index
const match = path.match(/^([^\[]+)(?:\[(\d+)\])?$/)
if (!match) return
const key = keyRenameMap[match[1]] || match[1]
const index = match[2] ? parseInt(match[2]) : null
const forceCommaSeparatedArray = typeHints[key] === "COMMA_SEPARATED_ARRAY"
const hasIndex = index !== null
// If we've reached the last path, set the value
if (remainingPaths.length === 0) {
// This is either an array or a value that is
// forced to be an array by the typeHints
if (hasIndex || forceCommaSeparatedArray) {
if (isNotArray(obj[key])) obj[key] = []
if (!hasIndex || forceCommaSeparatedArray) {
obj[key] = value.split(",")
return
}
obj[key][index] = value
return
}
obj[key] = value
return
}
if (hasIndex) {
// If the key is an array, ensure array and element at index exists
if (isNotArray(obj[key])) obj[key] = []
if (!obj[key][index]) obj[key][index] = {}
buildNestedObject(obj[key][index], remainingPaths, value)
return
}
// Otherwise, it should be an object
if (!obj[key]) obj[key] = {}
buildNestedObject(obj[key], remainingPaths, value)
}
}
function isNotArray(value: any) {
return !value || typeof value !== "object" || !Array.isArray(value)
}
type SerializeOptions = {
keyRenameMap?: Record<string, string>
typeHints?: Record<string, "COMMA_SEPARATED_ARRAY">
initialSearchParams?: URLSearchParams
}
/**
* Serializes an object into URL search parameters.
*
* @param obj - The object to serialize
* @param options.keyRenameMap - Optional mapping of keys to rename, ie { "oldKey": "newKey" }
* @param options.typeHints - Optional type hints to force certain keys to be treated as comma separated arrays
* @param options.initialSearchParams - Optional initial URL search parameters to merge with the serialized object
* @returns URLSearchParams - The serialized URL search parameters
*
* To force a key to be removed when merging with initialSearchParams, set its value to `null` in the object.
* Arrays are not merged, they will always replace existing values.
*/
export function serializeSearchParams(
obj: Record<string, any>,
options?: SerializeOptions
): URLSearchParams {
const params = new URLSearchParams(options?.initialSearchParams)
const keyRenameMap = options?.keyRenameMap || {}
const typeHints = options?.typeHints || {}
function buildParams(obj: unknown, prefix: string) {
if (obj === null || obj === undefined) return
if (!isRecord(obj)) {
params.set(prefix, String(obj))
return
}
for (const key in obj) {
const value = obj[key]
const renamedKey = keyRenameMap[key] || key
const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey
if (value === null) {
params.delete(paramKey)
continue
}
if (Array.isArray(value)) {
if (typeHints[key] === "COMMA_SEPARATED_ARRAY") {
params.set(paramKey, value.join(","))
continue
}
// If an array value already exists (from initialSearchParams),
// we need to first remove it since it can't be merged.
deleteAllKeysStartingWith(params, renamedKey)
value.forEach((item, index) => {
const indexedKey = `${renamedKey}[${index}]`
const arrayKey = prefix ? `${prefix}.${indexedKey}` : indexedKey
buildParams(item, arrayKey)
})
continue
}
if (typeof value === "object" && value !== null) {
buildParams(value, paramKey)
continue
}
params.set(paramKey, String(value))
}
}
buildParams(obj, "")
return params
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function deleteAllKeysStartingWith(searchParams: URLSearchParams, key: string) {
const keysToDelete = Array.from(searchParams.keys()).filter(
(k) => k.startsWith(key) || k === key
)
for (const k of keysToDelete) {
searchParams.delete(k)
}
}