@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
package/src/url-state.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type Accessor, createSignal, onCleanup, type Setter } from 'solid-js'
|
|
2
|
+
import {
|
|
3
|
+
encodeValue,
|
|
4
|
+
type UrlStateAdapter,
|
|
5
|
+
type UrlStateValue,
|
|
6
|
+
} from './url-serialize'
|
|
7
|
+
|
|
8
|
+
export type { UrlStateAdapter, UrlStateValue } from './url-serialize'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* URL state, router-agnostic by contract.
|
|
12
|
+
*
|
|
13
|
+
* `createUrlState` owns the signals whose values serialize to
|
|
14
|
+
* `URLSearchParams`: what makes a page reproducible by link — filters, view,
|
|
15
|
+
* page index, sort, density, time range. The app supplies the adapter, so the
|
|
16
|
+
* kit never imports a router: Solid Start's `useNavigate`, TanStack Router or
|
|
17
|
+
* a `history.replaceState` shim all satisfy {@link UrlStateAdapter}. Without
|
|
18
|
+
* an adapter the primitive is a plain controllable signal — tests and SSR stay
|
|
19
|
+
* clean, and the URL is simply never written.
|
|
20
|
+
*
|
|
21
|
+
* Writes **replace** the query string, never push (the back button leaves the
|
|
22
|
+
* page, it does not walk a filter history), and concurrent writes coalesce
|
|
23
|
+
* into one `replace` per frame. Search-like keys can be debounced so typing
|
|
24
|
+
* does not spam the history.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Options for {@link createUrlState}. */
|
|
28
|
+
export interface UrlStateOptions {
|
|
29
|
+
/** The router bridge. Absent means no URL access at all — pure signals. */
|
|
30
|
+
adapter?: UrlStateAdapter
|
|
31
|
+
/** Keys written after a debounce instead of immediately (search-like keys). */
|
|
32
|
+
debounce?: readonly string[]
|
|
33
|
+
/** Debounce window in ms. Defaults to `300`. */
|
|
34
|
+
debounceMs?: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The handle {@link createUrlState} returns: one accessor per key. */
|
|
38
|
+
export type UrlState<T extends Record<string, UrlStateValue>> = {
|
|
39
|
+
[K in keyof T]: Accessor<T[K]>
|
|
40
|
+
} & {
|
|
41
|
+
/** Write one key. `undefined` means absent — it is removed, never `""`. */
|
|
42
|
+
set<K extends keyof T>(key: K, value: T[K] | undefined): void
|
|
43
|
+
/** The params every key serializes to right now (for "copy link"). */
|
|
44
|
+
params(): URLSearchParams
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const DEFAULT_DEBOUNCE_MS = 300
|
|
48
|
+
|
|
49
|
+
/** Decode one raw param back, trusting the initial value's type. */
|
|
50
|
+
function decode(raw: string, initial: UrlStateValue): UrlStateValue {
|
|
51
|
+
if (typeof initial === 'string') return raw
|
|
52
|
+
if (typeof initial === 'number') {
|
|
53
|
+
const value = Number(raw)
|
|
54
|
+
return Number.isFinite(value) ? value : initial
|
|
55
|
+
}
|
|
56
|
+
if (typeof initial === 'boolean') return raw === 'true'
|
|
57
|
+
try {
|
|
58
|
+
return JSON.parse(raw) as UrlStateValue
|
|
59
|
+
} catch {
|
|
60
|
+
return initial
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Controllable signals serialized to `URLSearchParams`.
|
|
66
|
+
*
|
|
67
|
+
* `initial` is what the page renders when the URL says nothing about a key;
|
|
68
|
+
* the URL wins when it does. Keys are the app's, not the table's — `view.sort`
|
|
69
|
+
* rather than `sort` — so one URL can host several surfaces without collisions.
|
|
70
|
+
*/
|
|
71
|
+
export function createUrlState<T extends Record<string, UrlStateValue>>(
|
|
72
|
+
initial: T,
|
|
73
|
+
options: UrlStateOptions = {},
|
|
74
|
+
): UrlState<T> {
|
|
75
|
+
const keys = Object.keys(initial) as (keyof T & string)[]
|
|
76
|
+
const params = new URLSearchParams()
|
|
77
|
+
for (const key of keys) {
|
|
78
|
+
params.set(key, encodeValue(initial[key] as UrlStateValue))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const signals = new Map<
|
|
82
|
+
string,
|
|
83
|
+
[Accessor<UrlStateValue>, Setter<UrlStateValue>]
|
|
84
|
+
>()
|
|
85
|
+
const state: Record<string, unknown> = {}
|
|
86
|
+
for (const key of keys) {
|
|
87
|
+
const [get, set] = createSignal<UrlStateValue>(
|
|
88
|
+
initial[key] as UrlStateValue,
|
|
89
|
+
)
|
|
90
|
+
// Storing the accessor reference, not reading it — `state.view` is the
|
|
91
|
+
// signal, so the reactivity rule sees no tracked read to miss.
|
|
92
|
+
// eslint-disable-next-line solid/reactivity
|
|
93
|
+
signals.set(key, [get, set])
|
|
94
|
+
state[key] = get
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Read-once on mount: the URL seeds the signals, then signals drive.
|
|
98
|
+
if (options.adapter) {
|
|
99
|
+
try {
|
|
100
|
+
const search = new URLSearchParams(options.adapter.search())
|
|
101
|
+
for (const key of keys) {
|
|
102
|
+
const raw = search.get(key)
|
|
103
|
+
if (raw === null) continue
|
|
104
|
+
const value = decode(raw, initial[key] as UrlStateValue)
|
|
105
|
+
params.set(key, encodeValue(value))
|
|
106
|
+
signals.get(key)?.[1](() => value)
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// An unreadable URL is "absent everywhere": keep the initial values.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const debounceKeys = new Set(options.debounce ?? [])
|
|
114
|
+
const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS
|
|
115
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
116
|
+
let scheduled = false
|
|
117
|
+
let dirty = false
|
|
118
|
+
|
|
119
|
+
const flush = () => {
|
|
120
|
+
scheduled = false
|
|
121
|
+
dirty = false
|
|
122
|
+
options.adapter?.replace(new URLSearchParams(params))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Replace-not-push, batched per tick: one `replace` per coalesced write. */
|
|
126
|
+
const schedule = () => {
|
|
127
|
+
dirty = true
|
|
128
|
+
if (!options.adapter || scheduled) return
|
|
129
|
+
scheduled = true
|
|
130
|
+
queueMicrotask(flush)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
onCleanup(() => {
|
|
134
|
+
if (timer) clearTimeout(timer)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
const setKey = (
|
|
138
|
+
key: keyof T & string,
|
|
139
|
+
value: UrlStateValue | undefined,
|
|
140
|
+
): void => {
|
|
141
|
+
const entry = signals.get(key)
|
|
142
|
+
if (!entry) return
|
|
143
|
+
const [, set] = entry
|
|
144
|
+
if (value === undefined) {
|
|
145
|
+
params.delete(key)
|
|
146
|
+
set(() => initial[key] as UrlStateValue)
|
|
147
|
+
} else {
|
|
148
|
+
params.set(key, encodeValue(value))
|
|
149
|
+
set(() => value)
|
|
150
|
+
}
|
|
151
|
+
if (debounceKeys.has(key)) {
|
|
152
|
+
// A search-like key waits out the debounce window before its value
|
|
153
|
+
// reaches the URL, so typing does not spam the history.
|
|
154
|
+
if (timer) clearTimeout(timer)
|
|
155
|
+
dirty = true
|
|
156
|
+
timer = setTimeout(() => {
|
|
157
|
+
timer = null
|
|
158
|
+
if (dirty) flush()
|
|
159
|
+
}, debounceMs)
|
|
160
|
+
} else {
|
|
161
|
+
schedule()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
state['set'] = setKey
|
|
165
|
+
|
|
166
|
+
state['params'] = () => new URLSearchParams(params)
|
|
167
|
+
return state as unknown as UrlState<T>
|
|
168
|
+
}
|