@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.
Files changed (47) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +54 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +116 -0
  5. package/src/download.test.ts +14 -0
  6. package/src/download.ts +39 -0
  7. package/src/exports.test.ts +105 -0
  8. package/src/form/form.test.tsx +102 -0
  9. package/src/form/form.tsx +85 -0
  10. package/src/form/index.ts +1 -0
  11. package/src/format.test.ts +22 -0
  12. package/src/format.ts +36 -0
  13. package/src/index.ts +4 -0
  14. package/src/query/client.tsx +53 -0
  15. package/src/query/collection.test.tsx +151 -0
  16. package/src/query/collection.ts +192 -0
  17. package/src/query/index.ts +3 -0
  18. package/src/query/repository.ts +30 -0
  19. package/src/resource/client.test.ts +173 -0
  20. package/src/resource/client.ts +307 -0
  21. package/src/resource/document.test.ts +151 -0
  22. package/src/resource/document.ts +198 -0
  23. package/src/resource/idb.test.ts +169 -0
  24. package/src/resource/idb.ts +167 -0
  25. package/src/resource/index.ts +70 -0
  26. package/src/resource/list.test.ts +150 -0
  27. package/src/resource/list.ts +230 -0
  28. package/src/resource/local.test.ts +111 -0
  29. package/src/resource/local.ts +155 -0
  30. package/src/resource/realtime.test.ts +331 -0
  31. package/src/resource/realtime.ts +287 -0
  32. package/src/resource/resource.test.ts +186 -0
  33. package/src/resource/resource.ts +213 -0
  34. package/src/resource/useCall.test.ts +60 -0
  35. package/src/resource/useCall.ts +74 -0
  36. package/src/resource/useDoc.test.ts +31 -0
  37. package/src/resource/useDoc.ts +23 -0
  38. package/src/resource/useDoctype.test.ts +104 -0
  39. package/src/resource/useDoctype.ts +128 -0
  40. package/src/resource/useList.test.ts +32 -0
  41. package/src/resource/useList.ts +22 -0
  42. package/src/resource/useNewDoc.test.ts +67 -0
  43. package/src/resource/useNewDoc.ts +78 -0
  44. package/src/select.test.ts +13 -0
  45. package/src/select.ts +19 -0
  46. package/src/undo.test.ts +24 -0
  47. 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
+ })