@urbicon-ui/sveltekit-utils 7.0.1 → 8.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.md +43 -41
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/table-view.d.ts +163 -0
- package/dist/table-view.js +312 -0
- package/dist/url.svelte.d.ts +1 -101
- package/dist/url.svelte.js +9 -110
- package/dist/view-binding.svelte.d.ts +73 -0
- package/dist/view-binding.svelte.js +365 -0
- package/package.json +5 -5
- package/dist/table-query.d.ts +0 -204
- package/dist/table-query.js +0 -291
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Small, focused SvelteKit helpers that Urbicon apps share. Zero runtime dependenc
|
|
|
5
5
|
Currently shipping:
|
|
6
6
|
|
|
7
7
|
- **URL-state runes** — reactive `useUrlParam` / `useUrlArrayParam` that keep component state in sync with `?query=` parameters
|
|
8
|
-
- **Table
|
|
8
|
+
- **Table view ↔ URL** — `bindViewToUrl`, the URL home for a `@urbicon-ui/table` view object (`?q=…&sort=…&page=…`), plus the pure serializers for the load path
|
|
9
9
|
- **Cron runner** — interval-based background fetcher for scheduled server endpoints
|
|
10
10
|
- **SSE stream reader** — `streamSse`, a spec-correct async-generator client for one-shot POST `text/event-stream` endpoints (LLM relays)
|
|
11
11
|
|
|
@@ -57,60 +57,60 @@ 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
|
|
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
|
|
|
64
64
|
```svelte
|
|
65
65
|
<script lang="ts">
|
|
66
|
-
import { Table } from '@urbicon-ui/table';
|
|
67
|
-
import {
|
|
68
|
-
import { createTableQueryUrlSync } from '@urbicon-ui/sveltekit-utils/url.svelte';
|
|
66
|
+
import { Table, createTableView } from '@urbicon-ui/table';
|
|
67
|
+
import { bindViewToUrl } from '@urbicon-ui/sveltekit-utils/url.svelte';
|
|
69
68
|
|
|
70
|
-
const
|
|
69
|
+
const view = createTableView({ defaults: { pageSize: 25 } });
|
|
70
|
+
bindViewToUrl(view);
|
|
71
71
|
</script>
|
|
72
72
|
|
|
73
|
-
<Table
|
|
74
|
-
mode="server"
|
|
75
|
-
{columns}
|
|
76
|
-
itemsPerPage={25}
|
|
77
|
-
query={sync.viewState}
|
|
78
|
-
queryFn={async (query, { signal }) => {
|
|
79
|
-
sync.syncQuery(query); // mirror the query onto the URL (replaceState)
|
|
80
|
-
const res = await fetch(`/api/users?${tableQueryToSearchParams(query)}`, { signal });
|
|
81
|
-
const data = await res.json();
|
|
82
|
-
return { items: data.results, totalItems: data.total };
|
|
83
|
-
}}
|
|
84
|
-
/>
|
|
73
|
+
<Table {items} {columns} {view} />
|
|
85
74
|
```
|
|
86
75
|
|
|
87
|
-
|
|
76
|
+
Both calls belong in the component's initialisation. The init half runs synchronously — a `?sort=…` link renders sorted server HTML — and the runtime halves are effects: URL navigations apply to the view, the reader's changes reach the URL debounced.
|
|
88
77
|
|
|
89
|
-
|
|
78
|
+
The second argument is optional; every option has a default:
|
|
90
79
|
|
|
91
|
-
|
|
80
|
+
| Option | Default | Effect |
|
|
81
|
+
| ----------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `axes` | all six | Which axes this binding manages. An unbound axis never reaches the URL, whatever the view holds. |
|
|
83
|
+
| `debounceMs` | `300` | Delay before a view change is written to the URL. |
|
|
84
|
+
| `replaceState` | `true` | Replace the current history entry instead of pushing one, so rapid sort/filter/page edits do not flood the back button. |
|
|
85
|
+
| `prefix` | `''` | Key namespace (`prefix: 't_'` → `?t_q=…&t_page=…`) for a second bound table on the same page. |
|
|
86
|
+
| `reflectExternal` | `false` | Mirror an externally applied value (a storage seed) into the URL immediately. Off by default: the address bar does not change without reader interaction, and the seed reaches the URL with the first one anyway. |
|
|
92
87
|
|
|
93
|
-
|
|
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.
|
|
94
89
|
|
|
95
|
-
The pure serializers
|
|
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.
|
|
96
91
|
|
|
97
92
|
```typescript
|
|
93
|
+
// view-defaults.ts — imported by both the component and the load function
|
|
94
|
+
export const userView = { pageSize: 25, sort: { column: 'joined', direction: 'desc' } };
|
|
95
|
+
|
|
98
96
|
// +page.server.ts
|
|
99
|
-
import {
|
|
97
|
+
import { searchParamsToViewSnapshot } from '@urbicon-ui/sveltekit-utils/table-view';
|
|
98
|
+
import { userView } from './view-defaults';
|
|
100
99
|
|
|
101
|
-
export const load = async ({ url }) => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
};
|
|
100
|
+
export const load = async ({ url }) => ({
|
|
101
|
+
initialResult: await fetchUsers(searchParamsToViewSnapshot(url.searchParams, userView))
|
|
102
|
+
});
|
|
105
103
|
```
|
|
106
104
|
|
|
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
|
+
|
|
107
107
|
**Design notes**
|
|
108
108
|
|
|
109
|
-
- **Default elision** —
|
|
110
|
-
- **Read tolerant, write strict** — unparsable
|
|
111
|
-
- **Namespacing** — `prefix: 't_'` scopes all keys (`?t_q=…`) for multiple
|
|
112
|
-
- **
|
|
113
|
-
- **
|
|
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. `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
|
+
- **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
|
+
- **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** — `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
|
|
|
@@ -207,13 +207,15 @@ export const POST = async ({ request }) => {
|
|
|
207
207
|
|
|
208
208
|
## Exports
|
|
209
209
|
|
|
210
|
-
| Subpath | Contents
|
|
211
|
-
| --------------- |
|
|
212
|
-
| `.` | Barrel of all modules
|
|
213
|
-
| `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `
|
|
214
|
-
| `./table-
|
|
215
|
-
| `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner`
|
|
216
|
-
| `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError`
|
|
210
|
+
| Subpath | Contents |
|
|
211
|
+
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
212
|
+
| `.` | Barrel of all modules |
|
|
213
|
+
| `./url.svelte` | `useUrlParam`, `useUrlArrayParam`, `createUrlParam`, `updateUrlSearchParams`, `bindViewToUrl`, types |
|
|
214
|
+
| `./table-view` | `searchParamsToViewSnapshot`, `searchParamsToViewPartial`, `viewSnapshotToSearchParams`, `applyViewToSearchParams`, `assertValidViewSnapshot`, `viewAxesNamedBy`, `viewAxisKeys`, `TABLE_VIEW_AXES`, `TABLE_VIEW_FILTER_OPERATORS`, `TableViewLike`, types |
|
|
215
|
+
| `./cron` | `createCronRunner`, `CronJob`, `CronRunnerConfig`, `CronRunner` |
|
|
216
|
+
| `./sse` | `streamSse`, `SseEvent`, `StreamSseOptions`, `SseRequestError` |
|
|
217
|
+
|
|
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.
|
|
217
219
|
|
|
218
220
|
## Development
|
|
219
221
|
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL (de)serialization for the v8 table view vocabulary (`TableView` from
|
|
3
|
+
* `@urbicon-ui/table`: search, sort, page, pageSize, filters, groupBy).
|
|
4
|
+
*
|
|
5
|
+
* The types in this module are a **structural mirror** of the table package's
|
|
6
|
+
* view types — deliberately not imported, so this package carries no
|
|
7
|
+
* dependency on `@urbicon-ui/table`. A type-parity test over there guards the
|
|
8
|
+
* shapes against drift; `bindViewToUrl` accepts any object shaped like
|
|
9
|
+
* {@link TableViewLike}, which the real `TableView` is.
|
|
10
|
+
*
|
|
11
|
+
* The key scheme is the shipped one (`q`, `page`, `size`, `sort`, `dir`,
|
|
12
|
+
* `group`, `filter`) — deep links written for v7 keep parsing. The write
|
|
13
|
+
* side adds one compatible extension: an empty `filter=` marker, analogous
|
|
14
|
+
* to `sort=`, so a cleared filter set elides like every other axis (the
|
|
15
|
+
* shipped read side already tolerated it).
|
|
16
|
+
*/
|
|
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
|
+
}
|
|
34
|
+
/** One of the six view axes. Mirrors `ViewAxis` from `@urbicon-ui/table`. */
|
|
35
|
+
export type TableViewAxis = 'search' | 'sort' | 'page' | 'pageSize' | 'filters' | 'groupBy';
|
|
36
|
+
/** All six view axes, in vocabulary order. */
|
|
37
|
+
export declare const TABLE_VIEW_AXES: readonly TableViewAxis[];
|
|
38
|
+
/** Sort state of a view. Mirrors `ViewSort` from `@urbicon-ui/table`. */
|
|
39
|
+
export interface TableViewSort {
|
|
40
|
+
/** Column ID to sort by. */
|
|
41
|
+
column: string;
|
|
42
|
+
/** Sort direction. */
|
|
43
|
+
direction: 'asc' | 'desc';
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* A fully resolved view state — never `undefined` anywhere. Mirrors
|
|
47
|
+
* `TableViewSnapshot` from `@urbicon-ui/table`.
|
|
48
|
+
*/
|
|
49
|
+
export interface TableViewSnapshot {
|
|
50
|
+
search: string;
|
|
51
|
+
sort: TableViewSort | null;
|
|
52
|
+
page: number;
|
|
53
|
+
pageSize: number;
|
|
54
|
+
filters: TableViewFilter[];
|
|
55
|
+
groupBy: string | null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The surface {@link bindViewToUrl} needs from a view object — a structural
|
|
59
|
+
* mirror of the table package's `TableView` class. Field reads are reactive,
|
|
60
|
+
* field writes count as the reader's own change; `applyExternal` is the
|
|
61
|
+
* binding write surface, `claimAxes`/`releaseAxes` the fail-loud composition
|
|
62
|
+
* registry, and `originOf` the per-axis (revision, origin) bookkeeping the
|
|
63
|
+
* bindings decide by.
|
|
64
|
+
*/
|
|
65
|
+
export interface TableViewLike {
|
|
66
|
+
readonly defaults: TableViewSnapshot;
|
|
67
|
+
search: string;
|
|
68
|
+
sort: TableViewSort | null;
|
|
69
|
+
page: number;
|
|
70
|
+
pageSize: number;
|
|
71
|
+
filters: TableViewFilter[];
|
|
72
|
+
groupBy: string | null;
|
|
73
|
+
applyExternal(partial: Partial<TableViewSnapshot>, origin: 'external' | 'system'): void;
|
|
74
|
+
claimAxes(kind: 'url' | 'storage', axes: readonly TableViewAxis[]): void;
|
|
75
|
+
releaseAxes(kind: 'url' | 'storage', axes: readonly TableViewAxis[]): void;
|
|
76
|
+
markInitApplied(axes: readonly TableViewAxis[]): void;
|
|
77
|
+
wasInitApplied(axis: TableViewAxis): boolean;
|
|
78
|
+
originOf(axis: TableViewAxis): {
|
|
79
|
+
revision: number;
|
|
80
|
+
origin: 'user' | 'external' | 'system' | 'init';
|
|
81
|
+
};
|
|
82
|
+
snapshot(): TableViewSnapshot;
|
|
83
|
+
}
|
|
84
|
+
/** The URL keys a set of axes owns, with the configured prefix applied. */
|
|
85
|
+
export declare function viewAxisKeys(axes: readonly TableViewAxis[], prefix?: string): string[];
|
|
86
|
+
/** The axes a URL names — presence only for params it actually carries. */
|
|
87
|
+
export declare function viewAxesNamedBy(sp: URLSearchParams, prefix?: string): TableViewAxis[];
|
|
88
|
+
/**
|
|
89
|
+
* Parse search params into a **partial** view snapshot — a key per axis the
|
|
90
|
+
* URL actually carries, and nothing else. Read tolerant per key: an
|
|
91
|
+
* unparsable value on a *present* key falls back to the configured default
|
|
92
|
+
* for that axis (the key was present, so the axis stays claimed), and
|
|
93
|
+
* malformed filter entries are skipped individually.
|
|
94
|
+
*/
|
|
95
|
+
export declare function searchParamsToViewPartial(sp: URLSearchParams, defaults: Pick<TableViewSnapshot, 'page' | 'pageSize'>, prefix?: string): Partial<TableViewSnapshot>;
|
|
96
|
+
/**
|
|
97
|
+
* Resolve a full view snapshot from search params: every axis the URL names
|
|
98
|
+
* comes from the URL, every other one from `defaults` — the same resolution
|
|
99
|
+
* the URL binding performs at init, for code that has no view (a server
|
|
100
|
+
* `load`).
|
|
101
|
+
*
|
|
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).
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* ```ts
|
|
117
|
+
* // shared with the component that calls createTableView({ defaults })
|
|
118
|
+
* export const invoiceView = { pageSize: 25, sort: { column: 'date', direction: 'desc' } };
|
|
119
|
+
*
|
|
120
|
+
* export const load = async ({ url }) => ({
|
|
121
|
+
* initialResult: await fetchInvoices(searchParamsToViewSnapshot(url.searchParams, invoiceView))
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export declare function searchParamsToViewSnapshot(sp: URLSearchParams, defaults?: Partial<TableViewSnapshot>, prefix?: string): TableViewSnapshot;
|
|
126
|
+
/**
|
|
127
|
+
* Serialize a snapshot, eliding every axis that equals the defaults — the
|
|
128
|
+
* elision baseline *is* the view's defaults, structurally. `axes` restricts
|
|
129
|
+
* the output to a binding's own axes: an unbound axis never reaches the URL,
|
|
130
|
+
* no matter what the view holds.
|
|
131
|
+
*/
|
|
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;
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL (de)serialization for the v8 table view vocabulary (`TableView` from
|
|
3
|
+
* `@urbicon-ui/table`: search, sort, page, pageSize, filters, groupBy).
|
|
4
|
+
*
|
|
5
|
+
* The types in this module are a **structural mirror** of the table package's
|
|
6
|
+
* view types — deliberately not imported, so this package carries no
|
|
7
|
+
* dependency on `@urbicon-ui/table`. A type-parity test over there guards the
|
|
8
|
+
* shapes against drift; `bindViewToUrl` accepts any object shaped like
|
|
9
|
+
* {@link TableViewLike}, which the real `TableView` is.
|
|
10
|
+
*
|
|
11
|
+
* The key scheme is the shipped one (`q`, `page`, `size`, `sort`, `dir`,
|
|
12
|
+
* `group`, `filter`) — deep links written for v7 keep parsing. The write
|
|
13
|
+
* side adds one compatible extension: an empty `filter=` marker, analogous
|
|
14
|
+
* to `sort=`, so a cleared filter set elides like every other axis (the
|
|
15
|
+
* shipped read side already tolerated it).
|
|
16
|
+
*/
|
|
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
|
+
];
|
|
30
|
+
/** All six view axes, in vocabulary order. */
|
|
31
|
+
export const TABLE_VIEW_AXES = [
|
|
32
|
+
'search',
|
|
33
|
+
'sort',
|
|
34
|
+
'page',
|
|
35
|
+
'pageSize',
|
|
36
|
+
'filters',
|
|
37
|
+
'groupBy'
|
|
38
|
+
];
|
|
39
|
+
/** URL keys per axis, unprefixed — the §3.2 key scheme, grouped by owning axis. */
|
|
40
|
+
const AXIS_KEYS = {
|
|
41
|
+
search: ['q'],
|
|
42
|
+
sort: ['sort', 'dir'],
|
|
43
|
+
page: ['page'],
|
|
44
|
+
pageSize: ['size'],
|
|
45
|
+
filters: ['filter'],
|
|
46
|
+
groupBy: ['group']
|
|
47
|
+
};
|
|
48
|
+
/** The URL keys a set of axes owns, with the configured prefix applied. */
|
|
49
|
+
export function viewAxisKeys(axes, prefix = '') {
|
|
50
|
+
return axes.flatMap((axis) => AXIS_KEYS[axis].map((key) => `${prefix}${key}`));
|
|
51
|
+
}
|
|
52
|
+
function isFilterOperator(value) {
|
|
53
|
+
return TABLE_VIEW_FILTER_OPERATORS.includes(value);
|
|
54
|
+
}
|
|
55
|
+
/** Per-entry tolerance: a malformed entry becomes null and the caller skips it. */
|
|
56
|
+
function parseFilterParam(raw) {
|
|
57
|
+
const parts = raw.split(':');
|
|
58
|
+
if (parts.length !== 3)
|
|
59
|
+
return null;
|
|
60
|
+
const [encodedColumn, operator, encodedValue] = parts;
|
|
61
|
+
if (!isFilterOperator(operator))
|
|
62
|
+
return null;
|
|
63
|
+
try {
|
|
64
|
+
const column = decodeURIComponent(encodedColumn);
|
|
65
|
+
if (!column)
|
|
66
|
+
return null;
|
|
67
|
+
return { column, operator, value: decodeURIComponent(encodedValue) };
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** The axes a URL names — presence only for params it actually carries. */
|
|
74
|
+
export function viewAxesNamedBy(sp, prefix = '') {
|
|
75
|
+
const axes = [];
|
|
76
|
+
if (sp.get(`${prefix}q`) !== null)
|
|
77
|
+
axes.push('search');
|
|
78
|
+
if (sp.get(`${prefix}sort`) !== null)
|
|
79
|
+
axes.push('sort');
|
|
80
|
+
if (sp.get(`${prefix}page`) !== null)
|
|
81
|
+
axes.push('page');
|
|
82
|
+
if (sp.get(`${prefix}size`) !== null)
|
|
83
|
+
axes.push('pageSize');
|
|
84
|
+
if (sp.getAll(`${prefix}filter`).length > 0)
|
|
85
|
+
axes.push('filters');
|
|
86
|
+
if (sp.get(`${prefix}group`) !== null)
|
|
87
|
+
axes.push('groupBy');
|
|
88
|
+
return axes;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Parse search params into a **partial** view snapshot — a key per axis the
|
|
92
|
+
* URL actually carries, and nothing else. Read tolerant per key: an
|
|
93
|
+
* unparsable value on a *present* key falls back to the configured default
|
|
94
|
+
* for that axis (the key was present, so the axis stays claimed), and
|
|
95
|
+
* malformed filter entries are skipped individually.
|
|
96
|
+
*/
|
|
97
|
+
export function searchParamsToViewPartial(sp, defaults, prefix = '') {
|
|
98
|
+
const partial = {};
|
|
99
|
+
const rawSearch = sp.get(`${prefix}q`);
|
|
100
|
+
if (rawSearch !== null)
|
|
101
|
+
partial.search = rawSearch;
|
|
102
|
+
const rawSort = sp.get(`${prefix}sort`);
|
|
103
|
+
if (rawSort !== null) {
|
|
104
|
+
partial.sort =
|
|
105
|
+
rawSort === ''
|
|
106
|
+
? null // `sort: null` — "unsorted" is a value, not a sentinel
|
|
107
|
+
: { column: rawSort, direction: sp.get(`${prefix}dir`) === 'desc' ? 'desc' : 'asc' };
|
|
108
|
+
}
|
|
109
|
+
const rawPage = sp.get(`${prefix}page`);
|
|
110
|
+
if (rawPage !== null && /^\d+$/.test(rawPage) && Number(rawPage) >= 1) {
|
|
111
|
+
partial.page = Number(rawPage);
|
|
112
|
+
}
|
|
113
|
+
else if (rawPage !== null) {
|
|
114
|
+
partial.page = defaults.page;
|
|
115
|
+
}
|
|
116
|
+
const rawSize = sp.get(`${prefix}size`);
|
|
117
|
+
if (rawSize !== null && /^\d+$/.test(rawSize) && Number(rawSize) >= 1) {
|
|
118
|
+
partial.pageSize = Number(rawSize);
|
|
119
|
+
}
|
|
120
|
+
else if (rawSize !== null) {
|
|
121
|
+
partial.pageSize = defaults.pageSize;
|
|
122
|
+
}
|
|
123
|
+
// `filter=` (empty marker) and `filter=a:contains:b` both claim the axis;
|
|
124
|
+
// the empty marker claims it as *empty* — the compatible format extension.
|
|
125
|
+
const rawFilters = sp.getAll(`${prefix}filter`);
|
|
126
|
+
if (rawFilters.length > 0) {
|
|
127
|
+
partial.filters = rawFilters
|
|
128
|
+
.map(parseFilterParam)
|
|
129
|
+
.filter((f) => f !== null);
|
|
130
|
+
}
|
|
131
|
+
const rawGroup = sp.get(`${prefix}group`);
|
|
132
|
+
if (rawGroup !== null)
|
|
133
|
+
partial.groupBy = rawGroup === '' ? null : rawGroup;
|
|
134
|
+
return partial;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Resolve a full view snapshot from search params: every axis the URL names
|
|
138
|
+
* comes from the URL, every other one from `defaults` — the same resolution
|
|
139
|
+
* the URL binding performs at init, for code that has no view (a server
|
|
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
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
export function searchParamsToViewSnapshot(sp, defaults = {}, prefix = '') {
|
|
166
|
+
const resolved = resolveViewDefaults(defaults);
|
|
167
|
+
return { ...resolved, ...searchParamsToViewPartial(sp, resolved, prefix) };
|
|
168
|
+
}
|
|
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
|
+
*/
|
|
180
|
+
function resolveViewDefaults(defaults) {
|
|
181
|
+
return {
|
|
182
|
+
search: defaults.search ?? '',
|
|
183
|
+
sort: defaults.sort ? { ...defaults.sort } : null,
|
|
184
|
+
page: defaults.page ?? 1,
|
|
185
|
+
pageSize: defaults.pageSize ?? 10,
|
|
186
|
+
filters: defaults.filters ? [...defaults.filters] : [],
|
|
187
|
+
groupBy: defaults.groupBy || null
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Serialize a snapshot, eliding every axis that equals the defaults — the
|
|
192
|
+
* elision baseline *is* the view's defaults, structurally. `axes` restricts
|
|
193
|
+
* the output to a binding's own axes: an unbound axis never reaches the URL,
|
|
194
|
+
* no matter what the view holds.
|
|
195
|
+
*/
|
|
196
|
+
export function viewSnapshotToSearchParams(snapshot, defaults, axes = TABLE_VIEW_AXES, prefix = '') {
|
|
197
|
+
const sp = new URLSearchParams();
|
|
198
|
+
const bound = (axis) => axes.includes(axis);
|
|
199
|
+
if (bound('search') && snapshot.search !== defaults.search)
|
|
200
|
+
sp.set(`${prefix}q`, snapshot.search);
|
|
201
|
+
if (bound('page') && snapshot.page !== defaults.page)
|
|
202
|
+
sp.set(`${prefix}page`, String(snapshot.page));
|
|
203
|
+
if (bound('pageSize') && snapshot.pageSize !== defaults.pageSize)
|
|
204
|
+
sp.set(`${prefix}size`, String(snapshot.pageSize));
|
|
205
|
+
// `bound('sort')` gates BOTH branches — gating only the null-mismatch one
|
|
206
|
+
// let an unbound sort leak into the URL (an operator-precedence slip the
|
|
207
|
+
// spike caught; pinned by the axis-subset test).
|
|
208
|
+
const sortDiffers = bound('sort') &&
|
|
209
|
+
((snapshot.sort === null) !== (defaults.sort === null) ||
|
|
210
|
+
(snapshot.sort !== null &&
|
|
211
|
+
defaults.sort !== null &&
|
|
212
|
+
(snapshot.sort.column !== defaults.sort.column ||
|
|
213
|
+
snapshot.sort.direction !== defaults.sort.direction)));
|
|
214
|
+
if (sortDiffers) {
|
|
215
|
+
if (snapshot.sort === null) {
|
|
216
|
+
sp.set(`${prefix}sort`, ''); // explicitly unsorted — only reachable when defaults sort
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
sp.set(`${prefix}sort`, snapshot.sort.column);
|
|
220
|
+
if (snapshot.sort.direction === 'desc')
|
|
221
|
+
sp.set(`${prefix}dir`, 'desc');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
if (bound('groupBy') && snapshot.groupBy !== defaults.groupBy)
|
|
225
|
+
sp.set(`${prefix}group`, snapshot.groupBy ?? '');
|
|
226
|
+
const filtersDiffer = bound('filters') &&
|
|
227
|
+
(snapshot.filters.length !== defaults.filters.length ||
|
|
228
|
+
snapshot.filters.some((f, i) => f.column !== defaults.filters[i].column ||
|
|
229
|
+
f.operator !== defaults.filters[i].operator ||
|
|
230
|
+
f.value !== defaults.filters[i].value));
|
|
231
|
+
if (filtersDiffer) {
|
|
232
|
+
if (snapshot.filters.length === 0) {
|
|
233
|
+
// The format extension: an empty marker, analogous to `sort=`, so a
|
|
234
|
+
// cleared filter set elides like every other axis.
|
|
235
|
+
sp.set(`${prefix}filter`, '');
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
for (const filter of snapshot.filters) {
|
|
239
|
+
sp.append(`${prefix}filter`, `${encodeURIComponent(filter.column)}:${filter.operator}:${encodeURIComponent(filter.value)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return sp;
|
|
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
|
+
}
|