@talelk/newui-data 0.0.0-stage → 1.0.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 +54 -2
- package/package.json +54 -3
- package/skills/newui-data/SKILL.md +116 -0
- package/src/download.test.ts +14 -0
- package/src/download.ts +39 -0
- package/src/exports.test.ts +105 -0
- package/src/form/form.test.tsx +102 -0
- package/src/form/form.tsx +85 -0
- package/src/form/index.ts +1 -0
- package/src/format.test.ts +22 -0
- package/src/format.ts +36 -0
- package/src/index.ts +4 -0
- package/src/query/client.tsx +53 -0
- package/src/query/collection.test.tsx +151 -0
- package/src/query/collection.ts +192 -0
- package/src/query/index.ts +3 -0
- package/src/query/repository.ts +30 -0
- package/src/resource/client.test.ts +173 -0
- package/src/resource/client.ts +307 -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 +70 -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/undo.test.ts +24 -0
- package/src/undo.ts +37 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useCall } from './useCall'
|
|
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
|
+
afterEach(() => {
|
|
30
|
+
vi.unstubAllGlobals()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe('useCall', () => {
|
|
34
|
+
it('fetches immediately and exposes signals', async () => {
|
|
35
|
+
const fetchMock = stubFetch(mockResponse({ message: { count: 3 } }))
|
|
36
|
+
const call = useCall<{ count: number }>({
|
|
37
|
+
url: 'frappe.client.get_count',
|
|
38
|
+
method: 'GET',
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
await vi.waitFor(() => expect(call.data()).toEqual({ count: 3 }))
|
|
42
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.get_count')
|
|
43
|
+
expect(call.loading()).toBe(false)
|
|
44
|
+
expect(call.error()).toBe(null)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('does not fetch when immediate is false', async () => {
|
|
48
|
+
const fetchMock = stubFetch(mockResponse({ message: 1 }))
|
|
49
|
+
const call = useCall<number>({
|
|
50
|
+
url: 'myapp.api.count',
|
|
51
|
+
method: 'GET',
|
|
52
|
+
immediate: false,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
expect(fetchMock.mock.calls.length).toBe(0)
|
|
56
|
+
await call.reload()
|
|
57
|
+
expect(fetchMock.mock.calls.length).toBe(1)
|
|
58
|
+
expect(call.data()).toBe(1)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Accessor } from 'solid-js'
|
|
2
|
+
import type { RequestError } from './client'
|
|
3
|
+
import { createResource, type ResourceFetcher } from './resource'
|
|
4
|
+
|
|
5
|
+
/** Options for {@link useCall}. Mirrors {@link ResourceOptions} with signals out. */
|
|
6
|
+
export interface UseCallOptions<
|
|
7
|
+
TResponse = unknown,
|
|
8
|
+
TParams = Record<string, unknown>,
|
|
9
|
+
> {
|
|
10
|
+
url: string | (() => string)
|
|
11
|
+
/** HTTP method. Defaults to `GET`. */
|
|
12
|
+
method?: string
|
|
13
|
+
params?: TParams | (() => TParams)
|
|
14
|
+
/** Fetch on creation. Defaults to `true`. */
|
|
15
|
+
immediate?: boolean
|
|
16
|
+
initialData?: TResponse | null
|
|
17
|
+
transform?: (data: unknown) => TResponse
|
|
18
|
+
onSuccess?: (data: TResponse) => void
|
|
19
|
+
onError?: (error: RequestError) => void
|
|
20
|
+
fetcher?: ResourceFetcher
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The signal-based surface {@link useCall} returns. */
|
|
24
|
+
export interface UseCallReturn<TResponse = unknown> {
|
|
25
|
+
data: Accessor<TResponse | null>
|
|
26
|
+
loading: Accessor<boolean>
|
|
27
|
+
error: Accessor<RequestError | null>
|
|
28
|
+
fetched: Accessor<boolean>
|
|
29
|
+
/** Read: fetch, resolving even on failure. */
|
|
30
|
+
execute: () => Promise<TResponse | null>
|
|
31
|
+
fetch: () => Promise<TResponse | null>
|
|
32
|
+
reload: () => Promise<TResponse | null>
|
|
33
|
+
/** Write: fetch, rejecting on failure. */
|
|
34
|
+
submit: () => Promise<TResponse | null>
|
|
35
|
+
abort: () => void
|
|
36
|
+
reset: () => void
|
|
37
|
+
setData: (
|
|
38
|
+
data: TResponse | ((current: TResponse | null) => TResponse),
|
|
39
|
+
) => void
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A thin composable over {@link createResource}: call a Frappe method and read
|
|
44
|
+
* the result through signals.
|
|
45
|
+
*/
|
|
46
|
+
export function useCall<TResponse = unknown, TParams = Record<string, unknown>>(
|
|
47
|
+
options: UseCallOptions<TResponse, TParams>,
|
|
48
|
+
): UseCallReturn<TResponse> {
|
|
49
|
+
const resource = createResource<TResponse, unknown, TParams>({
|
|
50
|
+
url: options.url,
|
|
51
|
+
method: options.method ?? 'GET',
|
|
52
|
+
params: options.params,
|
|
53
|
+
initialData: options.initialData ?? null,
|
|
54
|
+
auto: options.immediate ?? true,
|
|
55
|
+
transform: options.transform,
|
|
56
|
+
onSuccess: options.onSuccess,
|
|
57
|
+
onError: options.onError,
|
|
58
|
+
fetcher: options.fetcher,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
data: resource.data,
|
|
63
|
+
loading: resource.loading,
|
|
64
|
+
error: resource.error,
|
|
65
|
+
fetched: resource.fetched,
|
|
66
|
+
execute: () => resource.reload(),
|
|
67
|
+
fetch: () => resource.reload(),
|
|
68
|
+
reload: () => resource.reload(),
|
|
69
|
+
submit: () => resource.submit(),
|
|
70
|
+
abort: resource.abort,
|
|
71
|
+
reset: resource.reset,
|
|
72
|
+
setData: resource.setData,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useDoc } from './useDoc'
|
|
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
|
+
afterEach(() => {
|
|
15
|
+
vi.unstubAllGlobals()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
describe('useDoc', () => {
|
|
19
|
+
it('delegates to the document resource', async () => {
|
|
20
|
+
const mock = vi.fn(async () => mockResponse({ message: { name: 'T-1' } }))
|
|
21
|
+
vi.stubGlobal('fetch', mock)
|
|
22
|
+
|
|
23
|
+
const doc = useDoc<{ name: string }>({ doctype: 'Task', name: 'T-1' })
|
|
24
|
+
|
|
25
|
+
await vi.waitFor(() => expect(doc.doc()).toEqual({ name: 'T-1' }))
|
|
26
|
+
expect(mock).toHaveBeenCalledWith(
|
|
27
|
+
'/api/method/frappe.client.get?doctype=Task&name=T-1',
|
|
28
|
+
expect.objectContaining({ method: 'GET' }),
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createDocumentResource,
|
|
3
|
+
type DocumentResource,
|
|
4
|
+
type DocumentResourceOptions,
|
|
5
|
+
} from './document'
|
|
6
|
+
|
|
7
|
+
/** Options for {@link useDoc}. */
|
|
8
|
+
export type UseDocOptions<TDoc = Record<string, unknown>> =
|
|
9
|
+
DocumentResourceOptions<TDoc>
|
|
10
|
+
|
|
11
|
+
/** The signal-based surface {@link useDoc} returns. */
|
|
12
|
+
export type UseDocReturn<TDoc = Record<string, unknown>> =
|
|
13
|
+
DocumentResource<TDoc>
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A thin composable over {@link createDocumentResource}: load a single
|
|
17
|
+
* document and edit it through signals.
|
|
18
|
+
*/
|
|
19
|
+
export function useDoc<TDoc = Record<string, unknown>>(
|
|
20
|
+
options: DocumentResourceOptions<TDoc>,
|
|
21
|
+
): DocumentResource<TDoc> {
|
|
22
|
+
return createDocumentResource(options)
|
|
23
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useDoctype } from './useDoctype'
|
|
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('useDoctype', () => {
|
|
39
|
+
it('insert posts the wrapped doc', async () => {
|
|
40
|
+
const fetchMock = stubFetch(mockResponse({ message: { name: 'T-1' } }))
|
|
41
|
+
const doctype = useDoctype<{ name: string }>('Task')
|
|
42
|
+
|
|
43
|
+
const created = await doctype.insert.submit({ name: 'T-1' })
|
|
44
|
+
|
|
45
|
+
expect(created).toEqual({ name: 'T-1' })
|
|
46
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.insert')
|
|
47
|
+
expect(bodyAt(fetchMock)).toEqual({ doc: { doctype: 'Task', name: 'T-1' } })
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('setValue splits the name out of the values', async () => {
|
|
51
|
+
const fetchMock = stubFetch(mockResponse({ message: { name: 'T-1' } }))
|
|
52
|
+
const doctype = useDoctype('Task')
|
|
53
|
+
|
|
54
|
+
await doctype.setValue.submit({ name: 'T-1', subject: 'X' })
|
|
55
|
+
|
|
56
|
+
expect(bodyAt(fetchMock)).toEqual({
|
|
57
|
+
doctype: 'Task',
|
|
58
|
+
name: 'T-1',
|
|
59
|
+
fieldname: { subject: 'X' },
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('delete posts the name', async () => {
|
|
64
|
+
const fetchMock = stubFetch(mockResponse({ message: 'ok' }))
|
|
65
|
+
const doctype = useDoctype('Task')
|
|
66
|
+
|
|
67
|
+
await doctype.delete.submit('T-1')
|
|
68
|
+
|
|
69
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.delete')
|
|
70
|
+
expect(bodyAt(fetchMock)).toEqual({ doctype: 'Task', name: 'T-1' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('runDocMethod posts dt/dn/method/args', async () => {
|
|
74
|
+
const fetchMock = stubFetch(mockResponse({ message: { ok: true } }))
|
|
75
|
+
const doctype = useDoctype('Task')
|
|
76
|
+
|
|
77
|
+
await doctype.runDocMethod.submit({
|
|
78
|
+
name: 'T-1',
|
|
79
|
+
method: 'submit',
|
|
80
|
+
params: { force: true },
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
expect(urlAt(fetchMock)).toBe('/api/method/run_doc_method')
|
|
84
|
+
expect(bodyAt(fetchMock)).toEqual({
|
|
85
|
+
dt: 'Task',
|
|
86
|
+
dn: 'T-1',
|
|
87
|
+
method: 'submit',
|
|
88
|
+
args: { force: true },
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('runMethod posts to the method name', async () => {
|
|
93
|
+
const fetchMock = stubFetch(mockResponse({ message: 'ok' }))
|
|
94
|
+
const doctype = useDoctype('Task')
|
|
95
|
+
|
|
96
|
+
await doctype.runMethod.submit({
|
|
97
|
+
method: 'myapp.api.ping',
|
|
98
|
+
params: { x: 1 },
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
expect(urlAt(fetchMock)).toBe('/api/method/myapp.api.ping')
|
|
102
|
+
expect(bodyAt(fetchMock)).toEqual({ x: 1 })
|
|
103
|
+
})
|
|
104
|
+
})
|