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
231 lines
6.9 KiB
TypeScript
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)
|
|
}
|
|
}
|