alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
164 lines (150 loc) • 5.19 kB
text/typescript
import type { Alepha, Static, TObject } from "alepha";
import { useAlepha } from "alepha/react";
import { useEffect, useState } from "react";
import { useRouter } from "./useRouter.ts";
/**
* Hook to manage query parameters in the URL using a defined schema.
*
* Two storage formats are supported via {@link UseQueryParamsHookOptions.format}:
* - `"base64"` (default) packs the whole object into a single opaque param
* (named by `key`, default `q`) — e.g. `?q=eyJ0YWIiOiJzZWN1cml0eSJ9`.
* - `"querystring"` spreads each schema field across its own readable param —
* e.g. `?tab=security`. In this mode `key` is ignored.
*
* By default the URL is updated with `replaceState` (no new history entry).
* Pass `push: true` to add a history entry instead, so the browser back
* button steps back through previous values.
*
* @example
* const [params, setParams] = useQueryParams(
* z.object({ tab: z.string().optional() }),
* { format: "querystring" },
* );
* // params.tab reads from `?tab=…`; setParams({ tab: "security" }) writes it.
*/
export const useQueryParams = <T extends TObject>(
schema: T,
options: UseQueryParamsHookOptions = {},
): [Partial<Static<T>>, (data: Static<T>) => void] => {
const alepha = useAlepha();
const router = useRouter();
const key = options.key ?? "q";
const format = options.format ?? "base64";
// The slice of the URL this hook reads from: a single opaque param in
// base64 mode, or the schema's own field names in querystring mode.
const read = (): Partial<Static<T>> | undefined =>
format === "querystring"
? decodeQueryString(alepha, schema, router.query)
: decodeBase64(alepha, schema, router.query[key]);
// A primitive the effect can depend on so it re-syncs only when the
// relevant part of the URL actually changes.
const signature =
format === "querystring"
? Object.keys(schema.properties)
.map((name) => `${name}=${router.query[name] ?? ""}`)
.join("&")
: router.query[key];
const [queryParams = {}, setQueryParams] = useState<
Partial<Static<T>> | undefined
>(read());
useEffect(() => {
setQueryParams(read());
}, [signature]);
return [
queryParams,
(next: Static<T>) => {
setQueryParams(next);
router.setQueryParams(
(data) =>
format === "querystring"
? writeQueryString(alepha, schema, data, next)
: { ...data, [key]: encodeBase64(alepha, schema, next) },
{ push: options.push },
);
},
];
};
// ---------------------------------------------------------------------------------------------------------------------
export interface UseQueryParamsHookOptions {
/**
* How the params are stored in the URL.
* - `"base64"` (default): the whole object in one opaque param (`key`).
* - `"querystring"`: each schema field as its own readable param (`key`
* is ignored).
*/
format?: "base64" | "querystring";
/**
* Param name used in `"base64"` format. Defaults to `"q"`. Ignored in
* `"querystring"` format, where the schema's field names are used.
*/
key?: string;
/**
* When `true`, updates push a new browser-history entry (`pushState`) so
* the back button returns to the previous value. Defaults to `false`,
* which replaces the current entry in place (`replaceState`).
*/
push?: boolean;
}
const encodeBase64 = (alepha: Alepha, schema: TObject, data: any) => {
return btoa(JSON.stringify(alepha.codec.decode(schema, data)));
};
const decodeBase64 = <T extends TObject>(
alepha: Alepha,
schema: T,
data: any,
): Static<T> | undefined => {
try {
return alepha.codec.decode(
schema,
JSON.parse(atob(decodeURIComponent(data))),
);
} catch {
return;
}
};
/**
* querystring read: each schema field is its own URL param. Mirrors the
* server's per-key query decode — `codec.decode(propertySchema, rawString)`
* coerces the raw string into the declared type (number, boolean, …).
*/
const decodeQueryString = <T extends TObject>(
alepha: Alepha,
schema: T,
query: Record<string, any>,
): Partial<Static<T>> | undefined => {
try {
const out: Record<string, any> = {};
for (const name of Object.keys(schema.properties)) {
const raw = query[name];
if (raw != null && raw !== "") {
out[name] = alepha.codec.decode(schema.properties[name], raw);
}
}
return out as Partial<Static<T>>;
} catch {
return;
}
};
/**
* querystring write: spread the decoded object across individual params,
* dropping empty values so the URL stays clean. Unrelated params (not part
* of the schema) are preserved.
*/
const writeQueryString = (
alepha: Alepha,
schema: TObject,
current: Record<string, any>,
next: any,
): Record<string, any> => {
const decoded = alepha.codec.decode(schema, next) as Record<string, any>;
const merged = { ...current };
for (const name of Object.keys(schema.properties)) {
const value = decoded?.[name];
if (value == null || value === "") {
delete merged[name];
} else {
merged[name] = String(value);
}
}
return merged;
};