@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.
Files changed (68) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +65 -2
  3. package/package.json +54 -3
  4. package/skills/newui-data/SKILL.md +170 -0
  5. package/src/aggregate.test.ts +231 -0
  6. package/src/aggregate.ts +320 -0
  7. package/src/config-store.test.ts +157 -0
  8. package/src/config-store.ts +295 -0
  9. package/src/csv-export.test.ts +156 -0
  10. package/src/csv-export.ts +129 -0
  11. package/src/dashboard.test.tsx +285 -0
  12. package/src/dashboard.tsx +381 -0
  13. package/src/download.test.ts +14 -0
  14. package/src/download.ts +39 -0
  15. package/src/exports.test.ts +135 -0
  16. package/src/form/autosave.test.ts +186 -0
  17. package/src/form/autosave.ts +194 -0
  18. package/src/form/config.test.tsx +288 -0
  19. package/src/form/config.ts +187 -0
  20. package/src/form/form.test.tsx +102 -0
  21. package/src/form/form.tsx +205 -0
  22. package/src/form/index.ts +3 -0
  23. package/src/format.test.ts +22 -0
  24. package/src/format.ts +36 -0
  25. package/src/index.ts +10 -0
  26. package/src/query/client.tsx +53 -0
  27. package/src/query/collection.test.tsx +278 -0
  28. package/src/query/collection.ts +207 -0
  29. package/src/query/index.ts +4 -0
  30. package/src/query/repository.ts +30 -0
  31. package/src/query/server-table.test.ts +136 -0
  32. package/src/query/server-table.ts +181 -0
  33. package/src/resource/client.test.ts +173 -0
  34. package/src/resource/client.ts +307 -0
  35. package/src/resource/connect-realtime.test.ts +135 -0
  36. package/src/resource/connect-realtime.ts +121 -0
  37. package/src/resource/document.test.ts +151 -0
  38. package/src/resource/document.ts +198 -0
  39. package/src/resource/idb.test.ts +169 -0
  40. package/src/resource/idb.ts +167 -0
  41. package/src/resource/index.ts +77 -0
  42. package/src/resource/list.test.ts +150 -0
  43. package/src/resource/list.ts +230 -0
  44. package/src/resource/local.test.ts +111 -0
  45. package/src/resource/local.ts +155 -0
  46. package/src/resource/realtime.test.ts +331 -0
  47. package/src/resource/realtime.ts +287 -0
  48. package/src/resource/resource.test.ts +186 -0
  49. package/src/resource/resource.ts +213 -0
  50. package/src/resource/useCall.test.ts +60 -0
  51. package/src/resource/useCall.ts +74 -0
  52. package/src/resource/useDoc.test.ts +31 -0
  53. package/src/resource/useDoc.ts +23 -0
  54. package/src/resource/useDoctype.test.ts +104 -0
  55. package/src/resource/useDoctype.ts +128 -0
  56. package/src/resource/useList.test.ts +32 -0
  57. package/src/resource/useList.ts +22 -0
  58. package/src/resource/useNewDoc.test.ts +67 -0
  59. package/src/resource/useNewDoc.ts +78 -0
  60. package/src/select.test.ts +13 -0
  61. package/src/select.ts +19 -0
  62. package/src/table-persist.test.ts +258 -0
  63. package/src/table-persist.ts +371 -0
  64. package/src/undo.test.ts +24 -0
  65. package/src/undo.ts +37 -0
  66. package/src/url-serialize.ts +93 -0
  67. package/src/url-state.test.ts +183 -0
  68. 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