@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,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
@@ -0,0 +1,24 @@
1
+ import { createRoot } from 'solid-js'
2
+ import { describe, expect, it } from 'vitest'
3
+ import { createUndo } from './undo'
4
+
5
+ describe('undo', () => {
6
+ it('captures, restores once, then reports nothing left', () => {
7
+ createRoot((dispose) => {
8
+ const undo = createUndo<number[]>()
9
+ expect(undo.snapshot()).toBeUndefined()
10
+ expect(undo.undo(() => {})).toBe(false)
11
+
12
+ undo.capture([1, 2])
13
+ const restored: number[][] = []
14
+ expect(undo.undo((value) => restored.push(value))).toBe(true)
15
+ expect(restored).toEqual([[1, 2]])
16
+ expect(undo.snapshot()).toBeUndefined()
17
+
18
+ undo.capture([3])
19
+ undo.clear()
20
+ expect(undo.snapshot()).toBeUndefined()
21
+ dispose()
22
+ })
23
+ })
24
+ })
package/src/undo.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { type Accessor, createSignal } from 'solid-js'
2
+
3
+ /**
4
+ * One-step undo.
5
+ *
6
+ * A snapshot signal, captured before a mutation, restored once. The page keeps
7
+ * owning its data and its notice copy; this owns the signal.
8
+ */
9
+ export interface Undo<T> {
10
+ /** The captured value, or `undefined` when there is nothing to undo. */
11
+ snapshot: Accessor<T | undefined>
12
+ /** Remember `value` as the state an undo returns to. */
13
+ capture: (value: T) => void
14
+ /**
15
+ * Hand the snapshot back to `restore` and clear it. Returns whether a
16
+ * snapshot existed, so the caller can word its notice either way.
17
+ */
18
+ undo: (restore: (value: T) => void) => boolean
19
+ /** Drop the snapshot without restoring. */
20
+ clear: () => void
21
+ }
22
+
23
+ export function createUndo<T>(): Undo<T> {
24
+ const [snapshot, setSnapshot] = createSignal<T | undefined>(undefined)
25
+ return {
26
+ snapshot,
27
+ capture: (value) => setSnapshot(() => value),
28
+ undo: (restore) => {
29
+ const previous = snapshot()
30
+ if (previous === undefined) return false
31
+ restore(previous)
32
+ setSnapshot(undefined)
33
+ return true
34
+ },
35
+ clear: () => setSnapshot(undefined),
36
+ }
37
+ }