@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,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
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
/** A single doctype-level write action. */
|
|
6
|
+
export interface DoctypeAction<TResult = unknown, TInput = unknown> {
|
|
7
|
+
data: Accessor<TResult | null>
|
|
8
|
+
loading: Accessor<boolean>
|
|
9
|
+
error: Accessor<RequestError | null>
|
|
10
|
+
submit: (input: TInput) => Promise<TResult | null>
|
|
11
|
+
reset: () => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Options for {@link useDoctype}. */
|
|
15
|
+
export interface UseDoctypeOptions {
|
|
16
|
+
fetcher?: ResourceFetcher
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Params for a document method. */
|
|
20
|
+
export interface RunDocMethodInput {
|
|
21
|
+
name: string
|
|
22
|
+
method: string
|
|
23
|
+
params?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Params for a doctype-level method. */
|
|
27
|
+
export interface RunMethodInput {
|
|
28
|
+
method: string
|
|
29
|
+
params?: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The write actions {@link useDoctype} returns. */
|
|
33
|
+
export interface UseDoctypeReturn<TDoc = Record<string, unknown>> {
|
|
34
|
+
insert: DoctypeAction<TDoc, Partial<TDoc>>
|
|
35
|
+
delete: DoctypeAction<unknown, string>
|
|
36
|
+
setValue: DoctypeAction<TDoc, Record<string, unknown> & { name: string }>
|
|
37
|
+
runDocMethod: DoctypeAction<unknown, RunDocMethodInput>
|
|
38
|
+
runMethod: DoctypeAction<unknown, RunMethodInput>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const INSERT_URL = 'frappe.client.insert'
|
|
42
|
+
const SET_VALUE_URL = 'frappe.client.set_value'
|
|
43
|
+
const DELETE_URL = 'frappe.client.delete'
|
|
44
|
+
const RUN_DOC_METHOD_URL = 'run_doc_method'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The signal-based replacement for frappe-ui's `useDoctype`: the doctype-level
|
|
48
|
+
* write actions (`insert`, `delete`, `setValue`, `runDocMethod`, `runMethod`),
|
|
49
|
+
* each an inert resource that fires on `submit`.
|
|
50
|
+
*/
|
|
51
|
+
export function useDoctype<TDoc = Record<string, unknown>>(
|
|
52
|
+
doctype: string,
|
|
53
|
+
options: UseDoctypeOptions = {},
|
|
54
|
+
): UseDoctypeReturn<TDoc> {
|
|
55
|
+
const { fetcher } = options
|
|
56
|
+
|
|
57
|
+
const insertResource = createResource<TDoc, Partial<TDoc>>({
|
|
58
|
+
url: INSERT_URL,
|
|
59
|
+
method: 'POST',
|
|
60
|
+
makeParams: (input) => ({ doc: { doctype, ...(input as Partial<TDoc>) } }),
|
|
61
|
+
fetcher,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const deleteResource = createResource<unknown, string>({
|
|
65
|
+
url: DELETE_URL,
|
|
66
|
+
method: 'POST',
|
|
67
|
+
makeParams: (input) => ({ doctype, name: input }),
|
|
68
|
+
fetcher,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const setValueResource = createResource<
|
|
72
|
+
TDoc,
|
|
73
|
+
Record<string, unknown> & { name: string }
|
|
74
|
+
>({
|
|
75
|
+
url: SET_VALUE_URL,
|
|
76
|
+
method: 'POST',
|
|
77
|
+
makeParams: ({ name, ...values }) => ({ doctype, name, fieldname: values }),
|
|
78
|
+
fetcher,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const runDocMethodResource = createResource<unknown, RunDocMethodInput>({
|
|
82
|
+
url: RUN_DOC_METHOD_URL,
|
|
83
|
+
method: 'POST',
|
|
84
|
+
makeParams: ({ name, method, params }) => ({
|
|
85
|
+
dt: doctype,
|
|
86
|
+
dn: name,
|
|
87
|
+
method,
|
|
88
|
+
args: params ?? {},
|
|
89
|
+
}),
|
|
90
|
+
fetcher,
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// `runMethod` POSTs to the method name itself, which is only known per call.
|
|
94
|
+
// `makeParams` runs before the URL is resolved (see `createResource`), so the
|
|
95
|
+
// signal it writes is already visible when the URL is read.
|
|
96
|
+
const [methodUrl, setMethodUrl] = createSignal('')
|
|
97
|
+
const runMethodResource = createResource<unknown, RunMethodInput>({
|
|
98
|
+
url: () => methodUrl(),
|
|
99
|
+
method: 'POST',
|
|
100
|
+
makeParams: ({ method, params }) => {
|
|
101
|
+
setMethodUrl(() => method)
|
|
102
|
+
return params ?? {}
|
|
103
|
+
},
|
|
104
|
+
fetcher,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function toAction<TResult, TInput>(
|
|
108
|
+
resource: Resource<TResult, TInput>,
|
|
109
|
+
): DoctypeAction<TResult, TInput> {
|
|
110
|
+
return {
|
|
111
|
+
data: resource.data,
|
|
112
|
+
loading: resource.loading,
|
|
113
|
+
error: resource.error,
|
|
114
|
+
submit: (input: TInput) => resource.submit(input),
|
|
115
|
+
reset: resource.reset,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
insert: toAction<TDoc, Partial<TDoc>>(insertResource),
|
|
121
|
+
delete: toAction<unknown, string>(deleteResource),
|
|
122
|
+
setValue: toAction<TDoc, Record<string, unknown> & { name: string }>(
|
|
123
|
+
setValueResource,
|
|
124
|
+
),
|
|
125
|
+
runDocMethod: toAction<unknown, RunDocMethodInput>(runDocMethodResource),
|
|
126
|
+
runMethod: toAction<unknown, RunMethodInput>(runMethodResource),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useList } from './useList'
|
|
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('useList', () => {
|
|
19
|
+
it('delegates to the list resource', async () => {
|
|
20
|
+
const mock = vi.fn(async () => mockResponse({ message: [{ name: 'T-1' }] }))
|
|
21
|
+
vi.stubGlobal('fetch', mock)
|
|
22
|
+
|
|
23
|
+
const list = useList<{ name: string }>({ doctype: 'Task' })
|
|
24
|
+
const rows = await list.reload()
|
|
25
|
+
|
|
26
|
+
expect(rows).toEqual([{ name: 'T-1' }])
|
|
27
|
+
expect(mock).toHaveBeenCalledWith(
|
|
28
|
+
'/api/method/frappe.client.get_list',
|
|
29
|
+
expect.objectContaining({ method: 'POST' }),
|
|
30
|
+
)
|
|
31
|
+
})
|
|
32
|
+
})
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createListResource,
|
|
3
|
+
type ListResource,
|
|
4
|
+
type ListResourceOptions,
|
|
5
|
+
} from './list'
|
|
6
|
+
|
|
7
|
+
/** Options for {@link useList}. */
|
|
8
|
+
export type UseListOptions<TRow = Record<string, unknown>> =
|
|
9
|
+
ListResourceOptions<TRow>
|
|
10
|
+
|
|
11
|
+
/** The signal-based surface {@link useList} returns. */
|
|
12
|
+
export type UseListReturn<TRow = Record<string, unknown>> = ListResource<TRow>
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A thin composable over {@link createListResource}: a paginated, filterable
|
|
16
|
+
* list read through signals.
|
|
17
|
+
*/
|
|
18
|
+
export function useList<TRow = Record<string, unknown>>(
|
|
19
|
+
options: ListResourceOptions<TRow>,
|
|
20
|
+
): ListResource<TRow> {
|
|
21
|
+
return createListResource(options)
|
|
22
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { useNewDoc } from './useNewDoc'
|
|
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('useNewDoc', () => {
|
|
39
|
+
it('builds a doc and inserts it', async () => {
|
|
40
|
+
const fetchMock = stubFetch(mockResponse({ message: { name: 'T-9' } }))
|
|
41
|
+
const newDoc = useNewDoc<{ name: string; subject: string }>('Task', {
|
|
42
|
+
subject: 'Hi',
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
expect(newDoc.doc()).toEqual({ subject: 'Hi' })
|
|
46
|
+
newDoc.setDoc((doc) => ({ ...doc, subject: 'Bye' }))
|
|
47
|
+
|
|
48
|
+
const created = await newDoc.submit()
|
|
49
|
+
|
|
50
|
+
expect(created).toEqual({ name: 'T-9' })
|
|
51
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.insert')
|
|
52
|
+
expect(bodyAt(fetchMock)).toEqual({
|
|
53
|
+
doc: { doctype: 'Task', subject: 'Bye' },
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('reset clears the draft', async () => {
|
|
58
|
+
stubFetch(mockResponse({ message: { name: 'T-9' } }))
|
|
59
|
+
const newDoc = useNewDoc<{ subject: string }>('Task', { subject: 'Hi' })
|
|
60
|
+
|
|
61
|
+
newDoc.setDoc({ subject: 'Bye' })
|
|
62
|
+
newDoc.reset()
|
|
63
|
+
|
|
64
|
+
expect(newDoc.doc()).toEqual({ subject: 'Hi' })
|
|
65
|
+
expect(newDoc.data()).toBe(null)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
import type { RequestError } from './client'
|
|
3
|
+
import { createResource, type ResourceFetcher } from './resource'
|
|
4
|
+
|
|
5
|
+
/** Options for {@link useNewDoc}. */
|
|
6
|
+
export interface UseNewDocOptions {
|
|
7
|
+
onSuccess?: (doc: Record<string, unknown>) => void
|
|
8
|
+
onError?: (error: RequestError) => void
|
|
9
|
+
fetcher?: ResourceFetcher
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The signal-based surface {@link useNewDoc} returns. */
|
|
13
|
+
export interface UseNewDocReturn<TDoc = Record<string, unknown>> {
|
|
14
|
+
/** The document being built, edited through {@link UseNewDocReturn.setDoc}. */
|
|
15
|
+
doc: Accessor<Partial<TDoc>>
|
|
16
|
+
setDoc: (
|
|
17
|
+
values: Partial<TDoc> | ((current: Partial<TDoc>) => Partial<TDoc>),
|
|
18
|
+
) => void
|
|
19
|
+
/** The created document, once `submit` resolves. */
|
|
20
|
+
data: Accessor<TDoc | null>
|
|
21
|
+
loading: Accessor<boolean>
|
|
22
|
+
error: Accessor<RequestError | null>
|
|
23
|
+
/** Insert the document (`frappe.client.insert`). */
|
|
24
|
+
submit: () => Promise<TDoc | null>
|
|
25
|
+
reset: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const INSERT_URL = 'frappe.client.insert'
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The signal-based replacement for frappe-ui's `useNewDoc`: build a document
|
|
32
|
+
* locally, then insert it.
|
|
33
|
+
*/
|
|
34
|
+
export function useNewDoc<TDoc extends object = Record<string, unknown>>(
|
|
35
|
+
doctype: string,
|
|
36
|
+
initialValues: Partial<TDoc> = {},
|
|
37
|
+
options: UseNewDocOptions = {},
|
|
38
|
+
): UseNewDocReturn<TDoc> {
|
|
39
|
+
const [doc, setDocSignal] = createSignal<Partial<TDoc>>({ ...initialValues })
|
|
40
|
+
|
|
41
|
+
const resource = createResource<TDoc, Record<string, unknown>>({
|
|
42
|
+
url: INSERT_URL,
|
|
43
|
+
method: 'POST',
|
|
44
|
+
makeParams: (input) => ({ doc: { doctype, ...input } }),
|
|
45
|
+
fetcher: options.fetcher,
|
|
46
|
+
onSuccess: (data) => options.onSuccess?.(data as Record<string, unknown>),
|
|
47
|
+
onError: options.onError,
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
function setDoc(
|
|
51
|
+
next: Partial<TDoc> | ((current: Partial<TDoc>) => Partial<TDoc>),
|
|
52
|
+
): void {
|
|
53
|
+
if (typeof next === 'function') {
|
|
54
|
+
setDocSignal((current) => next(current))
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
setDocSignal(() => next)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function submit(): Promise<TDoc | null> {
|
|
61
|
+
return resource.submit(doc() as Record<string, unknown>)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function reset(): void {
|
|
65
|
+
setDocSignal(() => ({ ...initialValues }))
|
|
66
|
+
resource.reset()
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
doc,
|
|
71
|
+
setDoc,
|
|
72
|
+
data: resource.data,
|
|
73
|
+
loading: resource.loading,
|
|
74
|
+
error: resource.error,
|
|
75
|
+
submit,
|
|
76
|
+
reset,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { first, optionsOf } from './select'
|
|
3
|
+
|
|
4
|
+
describe('select', () => {
|
|
5
|
+
it('mirrors values into options and unwraps with a fallback', () => {
|
|
6
|
+
expect(optionsOf(['Name', 'Size'])).toEqual([
|
|
7
|
+
{ label: 'Name', value: 'Name' },
|
|
8
|
+
{ label: 'Size', value: 'Size' },
|
|
9
|
+
])
|
|
10
|
+
expect(first(['Size'], 'Name')).toBe('Size')
|
|
11
|
+
expect(first([], 'Name')).toBe('Name')
|
|
12
|
+
})
|
|
13
|
+
})
|
package/src/select.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Select plumbing: a string list to options, and the chosen value back.
|
|
3
|
+
*
|
|
4
|
+
* Every page turned a list into `{ label, value }` inline and unwrapped the
|
|
5
|
+
* single-value array the same way. Two spellings, once.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface SelectOption {
|
|
9
|
+
label: string
|
|
10
|
+
value: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** `['Name', 'Size']` → `[{ label: 'Name', value: 'Name' }, …]`. */
|
|
14
|
+
export const optionsOf = (values: readonly string[]): SelectOption[] =>
|
|
15
|
+
values.map((value) => ({ label: value, value }))
|
|
16
|
+
|
|
17
|
+
/** The chosen value, or `fallback` when the selection is empty. */
|
|
18
|
+
export const first = (value: string[], fallback: string): string =>
|
|
19
|
+
value[0] ?? fallback
|