@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.
@@ -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
+ }
@@ -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
- for (const value of Object.values(root)) {
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
+ })