@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,287 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A realtime transport over a `WebSocket`.
|
|
5
|
+
*
|
|
6
|
+
* This is a **plain-`WebSocket`** helper, not a socket.io client: it opens one
|
|
7
|
+
* socket to a configurable URL and multiplexes subscriptions over it through
|
|
8
|
+
* `on`/`off`/`close`. The socket is created by an injectable
|
|
9
|
+
* {@link RealtimeSocketFactory}, so a consumer who already uses socket.io can
|
|
10
|
+
* pass `io(url)` as the factory — a socket.io `Socket` satisfies
|
|
11
|
+
* {@link RealtimeSocket} — and get named events for free. The wire protocol is
|
|
12
|
+
* deliberately *not* implemented here.
|
|
13
|
+
*
|
|
14
|
+
* The default factory adapts the native `WebSocket` to the same surface: it
|
|
15
|
+
* forwards the `open`/`close`/`error` lifecycle events and delivers each
|
|
16
|
+
* message as a single `message` event carrying the raw `event.data`. With the
|
|
17
|
+
* native transport there is no event multiplexing on the wire, so named
|
|
18
|
+
* subscriptions only fire under a transport that provides them (socket.io).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A listener registered with {@link Realtime.on}. */
|
|
22
|
+
export type RealtimeListener = (...args: unknown[]) => void
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The minimal socket surface the realtime helper drives. A native `WebSocket`
|
|
26
|
+
* is adapted to it by the default factory; a `socket.io-client` `Socket`
|
|
27
|
+
* already satisfies it, so it can be passed through
|
|
28
|
+
* {@link RealtimeOptions.factory} directly.
|
|
29
|
+
*/
|
|
30
|
+
export interface RealtimeSocket {
|
|
31
|
+
on(event: string, listener: RealtimeListener): unknown
|
|
32
|
+
off(event: string, listener: RealtimeListener): unknown
|
|
33
|
+
close(): void
|
|
34
|
+
send?(data: unknown): void
|
|
35
|
+
/** Whether the socket is currently connected (socket.io exposes this). */
|
|
36
|
+
readonly connected?: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Build the transport for a URL. See {@link RealtimeOptions.factory}. */
|
|
40
|
+
export type RealtimeSocketFactory = (
|
|
41
|
+
url: string,
|
|
42
|
+
protocols?: string | string[],
|
|
43
|
+
) => RealtimeSocket
|
|
44
|
+
|
|
45
|
+
/** The map of event name to payload, for a typed {@link Realtime}. */
|
|
46
|
+
export interface RealtimeEventMap {
|
|
47
|
+
[event: string]: unknown
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options for {@link createRealtime}. */
|
|
51
|
+
export interface RealtimeOptions {
|
|
52
|
+
/** WebSocket URL, or an accessor for a reactive URL. */
|
|
53
|
+
url: string | (() => string)
|
|
54
|
+
/** Subprotocols passed to the native `WebSocket` constructor. */
|
|
55
|
+
protocols?: string | string[]
|
|
56
|
+
/**
|
|
57
|
+
* Socket factory. Defaults to a native `WebSocket` adapter. Pass a
|
|
58
|
+
* socket.io client factory (`(url) => io(url)`) to reuse that transport.
|
|
59
|
+
*/
|
|
60
|
+
factory?: RealtimeSocketFactory
|
|
61
|
+
/** Connect on creation. Defaults to `true`. */
|
|
62
|
+
auto?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* Reconnect after an unexpected close, with exponential backoff. Defaults to
|
|
65
|
+
* `true`. Set `false` when the injected transport (socket.io) reconnects on
|
|
66
|
+
* its own.
|
|
67
|
+
*/
|
|
68
|
+
reconnect?: boolean
|
|
69
|
+
/** First reconnect delay in ms. Defaults to `1000`. */
|
|
70
|
+
reconnectDelay?: number
|
|
71
|
+
/** Cap on the reconnect delay in ms. Defaults to `30000`. */
|
|
72
|
+
maxReconnectDelay?: number
|
|
73
|
+
onOpen?: () => void
|
|
74
|
+
onClose?: (event?: unknown) => void
|
|
75
|
+
onError?: (event?: unknown) => void
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The signal-based realtime handle {@link createRealtime} returns. */
|
|
79
|
+
export interface Realtime<TEvents extends RealtimeEventMap = RealtimeEventMap> {
|
|
80
|
+
/** Whether the socket is currently connected. */
|
|
81
|
+
connected: Accessor<boolean>
|
|
82
|
+
/** Open the connection. Idempotent while already open. */
|
|
83
|
+
connect(): void
|
|
84
|
+
/** Subscribe to an event. Returns an unsubscribe function. */
|
|
85
|
+
on<K extends keyof TEvents & string>(
|
|
86
|
+
event: K,
|
|
87
|
+
listener: (data: TEvents[K]) => void,
|
|
88
|
+
): () => void
|
|
89
|
+
/** Unsubscribe a listener previously registered with `on`. */
|
|
90
|
+
off(event: string, listener: RealtimeListener): void
|
|
91
|
+
/** Send a payload; the native transport JSON-encodes non-strings. */
|
|
92
|
+
send(data: unknown): void
|
|
93
|
+
/** Close the connection and stop reconnecting. */
|
|
94
|
+
close(): void
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const DEFAULT_RECONNECT_DELAY = 1000
|
|
98
|
+
const DEFAULT_MAX_RECONNECT_DELAY = 30_000
|
|
99
|
+
|
|
100
|
+
/** Adapt a native `WebSocket` to {@link RealtimeSocket}. */
|
|
101
|
+
function nativeFactory(
|
|
102
|
+
url: string,
|
|
103
|
+
protocols?: string | string[],
|
|
104
|
+
): RealtimeSocket {
|
|
105
|
+
const socket = protocols ? new WebSocket(url, protocols) : new WebSocket(url)
|
|
106
|
+
const messageHandlers = new Map<
|
|
107
|
+
RealtimeListener,
|
|
108
|
+
(event: MessageEvent) => void
|
|
109
|
+
>()
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
on(event, listener) {
|
|
113
|
+
if (event === 'message') {
|
|
114
|
+
const handler = (message: MessageEvent) => listener(message.data)
|
|
115
|
+
messageHandlers.set(listener, handler)
|
|
116
|
+
socket.addEventListener('message', handler)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
socket.addEventListener(
|
|
120
|
+
event as keyof WebSocketEventMap,
|
|
121
|
+
listener as EventListener,
|
|
122
|
+
)
|
|
123
|
+
},
|
|
124
|
+
off(event, listener) {
|
|
125
|
+
if (event === 'message') {
|
|
126
|
+
const handler = messageHandlers.get(listener)
|
|
127
|
+
if (handler) {
|
|
128
|
+
socket.removeEventListener('message', handler)
|
|
129
|
+
messageHandlers.delete(listener)
|
|
130
|
+
}
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
socket.removeEventListener(
|
|
134
|
+
event as keyof WebSocketEventMap,
|
|
135
|
+
listener as EventListener,
|
|
136
|
+
)
|
|
137
|
+
},
|
|
138
|
+
close() {
|
|
139
|
+
socket.close()
|
|
140
|
+
},
|
|
141
|
+
send(data) {
|
|
142
|
+
socket.send(typeof data === 'string' ? data : JSON.stringify(data))
|
|
143
|
+
},
|
|
144
|
+
get connected() {
|
|
145
|
+
return socket.readyState === WebSocket.OPEN
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The default factory, or `undefined` when there is no browser `WebSocket`. */
|
|
151
|
+
function defaultFactory(): RealtimeSocketFactory | undefined {
|
|
152
|
+
if (typeof window === 'undefined' || typeof WebSocket === 'undefined') {
|
|
153
|
+
return undefined
|
|
154
|
+
}
|
|
155
|
+
return nativeFactory
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Open a WebSocket and expose typed `on`/`off`/`close` over it.
|
|
160
|
+
*
|
|
161
|
+
* `connected` is a signal. Subscriptions registered before the socket exists
|
|
162
|
+
* are replayed onto every (re)connection, so a consumer can call `on` in any
|
|
163
|
+
* order. Lifecycle events are accepted in both dialects — `open`/`close` for a
|
|
164
|
+
* native `WebSocket`, `connect`/`disconnect` for socket.io.
|
|
165
|
+
*/
|
|
166
|
+
export function createRealtime<
|
|
167
|
+
TEvents extends RealtimeEventMap = RealtimeEventMap,
|
|
168
|
+
>(options: RealtimeOptions): Realtime<TEvents> {
|
|
169
|
+
const [connected, setConnected] = createSignal(false)
|
|
170
|
+
const listeners = new Map<string, Set<RealtimeListener>>()
|
|
171
|
+
const reconnectEnabled = options.reconnect !== false
|
|
172
|
+
const baseDelay = options.reconnectDelay ?? DEFAULT_RECONNECT_DELAY
|
|
173
|
+
const maxDelay = options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY
|
|
174
|
+
|
|
175
|
+
let socket: RealtimeSocket | null = null
|
|
176
|
+
let stopped = false
|
|
177
|
+
let attempt = 0
|
|
178
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
179
|
+
|
|
180
|
+
const currentUrl = (): string =>
|
|
181
|
+
typeof options.url === 'function' ? options.url() : options.url
|
|
182
|
+
|
|
183
|
+
function handleOpen(): void {
|
|
184
|
+
attempt = 0
|
|
185
|
+
setConnected(true)
|
|
186
|
+
options.onOpen?.()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function handleClose(event?: unknown): void {
|
|
190
|
+
setConnected(false)
|
|
191
|
+
options.onClose?.(event)
|
|
192
|
+
socket = null
|
|
193
|
+
if (!stopped && reconnectEnabled) scheduleReconnect()
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function handleError(event?: unknown): void {
|
|
197
|
+
options.onError?.(event)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function scheduleReconnect(): void {
|
|
201
|
+
if (timer) return
|
|
202
|
+
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay)
|
|
203
|
+
attempt += 1
|
|
204
|
+
timer = setTimeout(() => {
|
|
205
|
+
timer = null
|
|
206
|
+
open()
|
|
207
|
+
}, delay)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function attach(target: RealtimeSocket): void {
|
|
211
|
+
const isCurrent = (): boolean => socket === target
|
|
212
|
+
target.on('open', () => {
|
|
213
|
+
if (isCurrent()) handleOpen()
|
|
214
|
+
})
|
|
215
|
+
target.on('connect', () => {
|
|
216
|
+
if (isCurrent()) handleOpen()
|
|
217
|
+
})
|
|
218
|
+
target.on('close', (event) => {
|
|
219
|
+
if (isCurrent()) handleClose(event)
|
|
220
|
+
})
|
|
221
|
+
target.on('disconnect', (event) => {
|
|
222
|
+
if (isCurrent()) handleClose(event)
|
|
223
|
+
})
|
|
224
|
+
target.on('error', (event) => {
|
|
225
|
+
if (isCurrent()) handleError(event)
|
|
226
|
+
})
|
|
227
|
+
for (const [event, set] of listeners) {
|
|
228
|
+
for (const listener of set) target.on(event, listener)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function open(): void {
|
|
233
|
+
if (socket || stopped) return
|
|
234
|
+
const create = options.factory ?? defaultFactory()
|
|
235
|
+
if (!create) return
|
|
236
|
+
const target = create(currentUrl(), options.protocols)
|
|
237
|
+
socket = target
|
|
238
|
+
attach(target)
|
|
239
|
+
// A socket that is already connected (e.g. socket.io with `autoConnect`)
|
|
240
|
+
// does not re-fire `connect`, so reflect its state now.
|
|
241
|
+
if (target.connected) handleOpen()
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function connect(): void {
|
|
245
|
+
stopped = false
|
|
246
|
+
open()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function on<K extends keyof TEvents & string>(
|
|
250
|
+
event: K,
|
|
251
|
+
listener: (data: TEvents[K]) => void,
|
|
252
|
+
): () => void {
|
|
253
|
+
const wrapped = listener as RealtimeListener
|
|
254
|
+
const set = listeners.get(event) ?? new Set<RealtimeListener>()
|
|
255
|
+
set.add(wrapped)
|
|
256
|
+
listeners.set(event, set)
|
|
257
|
+
socket?.on(event, wrapped)
|
|
258
|
+
return () => off(event, wrapped)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function off(event: string, listener: RealtimeListener): void {
|
|
262
|
+
listeners.get(event)?.delete(listener)
|
|
263
|
+
socket?.off(event, listener)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function send(data: unknown): void {
|
|
267
|
+
socket?.send?.(data)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function close(): void {
|
|
271
|
+
stopped = true
|
|
272
|
+
if (timer) {
|
|
273
|
+
clearTimeout(timer)
|
|
274
|
+
timer = null
|
|
275
|
+
}
|
|
276
|
+
const target = socket
|
|
277
|
+
// `handleClose` only runs for the current socket, so call it before the
|
|
278
|
+
// socket is detached; the transport's own close event is then ignored.
|
|
279
|
+
handleClose()
|
|
280
|
+
target?.close()
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Auto-connect on creation. `connect` is a plain action, not a signal read.
|
|
284
|
+
if (options.auto !== false) connect()
|
|
285
|
+
|
|
286
|
+
return { connected, connect, on, off, send, close }
|
|
287
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { RequestError } from './client'
|
|
3
|
+
import { createResource } from './resource'
|
|
4
|
+
|
|
5
|
+
function mockResponse(
|
|
6
|
+
body: unknown,
|
|
7
|
+
init: { ok?: boolean; status?: number; statusText?: string } = {},
|
|
8
|
+
): Response {
|
|
9
|
+
const { ok = true, status = 200, statusText = 'OK' } = init
|
|
10
|
+
return {
|
|
11
|
+
ok,
|
|
12
|
+
status,
|
|
13
|
+
statusText,
|
|
14
|
+
json: async () => body,
|
|
15
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
16
|
+
} as unknown as Response
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface FetchCall {
|
|
20
|
+
mock: { calls: Array<[string, RequestInit]> }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function stubFetch(...responses: Response[]): FetchCall {
|
|
24
|
+
const mock = vi.fn()
|
|
25
|
+
for (const response of responses) mock.mockResolvedValueOnce(response)
|
|
26
|
+
vi.stubGlobal('fetch', mock)
|
|
27
|
+
return mock as unknown as FetchCall
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function urlAt(mock: FetchCall, index = 0): string {
|
|
31
|
+
return mock.mock.calls[index]?.[0] ?? ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function bodyAt(mock: FetchCall, index = 0): Record<string, unknown> {
|
|
35
|
+
const call = mock.mock.calls[index]
|
|
36
|
+
return JSON.parse(String(call?.[1]?.body)) as Record<string, unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
vi.unstubAllGlobals()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('createResource', () => {
|
|
44
|
+
it('loads data and tracks the loading transition', async () => {
|
|
45
|
+
stubFetch(mockResponse({ message: { name: 'T-1' } }))
|
|
46
|
+
const resource = createResource<{ name: string }>({
|
|
47
|
+
url: 'frappe.client.get',
|
|
48
|
+
method: 'GET',
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
expect(resource.loading()).toBe(false)
|
|
52
|
+
const promise = resource.reload()
|
|
53
|
+
expect(resource.loading()).toBe(true)
|
|
54
|
+
|
|
55
|
+
const data = await promise
|
|
56
|
+
expect(data).toEqual({ name: 'T-1' })
|
|
57
|
+
expect(resource.data()).toEqual({ name: 'T-1' })
|
|
58
|
+
expect(resource.loading()).toBe(false)
|
|
59
|
+
expect(resource.error()).toBe(null)
|
|
60
|
+
expect(resource.fetched()).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('reload resolves on error, sets error and restores previous data', async () => {
|
|
64
|
+
stubFetch(
|
|
65
|
+
mockResponse({ message: { name: 'A' } }),
|
|
66
|
+
mockResponse(
|
|
67
|
+
{ _error_message: 'nope' },
|
|
68
|
+
{ ok: false, status: 500, statusText: 'Server Error' },
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
const resource = createResource<{ name: string }>({
|
|
72
|
+
url: 'frappe.client.get',
|
|
73
|
+
method: 'GET',
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
await resource.reload()
|
|
77
|
+
const result = await resource.reload()
|
|
78
|
+
|
|
79
|
+
expect(result).toEqual({ name: 'A' })
|
|
80
|
+
expect(resource.data()).toEqual({ name: 'A' })
|
|
81
|
+
expect(resource.error()).toBeInstanceOf(RequestError)
|
|
82
|
+
expect(resource.loading()).toBe(false)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('submit rejects on error', async () => {
|
|
86
|
+
stubFetch(
|
|
87
|
+
mockResponse(
|
|
88
|
+
{ _error_message: 'bad' },
|
|
89
|
+
{ ok: false, status: 400, statusText: 'Bad Request' },
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
const resource = createResource({ url: 'frappe.client.insert' })
|
|
93
|
+
|
|
94
|
+
await expect(resource.submit()).rejects.toBeInstanceOf(RequestError)
|
|
95
|
+
expect(resource.error()).toBeInstanceOf(RequestError)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('sends makeParams output to the configured url and method', async () => {
|
|
99
|
+
const fetchMock = stubFetch(mockResponse({ message: 'ok' }))
|
|
100
|
+
const resource = createResource<string, { name: string }>({
|
|
101
|
+
url: 'frappe.client.delete',
|
|
102
|
+
method: 'POST',
|
|
103
|
+
makeParams: ({ name }) => ({ doctype: 'Task', name }),
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
await resource.submit({ name: 'T-1' })
|
|
107
|
+
|
|
108
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.delete')
|
|
109
|
+
expect(bodyAt(fetchMock)).toEqual({ doctype: 'Task', name: 'T-1' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('auto fetches on creation', async () => {
|
|
113
|
+
const fetchMock = stubFetch(mockResponse({ message: 1 }))
|
|
114
|
+
const resource = createResource<number>({
|
|
115
|
+
url: 'myapp.api.count',
|
|
116
|
+
method: 'GET',
|
|
117
|
+
auto: true,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await vi.waitFor(() => expect(resource.data()).toBe(1))
|
|
121
|
+
expect(fetchMock.mock.calls.length).toBe(1)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('supports setData and reset', () => {
|
|
125
|
+
const resource = createResource<number>({ url: 'x', initialData: 0 })
|
|
126
|
+
|
|
127
|
+
resource.setData(5)
|
|
128
|
+
expect(resource.data()).toBe(5)
|
|
129
|
+
|
|
130
|
+
resource.setData((current) => (current ?? 0) + 1)
|
|
131
|
+
expect(resource.data()).toBe(6)
|
|
132
|
+
|
|
133
|
+
resource.reset()
|
|
134
|
+
expect(resource.data()).toBe(0)
|
|
135
|
+
expect(resource.fetched()).toBe(false)
|
|
136
|
+
expect(resource.loading()).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('aborts an in-flight request without surfacing an error', async () => {
|
|
140
|
+
const mock = vi.fn(
|
|
141
|
+
(_url: string, init?: RequestInit) =>
|
|
142
|
+
new Promise<Response>((_resolve, reject) => {
|
|
143
|
+
init?.signal?.addEventListener('abort', () => {
|
|
144
|
+
const error = new Error('aborted')
|
|
145
|
+
error.name = 'AbortError'
|
|
146
|
+
reject(error)
|
|
147
|
+
})
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
vi.stubGlobal('fetch', mock)
|
|
151
|
+
|
|
152
|
+
const resource = createResource({ url: 'myapp.api.slow' })
|
|
153
|
+
const promise = resource.reload()
|
|
154
|
+
expect(resource.loading()).toBe(true)
|
|
155
|
+
|
|
156
|
+
resource.abort()
|
|
157
|
+
await promise
|
|
158
|
+
|
|
159
|
+
expect(resource.loading()).toBe(false)
|
|
160
|
+
expect(resource.error()).toBe(null)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('discards a superseded response', async () => {
|
|
164
|
+
let resolveFirst: ((value: Response) => void) | undefined
|
|
165
|
+
const first = new Promise<Response>((resolve) => {
|
|
166
|
+
resolveFirst = resolve
|
|
167
|
+
})
|
|
168
|
+
const mock = vi
|
|
169
|
+
.fn()
|
|
170
|
+
.mockReturnValueOnce(first)
|
|
171
|
+
.mockResolvedValueOnce(mockResponse({ message: { name: 'second' } }))
|
|
172
|
+
vi.stubGlobal('fetch', mock)
|
|
173
|
+
|
|
174
|
+
const resource = createResource<{ name: string }>({
|
|
175
|
+
url: 'frappe.client.get',
|
|
176
|
+
method: 'GET',
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const stale = resource.reload()
|
|
180
|
+
const fresh = resource.reload()
|
|
181
|
+
resolveFirst?.(mockResponse({ message: { name: 'first' } }))
|
|
182
|
+
await Promise.all([stale, fresh])
|
|
183
|
+
|
|
184
|
+
expect(resource.data()).toEqual({ name: 'second' })
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
import {
|
|
3
|
+
isAbortError,
|
|
4
|
+
normalizeError,
|
|
5
|
+
type RequestError,
|
|
6
|
+
type RequestOptions,
|
|
7
|
+
request,
|
|
8
|
+
} from './client'
|
|
9
|
+
|
|
10
|
+
/** The request function a resource uses. Defaults to the module {@link request}. */
|
|
11
|
+
export type ResourceFetcher = <TResponse = unknown>(
|
|
12
|
+
options: RequestOptions,
|
|
13
|
+
) => Promise<TResponse>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options for {@link createResource}. `url` and `params` may be accessors so
|
|
17
|
+
* the resource re-reads them on every fetch (a reactive `name`, a filter).
|
|
18
|
+
*/
|
|
19
|
+
export interface ResourceOptions<
|
|
20
|
+
TData = unknown,
|
|
21
|
+
TInput = unknown,
|
|
22
|
+
TParams = Record<string, unknown>,
|
|
23
|
+
> {
|
|
24
|
+
/** Absolute URL, `/api/method/…`, or a dotted method name. Accessor allowed. */
|
|
25
|
+
url: string | (() => string)
|
|
26
|
+
/** HTTP method. Defaults to `POST`. */
|
|
27
|
+
method?: string
|
|
28
|
+
/** Static params, or an accessor for reactive params. */
|
|
29
|
+
params?: TParams | (() => TParams)
|
|
30
|
+
/**
|
|
31
|
+
* Map the value passed to `reload`/`submit` into request params. When absent,
|
|
32
|
+
* a passed value is used as the params directly.
|
|
33
|
+
*/
|
|
34
|
+
makeParams?: (input: TInput) => TParams
|
|
35
|
+
/** Value before the first fetch. */
|
|
36
|
+
initialData?: TData | null
|
|
37
|
+
/** Fetch on creation. Defaults to `false`. */
|
|
38
|
+
auto?: boolean
|
|
39
|
+
/** Map the raw response into `TData`. */
|
|
40
|
+
transform?: (data: unknown) => TData
|
|
41
|
+
onSuccess?: (data: TData) => void
|
|
42
|
+
onError?: (error: RequestError) => void
|
|
43
|
+
/** Custom request function; defaults to the module client. */
|
|
44
|
+
fetcher?: ResourceFetcher
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A generic, signal-based resource. Every field is an accessor; every action is
|
|
49
|
+
* a plain function. There is no Vue `ref`/`reactive` and no `v-model`.
|
|
50
|
+
*/
|
|
51
|
+
export interface Resource<TData = unknown, TInput = unknown> {
|
|
52
|
+
data: Accessor<TData | null>
|
|
53
|
+
loading: Accessor<boolean>
|
|
54
|
+
error: Accessor<RequestError | null>
|
|
55
|
+
/** True once a fetch has resolved at least once. */
|
|
56
|
+
fetched: Accessor<boolean>
|
|
57
|
+
/** The in-flight promise, or the last one. */
|
|
58
|
+
promise: Accessor<Promise<TData | null> | null>
|
|
59
|
+
/** Read: fetch, resolving with the data (or the previous data) even on failure. */
|
|
60
|
+
reload: (input?: TInput) => Promise<TData | null>
|
|
61
|
+
/** Alias of {@link Resource.reload}. */
|
|
62
|
+
fetch: (input?: TInput) => Promise<TData | null>
|
|
63
|
+
/** Write: like `reload`, but rejects on failure. */
|
|
64
|
+
submit: (input?: TInput) => Promise<TData | null>
|
|
65
|
+
/** Replace `data`, directly or from its current value. */
|
|
66
|
+
setData: (data: TData | ((current: TData | null) => TData)) => void
|
|
67
|
+
/** Back to `initialData`, clearing loading/error/fetched. */
|
|
68
|
+
reset: () => void
|
|
69
|
+
/** Abort the in-flight request without surfacing an error. */
|
|
70
|
+
abort: () => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The signal-based replacement for frappe-ui's `createResource`.
|
|
75
|
+
*
|
|
76
|
+
* `reload` (and its `fetch` alias) resolves on failure — a read reports through
|
|
77
|
+
* `error`. `submit` rejects on failure — a write must not run its success path.
|
|
78
|
+
* A new fetch aborts the previous one; a superseded response is discarded, so
|
|
79
|
+
* `data` always reflects the newest request.
|
|
80
|
+
*/
|
|
81
|
+
export function createResource<
|
|
82
|
+
TData = unknown,
|
|
83
|
+
TInput = unknown,
|
|
84
|
+
TParams = Record<string, unknown>,
|
|
85
|
+
>(options: ResourceOptions<TData, TInput, TParams>): Resource<TData, TInput> {
|
|
86
|
+
const fetcher: ResourceFetcher = options.fetcher ?? request
|
|
87
|
+
|
|
88
|
+
const [data, setDataSignal] = createSignal<TData | null>(
|
|
89
|
+
options.initialData ?? null,
|
|
90
|
+
)
|
|
91
|
+
const [loading, setLoading] = createSignal(false)
|
|
92
|
+
const [error, setError] = createSignal<RequestError | null>(null)
|
|
93
|
+
const [fetched, setFetched] = createSignal(false)
|
|
94
|
+
const [promise, setPromise] = createSignal<Promise<TData | null> | null>(null)
|
|
95
|
+
|
|
96
|
+
let controller: AbortController | null = null
|
|
97
|
+
// Monotonic request id. Only the newest request may write state; an older
|
|
98
|
+
// response (or a superseded abort) is ignored.
|
|
99
|
+
let sequence = 0
|
|
100
|
+
|
|
101
|
+
function resolveUrl(): string {
|
|
102
|
+
return typeof options.url === 'function' ? options.url() : options.url
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveParams(input: TInput | undefined): TParams | undefined {
|
|
106
|
+
if (options.makeParams) return options.makeParams(input as TInput)
|
|
107
|
+
if (input !== undefined) return input as unknown as TParams
|
|
108
|
+
const params = options.params
|
|
109
|
+
if (typeof params === 'function') return (params as () => TParams)()
|
|
110
|
+
return params
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function transform(value: unknown): TData {
|
|
114
|
+
if (options.transform) {
|
|
115
|
+
const result = options.transform(value)
|
|
116
|
+
if (result !== undefined) return result
|
|
117
|
+
}
|
|
118
|
+
return value as TData
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function execute(
|
|
122
|
+
input: TInput | undefined,
|
|
123
|
+
rejectOnError: boolean,
|
|
124
|
+
): Promise<TData | null> {
|
|
125
|
+
const current = ++sequence
|
|
126
|
+
controller?.abort()
|
|
127
|
+
controller = new AbortController()
|
|
128
|
+
setLoading(true)
|
|
129
|
+
setError(null)
|
|
130
|
+
const previous = data()
|
|
131
|
+
|
|
132
|
+
const params = resolveParams(input)
|
|
133
|
+
const result = fetcher<TData>({
|
|
134
|
+
url: resolveUrl(),
|
|
135
|
+
method: options.method,
|
|
136
|
+
params: params as Record<string, unknown> | undefined,
|
|
137
|
+
signal: controller.signal,
|
|
138
|
+
})
|
|
139
|
+
.then((response) => {
|
|
140
|
+
if (current !== sequence) return data()
|
|
141
|
+
const transformed = transform(response)
|
|
142
|
+
setDataSignal(() => transformed)
|
|
143
|
+
setFetched(true)
|
|
144
|
+
options.onSuccess?.(transformed)
|
|
145
|
+
return transformed
|
|
146
|
+
})
|
|
147
|
+
.catch((caught: unknown) => {
|
|
148
|
+
if (current !== sequence || isAbortError(caught)) return data()
|
|
149
|
+
const normalized = normalizeError(caught)
|
|
150
|
+
setError(normalized)
|
|
151
|
+
setDataSignal(() => previous)
|
|
152
|
+
options.onError?.(normalized)
|
|
153
|
+
if (rejectOnError) throw normalized
|
|
154
|
+
return data()
|
|
155
|
+
})
|
|
156
|
+
.finally(() => {
|
|
157
|
+
if (current === sequence) setLoading(false)
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
setPromise(result)
|
|
161
|
+
return result
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function reload(input?: TInput): Promise<TData | null> {
|
|
165
|
+
return execute(input, false)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function submit(input?: TInput): Promise<TData | null> {
|
|
169
|
+
return execute(input, true)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function setData(next: TData | ((current: TData | null) => TData)): void {
|
|
173
|
+
if (typeof next === 'function') {
|
|
174
|
+
const updater = next as (current: TData | null) => TData
|
|
175
|
+
setDataSignal(() => updater(data()))
|
|
176
|
+
return
|
|
177
|
+
}
|
|
178
|
+
setDataSignal(() => next)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function reset(): void {
|
|
182
|
+
controller?.abort()
|
|
183
|
+
sequence++
|
|
184
|
+
setDataSignal(() => options.initialData ?? null)
|
|
185
|
+
setLoading(false)
|
|
186
|
+
setError(null)
|
|
187
|
+
setFetched(false)
|
|
188
|
+
setPromise(null)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function abort(): void {
|
|
192
|
+
controller?.abort()
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Auto-fetch on creation. `reload` is a plain action, not a signal — the
|
|
196
|
+
// reactivity rule cannot tell the difference, so it is silenced here.
|
|
197
|
+
// eslint-disable-next-line solid/reactivity
|
|
198
|
+
if (options.auto) void reload()
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
data,
|
|
202
|
+
loading,
|
|
203
|
+
error,
|
|
204
|
+
fetched,
|
|
205
|
+
promise,
|
|
206
|
+
reload,
|
|
207
|
+
fetch: reload,
|
|
208
|
+
submit,
|
|
209
|
+
setData,
|
|
210
|
+
reset,
|
|
211
|
+
abort,
|
|
212
|
+
}
|
|
213
|
+
}
|