@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,381 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Accessor,
|
|
3
|
+
createContext,
|
|
4
|
+
createMemo,
|
|
5
|
+
createSignal,
|
|
6
|
+
type JSX,
|
|
7
|
+
untrack,
|
|
8
|
+
useContext,
|
|
9
|
+
} from 'solid-js'
|
|
10
|
+
import {
|
|
11
|
+
defaultsFor,
|
|
12
|
+
encodeValue,
|
|
13
|
+
pruneDefaults,
|
|
14
|
+
withPruning,
|
|
15
|
+
} from './url-serialize'
|
|
16
|
+
import {
|
|
17
|
+
createUrlState,
|
|
18
|
+
type UrlStateAdapter,
|
|
19
|
+
type UrlStateValue,
|
|
20
|
+
} from './url-state'
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The connective tissue of an analytics dashboard: one shared time range,
|
|
24
|
+
* one shared filter record, and one cross-filter slot that every widget
|
|
25
|
+
* reads and writes.
|
|
26
|
+
*
|
|
27
|
+
* `createDashboardState` owns the signals; `DashboardProvider` and
|
|
28
|
+
* `useDashboardState` keep charts, KPI cards and tables from prop-drilling
|
|
29
|
+
* them through the widget grid. What is reproducible by link — the preset,
|
|
30
|
+
* the custom dates, the cross-filter, every declared filter — round-trips
|
|
31
|
+
* through `createUrlState` when the app supplies an adapter, and stays a
|
|
32
|
+
* plain signal without one, so tests and SSR stay clean.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** The named ranges the kit ships. `'custom'` means the user set the dates. */
|
|
36
|
+
export type TimeRangePreset = '7d' | '30d' | 'qtd' | 'ytd' | 'custom'
|
|
37
|
+
|
|
38
|
+
/** The shared time window every widget filters by. */
|
|
39
|
+
export interface TimeRange {
|
|
40
|
+
/** Which preset produced this range; `'custom'` means set dates. */
|
|
41
|
+
preset: TimeRangePreset
|
|
42
|
+
/** Inclusive start — local midnight of the first day. */
|
|
43
|
+
from: Date
|
|
44
|
+
/** Inclusive end — local end-of-day of the last day. */
|
|
45
|
+
to: Date
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The one widget click other widgets filter by. */
|
|
49
|
+
export interface CrossFilter {
|
|
50
|
+
/** The column the click came from. */
|
|
51
|
+
column: string
|
|
52
|
+
/** The clicked value. */
|
|
53
|
+
value: string | number | boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Options for {@link createDashboardState}. */
|
|
57
|
+
export interface DashboardStateOptions {
|
|
58
|
+
/** Initial range. Defaults to the current 7 days. */
|
|
59
|
+
timeRange?: { preset?: TimeRangePreset; from?: Date; to?: Date }
|
|
60
|
+
/**
|
|
61
|
+
* The shared filter record. Declared keys get their own URL params
|
|
62
|
+
* (`f:<key>`); the empty string means unset.
|
|
63
|
+
*/
|
|
64
|
+
filters?: Record<string, UrlStateValue | undefined>
|
|
65
|
+
/** Router bridge. Present (and `sync` not false), state round-trips to the URL. */
|
|
66
|
+
adapter?: UrlStateAdapter
|
|
67
|
+
/** Turn URL writing off while still reading the adapter for deep links. */
|
|
68
|
+
sync?: boolean
|
|
69
|
+
/** Clock seam for tests. Defaults to `() => new Date()`. */
|
|
70
|
+
now?: () => Date
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The handle {@link createDashboardState} returns. */
|
|
74
|
+
export interface DashboardState {
|
|
75
|
+
/** The current window. Preset ranges are computed from `now()` on read. */
|
|
76
|
+
timeRange(): TimeRange
|
|
77
|
+
/**
|
|
78
|
+
* `'7d' | '30d' | 'qtd' | 'ytd'` recomputes the window; a partial with
|
|
79
|
+
* dates marks the range `'custom'`. An unknown preset is ignored.
|
|
80
|
+
*/
|
|
81
|
+
setTimeRange(next: TimeRangePreset | Partial<TimeRange>): void
|
|
82
|
+
/** The shared filters, declared and ad hoc. Unset keys read `undefined`. */
|
|
83
|
+
filters(): Record<string, UrlStateValue | undefined>
|
|
84
|
+
/** One filter. `undefined` returns it to its declared default. */
|
|
85
|
+
setFilter(key: string, value: UrlStateValue | undefined): void
|
|
86
|
+
/** Apply several filters at once — a merge, not a replacement. */
|
|
87
|
+
setFilters(next: Record<string, UrlStateValue | undefined>): void
|
|
88
|
+
/** The active cross-filter, or `null`. One slot, one value. */
|
|
89
|
+
cross(): CrossFilter | null
|
|
90
|
+
/**
|
|
91
|
+
* Set the cross-filter, replacing whatever held the slot. Setting the same
|
|
92
|
+
* column and value again clears it — the chip the widget's own click
|
|
93
|
+
* toggles — and `null` clears it outright.
|
|
94
|
+
*/
|
|
95
|
+
setCross(next: CrossFilter | null): void
|
|
96
|
+
/** The params this state serializes to right now (for "copy link"). */
|
|
97
|
+
params(): URLSearchParams
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const PRESET_NAMES: readonly TimeRangePreset[] = [
|
|
101
|
+
'7d',
|
|
102
|
+
'30d',
|
|
103
|
+
'qtd',
|
|
104
|
+
'ytd',
|
|
105
|
+
'custom',
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
function startOfDay(date: Date): Date {
|
|
109
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function endOfDay(date: Date): Date {
|
|
113
|
+
return new Date(
|
|
114
|
+
date.getFullYear(),
|
|
115
|
+
date.getMonth(),
|
|
116
|
+
date.getDate(),
|
|
117
|
+
23,
|
|
118
|
+
59,
|
|
119
|
+
59,
|
|
120
|
+
999,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The window a named preset stands for, ending with today. */
|
|
125
|
+
function presetRange(
|
|
126
|
+
preset: Exclude<TimeRangePreset, 'custom'>,
|
|
127
|
+
now: Date,
|
|
128
|
+
): { from: Date; to: Date } {
|
|
129
|
+
const to = endOfDay(now)
|
|
130
|
+
switch (preset) {
|
|
131
|
+
case '7d':
|
|
132
|
+
return {
|
|
133
|
+
from: startOfDay(
|
|
134
|
+
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 6),
|
|
135
|
+
),
|
|
136
|
+
to,
|
|
137
|
+
}
|
|
138
|
+
case '30d':
|
|
139
|
+
return {
|
|
140
|
+
from: startOfDay(
|
|
141
|
+
new Date(now.getFullYear(), now.getMonth(), now.getDate() - 29),
|
|
142
|
+
),
|
|
143
|
+
to,
|
|
144
|
+
}
|
|
145
|
+
case 'qtd':
|
|
146
|
+
return {
|
|
147
|
+
from: startOfDay(
|
|
148
|
+
new Date(now.getFullYear(), Math.floor(now.getMonth() / 3) * 3, 1),
|
|
149
|
+
),
|
|
150
|
+
to,
|
|
151
|
+
}
|
|
152
|
+
case 'ytd':
|
|
153
|
+
return { from: startOfDay(new Date(now.getFullYear(), 0, 1)), to }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isPreset(value: unknown): value is TimeRangePreset {
|
|
158
|
+
return (
|
|
159
|
+
typeof value === 'string' &&
|
|
160
|
+
(PRESET_NAMES as readonly string[]).includes(value)
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isCrossFilter(value: unknown): value is CrossFilter {
|
|
165
|
+
if (value === null || typeof value !== 'object') return false
|
|
166
|
+
const candidate = value as Partial<CrossFilter>
|
|
167
|
+
return (
|
|
168
|
+
typeof candidate.column === 'string' &&
|
|
169
|
+
(typeof candidate.value === 'string' ||
|
|
170
|
+
typeof candidate.value === 'number' ||
|
|
171
|
+
typeof candidate.value === 'boolean')
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Create the shared dashboard state. Call it once, at the root of the
|
|
177
|
+
* dashboard, and hand the result to `DashboardProvider`; widgets read it
|
|
178
|
+
* through `useDashboardState`.
|
|
179
|
+
*/
|
|
180
|
+
export function createDashboardState(
|
|
181
|
+
options: DashboardStateOptions = {},
|
|
182
|
+
): DashboardState {
|
|
183
|
+
const now = options.now ?? (() => new Date())
|
|
184
|
+
const sync = options.sync ?? options.adapter !== undefined
|
|
185
|
+
const adapter = sync ? options.adapter : undefined
|
|
186
|
+
|
|
187
|
+
// A `'custom'` seed without both dates is not a range; fall back to 7d
|
|
188
|
+
// rather than invent one.
|
|
189
|
+
const seededFrom = options.timeRange?.from
|
|
190
|
+
const seededTo = options.timeRange?.to
|
|
191
|
+
const seededCustom =
|
|
192
|
+
options.timeRange?.preset === 'custom' &&
|
|
193
|
+
seededFrom !== undefined &&
|
|
194
|
+
seededTo !== undefined
|
|
195
|
+
const seedPreset: TimeRangePreset =
|
|
196
|
+
options.timeRange?.preset === 'custom' && !seededCustom
|
|
197
|
+
? '7d'
|
|
198
|
+
: (options.timeRange?.preset ?? '7d')
|
|
199
|
+
|
|
200
|
+
const declared = Object.keys(options.filters ?? {})
|
|
201
|
+
const urlInitial: Record<string, UrlStateValue> = {
|
|
202
|
+
range: seedPreset,
|
|
203
|
+
from: seededCustom && seededFrom ? seededFrom.toISOString() : '',
|
|
204
|
+
to: seededCustom && seededTo ? seededTo.toISOString() : '',
|
|
205
|
+
cross: '',
|
|
206
|
+
}
|
|
207
|
+
for (const key of declared) {
|
|
208
|
+
urlInitial[`f:${key}`] = options.filters?.[key] ?? ''
|
|
209
|
+
}
|
|
210
|
+
const defaults = defaultsFor(urlInitial)
|
|
211
|
+
|
|
212
|
+
const url = createUrlState(urlInitial, {
|
|
213
|
+
...(adapter ? { adapter: withPruning(adapter, defaults) } : {}),
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// Reading `createUrlState`'s record by key runs into the
|
|
217
|
+
// `noUncheckedIndexedAccess` wall; this keeps every accessor honest.
|
|
218
|
+
const read = (key: string): UrlStateValue => {
|
|
219
|
+
const accessor = (
|
|
220
|
+
url as unknown as Record<string, Accessor<UrlStateValue> | undefined>
|
|
221
|
+
)[key]
|
|
222
|
+
return accessor ? accessor() : ''
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Undeclared filter keys arrive at runtime (a cross-filter writing a new
|
|
226
|
+
// column, say). They live in one signal of their own and still serialize.
|
|
227
|
+
const [extraFilters, setExtraFilters] = createSignal<
|
|
228
|
+
Record<string, UrlStateValue>
|
|
229
|
+
>({})
|
|
230
|
+
|
|
231
|
+
const timeRange = createMemo<TimeRange>(() => {
|
|
232
|
+
const preset = read('range')
|
|
233
|
+
if (isPreset(preset) && preset !== 'custom') {
|
|
234
|
+
return { preset, ...presetRange(preset, now()) }
|
|
235
|
+
}
|
|
236
|
+
const from = Date.parse(String(read('from')))
|
|
237
|
+
const to = Date.parse(String(read('to')))
|
|
238
|
+
if (!Number.isNaN(from) && !Number.isNaN(to)) {
|
|
239
|
+
return { preset: 'custom', from: new Date(from), to: new Date(to) }
|
|
240
|
+
}
|
|
241
|
+
// A custom range whose dates no longer parse is a stale link: reset the
|
|
242
|
+
// window, keep the label, do not crash.
|
|
243
|
+
return { preset: 'custom', ...presetRange('7d', now()) }
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
function setTimeRange(next: TimeRangePreset | Partial<TimeRange>): void {
|
|
247
|
+
if (typeof next === 'string') {
|
|
248
|
+
if (!isPreset(next)) {
|
|
249
|
+
console.warn(
|
|
250
|
+
`[newui-data] createDashboardState: "${next}" is not a time-range preset; ignored.`,
|
|
251
|
+
)
|
|
252
|
+
return
|
|
253
|
+
}
|
|
254
|
+
url.set('range', next)
|
|
255
|
+
if (next !== 'custom') {
|
|
256
|
+
url.set('from', undefined)
|
|
257
|
+
url.set('to', undefined)
|
|
258
|
+
}
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const preset = next.preset ?? 'custom'
|
|
262
|
+
if (isPreset(preset) && preset !== 'custom') {
|
|
263
|
+
url.set('range', preset)
|
|
264
|
+
url.set('from', undefined)
|
|
265
|
+
url.set('to', undefined)
|
|
266
|
+
return
|
|
267
|
+
}
|
|
268
|
+
const current = untrack(timeRange)
|
|
269
|
+
const from = next.from ?? current.from
|
|
270
|
+
const to = next.to ?? current.to
|
|
271
|
+
url.set('range', 'custom')
|
|
272
|
+
url.set('from', from.toISOString())
|
|
273
|
+
url.set('to', to.toISOString())
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const cross = createMemo<CrossFilter | null>(() => {
|
|
277
|
+
const raw = String(read('cross'))
|
|
278
|
+
if (raw === '') return null
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(raw) as unknown
|
|
281
|
+
return isCrossFilter(parsed) ? parsed : null
|
|
282
|
+
} catch {
|
|
283
|
+
return null
|
|
284
|
+
}
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
function setCross(next: CrossFilter | null): void {
|
|
288
|
+
if (next === null) {
|
|
289
|
+
url.set('cross', undefined)
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
const current = untrack(cross)
|
|
293
|
+
// One slot: the same click twice is a toggle, not a second filter.
|
|
294
|
+
if (
|
|
295
|
+
current !== null &&
|
|
296
|
+
current.column === next.column &&
|
|
297
|
+
current.value === next.value
|
|
298
|
+
) {
|
|
299
|
+
url.set('cross', undefined)
|
|
300
|
+
return
|
|
301
|
+
}
|
|
302
|
+
url.set('cross', JSON.stringify(next))
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function filterValue(key: string): UrlStateValue | undefined {
|
|
306
|
+
const raw = declared.includes(key)
|
|
307
|
+
? read(`f:${key}`)
|
|
308
|
+
: (extraFilters()[key] ?? '')
|
|
309
|
+
return raw === '' ? undefined : raw
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const filters = createMemo(() => {
|
|
313
|
+
const out: Record<string, UrlStateValue | undefined> = {}
|
|
314
|
+
for (const key of declared) out[key] = filterValue(key)
|
|
315
|
+
for (const key of Object.keys(extraFilters())) {
|
|
316
|
+
if (!declared.includes(key)) out[key] = filterValue(key)
|
|
317
|
+
}
|
|
318
|
+
return out
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
function setFilter(key: string, value: UrlStateValue | undefined): void {
|
|
322
|
+
if (declared.includes(key)) {
|
|
323
|
+
url.set(`f:${key}`, value)
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
setExtraFilters((extras) => ({ ...extras, [key]: value ?? '' }))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function setFilters(next: Record<string, UrlStateValue | undefined>): void {
|
|
330
|
+
for (const [key, value] of Object.entries(next)) setFilter(key, value)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function params(): URLSearchParams {
|
|
334
|
+
const out = url.params()
|
|
335
|
+
for (const [key, value] of Object.entries(extraFilters())) {
|
|
336
|
+
if (value === undefined || value === '') out.delete(`f:${key}`)
|
|
337
|
+
else out.set(`f:${key}`, encodeValue(value))
|
|
338
|
+
}
|
|
339
|
+
return pruneDefaults(out, defaults)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
timeRange,
|
|
344
|
+
setTimeRange,
|
|
345
|
+
filters,
|
|
346
|
+
setFilter,
|
|
347
|
+
setFilters,
|
|
348
|
+
cross,
|
|
349
|
+
setCross,
|
|
350
|
+
params,
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const DashboardContext = createContext<Accessor<DashboardState>>()
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Publish one `createDashboardState` to the widget grid, so a chart three
|
|
358
|
+
* levels down reads the same time range without a prop on every card.
|
|
359
|
+
*/
|
|
360
|
+
export function DashboardProvider(props: {
|
|
361
|
+
state: DashboardState
|
|
362
|
+
children: JSX.Element
|
|
363
|
+
}): JSX.Element {
|
|
364
|
+
const state = createMemo(() => props.state)
|
|
365
|
+
return (
|
|
366
|
+
<DashboardContext.Provider value={state}>
|
|
367
|
+
{props.children}
|
|
368
|
+
</DashboardContext.Provider>
|
|
369
|
+
)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** The dashboard state published by the enclosing `DashboardProvider`. */
|
|
373
|
+
export function useDashboardState(): DashboardState {
|
|
374
|
+
const state = useContext(DashboardContext)
|
|
375
|
+
if (!state) {
|
|
376
|
+
throw new Error(
|
|
377
|
+
'useDashboardState must be used within <DashboardProvider> (create the state with createDashboardState)',
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
return state()
|
|
381
|
+
}
|
package/src/exports.test.ts
CHANGED
|
@@ -26,28 +26,50 @@ const ENTRIES = {
|
|
|
26
26
|
const SURFACE: Record<keyof typeof ENTRIES, string[]> = {
|
|
27
27
|
// Formatting, selection, undo, downloads — and nothing that renders.
|
|
28
28
|
'.': [
|
|
29
|
+
'CONFIG_DOCUMENT_LIMIT',
|
|
30
|
+
'CONFIG_SCHEMA_VERSION',
|
|
31
|
+
'DashboardProvider',
|
|
32
|
+
'aggregate',
|
|
29
33
|
'bytes',
|
|
30
34
|
'count',
|
|
35
|
+
'createConfigStore',
|
|
36
|
+
'createDashboardState',
|
|
37
|
+
'createTablePersist',
|
|
31
38
|
'createUndo',
|
|
39
|
+
'createUrlState',
|
|
32
40
|
'csvText',
|
|
33
41
|
'downloadBlob',
|
|
34
42
|
'downloadCsv',
|
|
35
43
|
'downloadText',
|
|
44
|
+
'exportCsv',
|
|
36
45
|
'first',
|
|
37
46
|
'isEmail',
|
|
38
47
|
'optionsOf',
|
|
48
|
+
'rollup',
|
|
49
|
+
'timeBucket',
|
|
39
50
|
'usd',
|
|
40
51
|
'usdK',
|
|
52
|
+
'useDashboardState',
|
|
41
53
|
],
|
|
42
54
|
'./query': [
|
|
43
55
|
'DataProvider',
|
|
44
56
|
'createCollection',
|
|
45
57
|
'createDataClient',
|
|
46
58
|
'createListQuery',
|
|
59
|
+
'createServerTableState',
|
|
60
|
+
],
|
|
61
|
+
'./form': [
|
|
62
|
+
'Form',
|
|
63
|
+
'createAppForm',
|
|
64
|
+
'createAutoSave',
|
|
65
|
+
'fieldError',
|
|
66
|
+
'hiddenPaths',
|
|
67
|
+
'isUnderHidden',
|
|
68
|
+
'pruneHiddenValues',
|
|
47
69
|
],
|
|
48
|
-
'./form': ['Form', 'createAppForm', 'fieldError'],
|
|
49
70
|
'./resource': [
|
|
50
71
|
'RequestError',
|
|
72
|
+
'connectRealtime',
|
|
51
73
|
'createDocumentResource',
|
|
52
74
|
'createIdbStore',
|
|
53
75
|
'createListResource',
|
|
@@ -89,8 +111,16 @@ describe('@talelk/newui-data', () => {
|
|
|
89
111
|
|
|
90
112
|
it('keeps the root entry headless', () => {
|
|
91
113
|
// The root is the only entry with no component, no query client and no
|
|
92
|
-
// validator; every value it exports is a function over plain data
|
|
93
|
-
|
|
114
|
+
// validator; every value it exports is a function over plain data — with
|
|
115
|
+
// the exception of the config-store constants, plain numbers by design.
|
|
116
|
+
for (const [name, value] of Object.entries(root)) {
|
|
117
|
+
if (
|
|
118
|
+
name === 'CONFIG_SCHEMA_VERSION' ||
|
|
119
|
+
name === 'CONFIG_DOCUMENT_LIMIT'
|
|
120
|
+
) {
|
|
121
|
+
expect(typeof value).toBe('number')
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
94
124
|
expect(typeof value).toBe('function')
|
|
95
125
|
}
|
|
96
126
|
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { type AnyFormApi, createForm } from '@tanstack/solid-form'
|
|
2
|
+
import { createEffect, createRoot } from 'solid-js'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { createAutoSave } from './autosave'
|
|
5
|
+
import { createAppForm } from './form'
|
|
6
|
+
|
|
7
|
+
function memoryStorage() {
|
|
8
|
+
const map = new Map<string, string>()
|
|
9
|
+
return {
|
|
10
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
11
|
+
setItem: (key: string, value: string) => void map.set(key, value),
|
|
12
|
+
removeItem: (key: string) => void map.delete(key),
|
|
13
|
+
keys: () => [...map.keys()],
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function harness() {
|
|
18
|
+
// `createRoot` runs its callback synchronously, so `form` is set by return.
|
|
19
|
+
let form!: AnyFormApi
|
|
20
|
+
createRoot((dispose) => {
|
|
21
|
+
form = createForm(() => ({
|
|
22
|
+
defaultValues: { name: '' } satisfies { name: string },
|
|
23
|
+
validators: {
|
|
24
|
+
onChange: ({ value }: { value: { name: string } }) =>
|
|
25
|
+
value.name.length < 3 ? 'Too short.' : undefined,
|
|
26
|
+
},
|
|
27
|
+
}))
|
|
28
|
+
// Keep the root alive for the duration of the test.
|
|
29
|
+
void dispose
|
|
30
|
+
})
|
|
31
|
+
return form
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => vi.useFakeTimers())
|
|
35
|
+
|
|
36
|
+
describe('createAutoSave', () => {
|
|
37
|
+
it('saves a valid dirty value after the debounce', async () => {
|
|
38
|
+
const form = harness()
|
|
39
|
+
const save = vi.fn()
|
|
40
|
+
const auto = createAutoSave<{ name: string }>(form, { save, debounce: 100 })
|
|
41
|
+
|
|
42
|
+
form.setFieldValue('name', 'Ada Lovelace')
|
|
43
|
+
await vi.advanceTimersByTimeAsync(100)
|
|
44
|
+
|
|
45
|
+
expect(save).toHaveBeenCalledTimes(1)
|
|
46
|
+
expect(save.mock.calls[0]?.[0]).toEqual({ name: 'Ada Lovelace' })
|
|
47
|
+
expect(auto.saved()).toBe(true)
|
|
48
|
+
expect(auto.dirty()).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('does not save while the form is invalid', async () => {
|
|
52
|
+
const form = harness()
|
|
53
|
+
const save = vi.fn()
|
|
54
|
+
createAutoSave<{ name: string }>(form, { save, debounce: 50 })
|
|
55
|
+
|
|
56
|
+
form.setFieldValue('name', 'ad')
|
|
57
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
58
|
+
|
|
59
|
+
expect(save).not.toHaveBeenCalled()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('skips the write when the filter rejects', async () => {
|
|
63
|
+
const form = harness()
|
|
64
|
+
const save = vi.fn()
|
|
65
|
+
createAutoSave<{ name: string }>(form, {
|
|
66
|
+
save,
|
|
67
|
+
debounce: 50,
|
|
68
|
+
filter: (values) => values.name !== 'skip',
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
form.setFieldValue('name', 'skip')
|
|
72
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
73
|
+
|
|
74
|
+
expect(save).not.toHaveBeenCalled()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('persists a draft and clears it after a successful save', async () => {
|
|
78
|
+
const form = harness()
|
|
79
|
+
const storage = memoryStorage()
|
|
80
|
+
const save = vi.fn()
|
|
81
|
+
const auto = createAutoSave<{ name: string }>(form, {
|
|
82
|
+
save,
|
|
83
|
+
debounce: 50,
|
|
84
|
+
id: 'profile',
|
|
85
|
+
storage,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
form.setFieldValue('name', 'Ada Lovelace')
|
|
89
|
+
// The draft is written on the change, before the debounce fires.
|
|
90
|
+
expect(auto.hasDraft()).toBe(true)
|
|
91
|
+
expect(auto.restoreDraft()).toEqual({ name: 'Ada Lovelace' })
|
|
92
|
+
|
|
93
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
94
|
+
expect(save).toHaveBeenCalled()
|
|
95
|
+
expect(auto.hasDraft()).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('tracks hasDraft reactively', async () => {
|
|
99
|
+
const form = harness()
|
|
100
|
+
const seen: boolean[] = []
|
|
101
|
+
createRoot(() => {
|
|
102
|
+
const auto = createAutoSave<{ name: string }>(form, {
|
|
103
|
+
save: vi.fn(),
|
|
104
|
+
debounce: 50,
|
|
105
|
+
id: 'reactive',
|
|
106
|
+
storage: memoryStorage(),
|
|
107
|
+
})
|
|
108
|
+
createEffect(() => {
|
|
109
|
+
seen.push(auto.hasDraft())
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
form.setFieldValue('name', 'Ada Lovelace')
|
|
113
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
114
|
+
expect(seen).toEqual([false, true, false])
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
interface Doc {
|
|
119
|
+
mode: string
|
|
120
|
+
host: string
|
|
121
|
+
name: string
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** An app form whose `host` is hidden unless `mode` is `advanced`. */
|
|
125
|
+
function appHarness(onSubmit: (doc: Doc) => void = () => {}) {
|
|
126
|
+
let app!: ReturnType<typeof createAppForm<Doc>>
|
|
127
|
+
createRoot(() => {
|
|
128
|
+
app = createAppForm<Doc>({
|
|
129
|
+
defaultValues: { mode: 'simple', host: 'stale.example', name: '' },
|
|
130
|
+
ui: { host: { when: (values) => values.mode === 'advanced' } },
|
|
131
|
+
onSubmit,
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
return app
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describe('createAutoSave with an app form', () => {
|
|
138
|
+
it('saves the pruned document, the one submit sends', async () => {
|
|
139
|
+
const app = appHarness()
|
|
140
|
+
const save = vi.fn()
|
|
141
|
+
createAutoSave<Doc>(app, { save, debounce: 50 })
|
|
142
|
+
|
|
143
|
+
app.form.setFieldValue('name', 'Ada')
|
|
144
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
145
|
+
|
|
146
|
+
expect(save).toHaveBeenCalledWith({ mode: 'simple', name: 'Ada' })
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('keeps the draft unpruned', () => {
|
|
150
|
+
const app = appHarness()
|
|
151
|
+
const auto = createAutoSave<Doc>(app, {
|
|
152
|
+
save: vi.fn(),
|
|
153
|
+
debounce: 50,
|
|
154
|
+
id: 'settings',
|
|
155
|
+
storage: memoryStorage(),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
app.form.setFieldValue('name', 'Ada')
|
|
159
|
+
|
|
160
|
+
expect(auto.restoreDraft()).toEqual({
|
|
161
|
+
mode: 'simple',
|
|
162
|
+
host: 'stale.example',
|
|
163
|
+
name: 'Ada',
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('drops the draft on a successful submit and does not autosave after it', async () => {
|
|
168
|
+
const app = appHarness()
|
|
169
|
+
const save = vi.fn()
|
|
170
|
+
const auto = createAutoSave<Doc>(app, {
|
|
171
|
+
save,
|
|
172
|
+
debounce: 50,
|
|
173
|
+
id: 'settings',
|
|
174
|
+
storage: memoryStorage(),
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
app.form.setFieldValue('name', 'Ada')
|
|
178
|
+
expect(auto.hasDraft()).toBe(true)
|
|
179
|
+
await app.form.handleSubmit()
|
|
180
|
+
|
|
181
|
+
expect(auto.hasDraft()).toBe(false)
|
|
182
|
+
expect(auto.dirty()).toBe(false)
|
|
183
|
+
await vi.advanceTimersByTimeAsync(50)
|
|
184
|
+
expect(save).not.toHaveBeenCalled()
|
|
185
|
+
})
|
|
186
|
+
})
|