@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,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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createDocumentResource } from './document'
|
|
3
|
+
|
|
4
|
+
function mockResponse(body: unknown): Response {
|
|
5
|
+
return {
|
|
6
|
+
ok: true,
|
|
7
|
+
status: 200,
|
|
8
|
+
statusText: 'OK',
|
|
9
|
+
json: async () => body,
|
|
10
|
+
text: async () => JSON.stringify(body),
|
|
11
|
+
} as unknown as Response
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface FetchCall {
|
|
15
|
+
mock: { calls: Array<[string, RequestInit]> }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function stubFetch(...responses: Response[]): FetchCall {
|
|
19
|
+
const mock = vi.fn()
|
|
20
|
+
for (const response of responses) mock.mockResolvedValueOnce(response)
|
|
21
|
+
vi.stubGlobal('fetch', mock)
|
|
22
|
+
return mock as unknown as FetchCall
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function urlAt(mock: FetchCall, index = 0): string {
|
|
26
|
+
return mock.mock.calls[index]?.[0] ?? ''
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function bodyAt(mock: FetchCall, index = 0): Record<string, unknown> {
|
|
30
|
+
const call = mock.mock.calls[index]
|
|
31
|
+
return JSON.parse(String(call?.[1]?.body)) as Record<string, unknown>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
vi.unstubAllGlobals()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('createDocumentResource', () => {
|
|
39
|
+
it('loads the document and tracks dirty state', async () => {
|
|
40
|
+
stubFetch(mockResponse({ message: { name: 'T-1', subject: 'Hi' } }))
|
|
41
|
+
const resource = createDocumentResource<{ name: string; subject: string }>({
|
|
42
|
+
doctype: 'Task',
|
|
43
|
+
name: 'T-1',
|
|
44
|
+
auto: false,
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
await resource.reload()
|
|
48
|
+
expect(resource.doc()).toEqual({ name: 'T-1', subject: 'Hi' })
|
|
49
|
+
expect(resource.originalDoc()).toEqual({ name: 'T-1', subject: 'Hi' })
|
|
50
|
+
expect(resource.isDirty()).toBe(false)
|
|
51
|
+
|
|
52
|
+
resource.setDoc((doc) => ({
|
|
53
|
+
...(doc ?? { name: '', subject: '' }),
|
|
54
|
+
subject: 'Bye',
|
|
55
|
+
}))
|
|
56
|
+
expect(resource.doc()).toEqual({ name: 'T-1', subject: 'Bye' })
|
|
57
|
+
expect(resource.isDirty()).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('save posts only the changed fields', async () => {
|
|
61
|
+
const fetchMock = stubFetch(
|
|
62
|
+
mockResponse({ message: { name: 'T-1', subject: 'Hi', status: 'Open' } }),
|
|
63
|
+
mockResponse({
|
|
64
|
+
message: { name: 'T-1', subject: 'Bye', status: 'Open' },
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
const resource = createDocumentResource({
|
|
68
|
+
doctype: 'Task',
|
|
69
|
+
name: 'T-1',
|
|
70
|
+
auto: false,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await resource.reload()
|
|
74
|
+
resource.setDoc((doc) => ({
|
|
75
|
+
...(doc as Record<string, unknown>),
|
|
76
|
+
subject: 'Bye',
|
|
77
|
+
}))
|
|
78
|
+
await resource.save()
|
|
79
|
+
|
|
80
|
+
expect(urlAt(fetchMock, 1)).toBe('/api/method/frappe.client.set_value')
|
|
81
|
+
expect(bodyAt(fetchMock, 1)).toEqual({
|
|
82
|
+
doctype: 'Task',
|
|
83
|
+
name: 'T-1',
|
|
84
|
+
fieldname: { subject: 'Bye' },
|
|
85
|
+
})
|
|
86
|
+
expect(resource.isDirty()).toBe(false)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('save with no changes does not hit the network', async () => {
|
|
90
|
+
const fetchMock = stubFetch(mockResponse({ message: { name: 'T-1' } }))
|
|
91
|
+
const resource = createDocumentResource({
|
|
92
|
+
doctype: 'Task',
|
|
93
|
+
name: 'T-1',
|
|
94
|
+
auto: false,
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
await resource.reload()
|
|
98
|
+
expect(fetchMock.mock.calls.length).toBe(1)
|
|
99
|
+
|
|
100
|
+
await resource.save()
|
|
101
|
+
expect(fetchMock.mock.calls.length).toBe(1)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('setValue posts explicit values', async () => {
|
|
105
|
+
const fetchMock = stubFetch(
|
|
106
|
+
mockResponse({ message: { name: 'T-1' } }),
|
|
107
|
+
mockResponse({ message: { name: 'T-1', subject: 'X' } }),
|
|
108
|
+
)
|
|
109
|
+
const resource = createDocumentResource({
|
|
110
|
+
doctype: 'Task',
|
|
111
|
+
name: 'T-1',
|
|
112
|
+
auto: false,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
await resource.reload()
|
|
116
|
+
await resource.setValue({ subject: 'X' })
|
|
117
|
+
|
|
118
|
+
expect(bodyAt(fetchMock, 1)).toEqual({
|
|
119
|
+
doctype: 'Task',
|
|
120
|
+
name: 'T-1',
|
|
121
|
+
fieldname: { subject: 'X' },
|
|
122
|
+
})
|
|
123
|
+
expect(resource.doc()).toEqual({ name: 'T-1', subject: 'X' })
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('delete posts and clears the document', async () => {
|
|
127
|
+
const fetchMock = stubFetch(
|
|
128
|
+
mockResponse({ message: { name: 'T-1' } }),
|
|
129
|
+
mockResponse({ message: 'ok' }),
|
|
130
|
+
)
|
|
131
|
+
const resource = createDocumentResource({
|
|
132
|
+
doctype: 'Task',
|
|
133
|
+
name: 'T-1',
|
|
134
|
+
auto: false,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
await resource.reload()
|
|
138
|
+
await resource.delete()
|
|
139
|
+
|
|
140
|
+
expect(urlAt(fetchMock, 1)).toBe('/api/method/frappe.client.delete')
|
|
141
|
+
expect(bodyAt(fetchMock, 1)).toEqual({ doctype: 'Task', name: 'T-1' })
|
|
142
|
+
expect(resource.doc()).toBe(null)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('fetches on creation by default', async () => {
|
|
146
|
+
stubFetch(mockResponse({ message: { name: 'T-1' } }))
|
|
147
|
+
const resource = createDocumentResource({ doctype: 'Task', name: 'T-1' })
|
|
148
|
+
|
|
149
|
+
await vi.waitFor(() => expect(resource.doc()).toEqual({ name: 'T-1' }))
|
|
150
|
+
})
|
|
151
|
+
})
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
import type { RequestError } from './client'
|
|
3
|
+
import { createResource, type Resource, type ResourceFetcher } from './resource'
|
|
4
|
+
|
|
5
|
+
/** Per-operation overrides for a document resource. */
|
|
6
|
+
export interface DocumentOperationOptions<TResult> {
|
|
7
|
+
/** Overrides the default endpoint. */
|
|
8
|
+
url?: string
|
|
9
|
+
onSuccess?: (result: TResult) => void
|
|
10
|
+
onError?: (error: RequestError) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Options for {@link createDocumentResource}. */
|
|
14
|
+
export interface DocumentResourceOptions<TDoc = Record<string, unknown>> {
|
|
15
|
+
doctype: string
|
|
16
|
+
/** Document name, or an accessor for a reactive name. */
|
|
17
|
+
name: string | (() => string)
|
|
18
|
+
/** Fetch on creation. Defaults to `true`. */
|
|
19
|
+
auto?: boolean
|
|
20
|
+
/** Overrides the default get endpoint (`frappe.client.get`). */
|
|
21
|
+
url?: string
|
|
22
|
+
/** Map the raw document. */
|
|
23
|
+
transform?: (doc: TDoc) => TDoc
|
|
24
|
+
onSuccess?: (doc: TDoc) => void
|
|
25
|
+
onError?: (error: RequestError) => void
|
|
26
|
+
setValue?: DocumentOperationOptions<TDoc>
|
|
27
|
+
delete?: DocumentOperationOptions<unknown>
|
|
28
|
+
fetcher?: ResourceFetcher
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A single-document resource with local edits and dirty tracking. */
|
|
32
|
+
export interface DocumentResource<TDoc = Record<string, unknown>> {
|
|
33
|
+
doctype: string
|
|
34
|
+
name: Accessor<string>
|
|
35
|
+
doc: Accessor<TDoc | null>
|
|
36
|
+
/** The last saved/loaded copy, for dirty comparison. */
|
|
37
|
+
originalDoc: Accessor<TDoc | null>
|
|
38
|
+
isDirty: Accessor<boolean>
|
|
39
|
+
loading: Accessor<boolean>
|
|
40
|
+
error: Accessor<RequestError | null>
|
|
41
|
+
fetched: Accessor<boolean>
|
|
42
|
+
/** The underlying GET resource. */
|
|
43
|
+
get: Resource<TDoc>
|
|
44
|
+
/** POST the changed fields to `frappe.client.set_value`. */
|
|
45
|
+
save: () => Promise<TDoc | null>
|
|
46
|
+
/** POST explicit `fieldname` values. */
|
|
47
|
+
setValue: (values: Record<string, unknown>) => Promise<TDoc | null>
|
|
48
|
+
/** POST to `frappe.client.delete` and clear the doc. */
|
|
49
|
+
delete: () => Promise<unknown>
|
|
50
|
+
/** Refetch the document. */
|
|
51
|
+
reload: () => Promise<TDoc | null>
|
|
52
|
+
/** Edit the document locally. */
|
|
53
|
+
setDoc: (doc: TDoc | ((current: TDoc | null) => TDoc)) => void
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const GET_URL = 'frappe.client.get'
|
|
57
|
+
const SET_VALUE_URL = 'frappe.client.set_value'
|
|
58
|
+
const DELETE_URL = 'frappe.client.delete'
|
|
59
|
+
|
|
60
|
+
function cloneDoc<TDoc>(doc: TDoc | null): TDoc | null {
|
|
61
|
+
if (doc === null) return null
|
|
62
|
+
return JSON.parse(JSON.stringify(doc)) as TDoc
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The signal-based replacement for frappe-ui's `createDocumentResource`.
|
|
67
|
+
*
|
|
68
|
+
* `doc` is a local signal; `setDoc` edits it, `isDirty` compares it to
|
|
69
|
+
* `originalDoc`, and `save` sends only the changed fields (skipping the
|
|
70
|
+
* network entirely when nothing changed). `setValue`/`delete` write through.
|
|
71
|
+
*/
|
|
72
|
+
export function createDocumentResource<TDoc = Record<string, unknown>>(
|
|
73
|
+
options: DocumentResourceOptions<TDoc>,
|
|
74
|
+
): DocumentResource<TDoc> {
|
|
75
|
+
const name = (): string =>
|
|
76
|
+
typeof options.name === 'function' ? options.name() : options.name
|
|
77
|
+
|
|
78
|
+
const [doc, setDocSignal] = createSignal<TDoc | null>(null)
|
|
79
|
+
const [originalDoc, setOriginalDoc] = createSignal<TDoc | null>(null)
|
|
80
|
+
|
|
81
|
+
const transform = options.transform
|
|
82
|
+
const mapDoc = transform
|
|
83
|
+
? (data: unknown) => transform(data as TDoc)
|
|
84
|
+
: undefined
|
|
85
|
+
|
|
86
|
+
const get = createResource<TDoc>({
|
|
87
|
+
url: options.url ?? GET_URL,
|
|
88
|
+
method: 'GET',
|
|
89
|
+
params: () => ({ doctype: options.doctype, name: name() }),
|
|
90
|
+
transform: mapDoc,
|
|
91
|
+
fetcher: options.fetcher,
|
|
92
|
+
onSuccess: (data) => {
|
|
93
|
+
setDocSignal(() => data)
|
|
94
|
+
setOriginalDoc(() => cloneDoc(data))
|
|
95
|
+
options.onSuccess?.(data)
|
|
96
|
+
},
|
|
97
|
+
onError: (error) => {
|
|
98
|
+
setDocSignal(() => null)
|
|
99
|
+
setOriginalDoc(() => null)
|
|
100
|
+
options.onError?.(error)
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
const setValueResource = createResource<TDoc, Record<string, unknown>>({
|
|
105
|
+
url: options.setValue?.url ?? SET_VALUE_URL,
|
|
106
|
+
method: 'POST',
|
|
107
|
+
makeParams: (input) => ({
|
|
108
|
+
doctype: options.doctype,
|
|
109
|
+
name: name(),
|
|
110
|
+
fieldname: input,
|
|
111
|
+
}),
|
|
112
|
+
transform: mapDoc,
|
|
113
|
+
fetcher: options.fetcher,
|
|
114
|
+
onSuccess: (data) => {
|
|
115
|
+
setDocSignal(() => data)
|
|
116
|
+
setOriginalDoc(() => cloneDoc(data))
|
|
117
|
+
options.setValue?.onSuccess?.(data)
|
|
118
|
+
},
|
|
119
|
+
onError: (error) => {
|
|
120
|
+
// Roll the local edit back to the last saved copy.
|
|
121
|
+
setDocSignal(() => originalDoc())
|
|
122
|
+
options.setValue?.onError?.(error)
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
const deleteResource = createResource<unknown>({
|
|
127
|
+
url: options.delete?.url ?? DELETE_URL,
|
|
128
|
+
method: 'POST',
|
|
129
|
+
params: () => ({ doctype: options.doctype, name: name() }),
|
|
130
|
+
fetcher: options.fetcher,
|
|
131
|
+
onSuccess: (data) => {
|
|
132
|
+
setDocSignal(() => null)
|
|
133
|
+
setOriginalDoc(() => null)
|
|
134
|
+
options.delete?.onSuccess?.(data)
|
|
135
|
+
},
|
|
136
|
+
onError: (error) => options.delete?.onError?.(error),
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
const isDirty = (): boolean =>
|
|
140
|
+
JSON.stringify(doc() ?? null) !== JSON.stringify(originalDoc() ?? null)
|
|
141
|
+
|
|
142
|
+
function changedFields(): Record<string, unknown> {
|
|
143
|
+
const current = (cloneDoc(doc()) ?? {}) as Record<string, unknown>
|
|
144
|
+
const original = (originalDoc() ?? {}) as Record<string, unknown>
|
|
145
|
+
const values: Record<string, unknown> = {}
|
|
146
|
+
for (const key in current) {
|
|
147
|
+
if (JSON.stringify(current[key]) !== JSON.stringify(original[key])) {
|
|
148
|
+
values[key] = current[key]
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Identity/meta fields are never writable through `set_value`.
|
|
152
|
+
delete values['doctype']
|
|
153
|
+
delete values['name']
|
|
154
|
+
return values
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function setValue(values: Record<string, unknown>): Promise<TDoc | null> {
|
|
158
|
+
return setValueResource.submit(values)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function save(): Promise<TDoc | null> {
|
|
162
|
+
const values = changedFields()
|
|
163
|
+
if (Object.keys(values).length === 0) return Promise.resolve(doc())
|
|
164
|
+
return setValue(values)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setDoc(next: TDoc | ((current: TDoc | null) => TDoc)): void {
|
|
168
|
+
if (typeof next === 'function') {
|
|
169
|
+
const updater = next as (current: TDoc | null) => TDoc
|
|
170
|
+
setDocSignal(() => updater(doc()))
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
setDocSignal(() => next)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function reload(): Promise<TDoc | null> {
|
|
177
|
+
return get.reload()
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (options.auto !== false) void reload()
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
doctype: options.doctype,
|
|
184
|
+
name,
|
|
185
|
+
doc,
|
|
186
|
+
originalDoc,
|
|
187
|
+
isDirty,
|
|
188
|
+
loading: get.loading,
|
|
189
|
+
error: get.error,
|
|
190
|
+
fetched: get.fetched,
|
|
191
|
+
get,
|
|
192
|
+
save,
|
|
193
|
+
setValue,
|
|
194
|
+
delete: () => deleteResource.submit(),
|
|
195
|
+
reload,
|
|
196
|
+
setDoc,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createIdbStore } from './idb'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* jsdom ships no IndexedDB, so the native code path is driven by a minimal
|
|
6
|
+
* in-memory `IDBFactory` implementing exactly the subset `createIdbStore`
|
|
7
|
+
* uses: open + upgrade, one object store, and get/put/delete/getAllKeys/clear.
|
|
8
|
+
* Requests settle on a microtask, as the real API does, which is why the store
|
|
9
|
+
* must attach its handlers after the request is created.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface FakeRequest {
|
|
13
|
+
result: unknown
|
|
14
|
+
error: DOMException | Error | null
|
|
15
|
+
onsuccess: ((event: Event) => void) | null
|
|
16
|
+
onerror: ((event: Event) => void) | null
|
|
17
|
+
onupgradeneeded: ((event: Event) => void) | null
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeRequest(work: () => unknown): FakeRequest {
|
|
21
|
+
const request: FakeRequest = {
|
|
22
|
+
result: undefined,
|
|
23
|
+
error: null,
|
|
24
|
+
onsuccess: null,
|
|
25
|
+
onerror: null,
|
|
26
|
+
onupgradeneeded: null,
|
|
27
|
+
}
|
|
28
|
+
queueMicrotask(() => {
|
|
29
|
+
try {
|
|
30
|
+
request.result = work()
|
|
31
|
+
request.onsuccess?.(new Event('success'))
|
|
32
|
+
} catch (error) {
|
|
33
|
+
request.error = error as Error
|
|
34
|
+
request.onerror?.(new Event('error'))
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
return request
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function makeFactory(): { factory: IDBFactory; opens: () => number } {
|
|
41
|
+
const data = new Map<IDBValidKey, unknown>()
|
|
42
|
+
const stores = new Set<string>()
|
|
43
|
+
let opens = 0
|
|
44
|
+
|
|
45
|
+
const objectStore = {
|
|
46
|
+
get: (key: IDBValidKey) => makeRequest(() => data.get(key)),
|
|
47
|
+
put: (value: unknown, key: IDBValidKey) =>
|
|
48
|
+
makeRequest(() => {
|
|
49
|
+
data.set(key, value)
|
|
50
|
+
return key
|
|
51
|
+
}),
|
|
52
|
+
delete: (key: IDBValidKey) =>
|
|
53
|
+
makeRequest(() => {
|
|
54
|
+
data.delete(key)
|
|
55
|
+
return undefined
|
|
56
|
+
}),
|
|
57
|
+
getAllKeys: () => makeRequest(() => [...data.keys()]),
|
|
58
|
+
clear: () =>
|
|
59
|
+
makeRequest(() => {
|
|
60
|
+
data.clear()
|
|
61
|
+
return undefined
|
|
62
|
+
}),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const db = {
|
|
66
|
+
objectStoreNames: { contains: (name: string) => stores.has(name) },
|
|
67
|
+
createObjectStore: (name: string) => {
|
|
68
|
+
stores.add(name)
|
|
69
|
+
return {}
|
|
70
|
+
},
|
|
71
|
+
transaction: () => ({ objectStore: () => objectStore }),
|
|
72
|
+
close: () => {},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const factory = {
|
|
76
|
+
open: () => {
|
|
77
|
+
opens += 1
|
|
78
|
+
const request: FakeRequest = {
|
|
79
|
+
result: db,
|
|
80
|
+
error: null,
|
|
81
|
+
onsuccess: null,
|
|
82
|
+
onerror: null,
|
|
83
|
+
onupgradeneeded: null,
|
|
84
|
+
}
|
|
85
|
+
queueMicrotask(() => {
|
|
86
|
+
request.onupgradeneeded?.(new Event('upgradeneeded'))
|
|
87
|
+
request.onsuccess?.(new Event('success'))
|
|
88
|
+
})
|
|
89
|
+
return request as unknown as IDBOpenDBRequest
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { factory: factory as unknown as IDBFactory, opens: () => opens }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('createIdbStore', () => {
|
|
97
|
+
it('stores and reads structured-clone values through IndexedDB', async () => {
|
|
98
|
+
const { factory } = makeFactory()
|
|
99
|
+
const store = createIdbStore({ factory })
|
|
100
|
+
|
|
101
|
+
expect(store.persistent).toBe(true)
|
|
102
|
+
await store.set('doc', { name: 'T-1', tags: ['a', 'b'] })
|
|
103
|
+
expect(await store.get('doc')).toEqual({ name: 'T-1', tags: ['a', 'b'] })
|
|
104
|
+
expect(await store.get('missing')).toBe(null)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('lists, deletes and clears keys', async () => {
|
|
108
|
+
const { factory } = makeFactory()
|
|
109
|
+
const store = createIdbStore({ factory })
|
|
110
|
+
|
|
111
|
+
await store.set('a', 1)
|
|
112
|
+
await store.set('b', 2)
|
|
113
|
+
expect((await store.keys()).sort()).toEqual(['a', 'b'])
|
|
114
|
+
|
|
115
|
+
await store.delete('a')
|
|
116
|
+
expect(await store.keys()).toEqual(['b'])
|
|
117
|
+
|
|
118
|
+
await store.clear()
|
|
119
|
+
expect(await store.keys()).toEqual([])
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('opens the database once and reuses it', async () => {
|
|
123
|
+
const { factory, opens } = makeFactory()
|
|
124
|
+
const store = createIdbStore({ factory })
|
|
125
|
+
|
|
126
|
+
await store.set('a', 1)
|
|
127
|
+
await store.get('a')
|
|
128
|
+
await store.set('b', 2)
|
|
129
|
+
expect(opens()).toBe(1)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('falls back to memory when IndexedDB is unavailable', async () => {
|
|
133
|
+
const store = createIdbStore({ factory: null })
|
|
134
|
+
|
|
135
|
+
expect(store.persistent).toBe(false)
|
|
136
|
+
await store.set('k', { v: 1 })
|
|
137
|
+
expect(await store.get('k')).toEqual({ v: 1 })
|
|
138
|
+
expect(await store.keys()).toEqual(['k'])
|
|
139
|
+
await store.delete('k')
|
|
140
|
+
expect(await store.get('k')).toBe(null)
|
|
141
|
+
await store.clear()
|
|
142
|
+
expect(await store.keys()).toEqual([])
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('reports non-persistence when there is no global indexedDB', () => {
|
|
146
|
+
// jsdom does not implement IndexedDB, so the default is the memory store.
|
|
147
|
+
const store = createIdbStore()
|
|
148
|
+
expect(store.persistent).toBe(false)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('rejects when the database fails to open', async () => {
|
|
152
|
+
const failing = {
|
|
153
|
+
open: () => {
|
|
154
|
+
const request: FakeRequest = {
|
|
155
|
+
result: undefined,
|
|
156
|
+
error: new Error('permission denied'),
|
|
157
|
+
onsuccess: null,
|
|
158
|
+
onerror: null,
|
|
159
|
+
onupgradeneeded: null,
|
|
160
|
+
}
|
|
161
|
+
queueMicrotask(() => request.onerror?.(new Event('error')))
|
|
162
|
+
return request
|
|
163
|
+
},
|
|
164
|
+
} as unknown as IDBFactory
|
|
165
|
+
const store = createIdbStore({ factory: failing })
|
|
166
|
+
|
|
167
|
+
await expect(store.get('x')).rejects.toThrow('permission denied')
|
|
168
|
+
})
|
|
169
|
+
})
|