@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.
- package/LICENSE +202 -0
- package/README.md +65 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +170 -0
- 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/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +135 -0
- 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.test.tsx +102 -0
- package/src/form/form.tsx +205 -0
- package/src/form/index.ts +3 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +10 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +278 -0
- package/src/query/collection.ts +207 -0
- package/src/query/index.ts +4 -0
- package/src/query/repository.ts +30 -0
- package/src/query/server-table.test.ts +136 -0
- package/src/query/server-table.ts +181 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -0
- package/src/resource/connect-realtime.test.ts +135 -0
- package/src/resource/connect-realtime.ts +121 -0
- package/src/resource/document.test.ts +151 -0
- package/src/resource/document.ts +198 -0
- package/src/resource/idb.test.ts +169 -0
- package/src/resource/idb.ts +167 -0
- package/src/resource/index.ts +77 -0
- package/src/resource/list.test.ts +150 -0
- package/src/resource/list.ts +230 -0
- package/src/resource/local.test.ts +111 -0
- package/src/resource/local.ts +155 -0
- package/src/resource/realtime.test.ts +331 -0
- package/src/resource/realtime.ts +287 -0
- package/src/resource/resource.test.ts +186 -0
- package/src/resource/resource.ts +213 -0
- package/src/resource/useCall.test.ts +60 -0
- package/src/resource/useCall.ts +74 -0
- package/src/resource/useDoc.test.ts +31 -0
- package/src/resource/useDoc.ts +23 -0
- package/src/resource/useDoctype.test.ts +104 -0
- package/src/resource/useDoctype.ts +128 -0
- package/src/resource/useList.test.ts +32 -0
- package/src/resource/useList.ts +22 -0
- package/src/resource/useNewDoc.test.ts +67 -0
- package/src/resource/useNewDoc.ts +78 -0
- package/src/select.test.ts +13 -0
- package/src/select.ts +19 -0
- package/src/table-persist.test.ts +258 -0
- package/src/table-persist.ts +371 -0
- package/src/undo.test.ts +24 -0
- package/src/undo.ts +37 -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,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
|
+
}
|
package/src/undo.test.ts
ADDED
|
@@ -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
|
+
}
|