Merged in fix/refactor-booking-flow-search-params (pull request #2148)
Fix: refactor booking flow search params * wip: apply codemod and upgrade swc plugin * wip: design-system to react 19, fix issues from async (search)params * Prepare new parse function for booking flow search params * Prepare serialize function for booking flow search params * Improve handling of comma separated arrays * Slightly refactor for readability * Next abstracts URLSearchParams so handle the abstraction instead * Refactor booking widget to use new search params parsing * Rename search param functions * Refactor select-hotel to use new search param parser * Use new search params parser in select-rate and details * Fix hotelId type * Avoid passing down search params into BookingWidget components * More updates to use new types instead of SearchParams<T> * Remove types SelectHotelSearchParams and AlternativeSelectHotelSearchParams * Fix parseBookingWidgetSearchParams return type * Add error handling to booking search param parsers * Fix modifyRateIndex handling in details page * Clean up * Refactor booking widget search param serializing to util function * Move start page booking widget search param parsing to page * Use new search param serializer in HandleErrorCallback * Delete convertSearchParamsToObj & convertObjToSearchParams Approved-by: Michael Zetterberg
This commit is contained in:
209
apps/scandic-web/utils/searchParams.ts
Normal file
209
apps/scandic-web/utils/searchParams.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
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
|
||||
* @returns URLSearchParams - The serialized URL search parameters
|
||||
*/
|
||||
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
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (typeHints[key] === "COMMA_SEPARATED_ARRAY") {
|
||||
const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey
|
||||
params.set(paramKey, value.join(","))
|
||||
continue
|
||||
}
|
||||
|
||||
value.forEach((item, index) => {
|
||||
const indexedKey = `${renamedKey}[${index}]`
|
||||
const paramKey = prefix ? `${prefix}.${indexedKey}` : indexedKey
|
||||
buildParams(item, paramKey)
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const paramKey = prefix ? `${prefix}.${renamedKey}` : renamedKey
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user