@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,135 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/solid-query'
|
|
2
|
+
import { createSignal } from 'solid-js'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { connectRealtime } from './connect-realtime'
|
|
5
|
+
import type { Realtime } from './realtime'
|
|
6
|
+
|
|
7
|
+
/** A socket-shaped test double: `emit` stands in for a server push. */
|
|
8
|
+
function fakeRealtime() {
|
|
9
|
+
const handlers = new Map<string, Set<(data: unknown) => void>>()
|
|
10
|
+
const [connected, setConnected] = createSignal(false)
|
|
11
|
+
const socket: Realtime = {
|
|
12
|
+
connected,
|
|
13
|
+
connect: () => setConnected(true),
|
|
14
|
+
on(event, listener) {
|
|
15
|
+
const set = handlers.get(event) ?? new Set()
|
|
16
|
+
set.add(listener)
|
|
17
|
+
handlers.set(event, set)
|
|
18
|
+
return () => set.delete(listener)
|
|
19
|
+
},
|
|
20
|
+
off(event, listener) {
|
|
21
|
+
handlers.get(event)?.delete(listener)
|
|
22
|
+
},
|
|
23
|
+
send: () => {},
|
|
24
|
+
close: () => setConnected(false),
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
socket,
|
|
28
|
+
emit(event: string, data: unknown) {
|
|
29
|
+
for (const listener of handlers.get(event) ?? []) listener(data)
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeClient() {
|
|
35
|
+
const client = new QueryClient({
|
|
36
|
+
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
|
|
37
|
+
})
|
|
38
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries')
|
|
39
|
+
return { client, invalidate }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('connectRealtime', () => {
|
|
43
|
+
it('invalidates the doctype prefix for list events', () => {
|
|
44
|
+
const { client, invalidate } = makeClient()
|
|
45
|
+
const { socket, emit } = fakeRealtime()
|
|
46
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' })
|
|
47
|
+
|
|
48
|
+
emit('list_update', { doctype: 'Task' })
|
|
49
|
+
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['Task'] })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('invalidates the exact key for doc events', () => {
|
|
53
|
+
const { client, invalidate } = makeClient()
|
|
54
|
+
const { socket, emit } = fakeRealtime()
|
|
55
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' })
|
|
56
|
+
|
|
57
|
+
emit('list_update', { doctype: 'Task', name: 'TASK-1' })
|
|
58
|
+
expect(invalidate).toHaveBeenCalledWith({
|
|
59
|
+
queryKey: ['Task', 'TASK-1'],
|
|
60
|
+
exact: true,
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('honours an explicit event type over the name heuristic', () => {
|
|
65
|
+
const { client, invalidate } = makeClient()
|
|
66
|
+
const { socket, emit } = fakeRealtime()
|
|
67
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' })
|
|
68
|
+
|
|
69
|
+
emit('list_update', { doctype: 'Task', name: 'TASK-1', type: 'list' })
|
|
70
|
+
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['Task'] })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('filters by doctype and reports the normalized event', () => {
|
|
74
|
+
const { client, invalidate } = makeClient()
|
|
75
|
+
const { socket, emit } = fakeRealtime()
|
|
76
|
+
const onEvent = vi.fn()
|
|
77
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' }, { onEvent })
|
|
78
|
+
|
|
79
|
+
emit('list_update', { doctype: 'Invoice', name: 'INV-1' })
|
|
80
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
81
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
82
|
+
|
|
83
|
+
emit('list_update', { doctype: 'Task', name: 'TASK-1' })
|
|
84
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
85
|
+
type: 'doc',
|
|
86
|
+
doctype: 'Task',
|
|
87
|
+
name: 'TASK-1',
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('subscribes to the event names the app declares', () => {
|
|
92
|
+
const { client, invalidate } = makeClient()
|
|
93
|
+
const { socket, emit } = fakeRealtime()
|
|
94
|
+
connectRealtime(client, {
|
|
95
|
+
realtime: socket,
|
|
96
|
+
doctype: ['Task', 'Invoice'],
|
|
97
|
+
events: ['doc_update'],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
emit('list_update', { doctype: 'Task' })
|
|
101
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
102
|
+
emit('doc_update', { doctype: 'Invoice', name: 'INV-2' })
|
|
103
|
+
expect(invalidate).toHaveBeenCalledWith({
|
|
104
|
+
queryKey: ['Invoice', 'INV-2'],
|
|
105
|
+
exact: true,
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('ignores payloads that are not change events', () => {
|
|
110
|
+
const { client, invalidate } = makeClient()
|
|
111
|
+
const { socket, emit } = fakeRealtime()
|
|
112
|
+
const onEvent = vi.fn()
|
|
113
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' }, { onEvent })
|
|
114
|
+
|
|
115
|
+
emit('list_update', 'ping')
|
|
116
|
+
emit('list_update', { name: 'no-doctype' })
|
|
117
|
+
emit('list_update', null)
|
|
118
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
119
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('stops listening on close, leaving the socket itself alone', () => {
|
|
123
|
+
const { client, invalidate } = makeClient()
|
|
124
|
+
const { socket, emit } = fakeRealtime()
|
|
125
|
+
const connection = connectRealtime(client, {
|
|
126
|
+
realtime: socket,
|
|
127
|
+
doctype: 'Task',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
connection.close()
|
|
131
|
+
emit('list_update', { doctype: 'Task' })
|
|
132
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
133
|
+
expect(socket.connected()).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { QueryClient } from '@tanstack/solid-query'
|
|
2
|
+
import type { Realtime } from './realtime'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The realtime-to-cache bridge.
|
|
6
|
+
*
|
|
7
|
+
* `connectRealtime` subscribes to a {@link createRealtime} socket and
|
|
8
|
+
* invalidates the matching TanStack Query keys: a **list** event invalidates
|
|
9
|
+
* the `[doctype]` prefix — every query that reads that doctype — and a **doc**
|
|
10
|
+
* event invalidates exactly `[doctype, name]`. It is a function, not a hook:
|
|
11
|
+
* the cache is the integration point, and the socket factory finally has a
|
|
12
|
+
* consumer.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** A normalized change: `list` moves a whole doctype, `doc` one record. */
|
|
16
|
+
export interface RealtimeEvent {
|
|
17
|
+
type: 'list' | 'doc'
|
|
18
|
+
doctype: string
|
|
19
|
+
name?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the cache listens to. */
|
|
23
|
+
export interface RealtimeResource {
|
|
24
|
+
/** The socket returned by {@link createRealtime}. */
|
|
25
|
+
realtime: Realtime
|
|
26
|
+
/** Doctype(s) whose changes should invalidate the cache. */
|
|
27
|
+
doctype: string | readonly string[]
|
|
28
|
+
/**
|
|
29
|
+
* Event names carrying `{ doctype, name }` payloads. Defaults to
|
|
30
|
+
* `['list_update']` — the event Frappe emits for every change.
|
|
31
|
+
*/
|
|
32
|
+
events?: readonly string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Options for {@link connectRealtime}. */
|
|
36
|
+
export interface ConnectRealtimeOptions {
|
|
37
|
+
/** Called for every event that passes the `doctype` filter. */
|
|
38
|
+
onEvent?: (event: RealtimeEvent) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The handle {@link connectRealtime} returns. */
|
|
42
|
+
export interface RealtimeConnection {
|
|
43
|
+
/** Unsubscribe from the socket. The socket itself stays open — the app
|
|
44
|
+
* owns its lifecycle, and other consumers may still be on it. */
|
|
45
|
+
close(): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Normalize one socket payload. A payload with a `type` wins; otherwise a
|
|
50
|
+
* `name` means one document changed and its absence means the list did.
|
|
51
|
+
*/
|
|
52
|
+
function normalizeEvent(data: unknown): RealtimeEvent | undefined {
|
|
53
|
+
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
const payload = data as Record<string, unknown>
|
|
57
|
+
if (typeof payload['doctype'] !== 'string') return undefined
|
|
58
|
+
const name = typeof payload['name'] === 'string' ? payload['name'] : undefined
|
|
59
|
+
let type: 'list' | 'doc'
|
|
60
|
+
if (payload['type'] === 'list' || payload['type'] === 'doc') {
|
|
61
|
+
type = payload['type']
|
|
62
|
+
} else {
|
|
63
|
+
type = name ? 'doc' : 'list'
|
|
64
|
+
}
|
|
65
|
+
return { type, doctype: payload['doctype'], ...(name ? { name } : {}) }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Bridge a socket to a query cache.
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* const socket = createRealtime({ url: realtimeUrl })
|
|
73
|
+
* const connection = connectRealtime(queryClient, {
|
|
74
|
+
* realtime: socket,
|
|
75
|
+
* doctype: 'Task',
|
|
76
|
+
* onEvent: (event) => console.debug(event),
|
|
77
|
+
* })
|
|
78
|
+
* connection.close() // stop listening; the socket stays open
|
|
79
|
+
* ```
|
|
80
|
+
*/
|
|
81
|
+
export function connectRealtime(
|
|
82
|
+
queryClient: QueryClient,
|
|
83
|
+
resource: RealtimeResource,
|
|
84
|
+
options: ConnectRealtimeOptions = {},
|
|
85
|
+
): RealtimeConnection {
|
|
86
|
+
const wanted = new Set(
|
|
87
|
+
typeof resource.doctype === 'string'
|
|
88
|
+
? [resource.doctype]
|
|
89
|
+
: resource.doctype,
|
|
90
|
+
)
|
|
91
|
+
const events = resource.events ?? ['list_update']
|
|
92
|
+
const unsubscribes: (() => void)[] = []
|
|
93
|
+
|
|
94
|
+
for (const event of events) {
|
|
95
|
+
unsubscribes.push(
|
|
96
|
+
resource.realtime.on(event, (data) => {
|
|
97
|
+
const normalized = normalizeEvent(data)
|
|
98
|
+
if (!normalized) return
|
|
99
|
+
if (wanted.size > 0 && !wanted.has(normalized.doctype)) return
|
|
100
|
+
if (normalized.type === 'doc' && normalized.name) {
|
|
101
|
+
void queryClient.invalidateQueries({
|
|
102
|
+
queryKey: [normalized.doctype, normalized.name],
|
|
103
|
+
exact: true,
|
|
104
|
+
})
|
|
105
|
+
} else {
|
|
106
|
+
void queryClient.invalidateQueries({
|
|
107
|
+
queryKey: [normalized.doctype],
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
options.onEvent?.(normalized)
|
|
111
|
+
}),
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
close() {
|
|
117
|
+
for (const unsubscribe of unsubscribes) unsubscribe()
|
|
118
|
+
unsubscribes.length = 0
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/resource/index.ts
CHANGED
|
@@ -21,6 +21,13 @@ export {
|
|
|
21
21
|
resetConfig,
|
|
22
22
|
setConfig,
|
|
23
23
|
} from './client'
|
|
24
|
+
export type {
|
|
25
|
+
ConnectRealtimeOptions,
|
|
26
|
+
RealtimeConnection,
|
|
27
|
+
RealtimeEvent,
|
|
28
|
+
RealtimeResource,
|
|
29
|
+
} from './connect-realtime'
|
|
30
|
+
export { connectRealtime } from './connect-realtime'
|
|
24
31
|
export type {
|
|
25
32
|
DocumentOperationOptions,
|
|
26
33
|
DocumentResource,
|
|
@@ -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
|
+
})
|