Skip to content

Instantly share code, notes, and snippets.

@CasperEngl
Created February 19, 2024 01:28
Show Gist options
  • Save CasperEngl/8222dd6eb3dff7e77ff77edb27e7ef4c to your computer and use it in GitHub Desktop.
Save CasperEngl/8222dd6eb3dff7e77ff77edb27e7ef4c to your computer and use it in GitHub Desktop.

Revisions

  1. CasperEngl created this gist Feb 19, 2024.
    221 changes: 221 additions & 0 deletions use-zod-params.ts
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,221 @@
    import { debounce } from 'lodash-es'
    import type { MaybeRef } from 'vue'
    import { computed, reactive, unref, watch } from 'vue'
    import { useRoute } from 'vue-router'
    import { z } from 'zod'

    type PrimitiveZodTypes =
    | z.ZodString
    | z.ZodNumber
    | z.ZodDate
    | z.ZodArray<PrimitiveZodTypes>
    | z.ZodCatch<PrimitiveZodTypes>
    | z.ZodDefault<PrimitiveZodTypes>
    | z.ZodOptional<PrimitiveZodTypes>
    | z.ZodNullable<PrimitiveZodTypes>
    | z.ZodEffects<
    PrimitiveZodTypes,
    PrimitiveZodTypes | string | number,
    PrimitiveZodTypes | unknown
    >
    | z.ZodUnion<[PrimitiveZodTypes, PrimitiveZodTypes, ...PrimitiveZodTypes[]]>
    | z.ZodEnum<[string, string, ...string[]]>
    | z.ZodEffects<z.ZodOptional<z.ZodDate>, string, Date>
    | z.ZodLiteral<string>

    export type ZodObjectSchema = z.ZodObject<Record<string, PrimitiveZodTypes>, z.UnknownKeysParam>

    export function zodHasArray(schema: PrimitiveZodTypes): boolean {
    if (schema instanceof z.ZodArray) {
    return true
    }

    if (schema instanceof z.ZodObject) {
    return Object.values(schema.shape).some((value) => zodHasArray(value as PrimitiveZodTypes))
    }

    if (schema instanceof z.ZodUnion) {
    return schema.options.some(zodHasArray)
    }

    if (schema instanceof z.ZodEnum) {
    return false
    }

    if (schema instanceof z.ZodTuple) {
    return schema.items.some(zodHasArray)
    }

    if (schema instanceof z.ZodIntersection) {
    if ('left' in schema && 'right' in schema) {
    return schema.left
    ? zodHasArray(schema.left as PrimitiveZodTypes)
    : zodHasArray(schema.right as PrimitiveZodTypes)
    }
    }

    if (schema instanceof z.ZodEffects) {
    return zodHasArray(schema._def.schema)
    }

    if (schema && '_def' in schema && typeof schema._def === 'object' && 'innerType' in schema._def) {
    return zodHasArray(schema._def.innerType)
    }

    return false
    }


    function zodDefaultValue(schema: z.ZodType<unknown, unknown>) {
    if (schema instanceof z.ZodDefault) {
    return schema._def.defaultValue()
    }

    if (schema && '_def' in schema && typeof schema._def === 'object' && 'innerType' in schema._def) {
    return zodDefaultValue(schema._def.innerType as z.ZodType<unknown, unknown>)
    }

    return undefined
    }

    function zodCatchValue(schema: z.ZodType<unknown, unknown>) {
    if (schema instanceof z.ZodCatch) {
    return schema._def.catchValue({
    error: new z.ZodError([]),
    input: undefined,
    })
    }

    if (schema && '_def' in schema && typeof schema._def === 'object' && 'innerType' in schema._def) {
    return zodCatchValue(schema._def.innerType as z.ZodType<unknown, unknown>)
    }

    return undefined
    }

    type UseZodParamsOptions<Schema extends ZodObjectSchema> = {
    schema: Schema
    enabled?: MaybeRef<boolean>
    type: 'params' | 'query'
    debounceMs?: number
    }

    // Outside your export function, define a map to hold debounced functions
    const debouncedSetters = new Map()

    export function useZodParams<Schema extends ZodObjectSchema>(options: UseZodParamsOptions<Schema>) {
    const route = useRoute()
    const enabled = options.enabled ?? true
    const type = options.type ?? 'query'
    const debouncedMs = options.debounceMs ?? 300
    const mountedMatched = route.matched

    const hasMatchedRoute = computed(() => {
    return mountedMatched.some((m) => m.path === route.path)
    })

    const proxy = new Proxy(reactive({} as z.infer<Schema>), {
    get(target, key) {
    if (!unref(enabled)) return null
    if (!unref(hasMatchedRoute)) return null

    const schemaPath = options.schema.shape[key.toString()]

    /**
    * Vue Router returns a single value if there's only one array item.
    * For consistent parameter handling, convert it to an array if the
    * schema contains an array.
    */
    const updatedTarget = Object.fromEntries(
    Object.entries(target).map(([key, value]) => [
    key,
    zodHasArray(schemaPath) && !Array.isArray(value) ? [value] : value,
    ])
    )

    return Reflect.get(options.schema.parse(updatedTarget), key)
    },
    set(target, key, value) {
    if (!unref(enabled)) return false
    if (!unref(hasMatchedRoute)) return false

    const schemaPath = options.schema.shape[key.toString()]

    // Prepare the new value according to the schema
    const newValue = zodHasArray(schemaPath) && !Array.isArray(value) ? [value] : value

    // Check if there's already a debounced function for this key
    if (!debouncedSetters.has(key)) {
    const debouncedSetter = debounce((newValue) => {
    const url = new URL(window.location.href)

    if (Array.isArray(newValue)) {
    url.searchParams.delete(key.toString())

    for (const value of newValue) {
    url.searchParams.append(key.toString(), value.toString())
    }
    } else {
    url.searchParams.set(key.toString(), newValue ?? '')
    }

    window.history.replaceState(null, '', url.toString())

    // Remove the debounced function from the map after it's called
    debouncedSetters.delete(key)
    }, debouncedMs) // Adjust debounce time as needed

    // Store the debounced function in the map
    debouncedSetters.set(key, debouncedSetter)
    }

    // Retrieve the debounced function and call it with the new value
    debouncedSetters.get(key)(newValue)

    /**
    * Use Reflect.set to update the value of the key in the target object.
    * This is necessary to ensure that the changes are propagated to all
    * references of the object. It updates the target object immediately without debouncing,
    * ensuring the proxy object is up-to-date even though the router update is debounced.
    */
    return Reflect.set(target, key, newValue)
    },
    })

    // Watch for changes in the route and update the proxy accordingly
    watch(route, (newRoute) => {
    if (!unref(enabled)) return
    if (!unref(hasMatchedRoute)) return

    const existingKeys = Object.keys(proxy)
    const newKeys = Object.keys(newRoute[type])

    // Handle keys present in the proxy but not in the newRoute
    for (const key of existingKeys) {
    if (!newKeys.includes(key)) {
    Reflect.set(proxy, key, undefined)
    }
    }

    // Handle keys present in the newRoute
    for (const key of newKeys) {
    const schemaPath = options.schema.shape[key]
    const value = newRoute[type][key]
    const catchValue = zodCatchValue(schemaPath)
    const defaultValue = zodDefaultValue(schemaPath)

    Reflect.set(proxy, key, value || defaultValue || catchValue)
    }
    })

    for (const key of Object.keys(options.schema.shape)) {
    const schemaPath = options.schema.shape[key]
    const value = route[type][key]
    const catchValue = zodCatchValue(schemaPath)
    const defaultValue = zodDefaultValue(schemaPath)

    Reflect.set(proxy, key, value || defaultValue || catchValue)
    }

    return proxy
    }