@talelk/newui-data 1.0.0 → 1.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 +13 -2
- package/package.json +1 -1
- package/skills/newui-data/SKILL.md +59 -5
- package/src/aggregate.test.ts +231 -0
- package/src/aggregate.ts +320 -0
- package/src/config-store.test.ts +157 -0
- package/src/config-store.ts +295 -0
- package/src/csv-export.test.ts +156 -0
- package/src/csv-export.ts +129 -0
- package/src/dashboard.test.tsx +285 -0
- package/src/dashboard.tsx +381 -0
- package/src/exports.test.ts +33 -3
- package/src/form/autosave.test.ts +186 -0
- package/src/form/autosave.ts +194 -0
- package/src/form/config.test.tsx +288 -0
- package/src/form/config.ts +187 -0
- package/src/form/form.tsx +122 -2
- package/src/form/index.ts +2 -0
- package/src/index.ts +6 -0
- package/src/query/collection.test.tsx +130 -3
- package/src/query/collection.ts +15 -0
- package/src/query/index.ts +1 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/index.ts +7 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/url-serialize.ts +93 -0
- package/src/url-state.test.ts +183 -0
- package/src/url-state.ts +168 -0
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import type { Accessor } from 'solid-js'
|
|
2
|
+
import { type ConfigStorage, createConfigStore } from './config-store'
|
|
3
|
+
import { defaultsFor, withPruning } from './url-serialize'
|
|
4
|
+
import {
|
|
5
|
+
createUrlState,
|
|
6
|
+
type UrlStateAdapter,
|
|
7
|
+
type UrlStateValue,
|
|
8
|
+
} from './url-state'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* What a table remembers, and where it lives.
|
|
12
|
+
*
|
|
13
|
+
* `createTablePersist` splits table state by *who owns the question*:
|
|
14
|
+
*
|
|
15
|
+
* - The **URL half** — sorting, column filters, density, page size — is what
|
|
16
|
+
* makes a link reproducible, so it serializes through `createUrlState`
|
|
17
|
+
* behind the app's router adapter, pruned to only what differs from the
|
|
18
|
+
* default.
|
|
19
|
+
* - The **storage half** — the column layout (visibility, order, sizing,
|
|
20
|
+
* pinning) — is a personal preference that should survive a shared link,
|
|
21
|
+
* so it lives in a versioned `createConfigStore` document.
|
|
22
|
+
*
|
|
23
|
+
* The document carries the whole state, so a returning user gets their sort
|
|
24
|
+
* back even when the URL says nothing; the URL wins when it does. A persisted
|
|
25
|
+
* column that no longer exists in the table is dropped on read, not hidden
|
|
26
|
+
* forever.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Envelope-free payload version: a document without `v` predates this shape. */
|
|
30
|
+
const TABLE_PERSIST_SCHEMA_VERSION = 1
|
|
31
|
+
|
|
32
|
+
/** The `type` discriminator on the stored envelope. */
|
|
33
|
+
const TABLE_PERSIST_DOC_TYPE = 'table-view'
|
|
34
|
+
|
|
35
|
+
/** How the table's columns are laid out. Mirrors the TanStack column state. */
|
|
36
|
+
export interface TableColumnLayout {
|
|
37
|
+
/** Sparse visibility map — an absent column is visible. */
|
|
38
|
+
visibility: Record<string, boolean>
|
|
39
|
+
/** Left-to-right column ids. */
|
|
40
|
+
order: string[]
|
|
41
|
+
/** Pixel widths, keyed by column id. */
|
|
42
|
+
sizing: Record<string, number>
|
|
43
|
+
/** Pinned column ids, in pinned order. */
|
|
44
|
+
pinning: { left: string[]; right: string[] }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One sort entry. */
|
|
48
|
+
export type TableSorting = { id: string; desc: boolean }
|
|
49
|
+
|
|
50
|
+
/** One active column filter. */
|
|
51
|
+
export type TableColumnFilter = { id: string; value: UrlStateValue }
|
|
52
|
+
|
|
53
|
+
/** Row density, matching the table organism's axis. */
|
|
54
|
+
export type TableDensity = 'comfortable' | 'compact'
|
|
55
|
+
|
|
56
|
+
/** Everything `createTablePersist` remembers. */
|
|
57
|
+
export interface TablePersistState {
|
|
58
|
+
columns?: TableColumnLayout
|
|
59
|
+
sorting?: TableSorting[]
|
|
60
|
+
columnFilters?: TableColumnFilter[]
|
|
61
|
+
density?: TableDensity
|
|
62
|
+
pageSize?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The stored document: the state plus its payload version. */
|
|
66
|
+
export interface TablePersistDoc extends TablePersistState {
|
|
67
|
+
v?: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Options for {@link createTablePersist}. */
|
|
71
|
+
export interface TablePersistOptions {
|
|
72
|
+
/** Stable id — namespaces the URL keys and the document name. */
|
|
73
|
+
id: string
|
|
74
|
+
/** Router bridge for the URL half. Absent means the URL is never touched. */
|
|
75
|
+
url?: { adapter?: UrlStateAdapter }
|
|
76
|
+
/** Backend for the document. Defaults to `localStorage`. */
|
|
77
|
+
storage?: ConfigStorage
|
|
78
|
+
/**
|
|
79
|
+
* Called when a persist fails — a storage backend that rejects the write,
|
|
80
|
+
* or a document that no longer parses. The table keeps working either way;
|
|
81
|
+
* the app decides whether to say so.
|
|
82
|
+
*/
|
|
83
|
+
onPersistError?: (error: Error) => void
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The result of {@link createTablePersist}. */
|
|
87
|
+
export interface TablePersist {
|
|
88
|
+
/**
|
|
89
|
+
* Read the persisted state for a table with these column ids. A persisted
|
|
90
|
+
* column that is gone from the table is dropped from the layout and named
|
|
91
|
+
* in `dropped`; new columns are appended to the order, visible.
|
|
92
|
+
*/
|
|
93
|
+
hydrate(columns: readonly string[]): TablePersistState & { dropped: string[] }
|
|
94
|
+
/** Write the table's current state — both halves, whatever is present. */
|
|
95
|
+
commit(state: Partial<TablePersistState>): void
|
|
96
|
+
/** Forget everything persisted for this id. */
|
|
97
|
+
reset(): void
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The storage namespace every persisted table shares. */
|
|
101
|
+
const STORE_KEY = 'newui.tables'
|
|
102
|
+
|
|
103
|
+
function isLayout(value: unknown): value is TableColumnLayout {
|
|
104
|
+
if (value === null || typeof value !== 'object') return false
|
|
105
|
+
const layout = value as Partial<TableColumnLayout>
|
|
106
|
+
return (
|
|
107
|
+
typeof layout.visibility === 'object' &&
|
|
108
|
+
layout.visibility !== null &&
|
|
109
|
+
Array.isArray(layout.order) &&
|
|
110
|
+
typeof layout.sizing === 'object' &&
|
|
111
|
+
layout.sizing !== null &&
|
|
112
|
+
typeof layout.pinning === 'object' &&
|
|
113
|
+
layout.pinning !== null &&
|
|
114
|
+
Array.isArray(layout.pinning.left) &&
|
|
115
|
+
Array.isArray(layout.pinning.right)
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function isSorting(value: unknown): value is TableSorting[] {
|
|
120
|
+
return (
|
|
121
|
+
Array.isArray(value) &&
|
|
122
|
+
value.every(
|
|
123
|
+
(entry) =>
|
|
124
|
+
entry !== null &&
|
|
125
|
+
typeof entry === 'object' &&
|
|
126
|
+
typeof (entry as TableSorting).id === 'string' &&
|
|
127
|
+
typeof (entry as TableSorting).desc === 'boolean',
|
|
128
|
+
)
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function isColumnFilters(value: unknown): value is TableColumnFilter[] {
|
|
133
|
+
return (
|
|
134
|
+
Array.isArray(value) &&
|
|
135
|
+
value.every(
|
|
136
|
+
(entry) =>
|
|
137
|
+
entry !== null &&
|
|
138
|
+
typeof entry === 'object' &&
|
|
139
|
+
typeof (entry as TableColumnFilter).id === 'string',
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Upgrade anything a store hands back to the current shape. A payload with
|
|
146
|
+
* no `v` predates the versioned document: it wrote `columns` as the visible
|
|
147
|
+
* ids and `sort` instead of `sorting`. Fields that fail validation are
|
|
148
|
+
* dropped rather than trusted — a half-readable document is still better
|
|
149
|
+
* than none.
|
|
150
|
+
*/
|
|
151
|
+
function normalizeDoc(payload: unknown): TablePersistState {
|
|
152
|
+
if (payload === null || typeof payload !== 'object') return {}
|
|
153
|
+
const doc = payload as Record<string, unknown>
|
|
154
|
+
const legacy = doc['v'] === undefined
|
|
155
|
+
const state: TablePersistState = {}
|
|
156
|
+
|
|
157
|
+
const sorting = legacy ? doc['sort'] : doc['sorting']
|
|
158
|
+
if (isSorting(sorting)) state.sorting = sorting
|
|
159
|
+
|
|
160
|
+
const filters = doc['columnFilters']
|
|
161
|
+
if (isColumnFilters(filters)) state.columnFilters = filters
|
|
162
|
+
|
|
163
|
+
const density = doc['density']
|
|
164
|
+
if (density === 'comfortable' || density === 'compact')
|
|
165
|
+
state.density = density
|
|
166
|
+
|
|
167
|
+
const pageSize = doc['pageSize']
|
|
168
|
+
if (
|
|
169
|
+
typeof pageSize === 'number' &&
|
|
170
|
+
Number.isInteger(pageSize) &&
|
|
171
|
+
pageSize > 0
|
|
172
|
+
) {
|
|
173
|
+
state.pageSize = pageSize
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const columns = doc['columns']
|
|
177
|
+
if (legacy && Array.isArray(columns)) {
|
|
178
|
+
// v0: the array of visible ids was the whole layout.
|
|
179
|
+
const ids = columns.filter((id): id is string => typeof id === 'string')
|
|
180
|
+
state.columns = {
|
|
181
|
+
visibility: Object.fromEntries(ids.map((id) => [id, true])),
|
|
182
|
+
order: ids,
|
|
183
|
+
sizing: {},
|
|
184
|
+
pinning: { left: [], right: [] },
|
|
185
|
+
}
|
|
186
|
+
} else if (isLayout(columns)) {
|
|
187
|
+
state.columns = columns
|
|
188
|
+
}
|
|
189
|
+
return state
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create the persistence binding for one table. Call it once per table id,
|
|
194
|
+
* `hydrate` with the current column ids before first paint, and `commit`
|
|
195
|
+
* with the table's state whenever it changes.
|
|
196
|
+
*/
|
|
197
|
+
export function createTablePersist(options: TablePersistOptions): TablePersist {
|
|
198
|
+
const { id } = options
|
|
199
|
+
const reportError = (error: Error): void => {
|
|
200
|
+
options.onPersistError?.(error)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const urlKeys: Record<string, UrlStateValue> = {
|
|
204
|
+
[`${id}.sort`]: [],
|
|
205
|
+
[`${id}.filters`]: [],
|
|
206
|
+
[`${id}.density`]: '',
|
|
207
|
+
[`${id}.pageSize`]: 0,
|
|
208
|
+
}
|
|
209
|
+
const url = createUrlState(urlKeys, {
|
|
210
|
+
...(options.url?.adapter
|
|
211
|
+
? {
|
|
212
|
+
adapter: withPruning(options.url.adapter, defaultsFor(urlKeys)),
|
|
213
|
+
}
|
|
214
|
+
: {}),
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// Reading `createUrlState`'s record by key runs into the
|
|
218
|
+
// `noUncheckedIndexedAccess` wall; this keeps every accessor honest.
|
|
219
|
+
const read = (key: string): UrlStateValue => {
|
|
220
|
+
const accessor = (
|
|
221
|
+
url as unknown as Record<string, Accessor<UrlStateValue> | undefined>
|
|
222
|
+
)[key]
|
|
223
|
+
return accessor ? accessor() : ''
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const store = createConfigStore({
|
|
227
|
+
key: STORE_KEY,
|
|
228
|
+
...(options.storage ? { storage: options.storage } : {}),
|
|
229
|
+
migrate: (doc) => normalizeDoc(doc),
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
function readDoc(): TablePersistState {
|
|
233
|
+
const loaded = store.load<TablePersistDoc>(id)
|
|
234
|
+
if (loaded) return normalizeDoc(loaded.doc)
|
|
235
|
+
// Absent is silence; present-but-unreadable is a story the app hears once.
|
|
236
|
+
if (options.storage?.getItem(`${STORE_KEY}:${id}`) !== null) {
|
|
237
|
+
reportError(
|
|
238
|
+
new Error(
|
|
239
|
+
`createTablePersist: the stored document for "${id}" is unreadable and was treated as absent.`,
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
return {}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function readUrl(): Partial<TablePersistState> {
|
|
247
|
+
const state: Partial<TablePersistState> = {}
|
|
248
|
+
const sort = read(`${id}.sort`)
|
|
249
|
+
if (isSorting(sort) && sort.length > 0) state.sorting = sort
|
|
250
|
+
const filters = read(`${id}.filters`)
|
|
251
|
+
if (isColumnFilters(filters) && filters.length > 0)
|
|
252
|
+
state.columnFilters = filters
|
|
253
|
+
const density = read(`${id}.density`)
|
|
254
|
+
if (density === 'comfortable' || density === 'compact')
|
|
255
|
+
state.density = density
|
|
256
|
+
const pageSize = read(`${id}.pageSize`)
|
|
257
|
+
if (
|
|
258
|
+
typeof pageSize === 'number' &&
|
|
259
|
+
Number.isInteger(pageSize) &&
|
|
260
|
+
pageSize > 0
|
|
261
|
+
) {
|
|
262
|
+
state.pageSize = pageSize
|
|
263
|
+
}
|
|
264
|
+
return state
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Fit a persisted layout to the table that exists now: gone columns are
|
|
269
|
+
* dropped, new ones appended visible, everything else in order.
|
|
270
|
+
*/
|
|
271
|
+
function fitColumns(
|
|
272
|
+
persisted: TableColumnLayout,
|
|
273
|
+
current: readonly string[],
|
|
274
|
+
dropped: Set<string>,
|
|
275
|
+
): TableColumnLayout {
|
|
276
|
+
const keep = new Set(current)
|
|
277
|
+
const order: string[] = []
|
|
278
|
+
for (const column of persisted.order) {
|
|
279
|
+
if (typeof column !== 'string') continue
|
|
280
|
+
if (keep.has(column)) {
|
|
281
|
+
if (!order.includes(column)) order.push(column)
|
|
282
|
+
} else {
|
|
283
|
+
dropped.add(column)
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const column of current) {
|
|
287
|
+
if (!order.includes(column)) order.push(column)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const visibility: Record<string, boolean> = {}
|
|
291
|
+
for (const [column, visible] of Object.entries(persisted.visibility)) {
|
|
292
|
+
if (!keep.has(column)) dropped.add(column)
|
|
293
|
+
else if (typeof visible === 'boolean') visibility[column] = visible
|
|
294
|
+
}
|
|
295
|
+
const sizing: Record<string, number> = {}
|
|
296
|
+
for (const [column, width] of Object.entries(persisted.sizing)) {
|
|
297
|
+
if (!keep.has(column)) dropped.add(column)
|
|
298
|
+
else if (typeof width === 'number' && width > 0) sizing[column] = width
|
|
299
|
+
}
|
|
300
|
+
const pin = (side: readonly unknown[]): string[] =>
|
|
301
|
+
side.filter((column): column is string => {
|
|
302
|
+
if (typeof column !== 'string') return false
|
|
303
|
+
if (keep.has(column)) return true
|
|
304
|
+
dropped.add(column)
|
|
305
|
+
return false
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
visibility,
|
|
310
|
+
order,
|
|
311
|
+
sizing,
|
|
312
|
+
pinning: {
|
|
313
|
+
left: pin(persisted.pinning.left),
|
|
314
|
+
right: pin(persisted.pinning.right),
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
hydrate(columns: readonly string[]) {
|
|
321
|
+
const persisted = readDoc()
|
|
322
|
+
const state: TablePersistState & { dropped: string[] } = {
|
|
323
|
+
...persisted,
|
|
324
|
+
...readUrl(),
|
|
325
|
+
dropped: [],
|
|
326
|
+
}
|
|
327
|
+
if (persisted.columns) {
|
|
328
|
+
const dropped = new Set(state.dropped)
|
|
329
|
+
state.columns = fitColumns(persisted.columns, columns, dropped)
|
|
330
|
+
state.dropped = [...dropped]
|
|
331
|
+
}
|
|
332
|
+
return state
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
commit(state: Partial<TablePersistState>) {
|
|
336
|
+
if (state.sorting) url.set(`${id}.sort`, state.sorting)
|
|
337
|
+
if (state.columnFilters) url.set(`${id}.filters`, state.columnFilters)
|
|
338
|
+
if (state.density) url.set(`${id}.density`, state.density)
|
|
339
|
+
if (state.pageSize) url.set(`${id}.pageSize`, state.pageSize)
|
|
340
|
+
|
|
341
|
+
const current = readDoc()
|
|
342
|
+
const next: TablePersistDoc = {
|
|
343
|
+
...current,
|
|
344
|
+
...state,
|
|
345
|
+
v: TABLE_PERSIST_SCHEMA_VERSION,
|
|
346
|
+
}
|
|
347
|
+
try {
|
|
348
|
+
if (!store.save(id, next, { type: TABLE_PERSIST_DOC_TYPE })) {
|
|
349
|
+
reportError(
|
|
350
|
+
new Error(
|
|
351
|
+
`createTablePersist: the storage backend rejected the write for "${id}" (quota or disabled storage). The table state is not persisted.`,
|
|
352
|
+
),
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
reportError(
|
|
357
|
+
error instanceof Error
|
|
358
|
+
? error
|
|
359
|
+
: new Error(`createTablePersist: persisting "${id}" failed.`),
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
reset() {
|
|
365
|
+
store.remove(id)
|
|
366
|
+
for (const key of Object.keys(urlKeys)) {
|
|
367
|
+
url.set(key, undefined)
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
}
|
|
371
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The serialization half of URL state, shared by `createUrlState` and by the
|
|
3
|
+
* higher-level primitives that compose it (`createDashboardState`,
|
|
4
|
+
* `createTablePersist`).
|
|
5
|
+
*
|
|
6
|
+
* `encodeValue` is `createUrlState`'s encoding; a composing primitive needs to
|
|
7
|
+
* know what a key serializes to — to prune params that say nothing — so it
|
|
8
|
+
* lives here instead of being copied. The types live here for the same reason:
|
|
9
|
+
* `url-state.ts` re-exports them, so the public surface is unchanged.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A value that serializes into a query parameter. */
|
|
13
|
+
export type UrlStateValue =
|
|
14
|
+
| string
|
|
15
|
+
| number
|
|
16
|
+
| boolean
|
|
17
|
+
| readonly UrlStateValue[]
|
|
18
|
+
| { readonly [key: string]: UrlStateValue }
|
|
19
|
+
|
|
20
|
+
/** The router side of {@link createUrlState}. */
|
|
21
|
+
export interface UrlStateAdapter {
|
|
22
|
+
/**
|
|
23
|
+
* The current query string (`"?a=1"` or `"a=1"`). Read **once** on creation,
|
|
24
|
+
* so first paint renders from the URL and a shared link deep-links without
|
|
25
|
+
* a client redirect.
|
|
26
|
+
*/
|
|
27
|
+
search(): string
|
|
28
|
+
/** Replace the query string. Must not push a history entry. */
|
|
29
|
+
replace(params: URLSearchParams): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** JSON with object keys in sorted order, so a round-trip is stable. */
|
|
33
|
+
function stableStringify(value: unknown): string {
|
|
34
|
+
return JSON.stringify(value, (_key, part: unknown) => {
|
|
35
|
+
if (part !== null && typeof part === 'object' && !Array.isArray(part)) {
|
|
36
|
+
const record = part as Record<string, unknown>
|
|
37
|
+
const sorted: Record<string, unknown> = {}
|
|
38
|
+
for (const key of Object.keys(record).sort()) sorted[key] = record[key]
|
|
39
|
+
return sorted
|
|
40
|
+
}
|
|
41
|
+
return part
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Encode one value for its query parameter. Primitives stay as-is. */
|
|
46
|
+
export function encodeValue(value: UrlStateValue): string {
|
|
47
|
+
if (typeof value === 'string') return value
|
|
48
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
49
|
+
return String(value)
|
|
50
|
+
}
|
|
51
|
+
return stableStringify(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The params an initial record serializes to — what "default" means for a
|
|
56
|
+
* set of keys. A param that still carries its default value says nothing, so
|
|
57
|
+
* it should never reach a shared link.
|
|
58
|
+
*/
|
|
59
|
+
export function defaultsFor(
|
|
60
|
+
initial: Record<string, UrlStateValue>,
|
|
61
|
+
): URLSearchParams {
|
|
62
|
+
const params = new URLSearchParams()
|
|
63
|
+
for (const [key, value] of Object.entries(initial)) {
|
|
64
|
+
params.set(key, encodeValue(value))
|
|
65
|
+
}
|
|
66
|
+
return params
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Drop every param that still says what its default says. Mutates `params`. */
|
|
70
|
+
export function pruneDefaults(
|
|
71
|
+
params: URLSearchParams,
|
|
72
|
+
defaults: URLSearchParams,
|
|
73
|
+
): URLSearchParams {
|
|
74
|
+
for (const [key, value] of [...defaults]) {
|
|
75
|
+
if (params.get(key) === value) params.delete(key)
|
|
76
|
+
}
|
|
77
|
+
return params
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An adapter wrapper that never writes a param back when it still holds its
|
|
82
|
+
* default. The composing primitive owns the keys; the pruning rule is the
|
|
83
|
+
* "link shares only what is not the default" contract, stated once.
|
|
84
|
+
*/
|
|
85
|
+
export function withPruning(
|
|
86
|
+
adapter: UrlStateAdapter,
|
|
87
|
+
defaults: URLSearchParams,
|
|
88
|
+
): UrlStateAdapter {
|
|
89
|
+
return {
|
|
90
|
+
search: () => adapter.search(),
|
|
91
|
+
replace: (params) => adapter.replace(pruneDefaults(params, defaults)),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createRoot } from 'solid-js'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
createUrlState,
|
|
5
|
+
type UrlState,
|
|
6
|
+
type UrlStateAdapter,
|
|
7
|
+
type UrlStateOptions,
|
|
8
|
+
type UrlStateValue,
|
|
9
|
+
} from './url-state'
|
|
10
|
+
|
|
11
|
+
function setup<T extends Record<string, UrlStateValue>>(
|
|
12
|
+
initial: T,
|
|
13
|
+
options?: UrlStateOptions,
|
|
14
|
+
) {
|
|
15
|
+
let dispose!: () => void
|
|
16
|
+
let state!: UrlState<T>
|
|
17
|
+
createRoot((d) => {
|
|
18
|
+
dispose = d
|
|
19
|
+
state = createUrlState(initial, options)
|
|
20
|
+
})
|
|
21
|
+
return { state, dispose }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface FakeAdapter extends UrlStateAdapter {
|
|
25
|
+
/** The params handed to the last `replace`. */
|
|
26
|
+
last(): URLSearchParams
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function adapter(search = ''): FakeAdapter {
|
|
30
|
+
let last = new URLSearchParams(search)
|
|
31
|
+
const replace = vi.fn((params: URLSearchParams) => {
|
|
32
|
+
last = params
|
|
33
|
+
})
|
|
34
|
+
return {
|
|
35
|
+
search: () => search,
|
|
36
|
+
replace,
|
|
37
|
+
last: () => last,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Let queued microtasks (the per-tick `replace`) run. */
|
|
42
|
+
async function flushMicrotasks(): Promise<void> {
|
|
43
|
+
await Promise.resolve()
|
|
44
|
+
await Promise.resolve()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('createUrlState', () => {
|
|
48
|
+
it('behaves as a plain signal without an adapter, touching no URL', async () => {
|
|
49
|
+
const before = window.location.search
|
|
50
|
+
const { state, dispose } = setup({ view: 'list' })
|
|
51
|
+
expect(state.view()).toBe('list')
|
|
52
|
+
|
|
53
|
+
state.set('view', 'board')
|
|
54
|
+
await flushMicrotasks()
|
|
55
|
+
expect(state.view()).toBe('board')
|
|
56
|
+
expect(window.location.search).toBe(before)
|
|
57
|
+
expect(state.params().get('view')).toBe('board')
|
|
58
|
+
dispose()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('reads the URL once on mount, so first paint renders from the link', () => {
|
|
62
|
+
const url = adapter('n=42&tags=["x","y"]&broken=abc')
|
|
63
|
+
const { state, dispose } = setup(
|
|
64
|
+
{ n: 1, tags: [] as string[], broken: 7 },
|
|
65
|
+
{ adapter: url },
|
|
66
|
+
)
|
|
67
|
+
expect(state.n()).toBe(42)
|
|
68
|
+
expect(state.tags()).toEqual(['x', 'y'])
|
|
69
|
+
// An unparseable value is not a crash: the initial value wins.
|
|
70
|
+
expect(state.broken()).toBe(7)
|
|
71
|
+
dispose()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('serializes primitives as-is and objects with stable key order', async () => {
|
|
75
|
+
const url = adapter()
|
|
76
|
+
const { state, dispose } = setup(
|
|
77
|
+
{ page: 1, flag: false as boolean, range: { to: 30, from: 10 } },
|
|
78
|
+
{ adapter: url },
|
|
79
|
+
)
|
|
80
|
+
state.set('page', 3)
|
|
81
|
+
state.set('flag', true)
|
|
82
|
+
state.set('range', { to: 99, from: 0 })
|
|
83
|
+
await flushMicrotasks()
|
|
84
|
+
|
|
85
|
+
expect(url.replace).toHaveBeenCalledTimes(1)
|
|
86
|
+
const params = url.last()
|
|
87
|
+
expect(params.get('page')).toBe('3')
|
|
88
|
+
expect(params.get('flag')).toBe('true')
|
|
89
|
+
expect(params.get('range')).toBe('{"from":0,"to":99}')
|
|
90
|
+
|
|
91
|
+
const roundTrip = setup(
|
|
92
|
+
{ page: 1, flag: false as boolean, range: { to: 0, from: 0 } },
|
|
93
|
+
{ adapter: adapter(params.toString()) },
|
|
94
|
+
)
|
|
95
|
+
expect(roundTrip.state.page()).toBe(3)
|
|
96
|
+
expect(roundTrip.state.flag()).toBe(true)
|
|
97
|
+
expect(roundTrip.state.range()).toEqual({ from: 0, to: 99 })
|
|
98
|
+
dispose()
|
|
99
|
+
roundTrip.dispose()
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('treats undefined as absent — removed, never an empty string', async () => {
|
|
103
|
+
const url = adapter('q=hello')
|
|
104
|
+
const { state, dispose } = setup({ q: 'hello' }, { adapter: url })
|
|
105
|
+
state.set('q', undefined)
|
|
106
|
+
await flushMicrotasks()
|
|
107
|
+
|
|
108
|
+
expect(url.last().has('q')).toBe(false)
|
|
109
|
+
expect(url.last().get('q')).toBeNull()
|
|
110
|
+
expect(state.q()).toBe('hello')
|
|
111
|
+
dispose()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('coalesces several writes in one tick into a single replace', async () => {
|
|
115
|
+
const url = adapter()
|
|
116
|
+
const { state, dispose } = setup({ a: 1, b: 2 }, { adapter: url })
|
|
117
|
+
state.set('a', 10)
|
|
118
|
+
state.set('b', 20)
|
|
119
|
+
await flushMicrotasks()
|
|
120
|
+
expect(url.replace).toHaveBeenCalledTimes(1)
|
|
121
|
+
expect(url.last().get('a')).toBe('10')
|
|
122
|
+
expect(url.last().get('b')).toBe('20')
|
|
123
|
+
|
|
124
|
+
state.set('a', 11)
|
|
125
|
+
await flushMicrotasks()
|
|
126
|
+
expect(url.replace).toHaveBeenCalledTimes(2)
|
|
127
|
+
dispose()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('debounces search-like keys, resetting the window on every keystroke', async () => {
|
|
131
|
+
vi.useFakeTimers()
|
|
132
|
+
try {
|
|
133
|
+
const url = adapter()
|
|
134
|
+
const { state, dispose } = setup(
|
|
135
|
+
{ q: '', page: 1 },
|
|
136
|
+
{ adapter: url, debounce: ['q'] },
|
|
137
|
+
)
|
|
138
|
+
state.set('q', 'a')
|
|
139
|
+
expect(url.replace).not.toHaveBeenCalled()
|
|
140
|
+
vi.advanceTimersByTime(200)
|
|
141
|
+
state.set('q', 'ab')
|
|
142
|
+
expect(url.replace).not.toHaveBeenCalled()
|
|
143
|
+
vi.advanceTimersByTime(200)
|
|
144
|
+
expect(url.replace).not.toHaveBeenCalled()
|
|
145
|
+
vi.advanceTimersByTime(100)
|
|
146
|
+
expect(url.replace).toHaveBeenCalledTimes(1)
|
|
147
|
+
expect(url.last().get('q')).toBe('ab')
|
|
148
|
+
|
|
149
|
+
// A non-debounced key still writes immediately.
|
|
150
|
+
state.set('page', 4)
|
|
151
|
+
await vi.advanceTimersByTimeAsync(0)
|
|
152
|
+
expect(url.last().get('page')).toBe('4')
|
|
153
|
+
dispose()
|
|
154
|
+
} finally {
|
|
155
|
+
vi.useRealTimers()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('honours a custom debounce window', async () => {
|
|
160
|
+
vi.useFakeTimers()
|
|
161
|
+
try {
|
|
162
|
+
const url = adapter()
|
|
163
|
+
const { state, dispose } = setup(
|
|
164
|
+
{ q: '' },
|
|
165
|
+
{ adapter: url, debounce: ['q'], debounceMs: 50 },
|
|
166
|
+
)
|
|
167
|
+
state.set('q', 'x')
|
|
168
|
+
vi.advanceTimersByTime(50)
|
|
169
|
+
expect(url.last().get('q')).toBe('x')
|
|
170
|
+
dispose()
|
|
171
|
+
} finally {
|
|
172
|
+
vi.useRealTimers()
|
|
173
|
+
}
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('exposes params() as a snapshot that cannot corrupt the state', () => {
|
|
177
|
+
const { state, dispose } = setup({ view: 'list' })
|
|
178
|
+
const snapshot = state.params()
|
|
179
|
+
snapshot.set('view', 'tampered')
|
|
180
|
+
expect(state.params().get('view')).toBe('list')
|
|
181
|
+
dispose()
|
|
182
|
+
})
|
|
183
|
+
})
|