@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,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An IndexedDB-backed, Promise-based key/value store for caching resource data.
|
|
3
|
+
*
|
|
4
|
+
* The asynchronous sibling of {@link createLocalStore}, written against the
|
|
5
|
+
* native `indexedDB` API — no `idb`/`idb-keyval` dependency. Values are stored
|
|
6
|
+
* with the structured-clone algorithm, so objects, arrays, `Date`s, `Blob`s and
|
|
7
|
+
* the like round-trip without an explicit `JSON.stringify`.
|
|
8
|
+
*
|
|
9
|
+
* SSR-safe: with no `indexedDB` (the server, or a browser with it disabled) the
|
|
10
|
+
* store falls back to an in-memory `Map` and every method still resolves, so a
|
|
11
|
+
* caller never has to branch on `typeof window`. The `indexedDB` implementation
|
|
12
|
+
* is injectable through {@link IdbStoreOptions.factory}, which is also how the
|
|
13
|
+
* unit tests drive the native code path (jsdom ships no IndexedDB).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A Promise-based, structured-clone key/value cache. */
|
|
17
|
+
export interface IdbStore<T = unknown> {
|
|
18
|
+
/** The stored value, or `null` when the key is absent. */
|
|
19
|
+
get(key: string): Promise<T | null>
|
|
20
|
+
/** Persist a value. */
|
|
21
|
+
set(key: string, value: T): Promise<void>
|
|
22
|
+
/** Remove a key. */
|
|
23
|
+
delete(key: string): Promise<void>
|
|
24
|
+
/** Every key currently in the store. */
|
|
25
|
+
keys(): Promise<string[]>
|
|
26
|
+
/** Remove every key. */
|
|
27
|
+
clear(): Promise<void>
|
|
28
|
+
/** False when the store fell back to memory (SSR, or no IndexedDB). */
|
|
29
|
+
readonly persistent: boolean
|
|
30
|
+
/** Close the underlying database, if one was opened. */
|
|
31
|
+
close(): void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Options for {@link createIdbStore}. */
|
|
35
|
+
export interface IdbStoreOptions {
|
|
36
|
+
/** Database name. Defaults to `newui`. */
|
|
37
|
+
name?: string
|
|
38
|
+
/** Object store name. Defaults to `keyval`. */
|
|
39
|
+
store?: string
|
|
40
|
+
/** Database version. Defaults to `1`. */
|
|
41
|
+
version?: number
|
|
42
|
+
/** IndexedDB implementation. Defaults to the global `indexedDB`. */
|
|
43
|
+
factory?: IDBFactory | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function defaultFactory(): IDBFactory | null {
|
|
47
|
+
try {
|
|
48
|
+
return typeof indexedDB === 'undefined' ? null : indexedDB
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve an {@link IDBRequest} on success and reject on error. */
|
|
55
|
+
function promisify<T>(request: IDBRequest<T>): Promise<T> {
|
|
56
|
+
return new Promise<T>((resolve, reject) => {
|
|
57
|
+
request.onsuccess = () => resolve(request.result)
|
|
58
|
+
request.onerror = () =>
|
|
59
|
+
reject(request.error ?? new Error('IndexedDB request failed'))
|
|
60
|
+
})
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Create a Promise-based cache over IndexedDB.
|
|
65
|
+
*
|
|
66
|
+
* The database is opened lazily on the first operation and reused. When no
|
|
67
|
+
* IndexedDB implementation is available the store keeps values in memory, so
|
|
68
|
+
* `get`/`set`/`keys` behave identically (minus persistence) and `persistent`
|
|
69
|
+
* reports the difference.
|
|
70
|
+
*/
|
|
71
|
+
export function createIdbStore<T = unknown>(
|
|
72
|
+
options: IdbStoreOptions = {},
|
|
73
|
+
): IdbStore<T> {
|
|
74
|
+
const dbName = options.name ?? 'newui'
|
|
75
|
+
const storeName = options.store ?? 'keyval'
|
|
76
|
+
const version = options.version ?? 1
|
|
77
|
+
const factory =
|
|
78
|
+
options.factory === undefined ? defaultFactory() : options.factory
|
|
79
|
+
const memory = new Map<string, T>()
|
|
80
|
+
|
|
81
|
+
let opening: Promise<IDBDatabase> | null = null
|
|
82
|
+
|
|
83
|
+
function open(): Promise<IDBDatabase> {
|
|
84
|
+
if (!factory) {
|
|
85
|
+
return Promise.reject(new Error('IndexedDB is not available'))
|
|
86
|
+
}
|
|
87
|
+
if (opening) return opening
|
|
88
|
+
opening = new Promise<IDBDatabase>((resolve, reject) => {
|
|
89
|
+
const request = factory.open(dbName, version)
|
|
90
|
+
request.onupgradeneeded = () => {
|
|
91
|
+
const db = request.result
|
|
92
|
+
if (!db.objectStoreNames.contains(storeName)) {
|
|
93
|
+
db.createObjectStore(storeName)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
request.onsuccess = () => resolve(request.result)
|
|
97
|
+
request.onerror = () =>
|
|
98
|
+
reject(
|
|
99
|
+
request.error ?? new Error(`Failed to open IndexedDB "${dbName}"`),
|
|
100
|
+
)
|
|
101
|
+
})
|
|
102
|
+
return opening
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function store(mode: IDBTransactionMode): Promise<IDBObjectStore> {
|
|
106
|
+
const db = await open()
|
|
107
|
+
return db.transaction(storeName, mode).objectStore(storeName)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function get(key: string): Promise<T | null> {
|
|
111
|
+
if (!factory) return memory.get(key) ?? null
|
|
112
|
+
const objectStore = await store('readonly')
|
|
113
|
+
const value = (await promisify(objectStore.get(key))) as T | undefined
|
|
114
|
+
return value ?? null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function set(key: string, value: T): Promise<void> {
|
|
118
|
+
if (!factory) {
|
|
119
|
+
memory.set(key, value)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
const objectStore = await store('readwrite')
|
|
123
|
+
await promisify(objectStore.put(value, key))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function remove(key: string): Promise<void> {
|
|
127
|
+
if (!factory) {
|
|
128
|
+
memory.delete(key)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
const objectStore = await store('readwrite')
|
|
132
|
+
await promisify(objectStore.delete(key))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function keys(): Promise<string[]> {
|
|
136
|
+
if (!factory) return [...memory.keys()]
|
|
137
|
+
const objectStore = await store('readonly')
|
|
138
|
+
const all = await promisify(objectStore.getAllKeys())
|
|
139
|
+
return all.map(String)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function clear(): Promise<void> {
|
|
143
|
+
if (!factory) {
|
|
144
|
+
memory.clear()
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
const objectStore = await store('readwrite')
|
|
148
|
+
await promisify(objectStore.clear())
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function close(): void {
|
|
152
|
+
const current = opening
|
|
153
|
+
if (!current) return
|
|
154
|
+
opening = null
|
|
155
|
+
void current.then((db) => db.close()).catch(() => {})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
get,
|
|
160
|
+
set,
|
|
161
|
+
delete: remove,
|
|
162
|
+
keys,
|
|
163
|
+
clear,
|
|
164
|
+
persistent: factory !== null,
|
|
165
|
+
close,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signal-based data layer: a configurable request client, generic
|
|
3
|
+
* resources, and the thin composables over them.
|
|
4
|
+
*
|
|
5
|
+
* Ported from frappe-ui's `resources/` + `data-fetching/`, translated to Solid
|
|
6
|
+
* signals. No Vue `ref`/`reactive`, no `v-model` — every value is an accessor
|
|
7
|
+
* (`() => T`) and every action a plain function.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
ClientConfig,
|
|
12
|
+
RequestErrorOptions,
|
|
13
|
+
RequestOptions,
|
|
14
|
+
} from './client'
|
|
15
|
+
export {
|
|
16
|
+
getConfig,
|
|
17
|
+
isAbortError,
|
|
18
|
+
normalizeError,
|
|
19
|
+
RequestError,
|
|
20
|
+
request,
|
|
21
|
+
resetConfig,
|
|
22
|
+
setConfig,
|
|
23
|
+
} from './client'
|
|
24
|
+
export type {
|
|
25
|
+
ConnectRealtimeOptions,
|
|
26
|
+
RealtimeConnection,
|
|
27
|
+
RealtimeEvent,
|
|
28
|
+
RealtimeResource,
|
|
29
|
+
} from './connect-realtime'
|
|
30
|
+
export { connectRealtime } from './connect-realtime'
|
|
31
|
+
export type {
|
|
32
|
+
DocumentOperationOptions,
|
|
33
|
+
DocumentResource,
|
|
34
|
+
DocumentResourceOptions,
|
|
35
|
+
} from './document'
|
|
36
|
+
export { createDocumentResource } from './document'
|
|
37
|
+
export type { IdbStore, IdbStoreOptions } from './idb'
|
|
38
|
+
export { createIdbStore } from './idb'
|
|
39
|
+
export type {
|
|
40
|
+
ListFilters,
|
|
41
|
+
ListResource,
|
|
42
|
+
ListResourceOptions,
|
|
43
|
+
} from './list'
|
|
44
|
+
export { createListResource } from './list'
|
|
45
|
+
export type { LocalStore, LocalStoreOptions } from './local'
|
|
46
|
+
export { createLocalStore } from './local'
|
|
47
|
+
export type {
|
|
48
|
+
Realtime,
|
|
49
|
+
RealtimeEventMap,
|
|
50
|
+
RealtimeListener,
|
|
51
|
+
RealtimeOptions,
|
|
52
|
+
RealtimeSocket,
|
|
53
|
+
RealtimeSocketFactory,
|
|
54
|
+
} from './realtime'
|
|
55
|
+
export { createRealtime } from './realtime'
|
|
56
|
+
export type {
|
|
57
|
+
Resource,
|
|
58
|
+
ResourceFetcher,
|
|
59
|
+
ResourceOptions,
|
|
60
|
+
} from './resource'
|
|
61
|
+
export { createResource } from './resource'
|
|
62
|
+
export type { UseCallOptions, UseCallReturn } from './useCall'
|
|
63
|
+
export { useCall } from './useCall'
|
|
64
|
+
export type { UseDocOptions, UseDocReturn } from './useDoc'
|
|
65
|
+
export { useDoc } from './useDoc'
|
|
66
|
+
export type {
|
|
67
|
+
DoctypeAction,
|
|
68
|
+
RunDocMethodInput,
|
|
69
|
+
RunMethodInput,
|
|
70
|
+
UseDoctypeOptions,
|
|
71
|
+
UseDoctypeReturn,
|
|
72
|
+
} from './useDoctype'
|
|
73
|
+
export { useDoctype } from './useDoctype'
|
|
74
|
+
export type { UseListOptions, UseListReturn } from './useList'
|
|
75
|
+
export { useList } from './useList'
|
|
76
|
+
export type { UseNewDocOptions, UseNewDocReturn } from './useNewDoc'
|
|
77
|
+
export { useNewDoc } from './useNewDoc'
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { createListResource } from './list'
|
|
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('createListResource', () => {
|
|
39
|
+
it('reloads from the list endpoint with filters and a window limit', async () => {
|
|
40
|
+
const fetchMock = stubFetch(
|
|
41
|
+
mockResponse({ message: [{ name: 'T-1' }, { name: 'T-2' }] }),
|
|
42
|
+
)
|
|
43
|
+
const list = createListResource({
|
|
44
|
+
doctype: 'Task',
|
|
45
|
+
filters: { status: 'Open' },
|
|
46
|
+
pageLength: 20,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const rows = await list.reload()
|
|
50
|
+
|
|
51
|
+
expect(rows).toEqual([{ name: 'T-1' }, { name: 'T-2' }])
|
|
52
|
+
expect(urlAt(fetchMock)).toBe('/api/method/frappe.client.get_list')
|
|
53
|
+
expect(bodyAt(fetchMock)).toEqual({
|
|
54
|
+
doctype: 'Task',
|
|
55
|
+
filters: { status: 'Open' },
|
|
56
|
+
start: 0,
|
|
57
|
+
limit: 20,
|
|
58
|
+
limit_start: 0,
|
|
59
|
+
limit_page_length: 20,
|
|
60
|
+
})
|
|
61
|
+
expect(list.hasNextPage()).toBe(false)
|
|
62
|
+
expect(list.hasPreviousPage()).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('next advances the window and refetches', async () => {
|
|
66
|
+
const page = Array.from({ length: 20 }, (_, i) => ({ name: `T-${i}` }))
|
|
67
|
+
const fetchMock = stubFetch(
|
|
68
|
+
mockResponse({ message: page }),
|
|
69
|
+
mockResponse({ message: page }),
|
|
70
|
+
)
|
|
71
|
+
const list = createListResource({ doctype: 'Task', pageLength: 20 })
|
|
72
|
+
|
|
73
|
+
await list.reload()
|
|
74
|
+
expect(list.hasNextPage()).toBe(true)
|
|
75
|
+
|
|
76
|
+
await list.next()
|
|
77
|
+
expect(list.start()).toBe(20)
|
|
78
|
+
expect(list.hasPreviousPage()).toBe(true)
|
|
79
|
+
expect(bodyAt(fetchMock, 1)).toEqual({
|
|
80
|
+
doctype: 'Task',
|
|
81
|
+
filters: null,
|
|
82
|
+
start: 0,
|
|
83
|
+
limit: 40,
|
|
84
|
+
limit_start: 0,
|
|
85
|
+
limit_page_length: 40,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
await list.previous()
|
|
89
|
+
expect(list.start()).toBe(0)
|
|
90
|
+
expect(bodyAt(fetchMock, 2)).toEqual({
|
|
91
|
+
doctype: 'Task',
|
|
92
|
+
filters: null,
|
|
93
|
+
start: 0,
|
|
94
|
+
limit: 20,
|
|
95
|
+
limit_start: 0,
|
|
96
|
+
limit_page_length: 20,
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('setFilters resets to the first page and refetches', async () => {
|
|
101
|
+
const fetchMock = stubFetch(
|
|
102
|
+
mockResponse({ message: [] }),
|
|
103
|
+
mockResponse({ message: [] }),
|
|
104
|
+
)
|
|
105
|
+
const list = createListResource({ doctype: 'Task', pageLength: 20 })
|
|
106
|
+
|
|
107
|
+
await list.next()
|
|
108
|
+
await list.setFilters({ status: 'Closed' })
|
|
109
|
+
|
|
110
|
+
expect(list.start()).toBe(0)
|
|
111
|
+
expect(list.filters()).toEqual({ status: 'Closed' })
|
|
112
|
+
expect(bodyAt(fetchMock, 1)).toEqual({
|
|
113
|
+
doctype: 'Task',
|
|
114
|
+
filters: { status: 'Closed' },
|
|
115
|
+
start: 0,
|
|
116
|
+
limit: 20,
|
|
117
|
+
limit_start: 0,
|
|
118
|
+
limit_page_length: 20,
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('sets error and resolves on failure', async () => {
|
|
123
|
+
stubFetch({
|
|
124
|
+
ok: false,
|
|
125
|
+
status: 500,
|
|
126
|
+
statusText: 'Server Error',
|
|
127
|
+
json: async () => ({}),
|
|
128
|
+
text: async () => JSON.stringify({ _error_message: 'boom' }),
|
|
129
|
+
} as unknown as Response)
|
|
130
|
+
const list = createListResource({ doctype: 'Task' })
|
|
131
|
+
|
|
132
|
+
const rows = await list.reload()
|
|
133
|
+
|
|
134
|
+
expect(rows).toBe(null)
|
|
135
|
+
expect(list.error()).not.toBe(null)
|
|
136
|
+
expect(list.loading()).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('applies transform to the rows', async () => {
|
|
140
|
+
stubFetch(mockResponse({ message: [{ name: 'T-1' }, { name: 'T-2' }] }))
|
|
141
|
+
const list = createListResource<{ name: string }>({
|
|
142
|
+
doctype: 'Task',
|
|
143
|
+
transform: (rows) => rows.slice(0, 1),
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const rows = await list.reload()
|
|
147
|
+
expect(rows).toEqual([{ name: 'T-1' }])
|
|
148
|
+
expect(list.data()).toEqual([{ name: 'T-1' }])
|
|
149
|
+
})
|
|
150
|
+
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { type Accessor, createSignal } from 'solid-js'
|
|
2
|
+
import {
|
|
3
|
+
isAbortError,
|
|
4
|
+
normalizeError,
|
|
5
|
+
type RequestError,
|
|
6
|
+
request,
|
|
7
|
+
} from './client'
|
|
8
|
+
import type { ResourceFetcher } from './resource'
|
|
9
|
+
|
|
10
|
+
/** A Frappe filter map (`{ status: 'Open', name: ['like', '%x%'] }`). */
|
|
11
|
+
export type ListFilters = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
/** Options for {@link createListResource}. */
|
|
14
|
+
export interface ListResourceOptions<TRow = Record<string, unknown>> {
|
|
15
|
+
doctype: string
|
|
16
|
+
/** Fields to select. */
|
|
17
|
+
fields?: string[]
|
|
18
|
+
/** Filters, or an accessor for reactive filters. */
|
|
19
|
+
filters?: ListFilters | (() => ListFilters)
|
|
20
|
+
/** OR-filters (`or_filters`). */
|
|
21
|
+
orFilters?: ListFilters | (() => ListFilters)
|
|
22
|
+
/** `order_by` clause, e.g. `modified desc`. */
|
|
23
|
+
orderBy?: string
|
|
24
|
+
/** Initial page offset. Defaults to `0`. */
|
|
25
|
+
start?: number
|
|
26
|
+
/** Rows per page. Defaults to `20`. */
|
|
27
|
+
pageLength?: number
|
|
28
|
+
groupBy?: string
|
|
29
|
+
parent?: string
|
|
30
|
+
debug?: boolean | number
|
|
31
|
+
/** Overrides the default list endpoint. */
|
|
32
|
+
url?: string
|
|
33
|
+
/** Fetch on creation. Defaults to `false`. */
|
|
34
|
+
auto?: boolean
|
|
35
|
+
/** Map the raw rows. */
|
|
36
|
+
transform?: (rows: TRow[]) => TRow[]
|
|
37
|
+
onSuccess?: (rows: TRow[]) => void
|
|
38
|
+
onError?: (error: RequestError) => void
|
|
39
|
+
fetcher?: ResourceFetcher
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A paginated, filterable list resource. */
|
|
43
|
+
export interface ListResource<TRow = Record<string, unknown>> {
|
|
44
|
+
data: Accessor<TRow[] | null>
|
|
45
|
+
loading: Accessor<boolean>
|
|
46
|
+
error: Accessor<RequestError | null>
|
|
47
|
+
fetched: Accessor<boolean>
|
|
48
|
+
filters: Accessor<ListFilters | null>
|
|
49
|
+
/** Current page offset (the `start` of the window). */
|
|
50
|
+
start: Accessor<number>
|
|
51
|
+
pageLength: Accessor<number>
|
|
52
|
+
hasNextPage: Accessor<boolean>
|
|
53
|
+
hasPreviousPage: Accessor<boolean>
|
|
54
|
+
/** Refetch the window from the start. */
|
|
55
|
+
reload: () => Promise<TRow[] | null>
|
|
56
|
+
/** Alias of {@link ListResource.reload}. */
|
|
57
|
+
fetch: () => Promise<TRow[] | null>
|
|
58
|
+
/** Advance one page and refetch. */
|
|
59
|
+
next: () => Promise<TRow[] | null>
|
|
60
|
+
/** Go back one page and refetch. */
|
|
61
|
+
previous: () => Promise<TRow[] | null>
|
|
62
|
+
/** Replace the filters, reset to the first page, and refetch. */
|
|
63
|
+
setFilters: (filters: ListFilters) => Promise<TRow[] | null>
|
|
64
|
+
/** Replace `data` without a request. */
|
|
65
|
+
setData: (rows: TRow[]) => void
|
|
66
|
+
reset: () => void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_LIST_URL = 'frappe.client.get_list'
|
|
70
|
+
|
|
71
|
+
function resolveFilters(
|
|
72
|
+
value: ListFilters | (() => ListFilters) | undefined,
|
|
73
|
+
): ListFilters | undefined {
|
|
74
|
+
return typeof value === 'function' ? value() : value
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The signal-based replacement for frappe-ui's `createListResource`.
|
|
79
|
+
*
|
|
80
|
+
* Pagination is a sliding window: `reload` always fetches from `start = 0` with
|
|
81
|
+
* a limit of `start + pageLength`, so `data` holds every row up to the current
|
|
82
|
+
* page. `next`/`previous` move the window and refetch; `hasNextPage` is true
|
|
83
|
+
* when the server returned a full window.
|
|
84
|
+
*/
|
|
85
|
+
export function createListResource<TRow = Record<string, unknown>>(
|
|
86
|
+
options: ListResourceOptions<TRow>,
|
|
87
|
+
): ListResource<TRow> {
|
|
88
|
+
const fetcher: ResourceFetcher = options.fetcher ?? request
|
|
89
|
+
|
|
90
|
+
const [data, setDataSignal] = createSignal<TRow[] | null>(null)
|
|
91
|
+
const [loading, setLoading] = createSignal(false)
|
|
92
|
+
const [error, setError] = createSignal<RequestError | null>(null)
|
|
93
|
+
const [fetched, setFetched] = createSignal(false)
|
|
94
|
+
const [start, setStart] = createSignal(options.start ?? 0)
|
|
95
|
+
const [pageLength] = createSignal(options.pageLength ?? 20)
|
|
96
|
+
const [hasNextPage, setHasNextPage] = createSignal(true)
|
|
97
|
+
const hasPreviousPage = (): boolean => start() > 0
|
|
98
|
+
|
|
99
|
+
// `setFilters` wins over the option; both may be an accessor.
|
|
100
|
+
const [filtersOverride, setFiltersOverride] =
|
|
101
|
+
createSignal<ListFilters | null>(null)
|
|
102
|
+
const filters = (): ListFilters | null => {
|
|
103
|
+
const override = filtersOverride()
|
|
104
|
+
if (override) return override
|
|
105
|
+
const value = options.filters
|
|
106
|
+
if (typeof value === 'function') return value()
|
|
107
|
+
return value ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let controller: AbortController | null = null
|
|
111
|
+
let sequence = 0
|
|
112
|
+
|
|
113
|
+
function makeParams(limit: number): Record<string, unknown> {
|
|
114
|
+
return {
|
|
115
|
+
doctype: options.doctype,
|
|
116
|
+
fields: options.fields,
|
|
117
|
+
filters: filters(),
|
|
118
|
+
or_filters: resolveFilters(options.orFilters),
|
|
119
|
+
order_by: options.orderBy,
|
|
120
|
+
start: 0,
|
|
121
|
+
limit,
|
|
122
|
+
limit_start: 0,
|
|
123
|
+
limit_page_length: limit,
|
|
124
|
+
group_by: options.groupBy,
|
|
125
|
+
parent: options.parent,
|
|
126
|
+
debug: options.debug,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function transform(rows: TRow[]): TRow[] {
|
|
131
|
+
if (options.transform) {
|
|
132
|
+
const result = options.transform(rows)
|
|
133
|
+
if (Array.isArray(result)) return result
|
|
134
|
+
}
|
|
135
|
+
return rows
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reload(): Promise<TRow[] | null> {
|
|
139
|
+
const current = ++sequence
|
|
140
|
+
controller?.abort()
|
|
141
|
+
controller = new AbortController()
|
|
142
|
+
setLoading(true)
|
|
143
|
+
setError(null)
|
|
144
|
+
|
|
145
|
+
const limit = start() + pageLength()
|
|
146
|
+
const result = fetcher<TRow[]>({
|
|
147
|
+
url: options.url ?? DEFAULT_LIST_URL,
|
|
148
|
+
method: 'POST',
|
|
149
|
+
params: makeParams(limit),
|
|
150
|
+
signal: controller.signal,
|
|
151
|
+
})
|
|
152
|
+
.then((rows) => {
|
|
153
|
+
if (current !== sequence) return data()
|
|
154
|
+
const list = Array.isArray(rows) ? rows : []
|
|
155
|
+
setHasNextPage(list.length >= limit)
|
|
156
|
+
const transformed = transform(list)
|
|
157
|
+
setDataSignal(() => transformed)
|
|
158
|
+
setFetched(true)
|
|
159
|
+
options.onSuccess?.(transformed)
|
|
160
|
+
return transformed
|
|
161
|
+
})
|
|
162
|
+
.catch((caught: unknown) => {
|
|
163
|
+
if (current !== sequence || isAbortError(caught)) return data()
|
|
164
|
+
const normalized = normalizeError(caught)
|
|
165
|
+
setError(normalized)
|
|
166
|
+
options.onError?.(normalized)
|
|
167
|
+
return data()
|
|
168
|
+
})
|
|
169
|
+
.finally(() => {
|
|
170
|
+
if (current === sequence) setLoading(false)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
return result
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function next(): Promise<TRow[] | null> {
|
|
177
|
+
setStart(start() + pageLength())
|
|
178
|
+
return reload()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function previous(): Promise<TRow[] | null> {
|
|
182
|
+
setStart(Math.max(0, start() - pageLength()))
|
|
183
|
+
return reload()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function setFilters(next: ListFilters): Promise<TRow[] | null> {
|
|
187
|
+
setFiltersOverride(() => next)
|
|
188
|
+
setStart(0)
|
|
189
|
+
return reload()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function setData(rows: TRow[]): void {
|
|
193
|
+
setDataSignal(() => transform(rows))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function reset(): void {
|
|
197
|
+
controller?.abort()
|
|
198
|
+
sequence++
|
|
199
|
+
setDataSignal(() => null)
|
|
200
|
+
setLoading(false)
|
|
201
|
+
setError(null)
|
|
202
|
+
setFetched(false)
|
|
203
|
+
setStart(options.start ?? 0)
|
|
204
|
+
setHasNextPage(true)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Auto-fetch on creation. `reload` is a plain action, not a signal — the
|
|
208
|
+
// reactivity rule cannot tell the difference, so it is silenced here.
|
|
209
|
+
// eslint-disable-next-line solid/reactivity
|
|
210
|
+
if (options.auto) void reload()
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
data,
|
|
214
|
+
loading,
|
|
215
|
+
error,
|
|
216
|
+
fetched,
|
|
217
|
+
filters,
|
|
218
|
+
start,
|
|
219
|
+
pageLength,
|
|
220
|
+
hasNextPage,
|
|
221
|
+
hasPreviousPage,
|
|
222
|
+
reload,
|
|
223
|
+
fetch: reload,
|
|
224
|
+
next,
|
|
225
|
+
previous,
|
|
226
|
+
setFilters,
|
|
227
|
+
setData,
|
|
228
|
+
reset,
|
|
229
|
+
}
|
|
230
|
+
}
|