2026-02-03 17:02:17 +01:00
|
|
|
export type QueryParams = {
|
|
|
|
|
[key: string]:
|
|
|
|
|
| string
|
|
|
|
|
| number
|
|
|
|
|
| boolean
|
|
|
|
|
| (string | number)[]
|
|
|
|
|
| null
|
|
|
|
|
| undefined
|
|
|
|
|
| QueryParams;
|
|
|
|
|
};
|
2025-10-22 17:09:48 +02:00
|
|
|
|
|
|
|
|
type Method = "get" | "post" | "put" | "delete" | "patch" | "head" | "options";
|
2026-02-03 17:02:17 +01:00
|
|
|
type UrlDefaults = Record<string, unknown>;
|
2025-10-22 17:09:48 +02:00
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
let urlDefaults: () => UrlDefaults = () => ({});
|
2025-10-22 17:09:48 +02:00
|
|
|
|
|
|
|
|
export type RouteDefinition<TMethod extends Method | Method[]> = {
|
|
|
|
|
url: string;
|
|
|
|
|
} & (TMethod extends Method[] ? { methods: TMethod } : { method: TMethod });
|
|
|
|
|
|
|
|
|
|
export type RouteFormDefinition<TMethod extends Method> = {
|
|
|
|
|
action: string;
|
|
|
|
|
method: TMethod;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type RouteQueryOptions = {
|
|
|
|
|
query?: QueryParams;
|
|
|
|
|
mergeQuery?: QueryParams;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
const getValue = (value: string | number | boolean) => {
|
|
|
|
|
if (value === true) {
|
|
|
|
|
return "1";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (value === false) {
|
|
|
|
|
return "0";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return value.toString();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const addNestedParams = (
|
|
|
|
|
obj: QueryParams,
|
|
|
|
|
prefix: string,
|
|
|
|
|
params: URLSearchParams,
|
|
|
|
|
) => {
|
|
|
|
|
Object.entries(obj).forEach(([subKey, value]) => {
|
|
|
|
|
if (value === undefined) return;
|
|
|
|
|
|
|
|
|
|
const paramKey = `${prefix}[${subKey}]`;
|
|
|
|
|
|
|
|
|
|
if (Array.isArray(value)) {
|
|
|
|
|
value.forEach((v) => params.append(`${paramKey}[]`, getValue(v)));
|
|
|
|
|
} else if (value !== null && typeof value === "object") {
|
|
|
|
|
addNestedParams(value, paramKey, params);
|
|
|
|
|
} else if (["string", "number", "boolean"].includes(typeof value)) {
|
|
|
|
|
params.set(paramKey, getValue(value as string | number | boolean));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-22 17:09:48 +02:00
|
|
|
export const queryParams = (options?: RouteQueryOptions) => {
|
|
|
|
|
if (!options || (!options.query && !options.mergeQuery)) {
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const query = options.query ?? options.mergeQuery;
|
|
|
|
|
const includeExisting = options.mergeQuery !== undefined;
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams(
|
|
|
|
|
includeExisting && typeof window !== "undefined"
|
|
|
|
|
? window.location.search
|
|
|
|
|
: "",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
for (const key in query) {
|
2026-02-03 17:02:17 +01:00
|
|
|
const queryValue = query[key];
|
|
|
|
|
|
|
|
|
|
if (queryValue === undefined || queryValue === null) {
|
2025-10-22 17:09:48 +02:00
|
|
|
params.delete(key);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
if (Array.isArray(queryValue)) {
|
2025-10-22 17:09:48 +02:00
|
|
|
if (params.has(`${key}[]`)) {
|
|
|
|
|
params.delete(`${key}[]`);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
queryValue.forEach((value) => {
|
2025-10-22 17:09:48 +02:00
|
|
|
params.append(`${key}[]`, value.toString());
|
|
|
|
|
});
|
2026-02-03 17:02:17 +01:00
|
|
|
} else if (typeof queryValue === "object") {
|
2025-10-22 17:09:48 +02:00
|
|
|
params.forEach((_, paramKey) => {
|
|
|
|
|
if (paramKey.startsWith(`${key}[`)) {
|
|
|
|
|
params.delete(paramKey);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
addNestedParams(queryValue, key, params);
|
2025-10-22 17:09:48 +02:00
|
|
|
} else {
|
2026-02-03 17:02:17 +01:00
|
|
|
params.set(key, getValue(queryValue));
|
2025-10-22 17:09:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const str = params.toString();
|
|
|
|
|
|
|
|
|
|
return str.length > 0 ? `?${str}` : "";
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
export const setUrlDefaults = (params: UrlDefaults | (() => UrlDefaults)) => {
|
|
|
|
|
urlDefaults = typeof params === "function" ? params : () => params;
|
2025-10-22 17:09:48 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const addUrlDefault = (
|
|
|
|
|
key: string,
|
|
|
|
|
value: string | number | boolean,
|
|
|
|
|
) => {
|
2026-02-03 17:02:17 +01:00
|
|
|
const params = urlDefaults();
|
|
|
|
|
params[key] = value;
|
|
|
|
|
|
|
|
|
|
urlDefaults = () => params;
|
2025-10-22 17:09:48 +02:00
|
|
|
};
|
|
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
export const applyUrlDefaults = <T extends UrlDefaults | undefined>(
|
2025-10-22 17:09:48 +02:00
|
|
|
existing: T,
|
|
|
|
|
): T => {
|
2026-02-03 17:02:17 +01:00
|
|
|
const existingParams = { ...(existing ?? ({} as UrlDefaults)) };
|
|
|
|
|
const defaultParams = urlDefaults();
|
2025-10-22 17:09:48 +02:00
|
|
|
|
2026-02-03 17:02:17 +01:00
|
|
|
for (const key in defaultParams) {
|
2025-10-22 17:09:48 +02:00
|
|
|
if (
|
|
|
|
|
existingParams[key] === undefined &&
|
2026-02-03 17:02:17 +01:00
|
|
|
defaultParams[key] !== undefined
|
2025-10-22 17:09:48 +02:00
|
|
|
) {
|
2026-02-03 17:02:17 +01:00
|
|
|
(existingParams as Record<string, unknown>)[key] =
|
|
|
|
|
defaultParams[key];
|
2025-10-22 17:09:48 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return existingParams as T;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const validateParameters = (
|
|
|
|
|
args: Record<string, unknown> | undefined,
|
|
|
|
|
optional: string[],
|
|
|
|
|
) => {
|
|
|
|
|
const missing = optional.filter((key) => !args?.[key]);
|
|
|
|
|
const expectedMissing = optional.slice(missing.length * -1);
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < missing.length; i++) {
|
|
|
|
|
if (missing[i] !== expectedMissing[i]) {
|
|
|
|
|
throw Error(
|
|
|
|
|
"Unexpected optional parameters missing. Unable to generate a URL.",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|