@talelk/newui-data 0.0.0-stage → 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.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. package/src/url-state.ts +168 -0
@@ -0,0 +1,258 @@
1
+ import { createRoot } from 'solid-js'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import type { ConfigStorage } from './config-store'
4
+ import {
5
+ createTablePersist,
6
+ type TablePersist,
7
+ type TablePersistOptions,
8
+ } from './table-persist'
9
+ import type { UrlStateAdapter } from './url-state'
10
+
11
+ /** A Map-backed storage that records what was written. */
12
+ function memoryStorage(): ConfigStorage & { map: Map<string, string> } {
13
+ const map = new Map<string, string>()
14
+ return {
15
+ map,
16
+ getItem: (key) => map.get(key) ?? null,
17
+ setItem: (key, value) => void map.set(key, value),
18
+ removeItem: (key) => void map.delete(key),
19
+ keys: () => [...map.keys()],
20
+ }
21
+ }
22
+
23
+ function adapter(
24
+ search = '',
25
+ ): UrlStateAdapter & { last: () => URLSearchParams } {
26
+ let last = new URLSearchParams(search)
27
+ return {
28
+ search: () => search,
29
+ replace: vi.fn((params: URLSearchParams) => {
30
+ last = params
31
+ }),
32
+ last: () => last,
33
+ }
34
+ }
35
+
36
+ async function flushMicrotasks(): Promise<void> {
37
+ await Promise.resolve()
38
+ await Promise.resolve()
39
+ }
40
+
41
+ function setup(
42
+ options: Omit<TablePersistOptions, 'id'> & { id?: string } = {},
43
+ ) {
44
+ let dispose!: () => void
45
+ let persist!: TablePersist
46
+ createRoot((d) => {
47
+ dispose = d
48
+ persist = createTablePersist({ id: 'orders', ...options })
49
+ })
50
+ return { persist, dispose }
51
+ }
52
+
53
+ const layout = {
54
+ visibility: { date: true, amount: false },
55
+ order: ['date', 'amount'],
56
+ sizing: { date: 140 },
57
+ pinning: { left: ['date'], right: [] },
58
+ }
59
+
60
+ describe('createTablePersist', () => {
61
+ it('round-trips the whole state through the document', () => {
62
+ const storage = memoryStorage()
63
+ const { persist: first, dispose } = setup({ storage })
64
+
65
+ first.commit({
66
+ columns: layout,
67
+ sorting: [{ id: 'date', desc: true }],
68
+ columnFilters: [{ id: 'region', value: 'EMEA' }],
69
+ density: 'compact',
70
+ pageSize: 25,
71
+ })
72
+
73
+ const { persist: second } = setup({ storage })
74
+ expect(second.hydrate(['date', 'amount'])).toEqual({
75
+ columns: layout,
76
+ sorting: [{ id: 'date', desc: true }],
77
+ columnFilters: [{ id: 'region', value: 'EMEA' }],
78
+ density: 'compact',
79
+ pageSize: 25,
80
+ dropped: [],
81
+ })
82
+
83
+ // The document is a versioned envelope with the table-view type.
84
+ const raw = JSON.parse(
85
+ storage.map.get('newui.tables:orders') ?? '',
86
+ ) as Record<string, unknown>
87
+ expect(raw['v']).toBe(1)
88
+ expect(raw['type']).toBe('table-view')
89
+ dispose()
90
+ })
91
+
92
+ it('commits keep fields the table did not mention', () => {
93
+ const storage = memoryStorage()
94
+ const { persist, dispose } = setup({ storage })
95
+ persist.commit({ density: 'compact', pageSize: 25 })
96
+ persist.commit({ sorting: [{ id: 'amount', desc: false }] })
97
+
98
+ const { persist: reloaded } = setup({ storage })
99
+ const state = reloaded.hydrate([])
100
+ expect(state.density).toBe('compact')
101
+ expect(state.pageSize).toBe(25)
102
+ expect(state.sorting).toEqual([{ id: 'amount', desc: false }])
103
+ dispose()
104
+ })
105
+
106
+ it('drops persisted columns that no longer exist', () => {
107
+ const storage = memoryStorage()
108
+ const { persist, dispose } = setup({ storage })
109
+ persist.commit({
110
+ columns: {
111
+ visibility: { date: true, ghost: false, amount: true },
112
+ order: ['date', 'ghost', 'amount'],
113
+ sizing: { ghost: 100 },
114
+ pinning: { left: ['ghost'], right: [] },
115
+ },
116
+ })
117
+
118
+ const { persist: reloaded } = setup({ storage })
119
+ const state = reloaded.hydrate(['date', 'amount', 'new'])
120
+ expect(state.dropped).toEqual(['ghost'])
121
+ expect(state.columns).toEqual({
122
+ visibility: { date: true, amount: true },
123
+ order: ['date', 'amount', 'new'],
124
+ sizing: {},
125
+ pinning: { left: [], right: [] },
126
+ })
127
+ dispose()
128
+ })
129
+
130
+ it('migrates a document that predates the versioned shape', () => {
131
+ const storage = memoryStorage()
132
+ // What an older release wrote: visible ids in `columns`, `sort` not `sorting`.
133
+ storage.setItem(
134
+ 'newui.tables:legacy',
135
+ JSON.stringify({
136
+ v: 1,
137
+ name: 'legacy',
138
+ updatedAt: '2026-01-01T00:00:00.000Z',
139
+ doc: {
140
+ columns: ['date', 'amount'],
141
+ sort: [{ id: 'date', desc: true }],
142
+ },
143
+ }),
144
+ )
145
+ const { persist, dispose } = setup({ storage, id: 'legacy' })
146
+ const state = persist.hydrate(['date', 'amount'])
147
+ expect(state.sorting).toEqual([{ id: 'date', desc: true }])
148
+ expect(state.columns).toEqual({
149
+ visibility: { date: true, amount: true },
150
+ order: ['date', 'amount'],
151
+ sizing: {},
152
+ pinning: { left: [], right: [] },
153
+ })
154
+ dispose()
155
+ })
156
+
157
+ it('writes the view half to the URL and reads it back', async () => {
158
+ const bridge = adapter()
159
+ const storage = memoryStorage()
160
+ const { persist, dispose } = setup({
161
+ storage,
162
+ url: { adapter: bridge },
163
+ })
164
+
165
+ persist.commit({
166
+ sorting: [{ id: 'amount', desc: true }],
167
+ columnFilters: [{ id: 'region', value: 'EMEA' }],
168
+ density: 'compact',
169
+ pageSize: 50,
170
+ columns: layout,
171
+ })
172
+ await flushMicrotasks()
173
+
174
+ const params = bridge.last()
175
+ expect(JSON.parse(params.get('orders.sort') ?? '')).toEqual([
176
+ { id: 'amount', desc: true },
177
+ ])
178
+ expect(params.get('orders.pageSize')).toBe('50')
179
+ expect(params.get('orders.density')).toBe('compact')
180
+ // The column layout is a preference, not a link — never in the URL.
181
+ expect([...params.keys()].some((key) => key.includes('columns'))).toBe(
182
+ false,
183
+ )
184
+
185
+ const deep = adapter(new URLSearchParams(params).toString())
186
+ const { persist: reloaded } = setup({ storage, url: { adapter: deep } })
187
+ const state = reloaded.hydrate(['date', 'amount'])
188
+ expect(state.sorting).toEqual([{ id: 'amount', desc: true }])
189
+ expect(state.columnFilters).toEqual([{ id: 'region', value: 'EMEA' }])
190
+ expect(state.density).toBe('compact')
191
+ expect(state.pageSize).toBe(50)
192
+ expect(state.columns).toEqual(layout)
193
+ dispose()
194
+ })
195
+
196
+ it('lets the URL win over the document', () => {
197
+ const storage = memoryStorage()
198
+ const { persist, dispose } = setup({ storage })
199
+ persist.commit({ sorting: [{ id: 'date', desc: true }], pageSize: 10 })
200
+
201
+ const deep = adapter(
202
+ '?orders.sort=%5B%7B%22id%22%3A%22amount%22%2C%22desc%22%3Afalse%7D%5D',
203
+ )
204
+ const { persist: reloaded } = setup({ storage, url: { adapter: deep } })
205
+ expect(reloaded.hydrate([]).sorting).toEqual([
206
+ { id: 'amount', desc: false },
207
+ ])
208
+ dispose()
209
+ })
210
+
211
+ it('reports a storage backend that rejects the write', () => {
212
+ const rejecting: ConfigStorage = {
213
+ getItem: () => null,
214
+ setItem: () => {
215
+ throw new Error('quota')
216
+ },
217
+ removeItem: () => {},
218
+ keys: () => [],
219
+ }
220
+ const onPersistError = vi.fn()
221
+ const { persist, dispose } = setup({ storage: rejecting, onPersistError })
222
+ persist.commit({ pageSize: 25 })
223
+ expect(onPersistError).toHaveBeenCalledTimes(1)
224
+ expect(onPersistError.mock.calls[0]?.[0]?.message).toContain('orders')
225
+ dispose()
226
+ })
227
+
228
+ it('reports a stored document that no longer parses', () => {
229
+ const storage = memoryStorage()
230
+ storage.setItem('newui.tables:orders', '{not json')
231
+ const onPersistError = vi.fn()
232
+ const { persist, dispose } = setup({ storage, onPersistError })
233
+ expect(persist.hydrate(['date'])).toEqual({ dropped: [] })
234
+ expect(onPersistError).toHaveBeenCalledTimes(1)
235
+ dispose()
236
+ })
237
+
238
+ it('forgets everything on reset', async () => {
239
+ const bridge = adapter()
240
+ const storage = memoryStorage()
241
+ const { persist, dispose } = setup({ storage, url: { adapter: bridge } })
242
+ persist.commit({
243
+ columns: layout,
244
+ sorting: [{ id: 'date', desc: true }],
245
+ pageSize: 25,
246
+ })
247
+ await flushMicrotasks()
248
+
249
+ persist.reset()
250
+ await flushMicrotasks()
251
+
252
+ expect(storage.map.has('newui.tables:orders')).toBe(false)
253
+ expect([...bridge.last()].length).toBe(0)
254
+ const { persist: reloaded } = setup({ storage })
255
+ expect(reloaded.hydrate(['date', 'amount'])).toEqual({ dropped: [] })
256
+ dispose()
257
+ })
258
+ })
@@ -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,24 @@
1
+ import { createRoot } from 'solid-js'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createUndo } from './undo'
4
+
5
+ describe('undo', () => {
6
+ it('captures, restores once, then reports nothing left', () => {
7
+ createRoot((dispose) => {
8
+ const undo = createUndo<number[]>()
9
+ expect(undo.snapshot()).toBeUndefined()
10
+ expect(undo.undo(() => {})).toBe(false)
11
+
12
+ undo.capture([1, 2])
13
+ const restored: number[][] = []
14
+ expect(undo.undo((value) => restored.push(value))).toBe(true)
15
+ expect(restored).toEqual([[1, 2]])
16
+ expect(undo.snapshot()).toBeUndefined()
17
+
18
+ undo.capture([3])
19
+ undo.clear()
20
+ expect(undo.snapshot()).toBeUndefined()
21
+ dispose()
22
+ })
23
+ })
24
+ })
package/src/undo.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { type Accessor, createSignal } from 'solid-js'
2
+
3
+ /**
4
+ * One-step undo.
5
+ *
6
+ * A snapshot signal, captured before a mutation, restored once. The page keeps
7
+ * owning its data and its notice copy; this owns the signal.
8
+ */
9
+ export interface Undo<T> {
10
+ /** The captured value, or `undefined` when there is nothing to undo. */
11
+ snapshot: Accessor<T | undefined>
12
+ /** Remember `value` as the state an undo returns to. */
13
+ capture: (value: T) => void
14
+ /**
15
+ * Hand the snapshot back to `restore` and clear it. Returns whether a
16
+ * snapshot existed, so the caller can word its notice either way.
17
+ */
18
+ undo: (restore: (value: T) => void) => boolean
19
+ /** Drop the snapshot without restoring. */
20
+ clear: () => void
21
+ }
22
+
23
+ export function createUndo<T>(): Undo<T> {
24
+ const [snapshot, setSnapshot] = createSignal<T | undefined>(undefined)
25
+ return {
26
+ snapshot,
27
+ capture: (value) => setSnapshot(() => value),
28
+ undo: (restore) => {
29
+ const previous = snapshot()
30
+ if (previous === undefined) return false
31
+ restore(previous)
32
+ setSnapshot(undefined)
33
+ return true
34
+ },
35
+ clear: () => setSnapshot(undefined),
36
+ }
37
+ }