@urbicon-ui/sveltekit-utils 8.0.0 → 8.2.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.md +9 -10
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/table-view.d.ts +64 -24
- package/dist/table-view.js +119 -48
- package/dist/url.svelte.js +1 -1
- package/package.json +4 -9
- package/dist/table-query.d.ts +0 -158
- package/dist/table-query.js +0 -235
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ updateUrlSearchParams({ page: '1', tag: ['a', 'b'] }, { replaceState: true });
|
|
|
57
57
|
- URL updates use `goto()` with `replaceState: true`, `noScroll: true`, `keepFocus: true` — suited for filter/pagination UIs, not full page transitions.
|
|
58
58
|
- `useUrlParam` returns getters (not Svelte stores) so consumers can read the value lazily inside `$derived`/`$effect`.
|
|
59
59
|
|
|
60
|
-
## Table View ↔ URL (`url.svelte` + `table-
|
|
60
|
+
## Table View ↔ URL (`url.svelte` + `table-view`)
|
|
61
61
|
|
|
62
62
|
`bindViewToUrl` gives the view object of `@urbicon-ui/table` — search, sort, page, page size, filters, grouping — the URL as its home: the axes are mirrored onto query parameters (`?q=…&sort=…&page=…`), so the view survives a reload, can be shared as a link, and — unlike `localStorage` — is visible to the server.
|
|
63
63
|
|
|
@@ -87,30 +87,30 @@ The second argument is optional; every option has a default:
|
|
|
87
87
|
|
|
88
88
|
Because the binding re-reads the URL rather than capturing it, the browser's back button works: navigating back to a URL that no longer names `?sort` returns the table to its default sort.
|
|
89
89
|
|
|
90
|
-
The pure serializers work without SvelteKit — e.g. to parse the incoming query in a server `load` and fetch the first page during SSR. Use `
|
|
90
|
+
The pure serializers work without SvelteKit — e.g. to parse the incoming query in a server `load` and fetch the first page during SSR. Use `searchParamsToViewSnapshot` from `./table-view`: it takes the *same* defaults object the component hands `createTableView`, so the server cannot resolve an absent param differently from the client, and it hands back the very shape a managed `source.query` receives.
|
|
91
91
|
|
|
92
92
|
```typescript
|
|
93
93
|
// view-defaults.ts — imported by both the component and the load function
|
|
94
94
|
export const userView = { pageSize: 25, sort: { column: 'joined', direction: 'desc' } };
|
|
95
95
|
|
|
96
96
|
// +page.server.ts
|
|
97
|
-
import {
|
|
97
|
+
import { searchParamsToViewSnapshot } from '@urbicon-ui/sveltekit-utils/table-view';
|
|
98
98
|
import { userView } from './view-defaults';
|
|
99
99
|
|
|
100
100
|
export const load = async ({ url }) => ({
|
|
101
|
-
initialResult: await fetchUsers(
|
|
101
|
+
initialResult: await fetchUsers(searchParamsToViewSnapshot(url.searchParams, userView))
|
|
102
102
|
});
|
|
103
103
|
```
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
The `./table-query` subpath that used to hold a second copy of this codec — same URL scheme, wire-vocabulary spellings, no field for a default filter set — retired with the vocabulary split it served (#162).
|
|
106
106
|
|
|
107
107
|
**Design notes**
|
|
108
108
|
|
|
109
109
|
- **Default elision** — an axis whose value equals its default is not written; a table in its default state leaves the URL clean. The baseline *is* `view.defaults`, read off the object the binding decorates, so there is no second copy of the defaults to keep in step with the table's own.
|
|
110
|
-
- **Read tolerant, write strict** — an unparsable value on a param the URL actually carries falls back to that axis' default, and malformed `filter` entries are skipped individually
|
|
110
|
+
- **Read tolerant, write strict** — an unparsable value on a param the URL actually carries falls back to that axis' default, and malformed `filter` entries are skipped individually. `assertValidViewSnapshot` is the strict half: it throws on a structurally invalid view (non-positive page, unknown operator) instead of writing corrupt state, and `applyViewToSearchParams` calls it. `viewSnapshotToSearchParams` deliberately does not — it runs inside the binding on every view change, where a throw would cost the page rather than the URL.
|
|
111
111
|
- **Namespacing** — `prefix: 't_'` scopes all keys (`?t_q=…`) for multiple bound tables on one page; unrelated params are always preserved. Two prefixless bindings would manage the same keys, so that throws at registration instead of producing a link that loads the wrong table.
|
|
112
112
|
- **One writer per page** — every binding submits into one coalescing URL writer, so two tables land in a single navigation, each replacing only its own keys. A landing URL the writer itself sent is not applied back onto the view: an edit made while that navigation was in flight survives instead of being overwritten by the URL it raced.
|
|
113
|
-
- **Types** — `
|
|
113
|
+
- **Types** — `TableViewLike`, `TableViewSnapshot` and `TableViewFilter` mirror the table's view object structurally, so this package carries no dependency on `@urbicon-ui/table`. A parity test in the table package (`viewMirror.parity.test.ts`) pins the mirror: the shapes must stay mutually assignable, and the real `TableView` must satisfy `TableViewLike`, which is the entire mechanism by which this binding decorates a view it never imports.
|
|
114
114
|
|
|
115
115
|
## Cron Runner (`cron`)
|
|
116
116
|
|
|
@@ -211,12 +211,11 @@ export const POST = async ({ request }) => {
|
|
|
211
211
|
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
212
212
|
| `.` | Barrel of all modules |
|
|
213
213
|
| `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `bindViewToUrl`, types |
|
|
214
|
-
| `./table-view` | `
|
|
215
|
-
| `./table-query` | `tableQueryToSearchParams`, `searchParamsToTableQuery`, `applyTableQueryToSearchParams`, `TABLE_QUERY_FILTER_OPERATORS`, `TableQueryParams`, types |
|
|
214
|
+
| `./table-view` | `searchParamsToViewSnapshot`, `searchParamsToViewPartial`, `viewSnapshotToSearchParams`, `applyViewToSearchParams`, `assertValidViewSnapshot`, `viewAxesNamedBy`, `viewAxisKeys`, `TABLE_VIEW_AXES`, `TABLE_VIEW_FILTER_OPERATORS`, `TableViewLike`, types |
|
|
216
215
|
| `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
|
|
217
216
|
| `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
|
|
218
217
|
|
|
219
|
-
`bindViewToUrl` lives in its own module (`view-binding.svelte.ts`) and is re-exported from `./url.svelte`, which is its documented import path — it has no subpath of its own. `./table-view`
|
|
218
|
+
`bindViewToUrl` lives in its own module (`view-binding.svelte.ts`) and is re-exported from `./url.svelte`, which is its documented import path — it has no subpath of its own. `./table-view` is SvelteKit-free (it touches no `$app/*`), which is what lets a `load` function and a plain test use it; `./url.svelte` is the half that needs the router.
|
|
220
219
|
|
|
221
220
|
## Development
|
|
222
221
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/table-view.d.ts
CHANGED
|
@@ -14,7 +14,23 @@
|
|
|
14
14
|
* to `sort=`, so a cleared filter set elides like every other axis (the
|
|
15
15
|
* shipped read side already tolerated it).
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Filter operators supported by the table. Mirrors `FilterOperator` from
|
|
19
|
+
* `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
|
|
20
|
+
* params from the URL.
|
|
21
|
+
*/
|
|
22
|
+
export declare const TABLE_VIEW_FILTER_OPERATORS: readonly ["contains", "equals", "startsWith", "endsWith", "greaterThan", "lessThan"];
|
|
23
|
+
/** Filter operator of a view filter. Mirrors `@urbicon-ui/table`. */
|
|
24
|
+
export type TableViewFilterOperator = (typeof TABLE_VIEW_FILTER_OPERATORS)[number];
|
|
25
|
+
/** Single column filter on the `filters` axis. Mirrors `Filter` from `@urbicon-ui/table`. */
|
|
26
|
+
export interface TableViewFilter {
|
|
27
|
+
/** Column ID the filter applies to. */
|
|
28
|
+
column: string;
|
|
29
|
+
/** Filter operator. */
|
|
30
|
+
operator: TableViewFilterOperator;
|
|
31
|
+
/** Filter value (always a string, numeric operators convert internally). */
|
|
32
|
+
value: string;
|
|
33
|
+
}
|
|
18
34
|
/** One of the six view axes. Mirrors `ViewAxis` from `@urbicon-ui/table`. */
|
|
19
35
|
export type TableViewAxis = 'search' | 'sort' | 'page' | 'pageSize' | 'filters' | 'groupBy';
|
|
20
36
|
/** All six view axes, in vocabulary order. */
|
|
@@ -35,7 +51,7 @@ export interface TableViewSnapshot {
|
|
|
35
51
|
sort: TableViewSort | null;
|
|
36
52
|
page: number;
|
|
37
53
|
pageSize: number;
|
|
38
|
-
filters:
|
|
54
|
+
filters: TableViewFilter[];
|
|
39
55
|
groupBy: string | null;
|
|
40
56
|
}
|
|
41
57
|
/**
|
|
@@ -52,7 +68,7 @@ export interface TableViewLike {
|
|
|
52
68
|
sort: TableViewSort | null;
|
|
53
69
|
page: number;
|
|
54
70
|
pageSize: number;
|
|
55
|
-
filters:
|
|
71
|
+
filters: TableViewFilter[];
|
|
56
72
|
groupBy: string | null;
|
|
57
73
|
applyExternal(partial: Partial<TableViewSnapshot>, origin: 'external' | 'system'): void;
|
|
58
74
|
claimAxes(kind: 'url' | 'storage', axes: readonly TableViewAxis[]): void;
|
|
@@ -82,26 +98,19 @@ export declare function searchParamsToViewPartial(sp: URLSearchParams, defaults:
|
|
|
82
98
|
* comes from the URL, every other one from `defaults` — the same resolution
|
|
83
99
|
* the URL binding performs at init, for code that has no view (a server
|
|
84
100
|
* `load`).
|
|
85
|
-
*/
|
|
86
|
-
export declare function searchParamsToViewSnapshot(sp: URLSearchParams, defaults?: Partial<TableViewSnapshot>, prefix?: string): TableViewSnapshot;
|
|
87
|
-
/**
|
|
88
|
-
* Project a view snapshot into the wire shape a backend speaks — the same
|
|
89
|
-
* mapping the table applies before calling a managed `source.query`, so a
|
|
90
|
-
* server `load` and the table's own fetches send identical field names.
|
|
91
|
-
*/
|
|
92
|
-
export declare function viewSnapshotToTableQuery(snapshot: TableViewSnapshot): TableQueryParams;
|
|
93
|
-
/**
|
|
94
|
-
* The load-path counterpart of the URL binding: parse search params against
|
|
95
|
-
* the **view's own defaults** and hand back the query a fetch needs.
|
|
96
101
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
102
|
+
* This is also what a server `load` hands its fetch. Since v9 the view and
|
|
103
|
+
* the query speak one vocabulary (#162), so there is nothing to project on
|
|
104
|
+
* the way out: the object below is the same shape a managed `source.query`
|
|
105
|
+
* receives. The `searchParamsToViewQuery` / `viewSnapshotToTableQuery` pair
|
|
106
|
+
* that used to do the projecting were identity functions once the names
|
|
107
|
+
* agreed, and are gone.
|
|
108
|
+
*
|
|
109
|
+
* The `defaults` argument is the point: it takes the very object
|
|
110
|
+
* `createTableView({ defaults })` takes, so the server cannot resolve an
|
|
111
|
+
* absent param differently from the client — and a default filter set is
|
|
112
|
+
* expressible, which the old wire-vocabulary baseline could not manage no
|
|
113
|
+
* matter how it was written (#157 finding 2).
|
|
105
114
|
*
|
|
106
115
|
* @example
|
|
107
116
|
* ```ts
|
|
@@ -109,11 +118,11 @@ export declare function viewSnapshotToTableQuery(snapshot: TableViewSnapshot): T
|
|
|
109
118
|
* export const invoiceView = { pageSize: 25, sort: { column: 'date', direction: 'desc' } };
|
|
110
119
|
*
|
|
111
120
|
* export const load = async ({ url }) => ({
|
|
112
|
-
* initialResult: await fetchInvoices(
|
|
121
|
+
* initialResult: await fetchInvoices(searchParamsToViewSnapshot(url.searchParams, invoiceView))
|
|
113
122
|
* });
|
|
114
123
|
* ```
|
|
115
124
|
*/
|
|
116
|
-
export declare function
|
|
125
|
+
export declare function searchParamsToViewSnapshot(sp: URLSearchParams, defaults?: Partial<TableViewSnapshot>, prefix?: string): TableViewSnapshot;
|
|
117
126
|
/**
|
|
118
127
|
* Serialize a snapshot, eliding every axis that equals the defaults — the
|
|
119
128
|
* elision baseline *is* the view's defaults, structurally. `axes` restricts
|
|
@@ -121,3 +130,34 @@ export declare function searchParamsToViewQuery(sp: URLSearchParams, defaults?:
|
|
|
121
130
|
* no matter what the view holds.
|
|
122
131
|
*/
|
|
123
132
|
export declare function viewSnapshotToSearchParams(snapshot: TableViewSnapshot, defaults: TableViewSnapshot, axes?: readonly TableViewAxis[], prefix?: string): URLSearchParams;
|
|
133
|
+
/**
|
|
134
|
+
* Write-side validation: never serialize structurally invalid state.
|
|
135
|
+
*
|
|
136
|
+
* The strict half of the module's read-tolerant / write-strict contract, and
|
|
137
|
+
* deliberately NOT called by {@link viewSnapshotToSearchParams}: that one runs
|
|
138
|
+
* inside the URL binding on every view change, where a throw would take the
|
|
139
|
+
* whole table down over a `view.page = 0` a consumer wrote. Serializing a bad
|
|
140
|
+
* page there costs a wrong URL; throwing there costs the page.
|
|
141
|
+
*
|
|
142
|
+
* @throws TypeError when an axis holds a value the URL scheme cannot mean.
|
|
143
|
+
*/
|
|
144
|
+
export declare function assertValidViewSnapshot(snapshot: TableViewSnapshot): void;
|
|
145
|
+
/**
|
|
146
|
+
* Merge a view into existing search params: every key the given axes own is
|
|
147
|
+
* replaced by the serialized snapshot, and every other param is preserved
|
|
148
|
+
* untouched. Keys whose axis returned to its default are removed (the same
|
|
149
|
+
* elision {@link viewSnapshotToSearchParams} applies).
|
|
150
|
+
*
|
|
151
|
+
* This is the one thing the axis-scoped serializer cannot do on its own —
|
|
152
|
+
* everything else the retired `./table-query` module offered was the same
|
|
153
|
+
* codec under wire-vocabulary names (#162).
|
|
154
|
+
*
|
|
155
|
+
* @param existing - Current search params (not mutated — a copy is returned).
|
|
156
|
+
* @param snapshot - The view state to write.
|
|
157
|
+
* @param defaults - Elision baseline, structurally the view's own defaults.
|
|
158
|
+
* @param axes - Axes to write; every other axis is left alone in `existing`.
|
|
159
|
+
* @param prefix - Key prefix, to namespace multiple synced tables on a page.
|
|
160
|
+
* @returns New `URLSearchParams` with the view applied.
|
|
161
|
+
* @throws TypeError when the snapshot is structurally invalid (write strict).
|
|
162
|
+
*/
|
|
163
|
+
export declare function applyViewToSearchParams(existing: URLSearchParams, snapshot: TableViewSnapshot, defaults: TableViewSnapshot, axes?: readonly TableViewAxis[], prefix?: string): URLSearchParams;
|
package/dist/table-view.js
CHANGED
|
@@ -14,7 +14,19 @@
|
|
|
14
14
|
* to `sort=`, so a cleared filter set elides like every other axis (the
|
|
15
15
|
* shipped read side already tolerated it).
|
|
16
16
|
*/
|
|
17
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Filter operators supported by the table. Mirrors `FilterOperator` from
|
|
19
|
+
* `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
|
|
20
|
+
* params from the URL.
|
|
21
|
+
*/
|
|
22
|
+
export const TABLE_VIEW_FILTER_OPERATORS = [
|
|
23
|
+
'contains',
|
|
24
|
+
'equals',
|
|
25
|
+
'startsWith',
|
|
26
|
+
'endsWith',
|
|
27
|
+
'greaterThan',
|
|
28
|
+
'lessThan'
|
|
29
|
+
];
|
|
18
30
|
/** All six view axes, in vocabulary order. */
|
|
19
31
|
export const TABLE_VIEW_AXES = [
|
|
20
32
|
'search',
|
|
@@ -38,9 +50,9 @@ export function viewAxisKeys(axes, prefix = '') {
|
|
|
38
50
|
return axes.flatMap((axis) => AXIS_KEYS[axis].map((key) => `${prefix}${key}`));
|
|
39
51
|
}
|
|
40
52
|
function isFilterOperator(value) {
|
|
41
|
-
return
|
|
53
|
+
return TABLE_VIEW_FILTER_OPERATORS.includes(value);
|
|
42
54
|
}
|
|
43
|
-
/**
|
|
55
|
+
/** Per-entry tolerance: a malformed entry becomes null and the caller skips it. */
|
|
44
56
|
function parseFilterParam(raw) {
|
|
45
57
|
const parts = raw.split(':');
|
|
46
58
|
if (parts.length !== 3)
|
|
@@ -126,64 +138,55 @@ export function searchParamsToViewPartial(sp, defaults, prefix = '') {
|
|
|
126
138
|
* comes from the URL, every other one from `defaults` — the same resolution
|
|
127
139
|
* the URL binding performs at init, for code that has no view (a server
|
|
128
140
|
* `load`).
|
|
141
|
+
*
|
|
142
|
+
* This is also what a server `load` hands its fetch. Since v9 the view and
|
|
143
|
+
* the query speak one vocabulary (#162), so there is nothing to project on
|
|
144
|
+
* the way out: the object below is the same shape a managed `source.query`
|
|
145
|
+
* receives. The `searchParamsToViewQuery` / `viewSnapshotToTableQuery` pair
|
|
146
|
+
* that used to do the projecting were identity functions once the names
|
|
147
|
+
* agreed, and are gone.
|
|
148
|
+
*
|
|
149
|
+
* The `defaults` argument is the point: it takes the very object
|
|
150
|
+
* `createTableView({ defaults })` takes, so the server cannot resolve an
|
|
151
|
+
* absent param differently from the client — and a default filter set is
|
|
152
|
+
* expressible, which the old wire-vocabulary baseline could not manage no
|
|
153
|
+
* matter how it was written (#157 finding 2).
|
|
154
|
+
*
|
|
155
|
+
* @example
|
|
156
|
+
* ```ts
|
|
157
|
+
* // shared with the component that calls createTableView({ defaults })
|
|
158
|
+
* export const invoiceView = { pageSize: 25, sort: { column: 'date', direction: 'desc' } };
|
|
159
|
+
*
|
|
160
|
+
* export const load = async ({ url }) => ({
|
|
161
|
+
* initialResult: await fetchInvoices(searchParamsToViewSnapshot(url.searchParams, invoiceView))
|
|
162
|
+
* });
|
|
163
|
+
* ```
|
|
129
164
|
*/
|
|
130
165
|
export function searchParamsToViewSnapshot(sp, defaults = {}, prefix = '') {
|
|
131
166
|
const resolved = resolveViewDefaults(defaults);
|
|
132
167
|
return { ...resolved, ...searchParamsToViewPartial(sp, resolved, prefix) };
|
|
133
168
|
}
|
|
134
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Fill an unset axis with the table's own default — never `undefined`
|
|
171
|
+
* anywhere.
|
|
172
|
+
*
|
|
173
|
+
* The two composite axes are copied for the same reason `TableView`'s
|
|
174
|
+
* constructor copies them: the documented `load` pattern shares ONE defaults
|
|
175
|
+
* object between the server and the component (a module-scope
|
|
176
|
+
* `export const invoiceView = …`). Handing a caller's array straight back out
|
|
177
|
+
* means a `load` that sorts or normalises the snapshot in place poisons that
|
|
178
|
+
* shared constant for every later request on the same server process.
|
|
179
|
+
*/
|
|
135
180
|
function resolveViewDefaults(defaults) {
|
|
136
181
|
return {
|
|
137
182
|
search: defaults.search ?? '',
|
|
138
|
-
sort: defaults.sort
|
|
183
|
+
sort: defaults.sort ? { ...defaults.sort } : null,
|
|
139
184
|
page: defaults.page ?? 1,
|
|
140
185
|
pageSize: defaults.pageSize ?? 10,
|
|
141
|
-
filters: defaults.filters
|
|
186
|
+
filters: defaults.filters ? [...defaults.filters] : [],
|
|
142
187
|
groupBy: defaults.groupBy || null
|
|
143
188
|
};
|
|
144
189
|
}
|
|
145
|
-
/**
|
|
146
|
-
* Project a view snapshot into the wire shape a backend speaks — the same
|
|
147
|
-
* mapping the table applies before calling a managed `source.query`, so a
|
|
148
|
-
* server `load` and the table's own fetches send identical field names.
|
|
149
|
-
*/
|
|
150
|
-
export function viewSnapshotToTableQuery(snapshot) {
|
|
151
|
-
return {
|
|
152
|
-
page: snapshot.page,
|
|
153
|
-
itemsPerPage: snapshot.pageSize,
|
|
154
|
-
sortColumn: snapshot.sort?.column ?? '',
|
|
155
|
-
sortDirection: snapshot.sort?.direction ?? 'asc',
|
|
156
|
-
searchTerm: snapshot.search,
|
|
157
|
-
activeFilters: [...snapshot.filters],
|
|
158
|
-
groupByKey: snapshot.groupBy
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* The load-path counterpart of the URL binding: parse search params against
|
|
163
|
-
* the **view's own defaults** and hand back the query a fetch needs.
|
|
164
|
-
*
|
|
165
|
-
* The point is the defaults argument. `searchParamsToTableQuery` takes its
|
|
166
|
-
* baseline in the wire vocabulary (`itemsPerPage`, `sortColumn`/`sortDirection`,
|
|
167
|
-
* `groupByKey`) and has no field for filters at all, so a `load` had to keep a
|
|
168
|
-
* second, differently-spelled copy of what `createTableView({ defaults })`
|
|
169
|
-
* already says — and could not express a default filter set no matter how it
|
|
170
|
-
* was written (#157 finding 2). This one takes the very object the view takes:
|
|
171
|
-
* one spelling, all six axes, so the server cannot resolve an absent param
|
|
172
|
-
* differently from the client.
|
|
173
|
-
*
|
|
174
|
-
* @example
|
|
175
|
-
* ```ts
|
|
176
|
-
* // shared with the component that calls createTableView({ defaults })
|
|
177
|
-
* export const invoiceView = { pageSize: 25, sort: { column: 'date', direction: 'desc' } };
|
|
178
|
-
*
|
|
179
|
-
* export const load = async ({ url }) => ({
|
|
180
|
-
* initialResult: await fetchInvoices(searchParamsToViewQuery(url.searchParams, invoiceView))
|
|
181
|
-
* });
|
|
182
|
-
* ```
|
|
183
|
-
*/
|
|
184
|
-
export function searchParamsToViewQuery(sp, defaults = {}, prefix = '') {
|
|
185
|
-
return viewSnapshotToTableQuery(searchParamsToViewSnapshot(sp, defaults, prefix));
|
|
186
|
-
}
|
|
187
190
|
/**
|
|
188
191
|
* Serialize a snapshot, eliding every axis that equals the defaults — the
|
|
189
192
|
* elision baseline *is* the view's defaults, structurally. `axes` restricts
|
|
@@ -239,3 +242,71 @@ export function viewSnapshotToSearchParams(snapshot, defaults, axes = TABLE_VIEW
|
|
|
239
242
|
}
|
|
240
243
|
return sp;
|
|
241
244
|
}
|
|
245
|
+
/**
|
|
246
|
+
* Write-side validation: never serialize structurally invalid state.
|
|
247
|
+
*
|
|
248
|
+
* The strict half of the module's read-tolerant / write-strict contract, and
|
|
249
|
+
* deliberately NOT called by {@link viewSnapshotToSearchParams}: that one runs
|
|
250
|
+
* inside the URL binding on every view change, where a throw would take the
|
|
251
|
+
* whole table down over a `view.page = 0` a consumer wrote. Serializing a bad
|
|
252
|
+
* page there costs a wrong URL; throwing there costs the page.
|
|
253
|
+
*
|
|
254
|
+
* @throws TypeError when an axis holds a value the URL scheme cannot mean.
|
|
255
|
+
*/
|
|
256
|
+
export function assertValidViewSnapshot(snapshot) {
|
|
257
|
+
if (!Number.isSafeInteger(snapshot.page) || snapshot.page < 1) {
|
|
258
|
+
throw new TypeError(`[table-view] page must be a positive integer, got ${snapshot.page}`);
|
|
259
|
+
}
|
|
260
|
+
if (!Number.isSafeInteger(snapshot.pageSize) || snapshot.pageSize < 1) {
|
|
261
|
+
throw new TypeError(`[table-view] pageSize must be a positive integer, got ${snapshot.pageSize}`);
|
|
262
|
+
}
|
|
263
|
+
if (snapshot.sort !== null) {
|
|
264
|
+
if (!snapshot.sort.column) {
|
|
265
|
+
throw new TypeError('[table-view] sort.column must be a non-empty string, or sort must be null');
|
|
266
|
+
}
|
|
267
|
+
if (snapshot.sort.direction !== 'asc' && snapshot.sort.direction !== 'desc') {
|
|
268
|
+
throw new TypeError(`[table-view] sort.direction must be 'asc' or 'desc', got ${String(snapshot.sort.direction)}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (snapshot.groupBy === '') {
|
|
272
|
+
throw new TypeError("[table-view] groupBy must be a non-empty string or null, got ''");
|
|
273
|
+
}
|
|
274
|
+
for (const filter of snapshot.filters) {
|
|
275
|
+
if (!filter.column) {
|
|
276
|
+
throw new TypeError('[table-view] filter.column must be a non-empty string');
|
|
277
|
+
}
|
|
278
|
+
if (!isFilterOperator(filter.operator)) {
|
|
279
|
+
throw new TypeError(`[table-view] unknown filter operator '${String(filter.operator)}' on column '${filter.column}'`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Merge a view into existing search params: every key the given axes own is
|
|
285
|
+
* replaced by the serialized snapshot, and every other param is preserved
|
|
286
|
+
* untouched. Keys whose axis returned to its default are removed (the same
|
|
287
|
+
* elision {@link viewSnapshotToSearchParams} applies).
|
|
288
|
+
*
|
|
289
|
+
* This is the one thing the axis-scoped serializer cannot do on its own —
|
|
290
|
+
* everything else the retired `./table-query` module offered was the same
|
|
291
|
+
* codec under wire-vocabulary names (#162).
|
|
292
|
+
*
|
|
293
|
+
* @param existing - Current search params (not mutated — a copy is returned).
|
|
294
|
+
* @param snapshot - The view state to write.
|
|
295
|
+
* @param defaults - Elision baseline, structurally the view's own defaults.
|
|
296
|
+
* @param axes - Axes to write; every other axis is left alone in `existing`.
|
|
297
|
+
* @param prefix - Key prefix, to namespace multiple synced tables on a page.
|
|
298
|
+
* @returns New `URLSearchParams` with the view applied.
|
|
299
|
+
* @throws TypeError when the snapshot is structurally invalid (write strict).
|
|
300
|
+
*/
|
|
301
|
+
export function applyViewToSearchParams(existing, snapshot, defaults, axes = TABLE_VIEW_AXES, prefix = '') {
|
|
302
|
+
assertValidViewSnapshot(snapshot);
|
|
303
|
+
const serialized = viewSnapshotToSearchParams(snapshot, defaults, axes, prefix);
|
|
304
|
+
const next = new URLSearchParams(existing);
|
|
305
|
+
for (const key of viewAxisKeys(axes, prefix)) {
|
|
306
|
+
next.delete(key);
|
|
307
|
+
}
|
|
308
|
+
for (const [key, value] of serialized) {
|
|
309
|
+
next.append(key, value);
|
|
310
|
+
}
|
|
311
|
+
return next;
|
|
312
|
+
}
|
package/dist/url.svelte.js
CHANGED
|
@@ -8,7 +8,7 @@ import { page } from '$app/state';
|
|
|
8
8
|
// root's star exports ambiguous and silently drop them. The v7
|
|
9
9
|
// `createTableQueryUrlSync` factory is gone with the table's `query` prop —
|
|
10
10
|
// the URL home of a table view is `bindViewToUrl`; the load-path serializers
|
|
11
|
-
// (`
|
|
11
|
+
// (`searchParamsToViewSnapshot` & friends) live in `table-view`.
|
|
12
12
|
export { bindViewToUrl } from './view-binding.svelte.js';
|
|
13
13
|
/**
|
|
14
14
|
* Low-level escape hatch to update several params at once via `goto` (without a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbicon-ui/sveltekit-utils",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.2.0",
|
|
4
4
|
"description": "SvelteKit helper utilities — createCronRunner, streamSse, and URL-state runes",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -47,11 +47,6 @@
|
|
|
47
47
|
"import": "./dist/sse.js",
|
|
48
48
|
"default": "./dist/sse.js"
|
|
49
49
|
},
|
|
50
|
-
"./table-query": {
|
|
51
|
-
"types": "./dist/table-query.d.ts",
|
|
52
|
-
"import": "./dist/table-query.js",
|
|
53
|
-
"default": "./dist/table-query.js"
|
|
54
|
-
},
|
|
55
50
|
"./table-view": {
|
|
56
51
|
"types": "./dist/table-view.d.ts",
|
|
57
52
|
"import": "./dist/table-view.js",
|
|
@@ -83,14 +78,14 @@
|
|
|
83
78
|
"devDependencies": {
|
|
84
79
|
"@sveltejs/kit": "^2.70.2",
|
|
85
80
|
"@sveltejs/package": "^2.5.8",
|
|
86
|
-
"@sveltejs/vite-plugin-svelte": "^7.
|
|
81
|
+
"@sveltejs/vite-plugin-svelte": "^7.3.0",
|
|
87
82
|
"prettier": "^3.9.6",
|
|
88
83
|
"prettier-plugin-svelte": "^4.1.1",
|
|
89
84
|
"prettier-plugin-tailwindcss": "^0.8.1",
|
|
90
85
|
"svelte": "^5.56.8",
|
|
91
|
-
"svelte-check": "^4.7.
|
|
86
|
+
"svelte-check": "^4.7.5",
|
|
92
87
|
"typescript": "^6.0.3",
|
|
93
|
-
"vite": "^8.2.
|
|
88
|
+
"vite": "^8.2.1",
|
|
94
89
|
"vitest": "^4.1.9"
|
|
95
90
|
}
|
|
96
91
|
}
|
package/dist/table-query.d.ts
DELETED
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* URL (de)serialization for the query state a data table emits in server mode
|
|
3
|
-
* (`TableQuery` from `@urbicon-ui/table`: page, page size, sort, search term,
|
|
4
|
-
* column filters, grouping).
|
|
5
|
-
*
|
|
6
|
-
* The types in this module are a **structural mirror** of `TableQuery` — they
|
|
7
|
-
* are deliberately not imported from `@urbicon-ui/table`, so this package
|
|
8
|
-
* carries no dependency on the table package. Any object shaped like
|
|
9
|
-
* `TableQuery` is accepted; a type-parity test in `@urbicon-ui/table` guards
|
|
10
|
-
* the two shapes against drift.
|
|
11
|
-
*
|
|
12
|
-
* Serialization contract:
|
|
13
|
-
* - **Deterministic** — fixed key order (`q`, `page`, `size`, `sort`, `dir`,
|
|
14
|
-
* `group`, `filter`), stable filter order.
|
|
15
|
-
* - **Default elision** — values equal to the resolved defaults are not
|
|
16
|
-
* written; a table in its default state produces an empty query string.
|
|
17
|
-
* - **Read tolerant** — unparsable params fall back to the defaults, and
|
|
18
|
-
* malformed filter entries are skipped.
|
|
19
|
-
* - **Write strict** — a structurally invalid query (non-positive page,
|
|
20
|
-
* unknown filter operator, …) throws instead of writing corrupt state.
|
|
21
|
-
*/
|
|
22
|
-
/** Sort direction of a table query. Mirrors `@urbicon-ui/table`. */
|
|
23
|
-
export type TableQuerySortDirection = 'asc' | 'desc';
|
|
24
|
-
/**
|
|
25
|
-
* Filter operators supported by the table. Mirrors `FilterOperator` from
|
|
26
|
-
* `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
|
|
27
|
-
* params from the URL.
|
|
28
|
-
*/
|
|
29
|
-
export declare const TABLE_QUERY_FILTER_OPERATORS: readonly ["contains", "equals", "startsWith", "endsWith", "greaterThan", "lessThan"];
|
|
30
|
-
/** Filter operator of a table query filter. Mirrors `@urbicon-ui/table`. */
|
|
31
|
-
export type TableQueryFilterOperator = (typeof TABLE_QUERY_FILTER_OPERATORS)[number];
|
|
32
|
-
/** Single column filter of a table query. Mirrors `Filter` from `@urbicon-ui/table`. */
|
|
33
|
-
export interface TableQueryFilter {
|
|
34
|
-
/** Column ID the filter applies to. */
|
|
35
|
-
column: string;
|
|
36
|
-
/** Filter operator. */
|
|
37
|
-
operator: TableQueryFilterOperator;
|
|
38
|
-
/** Filter value (always a string, numeric operators convert internally). */
|
|
39
|
-
value: string;
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* Query state of a table in server mode. Structural mirror of `TableQuery`
|
|
43
|
-
* from `@urbicon-ui/table` — the object a managed `source.query` receives
|
|
44
|
-
* (or `viewToQuery` projects) is directly assignable.
|
|
45
|
-
*/
|
|
46
|
-
export interface TableQueryParams {
|
|
47
|
-
/** Current page (1-based). */
|
|
48
|
-
page: number;
|
|
49
|
-
/** Number of items per page. */
|
|
50
|
-
itemsPerPage: number;
|
|
51
|
-
/** Column ID to sort by, or empty string if no sort is active. */
|
|
52
|
-
sortColumn: string;
|
|
53
|
-
/** Sort direction. */
|
|
54
|
-
sortDirection: TableQuerySortDirection;
|
|
55
|
-
/** Full-text search term. */
|
|
56
|
-
searchTerm: string;
|
|
57
|
-
/** Active column filters. */
|
|
58
|
-
activeFilters: TableQueryFilter[];
|
|
59
|
-
/** Column ID for grouping, or null if ungrouped. */
|
|
60
|
-
groupByKey: string | null;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* Baseline used for default elision: query values equal to these defaults are
|
|
64
|
-
* omitted from the URL, and missing params parse back to them.
|
|
65
|
-
*
|
|
66
|
-
* A **wire-codec** baseline in the query shape — when your defaults live on a
|
|
67
|
-
* view object (`createTableView({ defaults })`), skip the manual projection
|
|
68
|
-
* and use `searchParamsToViewQuery` from `./table-view`, which takes the view
|
|
69
|
-
* itself. Unset fields fall back to the table's own defaults (page 1, 10
|
|
70
|
-
* items per page, no sort, empty search, ungrouped). Filters have no default
|
|
71
|
-
* field: active filters are always written, an absent `filter` param parses
|
|
72
|
-
* to none.
|
|
73
|
-
*/
|
|
74
|
-
export interface TableQueryDefaults {
|
|
75
|
-
/** Default page. @default 1 */
|
|
76
|
-
page?: number;
|
|
77
|
-
/** Default page size. @default 10 */
|
|
78
|
-
itemsPerPage?: number;
|
|
79
|
-
/** Default sort column ('' = unsorted). @default '' */
|
|
80
|
-
sortColumn?: string;
|
|
81
|
-
/** Default sort direction. @default 'asc' */
|
|
82
|
-
sortDirection?: TableQuerySortDirection;
|
|
83
|
-
/** Default search term. @default '' */
|
|
84
|
-
searchTerm?: string;
|
|
85
|
-
/** Default group key (null = ungrouped). @default null */
|
|
86
|
-
groupByKey?: string | null;
|
|
87
|
-
}
|
|
88
|
-
/** Options shared by the table-query (de)serializers. */
|
|
89
|
-
export interface TableQueryUrlOptions {
|
|
90
|
-
/** Elision baseline — see {@link TableQueryDefaults}. */
|
|
91
|
-
defaults?: TableQueryDefaults;
|
|
92
|
-
/**
|
|
93
|
-
* Prefix for every param key (`prefix: 't_'` → `?t_q=…&t_page=…`). Use it
|
|
94
|
-
* to namespace multiple synced tables on the same page.
|
|
95
|
-
* @default ''
|
|
96
|
-
*/
|
|
97
|
-
prefix?: string;
|
|
98
|
-
}
|
|
99
|
-
/**
|
|
100
|
-
* Serialize a table query into `URLSearchParams`, eliding every value that
|
|
101
|
-
* equals the resolved defaults (see {@link TableQueryDefaults}).
|
|
102
|
-
*
|
|
103
|
-
* Key scheme (each key optionally prefixed via `options.prefix`):
|
|
104
|
-
* - `q` — search term
|
|
105
|
-
* - `page` — 1-based page
|
|
106
|
-
* - `size` — items per page
|
|
107
|
-
* - `sort` — sort column; an **empty** `sort=` marks "explicitly unsorted"
|
|
108
|
-
* and is only written when the defaults specify a sort column
|
|
109
|
-
* - `dir` — `desc` (ascending is implied when absent)
|
|
110
|
-
* - `group` — group key; an empty `group=` marks "explicitly ungrouped"
|
|
111
|
-
* - `filter` — repeated, `<column>:<operator>:<value>` with column and value
|
|
112
|
-
* URI-component-encoded so the `:` separators stay unambiguous
|
|
113
|
-
*
|
|
114
|
-
* When `sortColumn` is empty the sort direction is meaningless and is
|
|
115
|
-
* normalized away (it parses back as `'asc'`).
|
|
116
|
-
*
|
|
117
|
-
* Also handy for building the backend request inside a managed
|
|
118
|
-
* `source.query` — the same scheme works as an API query string.
|
|
119
|
-
*
|
|
120
|
-
* @param query - Query emitted by the table (`TableQuery` is assignable).
|
|
121
|
-
* @param options - Elision defaults + key prefix.
|
|
122
|
-
* @returns Fresh `URLSearchParams` containing only non-default values.
|
|
123
|
-
* @throws TypeError when the query is structurally invalid (write strict).
|
|
124
|
-
*/
|
|
125
|
-
export declare function tableQueryToSearchParams(query: TableQueryParams, options?: TableQueryUrlOptions): URLSearchParams;
|
|
126
|
-
/**
|
|
127
|
-
* Parse `URLSearchParams` back into a full table query, filling every missing
|
|
128
|
-
* param from the resolved defaults (see {@link TableQueryDefaults}).
|
|
129
|
-
*
|
|
130
|
-
* Read tolerant: non-numeric `page`/`size` fall back to the defaults, an
|
|
131
|
-
* unknown `dir` becomes `'asc'`, and malformed `filter` entries (wrong shape,
|
|
132
|
-
* unknown operator, broken percent-encoding) are skipped individually.
|
|
133
|
-
*
|
|
134
|
-
* The wire-level codec, for consumers who hold their defaults in the query
|
|
135
|
-
* shape. For the SSR load path of a v8 view — running the initial fetch in a
|
|
136
|
-
* server `load` from `url.searchParams` — prefer `searchParamsToViewQuery`
|
|
137
|
-
* from `./table-view`: same keys, but it reads the defaults off the view
|
|
138
|
-
* object instead of a hand-projected baseline.
|
|
139
|
-
*
|
|
140
|
-
* @param params - Search params to read (not mutated).
|
|
141
|
-
* @param options - Fallback defaults + key prefix.
|
|
142
|
-
* @returns Complete query object (assignable to `TableQuery`).
|
|
143
|
-
*/
|
|
144
|
-
export declare function searchParamsToTableQuery(params: URLSearchParams, options?: TableQueryUrlOptions): TableQueryParams;
|
|
145
|
-
/**
|
|
146
|
-
* Merge a table query into existing search params: all managed keys (`q`,
|
|
147
|
-
* `page`, `size`, `sort`, `dir`, `group`, `filter` — with the configured
|
|
148
|
-
* prefix) are replaced by the serialized query, every other param is
|
|
149
|
-
* preserved untouched. Managed keys whose value returned to the default are
|
|
150
|
-
* removed (default elision).
|
|
151
|
-
*
|
|
152
|
-
* @param existing - Current search params (not mutated — a copy is returned).
|
|
153
|
-
* @param query - Query emitted by the table.
|
|
154
|
-
* @param options - Elision defaults + key prefix.
|
|
155
|
-
* @returns New `URLSearchParams` with the query applied.
|
|
156
|
-
* @throws TypeError when the query is structurally invalid (write strict).
|
|
157
|
-
*/
|
|
158
|
-
export declare function applyTableQueryToSearchParams(existing: URLSearchParams, query: TableQueryParams, options?: TableQueryUrlOptions): URLSearchParams;
|
package/dist/table-query.js
DELETED
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* URL (de)serialization for the query state a data table emits in server mode
|
|
3
|
-
* (`TableQuery` from `@urbicon-ui/table`: page, page size, sort, search term,
|
|
4
|
-
* column filters, grouping).
|
|
5
|
-
*
|
|
6
|
-
* The types in this module are a **structural mirror** of `TableQuery` — they
|
|
7
|
-
* are deliberately not imported from `@urbicon-ui/table`, so this package
|
|
8
|
-
* carries no dependency on the table package. Any object shaped like
|
|
9
|
-
* `TableQuery` is accepted; a type-parity test in `@urbicon-ui/table` guards
|
|
10
|
-
* the two shapes against drift.
|
|
11
|
-
*
|
|
12
|
-
* Serialization contract:
|
|
13
|
-
* - **Deterministic** — fixed key order (`q`, `page`, `size`, `sort`, `dir`,
|
|
14
|
-
* `group`, `filter`), stable filter order.
|
|
15
|
-
* - **Default elision** — values equal to the resolved defaults are not
|
|
16
|
-
* written; a table in its default state produces an empty query string.
|
|
17
|
-
* - **Read tolerant** — unparsable params fall back to the defaults, and
|
|
18
|
-
* malformed filter entries are skipped.
|
|
19
|
-
* - **Write strict** — a structurally invalid query (non-positive page,
|
|
20
|
-
* unknown filter operator, …) throws instead of writing corrupt state.
|
|
21
|
-
*/
|
|
22
|
-
/**
|
|
23
|
-
* Filter operators supported by the table. Mirrors `FilterOperator` from
|
|
24
|
-
* `@urbicon-ui/table`. Used as the runtime whitelist when parsing `filter`
|
|
25
|
-
* params from the URL.
|
|
26
|
-
*/
|
|
27
|
-
export const TABLE_QUERY_FILTER_OPERATORS = [
|
|
28
|
-
'contains',
|
|
29
|
-
'equals',
|
|
30
|
-
'startsWith',
|
|
31
|
-
'endsWith',
|
|
32
|
-
'greaterThan',
|
|
33
|
-
'lessThan'
|
|
34
|
-
];
|
|
35
|
-
/** Resolved param key names for one prefix. */
|
|
36
|
-
function paramKeys(prefix) {
|
|
37
|
-
return {
|
|
38
|
-
q: `${prefix}q`,
|
|
39
|
-
page: `${prefix}page`,
|
|
40
|
-
size: `${prefix}size`,
|
|
41
|
-
sort: `${prefix}sort`,
|
|
42
|
-
dir: `${prefix}dir`,
|
|
43
|
-
group: `${prefix}group`,
|
|
44
|
-
filter: `${prefix}filter`
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
function resolveDefaults(defaults) {
|
|
48
|
-
return {
|
|
49
|
-
page: defaults?.page ?? 1,
|
|
50
|
-
itemsPerPage: defaults?.itemsPerPage ?? 10,
|
|
51
|
-
sortColumn: defaults?.sortColumn ?? '',
|
|
52
|
-
sortDirection: defaults?.sortDirection ?? 'asc',
|
|
53
|
-
searchTerm: defaults?.searchTerm ?? '',
|
|
54
|
-
groupByKey: defaults?.groupByKey ?? null
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
function isFilterOperator(value) {
|
|
58
|
-
return TABLE_QUERY_FILTER_OPERATORS.includes(value);
|
|
59
|
-
}
|
|
60
|
-
/** Write-side validation: never serialize structurally invalid state. */
|
|
61
|
-
function assertValidQuery(query) {
|
|
62
|
-
if (!Number.isSafeInteger(query.page) || query.page < 1) {
|
|
63
|
-
throw new TypeError(`[table-query] page must be a positive integer, got ${query.page}`);
|
|
64
|
-
}
|
|
65
|
-
if (!Number.isSafeInteger(query.itemsPerPage) || query.itemsPerPage < 1) {
|
|
66
|
-
throw new TypeError(`[table-query] itemsPerPage must be a positive integer, got ${query.itemsPerPage}`);
|
|
67
|
-
}
|
|
68
|
-
if (query.sortDirection !== 'asc' && query.sortDirection !== 'desc') {
|
|
69
|
-
throw new TypeError(`[table-query] sortDirection must be 'asc' or 'desc', got ${String(query.sortDirection)}`);
|
|
70
|
-
}
|
|
71
|
-
if (query.groupByKey === '') {
|
|
72
|
-
throw new TypeError("[table-query] groupByKey must be a non-empty string or null, got ''");
|
|
73
|
-
}
|
|
74
|
-
for (const filter of query.activeFilters) {
|
|
75
|
-
if (!filter.column) {
|
|
76
|
-
throw new TypeError('[table-query] filter.column must be a non-empty string');
|
|
77
|
-
}
|
|
78
|
-
if (!isFilterOperator(filter.operator)) {
|
|
79
|
-
throw new TypeError(`[table-query] unknown filter operator '${String(filter.operator)}' on column '${filter.column}'`);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
/** Read-side tolerant integer parsing: anything non-numeric → null. */
|
|
84
|
-
function parsePositiveInt(raw) {
|
|
85
|
-
if (raw === null || !/^\d+$/.test(raw))
|
|
86
|
-
return null;
|
|
87
|
-
const value = Number(raw);
|
|
88
|
-
return Number.isSafeInteger(value) && value >= 1 ? value : null;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Parse one `filter` param value (`<column>:<operator>:<value>`, column and
|
|
92
|
-
* value URI-component-encoded). Returns null for malformed entries — the
|
|
93
|
-
* caller skips them (read tolerant).
|
|
94
|
-
*/
|
|
95
|
-
function parseFilterParam(raw) {
|
|
96
|
-
const parts = raw.split(':');
|
|
97
|
-
if (parts.length !== 3)
|
|
98
|
-
return null;
|
|
99
|
-
const [encodedColumn, operator, encodedValue] = parts;
|
|
100
|
-
if (!isFilterOperator(operator))
|
|
101
|
-
return null;
|
|
102
|
-
try {
|
|
103
|
-
const column = decodeURIComponent(encodedColumn);
|
|
104
|
-
if (!column)
|
|
105
|
-
return null;
|
|
106
|
-
return { column, operator, value: decodeURIComponent(encodedValue) };
|
|
107
|
-
}
|
|
108
|
-
catch {
|
|
109
|
-
// Malformed percent-encoding (URIError) — skip the entry.
|
|
110
|
-
return null;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
/**
|
|
114
|
-
* Serialize a table query into `URLSearchParams`, eliding every value that
|
|
115
|
-
* equals the resolved defaults (see {@link TableQueryDefaults}).
|
|
116
|
-
*
|
|
117
|
-
* Key scheme (each key optionally prefixed via `options.prefix`):
|
|
118
|
-
* - `q` — search term
|
|
119
|
-
* - `page` — 1-based page
|
|
120
|
-
* - `size` — items per page
|
|
121
|
-
* - `sort` — sort column; an **empty** `sort=` marks "explicitly unsorted"
|
|
122
|
-
* and is only written when the defaults specify a sort column
|
|
123
|
-
* - `dir` — `desc` (ascending is implied when absent)
|
|
124
|
-
* - `group` — group key; an empty `group=` marks "explicitly ungrouped"
|
|
125
|
-
* - `filter` — repeated, `<column>:<operator>:<value>` with column and value
|
|
126
|
-
* URI-component-encoded so the `:` separators stay unambiguous
|
|
127
|
-
*
|
|
128
|
-
* When `sortColumn` is empty the sort direction is meaningless and is
|
|
129
|
-
* normalized away (it parses back as `'asc'`).
|
|
130
|
-
*
|
|
131
|
-
* Also handy for building the backend request inside a managed
|
|
132
|
-
* `source.query` — the same scheme works as an API query string.
|
|
133
|
-
*
|
|
134
|
-
* @param query - Query emitted by the table (`TableQuery` is assignable).
|
|
135
|
-
* @param options - Elision defaults + key prefix.
|
|
136
|
-
* @returns Fresh `URLSearchParams` containing only non-default values.
|
|
137
|
-
* @throws TypeError when the query is structurally invalid (write strict).
|
|
138
|
-
*/
|
|
139
|
-
export function tableQueryToSearchParams(query, options = {}) {
|
|
140
|
-
assertValidQuery(query);
|
|
141
|
-
const d = resolveDefaults(options.defaults);
|
|
142
|
-
const k = paramKeys(options.prefix ?? '');
|
|
143
|
-
const sp = new URLSearchParams();
|
|
144
|
-
if (query.searchTerm !== d.searchTerm)
|
|
145
|
-
sp.set(k.q, query.searchTerm);
|
|
146
|
-
if (query.page !== d.page)
|
|
147
|
-
sp.set(k.page, String(query.page));
|
|
148
|
-
if (query.itemsPerPage !== d.itemsPerPage)
|
|
149
|
-
sp.set(k.size, String(query.itemsPerPage));
|
|
150
|
-
if (query.sortColumn === '') {
|
|
151
|
-
// Unsorted: only mark explicitly when the defaults would re-introduce a sort.
|
|
152
|
-
if (d.sortColumn !== '')
|
|
153
|
-
sp.set(k.sort, '');
|
|
154
|
-
}
|
|
155
|
-
else if (query.sortColumn !== d.sortColumn || query.sortDirection !== d.sortDirection) {
|
|
156
|
-
sp.set(k.sort, query.sortColumn);
|
|
157
|
-
if (query.sortDirection === 'desc')
|
|
158
|
-
sp.set(k.dir, 'desc');
|
|
159
|
-
}
|
|
160
|
-
if (query.groupByKey !== d.groupByKey)
|
|
161
|
-
sp.set(k.group, query.groupByKey ?? '');
|
|
162
|
-
for (const filter of query.activeFilters) {
|
|
163
|
-
sp.append(k.filter, `${encodeURIComponent(filter.column)}:${filter.operator}:${encodeURIComponent(filter.value)}`);
|
|
164
|
-
}
|
|
165
|
-
return sp;
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Parse `URLSearchParams` back into a full table query, filling every missing
|
|
169
|
-
* param from the resolved defaults (see {@link TableQueryDefaults}).
|
|
170
|
-
*
|
|
171
|
-
* Read tolerant: non-numeric `page`/`size` fall back to the defaults, an
|
|
172
|
-
* unknown `dir` becomes `'asc'`, and malformed `filter` entries (wrong shape,
|
|
173
|
-
* unknown operator, broken percent-encoding) are skipped individually.
|
|
174
|
-
*
|
|
175
|
-
* The wire-level codec, for consumers who hold their defaults in the query
|
|
176
|
-
* shape. For the SSR load path of a v8 view — running the initial fetch in a
|
|
177
|
-
* server `load` from `url.searchParams` — prefer `searchParamsToViewQuery`
|
|
178
|
-
* from `./table-view`: same keys, but it reads the defaults off the view
|
|
179
|
-
* object instead of a hand-projected baseline.
|
|
180
|
-
*
|
|
181
|
-
* @param params - Search params to read (not mutated).
|
|
182
|
-
* @param options - Fallback defaults + key prefix.
|
|
183
|
-
* @returns Complete query object (assignable to `TableQuery`).
|
|
184
|
-
*/
|
|
185
|
-
export function searchParamsToTableQuery(params, options = {}) {
|
|
186
|
-
const d = resolveDefaults(options.defaults);
|
|
187
|
-
const k = paramKeys(options.prefix ?? '');
|
|
188
|
-
let sortColumn = d.sortColumn;
|
|
189
|
-
let sortDirection = d.sortDirection;
|
|
190
|
-
const rawSort = params.get(k.sort);
|
|
191
|
-
if (rawSort !== null) {
|
|
192
|
-
sortColumn = rawSort;
|
|
193
|
-
sortDirection = rawSort !== '' && params.get(k.dir) === 'desc' ? 'desc' : 'asc';
|
|
194
|
-
}
|
|
195
|
-
const rawGroup = params.get(k.group);
|
|
196
|
-
const activeFilters = [];
|
|
197
|
-
for (const raw of params.getAll(k.filter)) {
|
|
198
|
-
const filter = parseFilterParam(raw);
|
|
199
|
-
if (filter)
|
|
200
|
-
activeFilters.push(filter);
|
|
201
|
-
}
|
|
202
|
-
return {
|
|
203
|
-
page: parsePositiveInt(params.get(k.page)) ?? d.page,
|
|
204
|
-
itemsPerPage: parsePositiveInt(params.get(k.size)) ?? d.itemsPerPage,
|
|
205
|
-
sortColumn,
|
|
206
|
-
sortDirection,
|
|
207
|
-
searchTerm: params.get(k.q) ?? d.searchTerm,
|
|
208
|
-
activeFilters,
|
|
209
|
-
groupByKey: rawGroup !== null ? (rawGroup === '' ? null : rawGroup) : d.groupByKey
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
/**
|
|
213
|
-
* Merge a table query into existing search params: all managed keys (`q`,
|
|
214
|
-
* `page`, `size`, `sort`, `dir`, `group`, `filter` — with the configured
|
|
215
|
-
* prefix) are replaced by the serialized query, every other param is
|
|
216
|
-
* preserved untouched. Managed keys whose value returned to the default are
|
|
217
|
-
* removed (default elision).
|
|
218
|
-
*
|
|
219
|
-
* @param existing - Current search params (not mutated — a copy is returned).
|
|
220
|
-
* @param query - Query emitted by the table.
|
|
221
|
-
* @param options - Elision defaults + key prefix.
|
|
222
|
-
* @returns New `URLSearchParams` with the query applied.
|
|
223
|
-
* @throws TypeError when the query is structurally invalid (write strict).
|
|
224
|
-
*/
|
|
225
|
-
export function applyTableQueryToSearchParams(existing, query, options = {}) {
|
|
226
|
-
const serialized = tableQueryToSearchParams(query, options);
|
|
227
|
-
const next = new URLSearchParams(existing);
|
|
228
|
-
for (const key of Object.values(paramKeys(options.prefix ?? ''))) {
|
|
229
|
-
next.delete(key);
|
|
230
|
-
}
|
|
231
|
-
for (const [key, value] of serialized) {
|
|
232
|
-
next.append(key, value);
|
|
233
|
-
}
|
|
234
|
-
return next;
|
|
235
|
-
}
|