@querykitjs/web 3.1.0 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +208 -38
- package/README.md +209 -39
- package/dist/angular/index.cjs +2 -0
- package/dist/angular/index.cjs.map +1 -0
- package/dist/angular/index.d.cts +70 -0
- package/dist/angular/index.d.ts +70 -0
- package/dist/angular/index.js +2 -0
- package/dist/angular/index.js.map +1 -0
- package/dist/index.cjs +1 -360
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +75 -3
- package/dist/index.d.ts +75 -3
- package/dist/index.js +1 -336
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +1 -147
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +11 -4
- package/dist/react/index.d.ts +11 -4
- package/dist/react/index.js +1 -144
- package/dist/react/index.js.map +1 -1
- package/dist/{registry-B1WohYab.d.cts → registry-CEq4xFui.d.cts} +111 -53
- package/dist/{registry-B1WohYab.d.ts → registry-CEq4xFui.d.ts} +111 -53
- package/dist/vue/index.cjs +2 -0
- package/dist/vue/index.cjs.map +1 -0
- package/dist/vue/index.d.cts +64 -0
- package/dist/vue/index.d.ts +64 -0
- package/dist/vue/index.js +2 -0
- package/dist/vue/index.js.map +1 -0
- package/package.json +100 -15
- package/skills/querykit-web/SKILL.md +241 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { AdapterName, ListSchema, UrlConfig, WithFor, ListPayload, SortInput, Resource } from '../index.js';
|
|
2
|
+
import { MaybeRefOrGetter, ComputedRef } from 'vue';
|
|
3
|
+
import '@querykitjs/core';
|
|
4
|
+
|
|
5
|
+
interface UseListParamsBaseOptions<A extends AdapterName> {
|
|
6
|
+
/** URL↔filter schema ({@link defineListSchema} / `resource.schema`). */
|
|
7
|
+
schema: ListSchema;
|
|
8
|
+
/** Current URL search params — a ref, a getter or a plain value (from your router). */
|
|
9
|
+
searchParams: MaybeRefOrGetter<URLSearchParams>;
|
|
10
|
+
/** Apply new search params (`(next) => void`). */
|
|
11
|
+
setSearchParams: (next: URLSearchParams) => void;
|
|
12
|
+
/** URL param names (page/size/sortType) — optional. */
|
|
13
|
+
url?: UrlConfig;
|
|
14
|
+
/** Fixed relations to load on every request (not URL-driven). */
|
|
15
|
+
with?: WithFor<A>;
|
|
16
|
+
}
|
|
17
|
+
interface UseListParamsResult {
|
|
18
|
+
/** Ready-to-send list payload (built from the URL via the resource's defaults). */
|
|
19
|
+
params: ComputedRef<ListPayload>;
|
|
20
|
+
/**
|
|
21
|
+
* The URL's page or sort was past the limits and was clamped (`?page=9999` → the last
|
|
22
|
+
* allowed page). The app may rewrite the URL (`setPage(params.value.page)`).
|
|
23
|
+
*/
|
|
24
|
+
clamped: ComputedRef<boolean>;
|
|
25
|
+
searchParams: ComputedRef<URLSearchParams>;
|
|
26
|
+
/** Set/clear a filter param (resets `page`). */
|
|
27
|
+
setParam: (name: string, value: string | null | undefined) => void;
|
|
28
|
+
setPage: (page: number) => void;
|
|
29
|
+
setSize: (size: number) => void;
|
|
30
|
+
setSort: (sort: SortInput) => void;
|
|
31
|
+
reset: (keep?: string[]) => void;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Router-agnostic list URL-state composable — the Vue twin of the React
|
|
35
|
+
* `useListParamsBase`: the same reads, clamping and setters, as computed refs. Bring your
|
|
36
|
+
* own `searchParams`/`setSearchParams` (Nuxt `useRoute`, a custom router, …).
|
|
37
|
+
*/
|
|
38
|
+
declare function useListParamsBase<T, A extends AdapterName>(resource: Resource<T, A>, options: UseListParamsBaseOptions<A>): UseListParamsResult;
|
|
39
|
+
|
|
40
|
+
interface UseListParamsOptions<A extends AdapterName> {
|
|
41
|
+
/** URL↔filter schema ({@link defineListSchema} / `resource.schema`). */
|
|
42
|
+
schema: ListSchema;
|
|
43
|
+
/** URL param names (page/size/sortType) — optional. */
|
|
44
|
+
url?: UrlConfig;
|
|
45
|
+
/** Fixed relations to load on every request (not URL-driven). */
|
|
46
|
+
with?: WithFor<A>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* List URL-state composable — turnkey with **vue-router** 4 / 5 (`useRoute` + `useRouter`).
|
|
50
|
+
* Reads filters/sort/page from the route query via the schema, builds a ready-to-send
|
|
51
|
+
* payload through the `resource`, and returns URL setters that `router.push({ query })` (a
|
|
52
|
+
* new history entry; filter/sort/size changes reset `page`). Does **not** fetch.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* ```ts
|
|
56
|
+
* const users = qk.resource<IUser>("users");
|
|
57
|
+
* const { params, setPage, setSort } = useListParams(users, { schema: usersSchema });
|
|
58
|
+
* const { data } = useQuery({ queryKey: computed(() => users.keys.list(params.value)), queryFn: () => post("/users/list", params.value) });
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
declare function useListParams<T, A extends AdapterName>(resource: Resource<T, A>, options: UseListParamsOptions<A>): UseListParamsResult;
|
|
62
|
+
|
|
63
|
+
export { useListParams, useListParamsBase };
|
|
64
|
+
export type { UseListParamsBaseOptions, UseListParamsOptions, UseListParamsResult };
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import {useRoute,useRouter}from'vue-router';import {computed,toValue}from'vue';import'@querykitjs/core';function P(a){let t=new URLSearchParams;for(let[s,e]of Object.entries(a))for(let r of Array.isArray(e)?e:[e])r!==void 0&&t.append(s,r??"");return t}function g(a){let t={};for(let s of new Set(a.keys())){let e=a.getAll(s);t[s]=e.length>1?e:e[0]??"";}return t}var p={pageParam:"page",sizeParam:"size",sortParam:"sortType"};function A(a){return a.map(t=>typeof t=="string"?t.trim():t&&t.key?(t.direction==="desc"?"-":"")+t.key:"").filter(Boolean).join(",")}function u(a){return new URLSearchParams(a)}function S(a,t,s,e={}){let r={...p,...e},o=u(a);return o.delete(r.pageParam),s==null||s===""?o.delete(t):o.set(t,s),o}function L(a,t,s={}){let e={...p,...s},r=u(a);return r.set(e.pageParam,String(t)),r}function h(a,t,s={}){let e={...p,...s},r=u(a);return r.delete(e.pageParam),r.set(e.sizeParam,String(t)),r}function y(a,t,s={}){let e={...p,...s},r=u(a);r.delete(e.pageParam);let o=A(t);return o?r.set(e.sortParam,o):r.delete(e.sortParam),r}function U(a,t=[]){let s=new URLSearchParams;for(let e of t){let r=a.get(e);r!==null&&s.set(e,r);}return s}function R(a,t){let{schema:s,url:e,with:r}=t;return {read(o){let n=a.fromSearchParams(o,{schema:s,url:e,with:r});return {params:n,clamped:n.clamped===true}},actions(o){return {setParam:(n,m)=>S(o,n,m,e),setPage:n=>L(o,n,e),setSize:n=>h(o,n,e),setSort:n=>y(o,n,e),reset:n=>U(o,n)}}}}function f(a,t){let{schema:s,setSearchParams:e,url:r,with:o}=t,n=R(a,{schema:s,url:r,with:o}),m=computed(()=>toValue(t.searchParams)),d=computed(()=>n.read(m.value)),c=()=>n.actions(m.value);return {params:computed(()=>d.value.params),clamped:computed(()=>d.value.clamped),searchParams:m,setParam:(i,x)=>e(c().setParam(i,x)),setPage:i=>e(c().setPage(i)),setSize:i=>e(c().setSize(i)),setSort:i=>e(c().setSort(i)),reset:i=>e(c().reset(i))}}function T(a,t){let s=useRoute(),e=useRouter();return f(a,{...t,searchParams:()=>P(s.query),setSearchParams:r=>{e.push({query:g(r)});}})}export{T as useListParams,f as useListParamsBase};//# sourceMappingURL=index.js.map
|
|
2
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/route-query.ts","../../src/url.ts","../../src/list-state.ts","../../src/vue/use-list-params-base.ts","../../src/vue/use-list-params.ts"],"names":["queryToSearchParams","query","searchParams","key","value","item","searchParamsToQuery","values","URL_DEFAULTS","encodeSort","sort","i","clone","setParam","name","url","cfg","next","setPage","page","setSize","size","setSort","encoded","resetParams","keep","createListState","resource","options","schema","withRel","params","useListParamsBase","setSearchParams","state","computed","toValue","read","actions","useListParams","route","useRoute","router","useRouter"],"mappings":"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","file":"index.js","sourcesContent":["/**\n^ * A route query as vue-router gives it: v4 has `string | null | (string | null)[]` per key,\n * v5 always an array. `null` is a key without `=` (`?q`).\n */\nexport type RouteQuery = Record<string, string | null | (string | null)[] | undefined>;\n\n/** Route query → search params: a repeated key keeps every value; `null` becomes an empty value. */\nexport function queryToSearchParams(query: RouteQuery): URLSearchParams {\n const searchParams = new URLSearchParams();\n for (const [key, value] of Object.entries(query)) {\n for (const item of Array.isArray(value) ? value : [value]) {\n if (item !== undefined) searchParams.append(key, item ?? \"\");\n }\n }\n return searchParams;\n}\n\n/** Search params → route query for `router.push({ query })`: one value → string, a repeated key → array. */\nexport function searchParamsToQuery(searchParams: URLSearchParams): Record<string, string | string[]> {\n const query: Record<string, string | string[]> = {};\n for (const key of new Set(searchParams.keys())) {\n const values = searchParams.getAll(key);\n query[key] = values.length > 1 ? values : (values[0] ?? \"\");\n }\n return query;\n}\n","import { DEFAULT_MAX_OFFSET, DEFAULT_MAX_PER_PAGE, DEFAULT_MAX_SORT_FIELDS, DEFAULT_PER_PAGE } from \"@querykitjs/core\";\n\nimport { buildListParams, clampInt } from \"./query\";\nimport type { FieldDescriptor, ListSchema } from \"./schema\";\nimport type { FieldCondition, Filter, FilterNode, FilterValue, ListParams, ListPayload, Sort, SortDirection, SortInput } from \"./types\";\n\n/** URL param nomlari (sahifa/o'lcham/sort) — sozlanadigan. */\nexport interface UrlConfig {\n pageParam?: string;\n sizeParam?: string;\n sortParam?: string;\n}\n\nconst URL_DEFAULTS: Required<UrlConfig> = {\n pageParam: \"page\",\n sizeParam: \"size\",\n sortParam: \"sortType\",\n};\n\n/**\n * `\"-createdAt,id,-appNumber\"` → `[{ key, direction }, …]` — comma-separated,\n * order preserved, `-` prefix = `desc` (else `asc`). Empty → `undefined`.\n */\nexport function decodeSort(value: string | null | undefined): Sort | undefined {\n if (!value) return undefined;\n const items = value\n .split(\",\")\n .map(s => s.trim())\n .filter(Boolean)\n .map(tok => {\n const desc = tok.startsWith(\"-\");\n return { key: desc ? tok.slice(1) : tok, direction: (desc ? \"desc\" : \"asc\") as SortDirection };\n })\n .filter(i => i.key); // drop empty keys (e.g. a lone \"-\")\n return items.length ? items : undefined;\n}\n\n/** `[{ key, direction }, …]` (or `\"-field\"` shorthand strings) → `\"-createdAt,id,-appNumber\"` (empty → `\"\"`). */\nexport function encodeSort(sort: SortInput): string {\n return sort\n .map(i => (typeof i === \"string\" ? i.trim() : i && i.key ? (i.direction === \"desc\" ? \"-\" : \"\") + i.key : \"\"))\n .filter(Boolean)\n .join(\",\");\n}\n\n/**\n * Schema + searchParams → filter. Applies `range`/`between`/`search`/`split`/\n * `default`/`trim`; empty/absent params are dropped. Returns a flat\n * `FieldCondition[]` (implicit AND), or a tree when a `search` OR-group is\n * present (`{ and: [orGroup, …conditions] }`).\n */\nexport function schemaToFilter(schema: ListSchema, searchParams: URLSearchParams): Filter {\n const conditions: FieldCondition[] = [];\n const groups: FilterNode[] = [];\n\n for (const [param, d] of Object.entries(schema) as [string, FieldDescriptor][]) {\n const key = d.key ?? param;\n\n // range → two inclusive conditions (>= , <=)\n if (d.range) {\n const from = searchParams.get(d.range[0]);\n const to = searchParams.get(d.range[1]);\n if (from) conditions.push(cond(key, \">=\", from));\n if (to) conditions.push(cond(key, \"<=\", to));\n continue;\n }\n\n // between → one condition with a 2-tuple value\n if (d.between) {\n const from = searchParams.get(d.between[0]);\n const to = searchParams.get(d.between[1]);\n if (from && to) conditions.push(cond(key, \"between\", [from, to]));\n continue;\n }\n\n // read value, falling back to `default` only when the param is ABSENT\n // (an empty `?x=` means the user cleared the filter → prune, don't default).\n const raw = searchParams.get(param);\n let value: FilterValue | undefined = raw !== null ? raw : d.default;\n if (typeof value === \"string\" && d.trim) value = value.trim();\n if (value === undefined || value === null || value === \"\") continue;\n if (Array.isArray(value) && value.length === 0) continue;\n\n // search → OR of `contains` across the listed fields (needs a string)\n if (d.search && typeof value === \"string\") {\n groups.push({ or: d.search.map(field => cond(field, \"%_%\", value as string)) });\n continue;\n }\n\n // split → comma-string → array (for `in`/`notIn`); guarded against empties\n if (d.split && typeof value === \"string\") {\n const delimiter = d.split === true ? \",\" : d.split;\n const parts = value\n .split(delimiter)\n .map(s => s.trim())\n .filter(Boolean);\n if (parts.length === 0) continue;\n value = parts;\n }\n\n conditions.push(cond(key, d.operation ?? \"=\", value));\n }\n\n return groups.length === 0 ? conditions : { and: [...groups, ...conditions] };\n}\n\nfunction cond(key: string, operation: FieldCondition[\"operation\"], value: FilterValue): FieldCondition {\n return { key, operation, value };\n}\n\n/**\n * The bounds the URL readers clamp to — the builder's own (`createRegistry`\n * `defaults`); an absent one is the core default.\n */\nexport interface UrlLimits {\n perPage?: number;\n maxPerPage?: number;\n maxOffset?: number;\n maxSortFields?: number;\n}\n\n/** {@link ListParams} read from a URL. `clamped` — the URL's page or sort was cut to the limits. */\nexport type UrlListParams = ListParams & { clamped: boolean };\n\n/**\n * A list payload built from a URL. `clamped` is non-enumerable: readable in code,\n * never serialized, so the wire payload is unchanged.\n */\nexport type UrlListPayload = ListPayload & { readonly clamped?: boolean };\n\n/** Marks a payload with a non-enumerable `clamped` flag. */\nexport function withClamped(payload: ListPayload, clamped: boolean): UrlListPayload {\n return Object.defineProperty(payload, \"clamped\", { value: clamped, enumerable: false });\n}\n\n/** The last page the builder accepts: `(page - 1) × perPage ≤ maxOffset`. */\nfunction lastPage(perPage: number | undefined, limits: UrlLimits): number {\n const resolved = clampInt(perPage, limits.perPage ?? DEFAULT_PER_PAGE, limits.maxPerPage ?? DEFAULT_MAX_PER_PAGE);\n return Math.floor((limits.maxOffset ?? DEFAULT_MAX_OFFSET) / resolved) + 1;\n}\n\n/**\n * searchParams → normalizatsiyalanmagan {@link ListParams} (filter/sort/page/perPage).\n * The URL is user input: a page past `maxOffset` becomes the last allowed page and a\n * sort past `maxSortFields` keeps its first fields, with `clamped: true` — so a\n * hand-edited `?page=9999` never throws during render. The builders still throw\n * for a direct call.\n */\nexport function readListParams(schema: ListSchema, searchParams: URLSearchParams, url: UrlConfig = {}, limits: UrlLimits = {}): UrlListParams {\n const cfg = { ...URL_DEFAULTS, ...url };\n const size = searchParams.get(cfg.sizeParam);\n const perPage = size ? Number(size) : undefined;\n const page = Math.trunc(Number(searchParams.get(cfg.pageParam))) || 1;\n const sort = decodeSort(searchParams.get(cfg.sortParam));\n const maxPage = lastPage(perPage, limits);\n const maxSortFields = limits.maxSortFields ?? DEFAULT_MAX_SORT_FIELDS;\n const isPageClamped = page > maxPage;\n const isSortClamped = sort !== undefined && sort.length > maxSortFields;\n return {\n filter: schemaToFilter(schema, searchParams),\n sort: isSortClamped ? sort.slice(0, maxSortFields) : sort,\n page: isPageClamped ? maxPage : page,\n perPage,\n clamped: isPageClamped || isSortClamped,\n };\n}\n\n/** searchParams → to'liq, yuborishga tayyor {@link ListPayload} (core default limits; clamped like {@link readListParams}). */\nexport function searchParamsToPayload(schema: ListSchema, searchParams: URLSearchParams, url?: UrlConfig): UrlListPayload {\n const params = readListParams(schema, searchParams, url);\n return withClamped(buildListParams(params), params.clamped);\n}\n\n/* ------------------------- URL yozish (immutable) ------------------------- */\n/* Har biri YANGI URLSearchParams qaytaradi; filter/sort o'zgarsa `page` reset. */\n\nfunction clone(searchParams: URLSearchParams): URLSearchParams {\n return new URLSearchParams(searchParams);\n}\n\n/** Filter param'ini o'rnatadi/o'chiradi va `page`ni reset qiladi. */\nexport function setParam(searchParams: URLSearchParams, name: string, value: string | null | undefined, url: UrlConfig = {}): URLSearchParams {\n const cfg = { ...URL_DEFAULTS, ...url };\n const next = clone(searchParams);\n next.delete(cfg.pageParam);\n if (value === null || value === undefined || value === \"\") next.delete(name);\n else next.set(name, value);\n return next;\n}\n\n/** Sahifani o'rnatadi (`page` reset qilinmaydi). */\nexport function setPage(searchParams: URLSearchParams, page: number, url: UrlConfig = {}): URLSearchParams {\n const cfg = { ...URL_DEFAULTS, ...url };\n const next = clone(searchParams);\n next.set(cfg.pageParam, String(page));\n return next;\n}\n\n/** Sahifa o'lchamini o'rnatadi va `page`ni reset qiladi. */\nexport function setSize(searchParams: URLSearchParams, size: number, url: UrlConfig = {}): URLSearchParams {\n const cfg = { ...URL_DEFAULTS, ...url };\n const next = clone(searchParams);\n next.delete(cfg.pageParam);\n next.set(cfg.sizeParam, String(size));\n return next;\n}\n\n/** Sortni o'rnatadi (`[{ key, direction }, …]`) va `page`ni reset qiladi. */\nexport function setSort(searchParams: URLSearchParams, sort: SortInput, url: UrlConfig = {}): URLSearchParams {\n const cfg = { ...URL_DEFAULTS, ...url };\n const next = clone(searchParams);\n next.delete(cfg.pageParam);\n const encoded = encodeSort(sort);\n if (encoded) next.set(cfg.sortParam, encoded);\n else next.delete(cfg.sortParam);\n return next;\n}\n\n/** Barcha list param'larini tozalaydi (ixtiyoriy saqlanadigan kalitlar bilan). */\nexport function resetParams(searchParams: URLSearchParams, keep: string[] = []): URLSearchParams {\n const next = new URLSearchParams();\n for (const key of keep) {\n const value = searchParams.get(key);\n if (value !== null) next.set(key, value);\n }\n return next;\n}\n","import type { AdapterName, WithFor } from \"./adapter\";\nimport type { Resource } from \"./registry\";\nimport type { ListSchema } from \"./schema\";\nimport type { ListPayload, SortInput } from \"./types\";\nimport type { UrlConfig } from \"./url\";\nimport { resetParams, setPage, setParam, setSize, setSort } from \"./url\";\n\nexport interface ListStateOptions<A extends AdapterName> {\n /** URL↔filter schema ({@link defineListSchema} / `resource.schema`). */\n schema: ListSchema;\n /** URL param names (page/size/sortType) — optional. */\n url?: UrlConfig;\n /** Fixed relations to load on every request (not URL-driven). */\n with?: WithFor<A>;\n}\n\nexport interface ListStateRead {\n /** Ready-to-send list payload (built from the URL via the resource's defaults). */\n params: ListPayload;\n /** The URL's page or sort was past the limits and was clamped (`?page=9999` → the last allowed page). */\n clamped: boolean;\n}\n\n/** URL setters: each returns the NEXT search params and never mutates the given ones. */\nexport interface ListStateActions {\n /** Set/clear a filter param (resets `page`). */\n setParam: (name: string, value: string | null | undefined) => URLSearchParams;\n setPage: (page: number) => URLSearchParams;\n /** Resets `page`. */\n setSize: (size: number) => URLSearchParams;\n /** Resets `page`; an empty sort clears the param. */\n setSort: (sort: SortInput) => URLSearchParams;\n /** Clears every param except `keep`. */\n reset: (keep?: string[]) => URLSearchParams;\n}\n\nexport interface ListState {\n read: (searchParams: URLSearchParams) => ListStateRead;\n actions: (searchParams: URLSearchParams) => ListStateActions;\n}\n\n/**\n * Framework-free list URL state — the logic behind `useListParams` in `@querykitjs/web/react`\n * and `@querykitjs/web/vue`, for any other framework (Svelte, Solid, vanilla). Pure: it reads\n * search params and returns the next ones; navigating is the caller's job.\n *\n * @example\n * ```ts\n * const list = createListState(users, { schema: usersSchema });\n * const { params, clamped } = list.read(new URLSearchParams(location.search));\n * history.pushState(null, \"\", `?${list.actions(new URLSearchParams(location.search)).setPage(2)}`);\n * ```\n */\nexport function createListState<T, A extends AdapterName>(resource: Resource<T, A>, options: ListStateOptions<A>): ListState {\n const { schema, url, with: withRel } = options;\n return {\n read(searchParams) {\n const params = resource.fromSearchParams(searchParams, { schema, url, with: withRel });\n return { params, clamped: params.clamped === true };\n },\n actions(searchParams) {\n return {\n setParam: (name, value) => setParam(searchParams, name, value, url),\n setPage: page => setPage(searchParams, page, url),\n setSize: size => setSize(searchParams, size, url),\n setSort: sort => setSort(searchParams, sort, url),\n reset: keep => resetParams(searchParams, keep),\n };\n },\n };\n}\n","import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from \"vue\";\n\nimport type { AdapterName, WithFor } from \"../adapter\";\nimport { createListState } from \"../list-state\";\nimport type { Resource } from \"../registry\";\nimport type { ListSchema } from \"../schema\";\nimport type { ListPayload, SortInput } from \"../types\";\nimport type { UrlConfig } from \"../url\";\n\nexport interface UseListParamsBaseOptions<A extends AdapterName> {\n /** URL↔filter schema ({@link defineListSchema} / `resource.schema`). */\n schema: ListSchema;\n /** Current URL search params — a ref, a getter or a plain value (from your router). */\n searchParams: MaybeRefOrGetter<URLSearchParams>;\n /** Apply new search params (`(next) => void`). */\n setSearchParams: (next: URLSearchParams) => void;\n /** URL param names (page/size/sortType) — optional. */\n url?: UrlConfig;\n /** Fixed relations to load on every request (not URL-driven). */\n with?: WithFor<A>;\n}\n\nexport interface UseListParamsResult {\n /** Ready-to-send list payload (built from the URL via the resource's defaults). */\n params: ComputedRef<ListPayload>;\n /**\n * The URL's page or sort was past the limits and was clamped (`?page=9999` → the last\n * allowed page). The app may rewrite the URL (`setPage(params.value.page)`).\n */\n clamped: ComputedRef<boolean>;\n searchParams: ComputedRef<URLSearchParams>;\n /** Set/clear a filter param (resets `page`). */\n setParam: (name: string, value: string | null | undefined) => void;\n setPage: (page: number) => void;\n setSize: (size: number) => void;\n setSort: (sort: SortInput) => void;\n reset: (keep?: string[]) => void;\n}\n\n/**\n * Router-agnostic list URL-state composable — the Vue twin of the React\n * `useListParamsBase`: the same reads, clamping and setters, as computed refs. Bring your\n * own `searchParams`/`setSearchParams` (Nuxt `useRoute`, a custom router, …).\n */\nexport function useListParamsBase<T, A extends AdapterName>(resource: Resource<T, A>, options: UseListParamsBaseOptions<A>): UseListParamsResult {\n const { schema, setSearchParams, url, with: withRel } = options;\n const state = createListState(resource, { schema, url, with: withRel });\n const searchParams = computed(() => toValue(options.searchParams));\n const read = computed(() => state.read(searchParams.value));\n const actions = () => state.actions(searchParams.value);\n\n return {\n params: computed(() => read.value.params),\n clamped: computed(() => read.value.clamped),\n searchParams,\n setParam: (name, value) => setSearchParams(actions().setParam(name, value)),\n setPage: page => setSearchParams(actions().setPage(page)),\n setSize: size => setSearchParams(actions().setSize(size)),\n setSort: sort => setSearchParams(actions().setSort(sort)),\n reset: keep => setSearchParams(actions().reset(keep)),\n };\n}\n","import { useRoute, useRouter } from \"vue-router\";\n\nimport type { AdapterName, WithFor } from \"../adapter\";\nimport type { Resource } from \"../registry\";\nimport type { ListSchema } from \"../schema\";\nimport type { UrlConfig } from \"../url\";\nimport { queryToSearchParams, searchParamsToQuery } from \"../route-query\";\nimport { useListParamsBase, type UseListParamsResult } from \"./use-list-params-base\";\n\nexport interface UseListParamsOptions<A extends AdapterName> {\n /** URL↔filter schema ({@link defineListSchema} / `resource.schema`). */\n schema: ListSchema;\n /** URL param names (page/size/sortType) — optional. */\n url?: UrlConfig;\n /** Fixed relations to load on every request (not URL-driven). */\n with?: WithFor<A>;\n}\n\n/**\n * List URL-state composable — turnkey with **vue-router** 4 / 5 (`useRoute` + `useRouter`).\n * Reads filters/sort/page from the route query via the schema, builds a ready-to-send\n * payload through the `resource`, and returns URL setters that `router.push({ query })` (a\n * new history entry; filter/sort/size changes reset `page`). Does **not** fetch.\n *\n * @example\n * ```ts\n * const users = qk.resource<IUser>(\"users\");\n * const { params, setPage, setSort } = useListParams(users, { schema: usersSchema });\n * const { data } = useQuery({ queryKey: computed(() => users.keys.list(params.value)), queryFn: () => post(\"/users/list\", params.value) });\n * ```\n */\nexport function useListParams<T, A extends AdapterName>(resource: Resource<T, A>, options: UseListParamsOptions<A>): UseListParamsResult {\n const route = useRoute();\n const router = useRouter();\n return useListParamsBase(resource, {\n ...options,\n searchParams: () => queryToSearchParams(route.query),\n setSearchParams: next => void router.push({ query: searchParamsToQuery(next) }),\n });\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@querykitjs/web",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.1.0",
|
|
4
4
|
"description": "Frontend query-building for querykit — typed filters, pagination params & URL sync. Zero-dependency; you send the request.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": {
|
|
@@ -23,18 +23,62 @@
|
|
|
23
23
|
"types": "./dist/index.d.ts",
|
|
24
24
|
"exports": {
|
|
25
25
|
".": {
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
"import": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"default": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"require": {
|
|
31
|
+
"types": "./dist/index.d.cts",
|
|
32
|
+
"default": "./dist/index.cjs"
|
|
33
|
+
}
|
|
29
34
|
},
|
|
30
35
|
"./react": {
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
"import": {
|
|
37
|
+
"types": "./dist/react/index.d.ts",
|
|
38
|
+
"default": "./dist/react/index.js"
|
|
39
|
+
},
|
|
40
|
+
"require": {
|
|
41
|
+
"types": "./dist/react/index.d.cts",
|
|
42
|
+
"default": "./dist/react/index.cjs"
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"./vue": {
|
|
46
|
+
"import": {
|
|
47
|
+
"types": "./dist/vue/index.d.ts",
|
|
48
|
+
"default": "./dist/vue/index.js"
|
|
49
|
+
},
|
|
50
|
+
"require": {
|
|
51
|
+
"types": "./dist/vue/index.d.cts",
|
|
52
|
+
"default": "./dist/vue/index.cjs"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"./angular": {
|
|
56
|
+
"import": {
|
|
57
|
+
"types": "./dist/angular/index.d.ts",
|
|
58
|
+
"default": "./dist/angular/index.js"
|
|
59
|
+
},
|
|
60
|
+
"require": {
|
|
61
|
+
"types": "./dist/angular/index.d.cts",
|
|
62
|
+
"default": "./dist/angular/index.cjs"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"typesVersions": {
|
|
67
|
+
"*": {
|
|
68
|
+
"react": [
|
|
69
|
+
"./dist/react/index.d.ts"
|
|
70
|
+
],
|
|
71
|
+
"vue": [
|
|
72
|
+
"./dist/vue/index.d.ts"
|
|
73
|
+
],
|
|
74
|
+
"angular": [
|
|
75
|
+
"./dist/angular/index.d.ts"
|
|
76
|
+
]
|
|
34
77
|
}
|
|
35
78
|
},
|
|
36
79
|
"files": [
|
|
37
80
|
"dist",
|
|
81
|
+
"skills/querykit-web",
|
|
38
82
|
"README.md",
|
|
39
83
|
"README.en.md",
|
|
40
84
|
"LICENSE"
|
|
@@ -43,31 +87,70 @@
|
|
|
43
87
|
"publishConfig": {
|
|
44
88
|
"access": "public"
|
|
45
89
|
},
|
|
90
|
+
"engines": {
|
|
91
|
+
"node": ">=22.12"
|
|
92
|
+
},
|
|
46
93
|
"scripts": {
|
|
47
94
|
"build": "tsup",
|
|
48
95
|
"dev": "tsup --watch",
|
|
49
96
|
"typecheck": "tsc --noEmit",
|
|
50
|
-
"test:smoke": "bun run test/smoke.ts"
|
|
97
|
+
"test:smoke": "bun run test/smoke.ts && bun run test/url-state.ts && bun run test/vue.ts && bun run test/angular.ts && bun run test:real-routers",
|
|
98
|
+
"test:angular": "bun run test/angular.ts",
|
|
99
|
+
"test:real-routers": "bun run test/real-router-react.ts && bun run test/real-router-vue.ts && bun run test/real-router-angular.ts",
|
|
100
|
+
"test:url-state": "bun run test/url-state.ts",
|
|
101
|
+
"test:hook": "bun run test/hook.ts",
|
|
102
|
+
"test:vue": "bun run test/vue.ts"
|
|
51
103
|
},
|
|
52
104
|
"dependencies": {
|
|
53
|
-
"@querykitjs/core": "^1.
|
|
105
|
+
"@querykitjs/core": "^2.1.0"
|
|
54
106
|
},
|
|
55
107
|
"peerDependencies": {
|
|
56
|
-
"react": "
|
|
57
|
-
"react-router
|
|
108
|
+
"react": "^18.3 || ^19.2.7",
|
|
109
|
+
"react-router": "^7.0 || ^8.0",
|
|
110
|
+
"vue": "^3.5",
|
|
111
|
+
"vue-router": "^4.6 || ^5",
|
|
112
|
+
"@angular/core": "^21 || ^22",
|
|
113
|
+
"@angular/router": "^21 || ^22",
|
|
114
|
+
"rxjs": "^7.8"
|
|
58
115
|
},
|
|
59
116
|
"peerDependenciesMeta": {
|
|
60
117
|
"react": {
|
|
61
118
|
"optional": true
|
|
62
119
|
},
|
|
63
|
-
"react-router
|
|
120
|
+
"react-router": {
|
|
121
|
+
"optional": true
|
|
122
|
+
},
|
|
123
|
+
"vue": {
|
|
124
|
+
"optional": true
|
|
125
|
+
},
|
|
126
|
+
"vue-router": {
|
|
127
|
+
"optional": true
|
|
128
|
+
},
|
|
129
|
+
"@angular/core": {
|
|
130
|
+
"optional": true
|
|
131
|
+
},
|
|
132
|
+
"@angular/router": {
|
|
133
|
+
"optional": true
|
|
134
|
+
},
|
|
135
|
+
"rxjs": {
|
|
64
136
|
"optional": true
|
|
65
137
|
}
|
|
66
138
|
},
|
|
67
139
|
"devDependencies": {
|
|
68
|
-
"@
|
|
69
|
-
"
|
|
70
|
-
"
|
|
140
|
+
"@angular/common": "22.2.4",
|
|
141
|
+
"@angular/compiler": "22.2.4",
|
|
142
|
+
"@angular/core": "22.2.4",
|
|
143
|
+
"@angular/platform-browser": "22.2.4",
|
|
144
|
+
"@angular/router": "22.2.4",
|
|
145
|
+
"@types/react": "19.3.0",
|
|
146
|
+
"@types/react-dom": "19.3.0",
|
|
147
|
+
"happy-dom": "21.0.4",
|
|
148
|
+
"react": "19.3.1",
|
|
149
|
+
"react-dom": "19.3.1",
|
|
150
|
+
"react-router": "8.5.1",
|
|
151
|
+
"rxjs": "7.8.2",
|
|
152
|
+
"vue": "3.5.44",
|
|
153
|
+
"vue-router": "5.4.0"
|
|
71
154
|
},
|
|
72
155
|
"keywords": [
|
|
73
156
|
"querykit",
|
|
@@ -78,6 +161,8 @@
|
|
|
78
161
|
"url-state",
|
|
79
162
|
"search-params",
|
|
80
163
|
"react",
|
|
164
|
+
"vue",
|
|
165
|
+
"angular",
|
|
81
166
|
"frontend",
|
|
82
167
|
"typescript"
|
|
83
168
|
]
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: querykit-web
|
|
3
|
+
description: Use when frontend code builds or reads a querykit list request (@querykitjs/web, /react, /vue, /angular) - createRegistry({ adapter }), qk.resource<T>(name), f filters, sort, list / infinite / cursor payloads, withTotal, parseList / parseInfinite / parseCursor, search, adapter-aware with, defineListSchema and URL helpers (readListParams, setPage, setSort), useListParams / useListParamsBase (clamped) for React and Vue, injectListParams for Angular, createListState, fromSearchParams for SSR, query keys, or reading a 400 with isQueryKitError.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Safety rules
|
|
7
|
+
|
|
8
|
+
- DO treat the server as the authority: `filterable`, `sortable` and the filter limits live in the backend repository and its validator. The web package does not repeat them, so a refused filter comes back as a 400.
|
|
9
|
+
- DON'T write `page` / `perPage` / `limit` math by hand. Build every payload with `resource.list` / `infinite` / `cursor` (or `buildListParams` …): `perPage` and `limit` are clamped to `200`, and an offset past `maxOffset` (`10000`) or more than `maxSortFields` (`3`) sort fields throws an `Error`. Use a cursor for deep pages.
|
|
10
|
+
- DO read list state from the URL with `readListParams`, `fromSearchParams` or `useListParams`: they never throw, they clamp a too-deep `page` or too-long `sort` and report it as `clamped: true` (`clamped` is not sent to the server).
|
|
11
|
+
- DON'T send `columns`, `with` or `withDeleted` unless the backend opened them on purpose (validator `allow`, repository `relations`). In `with`, name only the relations and columns the backend lists in `relations`; anything else is `forbidden-key`.
|
|
12
|
+
- DO send dates as ISO strings: the wire is JSON.
|
|
13
|
+
- DO read a 400 body with `isQueryKitError(body)` and use its `reason`, `site` and `key` (for example, mark the field named by `key`).
|
|
14
|
+
- DO handle `withTotal: false`: `meta.totalCount` and `meta.totalPages` are then `null`, so show "next" / "previous" from `hasNext` / `hasPrev` instead of a page count.
|
|
15
|
+
|
|
16
|
+
## When to use
|
|
17
|
+
|
|
18
|
+
- A browser or SSR frontend (React, Next.js, Vue, Nuxt, Angular, any fetch client) that talks to a querykit backend.
|
|
19
|
+
- Building list, infinite-scroll or cursor request bodies, keeping filters in the URL, and caching with TanStack Query or SWR.
|
|
20
|
+
|
|
21
|
+
The package builds the payload only. You send the request yourself.
|
|
22
|
+
|
|
23
|
+
## Setup
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import { createRegistry } from "@querykitjs/web";
|
|
27
|
+
|
|
28
|
+
interface User {
|
|
29
|
+
id: number;
|
|
30
|
+
name: string;
|
|
31
|
+
status: "active" | "blocked";
|
|
32
|
+
createdAt: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const qk = createRegistry({
|
|
36
|
+
adapter: "drizzle-pg", // "mongoose" | "mongodb" | "drizzle-pg" | "drizzle-sqlite" | "prisma-pg" — types `with` only
|
|
37
|
+
defaults: { perPage: 20, limit: 20 },
|
|
38
|
+
timestamps: { createdAt: "createdAt" }, // same name as the backend; the default sort is this field, desc
|
|
39
|
+
});
|
|
40
|
+
export const users = qk.resource<User>("users"); // the name is the cache-key namespace
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Common tasks
|
|
44
|
+
|
|
45
|
+
### Filter, sort and an offset page
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { createRegistry } from "@querykitjs/web";
|
|
49
|
+
|
|
50
|
+
const users = createRegistry({ adapter: "prisma-pg" }).resource<{ id: number; name: string; age: number; createdAt: string }>("users");
|
|
51
|
+
const { f } = users;
|
|
52
|
+
|
|
53
|
+
const body = users.list({
|
|
54
|
+
filter: f.and(f.gte("age", 18), f.or(f.contains("name", "ali"), f.isNull("age"))),
|
|
55
|
+
sort: [
|
|
56
|
+
{ key: "name", direction: "asc" },
|
|
57
|
+
{ key: "createdAt", direction: "desc" },
|
|
58
|
+
],
|
|
59
|
+
page: 2,
|
|
60
|
+
perPage: 20,
|
|
61
|
+
withTotal: false, // the server skips the count
|
|
62
|
+
});
|
|
63
|
+
const res = await fetch("/api/users/list", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
|
64
|
+
const { data, meta } = users.parseList(await res.json());
|
|
65
|
+
console.log(data.length, meta.totalCount ?? "unknown", meta.hasNext);
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Infinite scroll and cursor payloads
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { createRegistry } from "@querykitjs/web";
|
|
72
|
+
|
|
73
|
+
const posts = createRegistry({ adapter: "mongoose" }).resource<{ _id: string; title: string; createdAt: string }>("posts");
|
|
74
|
+
|
|
75
|
+
const feedBody = posts.infinite({ limit: 20, offset: 40 });
|
|
76
|
+
const firstPage = posts.cursor({ limit: 20, order: "desc", cursorKey: "createdAt" }); // no sort on a cursor
|
|
77
|
+
declare const raw: unknown;
|
|
78
|
+
const feed = posts.parseInfinite(raw as Parameters<typeof posts.parseInfinite>[0]);
|
|
79
|
+
const page = posts.parseCursor(raw as Parameters<typeof posts.parseCursor>[0]);
|
|
80
|
+
const nextBody = posts.cursor({ limit: 20, order: "desc", cursorKey: "createdAt", cursor: page.meta.nextCursor });
|
|
81
|
+
console.log(feedBody, firstPage, feed.meta.hasMore, feed.meta.nextOffset, nextBody);
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Search box across several fields
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { createRegistry } from "@querykitjs/web";
|
|
88
|
+
|
|
89
|
+
const users = createRegistry({ adapter: "drizzle-pg" }).resource<{ id: number; name: string; email: string }>("users");
|
|
90
|
+
|
|
91
|
+
const filter = users.search("ali", ["name", "email"], { operation: "startsWith" }); // OR of startsWith
|
|
92
|
+
const body = users.list({ filter, page: 1 });
|
|
93
|
+
console.log(body);
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Relations with an adapter-aware `with`
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { createRegistry } from "@querykitjs/web";
|
|
100
|
+
|
|
101
|
+
const posts = createRegistry({ adapter: "drizzle-pg" }).resource<{ id: number; title: string }>("posts");
|
|
102
|
+
|
|
103
|
+
// only relations and columns the backend lists in `relations`
|
|
104
|
+
const body = posts.list({ with: { author: { columns: { name: true } } }, page: 1 });
|
|
105
|
+
console.log(body.with);
|
|
106
|
+
|
|
107
|
+
// `@querykitjs/mongodb` loads no relations: `with` is a type error there — fetch the author with a second request
|
|
108
|
+
const comments = createRegistry({ adapter: "mongodb" }).resource<{ _id: string; authorId: string }>("comments");
|
|
109
|
+
const page = comments.list({ page: 1 });
|
|
110
|
+
console.log(page.page);
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Filters in the URL
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { defineListSchema, readListParams, setPage, setSort } from "@querykitjs/web";
|
|
117
|
+
|
|
118
|
+
const schema = defineListSchema({
|
|
119
|
+
status: { operation: "=", default: "active" },
|
|
120
|
+
q: { search: ["name", "email"] },
|
|
121
|
+
createdAt: { range: ["fromDate", "toDate"] },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const read = readListParams(schema, new URLSearchParams("page=9999&size=20&q=ali"));
|
|
125
|
+
console.log(read.page, read.clamped); // 501 true — clamped, never thrown
|
|
126
|
+
|
|
127
|
+
let sp = new URLSearchParams(location.search);
|
|
128
|
+
sp = setPage(sp, 2);
|
|
129
|
+
sp = setSort(sp, [{ key: "name", direction: "asc" }]); // resets the page
|
|
130
|
+
history.replaceState(null, "", `?${sp.toString()}`);
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### React list page with `useListParams`
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import { createRegistry } from "@querykitjs/web";
|
|
137
|
+
import { useListParams } from "@querykitjs/web/react";
|
|
138
|
+
|
|
139
|
+
const users = createRegistry({ adapter: "mongoose" }).resource<{ _id: string; name: string; status: string }>("users");
|
|
140
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
|
|
141
|
+
|
|
142
|
+
export function UsersListPage() {
|
|
143
|
+
const { params, clamped, setParam, setPage } = useListParams(users, { schema: usersSchema });
|
|
144
|
+
if (clamped) setPage(params.page ?? 1); // fix a hand-edited URL
|
|
145
|
+
return (
|
|
146
|
+
<div>
|
|
147
|
+
<input placeholder="Name" onChange={event => setParam("name", event.target.value)} />
|
|
148
|
+
<pre>{JSON.stringify(users.keys.list(params))}</pre>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Vue list page with `useListParams`
|
|
155
|
+
|
|
156
|
+
`@querykitjs/web/vue` has the same composables (peers `vue` ^3.5, `vue-router` 4 / 5). `params`, `clamped` and `searchParams` are computed refs; a setter calls `router.push({ query })`.
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
import { createRegistry } from "@querykitjs/web";
|
|
160
|
+
import { useListParams } from "@querykitjs/web/vue";
|
|
161
|
+
import { computed } from "vue";
|
|
162
|
+
|
|
163
|
+
const users = createRegistry({ adapter: "drizzle-pg" }).resource<{ id: number; name: string; status: string }>("users");
|
|
164
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
|
|
165
|
+
|
|
166
|
+
// call inside <script setup> or setup()
|
|
167
|
+
export function useUsersList() {
|
|
168
|
+
const { params, clamped, setParam, setPage } = useListParams(users, { schema: usersSchema });
|
|
169
|
+
if (clamped.value) setPage(params.value.page ?? 1); // fix a hand-edited URL
|
|
170
|
+
const queryKey = computed(() => users.keys.list(params.value));
|
|
171
|
+
return { params, queryKey, setName: (name: string) => setParam("name", name) };
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Angular list page with `injectListParams`
|
|
176
|
+
|
|
177
|
+
`@querykitjs/web/angular` has the same state as signals (peers `@angular/core`, `@angular/router` 21 / 22, `rxjs`). A setter calls `router.navigate`. Call it in an injection context, or Angular throws `NG0203`.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { computed } from "@angular/core";
|
|
181
|
+
import { createRegistry } from "@querykitjs/web";
|
|
182
|
+
import { injectListParams } from "@querykitjs/web/angular";
|
|
183
|
+
|
|
184
|
+
const users = createRegistry({ adapter: "drizzle-pg" }).resource<{ id: number; name: string; status: string }>("users");
|
|
185
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true }, status: { operation: "=" } });
|
|
186
|
+
|
|
187
|
+
// call from a component field initializer or constructor
|
|
188
|
+
export function injectUsersList() {
|
|
189
|
+
const list = injectListParams(users, { schema: usersSchema });
|
|
190
|
+
const queryKey = computed(() => users.keys.list(list.params()));
|
|
191
|
+
return { list, queryKey, setName: (name: string) => list.setParam("name", name) };
|
|
192
|
+
}
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Any other framework with `createListState`
|
|
196
|
+
|
|
197
|
+
```ts
|
|
198
|
+
import { createListState, createRegistry } from "@querykitjs/web";
|
|
199
|
+
|
|
200
|
+
const users = createRegistry({ adapter: "prisma-pg" }).resource<{ id: number; name: string }>("users");
|
|
201
|
+
const list = createListState(users, { schema: users.schema({ name: { operation: "%_%" } }) });
|
|
202
|
+
|
|
203
|
+
const current = new URLSearchParams("name=ali&page=3");
|
|
204
|
+
const { params } = list.read(current); // the payload to send
|
|
205
|
+
const next = list.actions(current).setSort([{ key: "name", direction: "asc" }]); // new params, page reset
|
|
206
|
+
history.pushState(null, "", `?${next.toString()}`);
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
### SSR payload and a refused request
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
import { createRegistry, isQueryKitError } from "@querykitjs/web";
|
|
213
|
+
|
|
214
|
+
const users = createRegistry({ adapter: "prisma-pg" }).resource<{ id: number; name: string }>("users");
|
|
215
|
+
const usersSchema = users.schema({ name: { operation: "%_%", trim: true } });
|
|
216
|
+
|
|
217
|
+
export async function loadUsers(searchParams: URLSearchParams) {
|
|
218
|
+
const body = users.fromSearchParams(searchParams, { schema: usersSchema });
|
|
219
|
+
const res = await fetch("https://api.example.com/users/list", { method: "POST", body: JSON.stringify(body) });
|
|
220
|
+
const json: unknown = await res.json();
|
|
221
|
+
if (res.status === 400) {
|
|
222
|
+
const error = (json as { error?: unknown }).error;
|
|
223
|
+
if (isQueryKitError(error)) return { fieldError: { key: error.key, reason: error.reason, site: error.site } };
|
|
224
|
+
}
|
|
225
|
+
return users.parseList(json as Parameters<typeof users.parseList>[0]);
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Request contract
|
|
230
|
+
|
|
231
|
+
The same JSON shape as every querykit package (`@querykitjs/core`): conditions `{ key, operation?, value? }`, groups `{ and }` / `{ or }` / `{ not }` (a flat array = AND), `sort` always `[{ key, direction? }]`, offset `page` / `perPage` / `withTotal`, infinite `limit` / `offset`, cursor `limit` / `cursor` / `cursorKey` / `order` / `direction` (no `sort`). Meta is snake_case: `total_items`, `total_pages`, `has_next`, `has_more`, `next_offset`, `next_cursor`, `prev_cursor`. The body types are `WireOffsetParams`, `WireInfiniteParams` and `WireCursorParams`; validate with `@querykitjs/zod` or `@querykitjs/class-validator`, then pass the body as is. Refusals are `QueryKitError` with `{ source, site, key, operation?, reason }`.
|
|
232
|
+
|
|
233
|
+
`parseList` / `parseInfinite` / `parseCursor` map that meta to camelCase: `totalCount`, `totalPages`, `currentPage`, `perPage`, `hasNext`, `hasPrev`; `limit`, `offset`, `count`, `hasMore`, `nextOffset`; `nextCursor`, `prevCursor`.
|
|
234
|
+
|
|
235
|
+
## Mistakes to avoid
|
|
236
|
+
|
|
237
|
+
- Expecting `list` / `infinite` / `cursor` to fetch: they return a plain payload object, synchronously.
|
|
238
|
+
- Passing `sort` to `cursor`: cursor pages order by `cursorKey` with `order` and `direction`.
|
|
239
|
+
- Computing `meta.totalCount + 1` without a `null` check: it is `null` after `withTotal: false`.
|
|
240
|
+
- Validating filter size in the browser: depth, condition count and string length are checked by the server only.
|
|
241
|
+
- Using `useListParams` outside react-router: use `useListParamsBase` with your own `searchParams` and `setSearchParams`.
|