@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,173 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
getConfig,
|
|
4
|
+
isAbortError,
|
|
5
|
+
normalizeError,
|
|
6
|
+
RequestError,
|
|
7
|
+
request,
|
|
8
|
+
resetConfig,
|
|
9
|
+
setConfig,
|
|
10
|
+
} from './client'
|
|
11
|
+
|
|
12
|
+
function mockResponse(
|
|
13
|
+
body: unknown,
|
|
14
|
+
init: { ok?: boolean; status?: number; statusText?: string } = {},
|
|
15
|
+
): Response {
|
|
16
|
+
const { ok = true, status = 200, statusText = 'OK' } = init
|
|
17
|
+
return {
|
|
18
|
+
ok,
|
|
19
|
+
status,
|
|
20
|
+
statusText,
|
|
21
|
+
json: async () => body,
|
|
22
|
+
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
|
23
|
+
} as unknown as Response
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FetchCall {
|
|
27
|
+
mock: { calls: Array<[string, RequestInit]> }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function stubFetch(response: Response): FetchCall {
|
|
31
|
+
const mock = vi.fn(async () => response)
|
|
32
|
+
vi.stubGlobal('fetch', mock)
|
|
33
|
+
return mock as unknown as FetchCall
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
vi.unstubAllGlobals()
|
|
38
|
+
resetConfig()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('request', () => {
|
|
42
|
+
it('POSTs a dotted method to /api/method and unwraps message', async () => {
|
|
43
|
+
const fetchMock = stubFetch(mockResponse({ message: [1, 2, 3] }))
|
|
44
|
+
const result = await request({
|
|
45
|
+
url: 'frappe.client.get_list',
|
|
46
|
+
params: { doctype: 'Task' },
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
expect(result).toEqual([1, 2, 3])
|
|
50
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
51
|
+
'/api/method/frappe.client.get_list',
|
|
52
|
+
expect.objectContaining({
|
|
53
|
+
method: 'POST',
|
|
54
|
+
body: JSON.stringify({ doctype: 'Task' }),
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('appends GET params as the query string', async () => {
|
|
60
|
+
const fetchMock = stubFetch(mockResponse({ message: { name: 'T-1' } }))
|
|
61
|
+
await request({
|
|
62
|
+
url: 'frappe.client.get',
|
|
63
|
+
method: 'GET',
|
|
64
|
+
params: { doctype: 'Task', name: 'T-1' },
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
68
|
+
'/api/method/frappe.client.get?doctype=Task&name=T-1',
|
|
69
|
+
expect.objectContaining({ method: 'GET' }),
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('prepends the configured base URL, headers and credentials', async () => {
|
|
74
|
+
setConfig({
|
|
75
|
+
baseUrl: 'https://example.com/',
|
|
76
|
+
headers: { 'X-Frappe-Site-Name': 'site' },
|
|
77
|
+
})
|
|
78
|
+
const fetchMock = stubFetch(mockResponse({ message: 'pong' }))
|
|
79
|
+
await request({ url: '/api/method/ping' })
|
|
80
|
+
|
|
81
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
82
|
+
'https://example.com/api/method/ping',
|
|
83
|
+
expect.objectContaining({
|
|
84
|
+
credentials: 'include',
|
|
85
|
+
headers: expect.objectContaining({
|
|
86
|
+
Accept: 'application/json',
|
|
87
|
+
'X-Frappe-Site-Name': 'site',
|
|
88
|
+
}),
|
|
89
|
+
}),
|
|
90
|
+
)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('accepts a headers getter', async () => {
|
|
94
|
+
setConfig({ headers: () => ({ Authorization: 'token abc' }) })
|
|
95
|
+
const fetchMock = stubFetch(mockResponse({ message: 'ok' }))
|
|
96
|
+
await request({ url: 'myapp.api.ping' })
|
|
97
|
+
|
|
98
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
99
|
+
'/api/method/myapp.api.ping',
|
|
100
|
+
expect.objectContaining({
|
|
101
|
+
headers: expect.objectContaining({ Authorization: 'token abc' }),
|
|
102
|
+
}),
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('returns the whole body when it carries docs', async () => {
|
|
107
|
+
stubFetch(mockResponse({ docs: [{ name: 'T-1' }], message: 'ignored' }))
|
|
108
|
+
const result = await request({ url: 'run_doc_method' })
|
|
109
|
+
expect(result).toEqual({ docs: [{ name: 'T-1' }], message: 'ignored' })
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('returns the whole body for the login endpoint', async () => {
|
|
113
|
+
stubFetch(mockResponse({ message: 'Logged In', full_name: 'Ada' }))
|
|
114
|
+
const result = await request({ url: '/api/method/login', method: 'POST' })
|
|
115
|
+
expect(result).toEqual({ message: 'Logged In', full_name: 'Ada' })
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('rejects with a normalized RequestError on a non-2xx response', async () => {
|
|
119
|
+
stubFetch(
|
|
120
|
+
mockResponse(
|
|
121
|
+
{
|
|
122
|
+
exc_type: 'ValidationError',
|
|
123
|
+
_server_messages: JSON.stringify([
|
|
124
|
+
JSON.stringify({ message: 'Bad value' }),
|
|
125
|
+
]),
|
|
126
|
+
},
|
|
127
|
+
{ ok: false, status: 417, statusText: 'Expectation Failed' },
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const error = (await request({ url: 'x.y' }).catch(
|
|
132
|
+
(e) => e,
|
|
133
|
+
)) as RequestError
|
|
134
|
+
expect(error).toBeInstanceOf(RequestError)
|
|
135
|
+
expect(error.status).toBe(417)
|
|
136
|
+
expect(error.excType).toBe('ValidationError')
|
|
137
|
+
expect(error.messages).toEqual(['Bad value'])
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('falls back to Internal Server Error when the body has no message', async () => {
|
|
141
|
+
stubFetch(mockResponse({}, { ok: false, status: 500 }))
|
|
142
|
+
const error = (await request({ url: 'x.y' }).catch(
|
|
143
|
+
(e) => e,
|
|
144
|
+
)) as RequestError
|
|
145
|
+
expect(error.messages).toEqual(['Internal Server Error'])
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('throws when url is missing', async () => {
|
|
149
|
+
await expect(request({ url: '' })).rejects.toBeInstanceOf(RequestError)
|
|
150
|
+
})
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
describe('config', () => {
|
|
154
|
+
it('merges keys and resets', () => {
|
|
155
|
+
setConfig({ baseUrl: '/base' })
|
|
156
|
+
setConfig({ headers: { A: '1' } })
|
|
157
|
+
expect(getConfig()).toEqual({ baseUrl: '/base', headers: { A: '1' } })
|
|
158
|
+
resetConfig()
|
|
159
|
+
expect(getConfig()).toEqual({})
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('normalizeError', () => {
|
|
164
|
+
it('coerces arbitrary values and preserves the name', () => {
|
|
165
|
+
expect(normalizeError('boom')).toBeInstanceOf(RequestError)
|
|
166
|
+
|
|
167
|
+
const abort = new Error('aborted')
|
|
168
|
+
abort.name = 'AbortError'
|
|
169
|
+
const normalized = normalizeError(abort)
|
|
170
|
+
expect(normalized).toBeInstanceOf(RequestError)
|
|
171
|
+
expect(isAbortError(normalized)).toBe(true)
|
|
172
|
+
})
|
|
173
|
+
})
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework-neutral request client for the resource layer.
|
|
3
|
+
*
|
|
4
|
+
* A Solid port of frappe-ui's `frappeRequest` + `request` pair, reduced to what
|
|
5
|
+
* the signal-based resources need: a configurable base URL / headers, POST to
|
|
6
|
+
* `/api/method/<dotted.method>`, JSON in and out, and one error shape for every
|
|
7
|
+
* way a request can fail. It is pure — the only side effect is `fetch` — so the
|
|
8
|
+
* whole thing is testable by stubbing the global.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here touches Solid. `resource.ts` wraps it with signals.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Configuration merged by {@link setConfig}. Every field is optional. */
|
|
14
|
+
export interface ClientConfig {
|
|
15
|
+
/** Base URL prepended to relative (`/`-rooted) request URLs. */
|
|
16
|
+
baseUrl?: string
|
|
17
|
+
/** Extra headers merged into every request. Static, or a getter. */
|
|
18
|
+
headers?: Record<string, string> | (() => Record<string, string>)
|
|
19
|
+
/**
|
|
20
|
+
* Credentials mode. Defaults to `same-origin`, or `include` when a
|
|
21
|
+
* `baseUrl` is configured (cookie-based cross-origin auth).
|
|
22
|
+
*/
|
|
23
|
+
credentials?: RequestCredentials
|
|
24
|
+
/** Called with Frappe's `_server_messages` when a successful response carries them. */
|
|
25
|
+
onServerMessages?: (messages: string[]) => void
|
|
26
|
+
/** The fetch implementation. Defaults to the global `fetch`. */
|
|
27
|
+
fetch?: typeof fetch
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const defaults: ClientConfig = {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Merge configuration. Keys are merged, not replaced, so calling it twice
|
|
34
|
+
* (`baseUrl`, then `headers`) keeps both. Use {@link resetConfig} in tests.
|
|
35
|
+
*/
|
|
36
|
+
export function setConfig(config: ClientConfig): void {
|
|
37
|
+
Object.assign(defaults, config)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A copy of the current configuration. */
|
|
41
|
+
export function getConfig(): ClientConfig {
|
|
42
|
+
return { ...defaults }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Clear every configured value, back to the built-in defaults. */
|
|
46
|
+
export function resetConfig(): void {
|
|
47
|
+
for (const key of Object.keys(defaults) as (keyof ClientConfig)[]) {
|
|
48
|
+
delete defaults[key]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options for a single {@link request}. */
|
|
53
|
+
export interface RequestOptions {
|
|
54
|
+
/** Absolute URL, `/api/method/…`, or a dotted method name (`frappe.client.get`). */
|
|
55
|
+
url: string
|
|
56
|
+
/** HTTP method. Defaults to `POST`, Frappe's convention for method calls. */
|
|
57
|
+
method?: string
|
|
58
|
+
/** Query params (GET) or JSON body (everything else). */
|
|
59
|
+
params?: Record<string, unknown>
|
|
60
|
+
/** Per-request headers, merged last. */
|
|
61
|
+
headers?: Record<string, string>
|
|
62
|
+
/** Abort signal for the fetch. */
|
|
63
|
+
signal?: AbortSignal
|
|
64
|
+
/** Overrides the configured credentials mode. */
|
|
65
|
+
credentials?: RequestCredentials
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One field of Frappe's JSON error body. */
|
|
69
|
+
interface FrappeResponseBody {
|
|
70
|
+
docs?: unknown
|
|
71
|
+
exc?: string
|
|
72
|
+
exc_type?: string
|
|
73
|
+
message?: unknown
|
|
74
|
+
_server_messages?: string
|
|
75
|
+
_error_message?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Options accepted by {@link RequestError}. */
|
|
79
|
+
export interface RequestErrorOptions {
|
|
80
|
+
status?: number
|
|
81
|
+
response?: Response
|
|
82
|
+
excType?: string
|
|
83
|
+
exc?: string
|
|
84
|
+
messages?: string[]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* What every request failure rejects with: transport errors, non-2xx
|
|
89
|
+
* responses, and an ok response whose body will not parse. Narrow by field
|
|
90
|
+
* (`error.status`, `error.messages`); `messages` is always a `string[]`.
|
|
91
|
+
*/
|
|
92
|
+
export class RequestError extends Error {
|
|
93
|
+
readonly status?: number
|
|
94
|
+
readonly response?: Response
|
|
95
|
+
readonly excType?: string
|
|
96
|
+
readonly exc?: string
|
|
97
|
+
readonly messages: string[]
|
|
98
|
+
|
|
99
|
+
constructor(message: string, options: RequestErrorOptions = {}) {
|
|
100
|
+
super(message)
|
|
101
|
+
this.name = 'RequestError'
|
|
102
|
+
this.status = options.status
|
|
103
|
+
this.response = options.response
|
|
104
|
+
this.excType = options.excType
|
|
105
|
+
this.exc = options.exc
|
|
106
|
+
this.messages = options.messages ?? []
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** True for a deliberate abort, which is not a resource error. */
|
|
111
|
+
export function isAbortError(error: unknown): boolean {
|
|
112
|
+
return (
|
|
113
|
+
!!error &&
|
|
114
|
+
typeof error === 'object' &&
|
|
115
|
+
(error as { name?: unknown }).name === 'AbortError'
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Coerce anything thrown by `fetch` (or the caller's `transform`) into a
|
|
121
|
+
* {@link RequestError}, preserving the original `name` so `AbortError` stays
|
|
122
|
+
* detectable.
|
|
123
|
+
*/
|
|
124
|
+
export function normalizeError(error: unknown): RequestError {
|
|
125
|
+
if (error instanceof RequestError) return error
|
|
126
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
127
|
+
const normalized = new RequestError(message)
|
|
128
|
+
if (error instanceof Error && error.name) normalized.name = error.name
|
|
129
|
+
return normalized
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function resolveUrl(rawUrl: string): string {
|
|
133
|
+
if (/^https?:\/\//i.test(rawUrl)) return rawUrl
|
|
134
|
+
const path = rawUrl.startsWith('/') ? rawUrl : `/api/method/${rawUrl}`
|
|
135
|
+
const { baseUrl } = defaults
|
|
136
|
+
if (baseUrl && path.startsWith('/')) {
|
|
137
|
+
return baseUrl.replace(/\/+$/, '') + path
|
|
138
|
+
}
|
|
139
|
+
return path
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function resolveHeaders(options: RequestOptions): Record<string, string> {
|
|
143
|
+
const configHeaders = defaults.headers
|
|
144
|
+
const extra =
|
|
145
|
+
typeof configHeaders === 'function'
|
|
146
|
+
? configHeaders()
|
|
147
|
+
: (configHeaders ?? {})
|
|
148
|
+
const headers: Record<string, string> = {
|
|
149
|
+
Accept: 'application/json',
|
|
150
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
151
|
+
...extra,
|
|
152
|
+
...(options.headers ?? {}),
|
|
153
|
+
}
|
|
154
|
+
if (typeof window !== 'undefined' && window.csrf_token) {
|
|
155
|
+
if (window.csrf_token !== '{{ csrf_token }}') {
|
|
156
|
+
headers['X-Frappe-CSRF-Token'] = window.csrf_token
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return headers
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
declare global {
|
|
163
|
+
interface Window {
|
|
164
|
+
csrf_token?: string
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function appendQuery(url: string, params: Record<string, unknown>): string {
|
|
169
|
+
const search = new URLSearchParams()
|
|
170
|
+
for (const [key, value] of Object.entries(params)) {
|
|
171
|
+
if (value === undefined || value === null) continue
|
|
172
|
+
search.append(
|
|
173
|
+
key,
|
|
174
|
+
typeof value === 'object' ? JSON.stringify(value) : String(value),
|
|
175
|
+
)
|
|
176
|
+
}
|
|
177
|
+
const query = search.toString()
|
|
178
|
+
return query ? `${url}?${query}` : url
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isLoginUrl(url: string): boolean {
|
|
182
|
+
const path = url.split(/[?#]/)[0]?.replace(/\/+$/, '') ?? ''
|
|
183
|
+
return path === '/api/method/login' || path.endsWith('/api/method/login')
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseExc(exc: string | undefined): string | undefined {
|
|
187
|
+
if (!exc) return undefined
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(exc) as unknown
|
|
190
|
+
if (Array.isArray(parsed) && parsed.length > 0) return String(parsed[0])
|
|
191
|
+
return exc
|
|
192
|
+
} catch {
|
|
193
|
+
return exc
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function normalizeMessages(body: FrappeResponseBody): string[] {
|
|
198
|
+
let messages: string[] = []
|
|
199
|
+
if (body._server_messages) {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(body._server_messages) as unknown
|
|
202
|
+
if (Array.isArray(parsed)) messages = parsed.map(String)
|
|
203
|
+
} catch {
|
|
204
|
+
messages = []
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (body.message !== undefined)
|
|
208
|
+
messages = messages.concat(String(body.message))
|
|
209
|
+
messages = messages
|
|
210
|
+
.map((message) => {
|
|
211
|
+
try {
|
|
212
|
+
const parsed = JSON.parse(message) as { message?: string }
|
|
213
|
+
return parsed.message ?? message
|
|
214
|
+
} catch {
|
|
215
|
+
return message
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
.filter(Boolean)
|
|
219
|
+
if (messages.length === 0) {
|
|
220
|
+
messages = body._error_message
|
|
221
|
+
? [body._error_message]
|
|
222
|
+
: ['Internal Server Error']
|
|
223
|
+
}
|
|
224
|
+
return messages
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function reportServerMessages(body: FrappeResponseBody): void {
|
|
228
|
+
if (!body._server_messages) return
|
|
229
|
+
const handler = defaults.onServerMessages
|
|
230
|
+
if (!handler) return
|
|
231
|
+
try {
|
|
232
|
+
const parsed = JSON.parse(body._server_messages) as unknown
|
|
233
|
+
if (Array.isArray(parsed)) handler(parsed.map(String))
|
|
234
|
+
} catch {
|
|
235
|
+
// A malformed `_server_messages` is not worth failing an ok response over.
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Fetch a Frappe method and return its `message`.
|
|
241
|
+
*
|
|
242
|
+
* `url` may be absolute, `/api/method/…`, or a bare dotted method name. GET
|
|
243
|
+
* params become the query string; every other method sends them as a JSON body.
|
|
244
|
+
* A body carrying `docs` (or the login endpoint) is returned whole; otherwise
|
|
245
|
+
* `message` is unwrapped. Failures reject with a {@link RequestError}.
|
|
246
|
+
*/
|
|
247
|
+
export async function request<TResponse = unknown>(
|
|
248
|
+
options: RequestOptions,
|
|
249
|
+
): Promise<TResponse> {
|
|
250
|
+
if (!options.url) {
|
|
251
|
+
throw new RequestError('[request] options.url is required')
|
|
252
|
+
}
|
|
253
|
+
const method = (options.method ?? 'POST').toUpperCase()
|
|
254
|
+
const resolved = resolveUrl(options.url)
|
|
255
|
+
const headers = resolveHeaders(options)
|
|
256
|
+
|
|
257
|
+
let url = resolved
|
|
258
|
+
let body: string | undefined
|
|
259
|
+
if (options.params && method === 'GET') {
|
|
260
|
+
url = appendQuery(resolved, options.params)
|
|
261
|
+
} else if (options.params) {
|
|
262
|
+
body = JSON.stringify(options.params)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const credentials =
|
|
266
|
+
options.credentials ??
|
|
267
|
+
defaults.credentials ??
|
|
268
|
+
(defaults.baseUrl && resolved.startsWith(defaults.baseUrl)
|
|
269
|
+
? 'include'
|
|
270
|
+
: undefined)
|
|
271
|
+
|
|
272
|
+
const fetchImpl = defaults.fetch ?? fetch
|
|
273
|
+
const response = await fetchImpl(url, {
|
|
274
|
+
method,
|
|
275
|
+
headers,
|
|
276
|
+
body,
|
|
277
|
+
signal: options.signal,
|
|
278
|
+
...(credentials ? { credentials } : {}),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (response.ok) {
|
|
282
|
+
const data = (await response.json()) as FrappeResponseBody
|
|
283
|
+
reportServerMessages(data)
|
|
284
|
+
if (data.docs !== undefined || isLoginUrl(url)) {
|
|
285
|
+
return data as TResponse
|
|
286
|
+
}
|
|
287
|
+
return data.message as TResponse
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const text = await response.text()
|
|
291
|
+
let errorBody: FrappeResponseBody = {}
|
|
292
|
+
try {
|
|
293
|
+
errorBody = JSON.parse(text) as FrappeResponseBody
|
|
294
|
+
} catch {
|
|
295
|
+
// A non-JSON error body (login HTML, proxy error) still fails as a request.
|
|
296
|
+
}
|
|
297
|
+
const detail = [url, errorBody.exc_type, errorBody._error_message]
|
|
298
|
+
.filter(Boolean)
|
|
299
|
+
.join(' ')
|
|
300
|
+
throw new RequestError(detail || response.statusText || 'Request failed', {
|
|
301
|
+
status: response.status,
|
|
302
|
+
response,
|
|
303
|
+
excType: errorBody.exc_type,
|
|
304
|
+
exc: parseExc(errorBody.exc),
|
|
305
|
+
messages: normalizeMessages(errorBody),
|
|
306
|
+
})
|
|
307
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { QueryClient } from '@tanstack/solid-query'
|
|
2
|
+
import { createSignal } from 'solid-js'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { connectRealtime } from './connect-realtime'
|
|
5
|
+
import type { Realtime } from './realtime'
|
|
6
|
+
|
|
7
|
+
/** A socket-shaped test double: `emit` stands in for a server push. */
|
|
8
|
+
function fakeRealtime() {
|
|
9
|
+
const handlers = new Map<string, Set<(data: unknown) => void>>()
|
|
10
|
+
const [connected, setConnected] = createSignal(false)
|
|
11
|
+
const socket: Realtime = {
|
|
12
|
+
connected,
|
|
13
|
+
connect: () => setConnected(true),
|
|
14
|
+
on(event, listener) {
|
|
15
|
+
const set = handlers.get(event) ?? new Set()
|
|
16
|
+
set.add(listener)
|
|
17
|
+
handlers.set(event, set)
|
|
18
|
+
return () => set.delete(listener)
|
|
19
|
+
},
|
|
20
|
+
off(event, listener) {
|
|
21
|
+
handlers.get(event)?.delete(listener)
|
|
22
|
+
},
|
|
23
|
+
send: () => {},
|
|
24
|
+
close: () => setConnected(false),
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
socket,
|
|
28
|
+
emit(event: string, data: unknown) {
|
|
29
|
+
for (const listener of handlers.get(event) ?? []) listener(data)
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeClient() {
|
|
35
|
+
const client = new QueryClient({
|
|
36
|
+
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
|
|
37
|
+
})
|
|
38
|
+
const invalidate = vi.spyOn(client, 'invalidateQueries')
|
|
39
|
+
return { client, invalidate }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('connectRealtime', () => {
|
|
43
|
+
it('invalidates the doctype prefix for list events', () => {
|
|
44
|
+
const { client, invalidate } = makeClient()
|
|
45
|
+
const { socket, emit } = fakeRealtime()
|
|
46
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' })
|
|
47
|
+
|
|
48
|
+
emit('list_update', { doctype: 'Task' })
|
|
49
|
+
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['Task'] })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('invalidates the exact key for doc events', () => {
|
|
53
|
+
const { client, invalidate } = makeClient()
|
|
54
|
+
const { socket, emit } = fakeRealtime()
|
|
55
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' })
|
|
56
|
+
|
|
57
|
+
emit('list_update', { doctype: 'Task', name: 'TASK-1' })
|
|
58
|
+
expect(invalidate).toHaveBeenCalledWith({
|
|
59
|
+
queryKey: ['Task', 'TASK-1'],
|
|
60
|
+
exact: true,
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('honours an explicit event type over the name heuristic', () => {
|
|
65
|
+
const { client, invalidate } = makeClient()
|
|
66
|
+
const { socket, emit } = fakeRealtime()
|
|
67
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' })
|
|
68
|
+
|
|
69
|
+
emit('list_update', { doctype: 'Task', name: 'TASK-1', type: 'list' })
|
|
70
|
+
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['Task'] })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('filters by doctype and reports the normalized event', () => {
|
|
74
|
+
const { client, invalidate } = makeClient()
|
|
75
|
+
const { socket, emit } = fakeRealtime()
|
|
76
|
+
const onEvent = vi.fn()
|
|
77
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' }, { onEvent })
|
|
78
|
+
|
|
79
|
+
emit('list_update', { doctype: 'Invoice', name: 'INV-1' })
|
|
80
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
81
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
82
|
+
|
|
83
|
+
emit('list_update', { doctype: 'Task', name: 'TASK-1' })
|
|
84
|
+
expect(onEvent).toHaveBeenCalledWith({
|
|
85
|
+
type: 'doc',
|
|
86
|
+
doctype: 'Task',
|
|
87
|
+
name: 'TASK-1',
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('subscribes to the event names the app declares', () => {
|
|
92
|
+
const { client, invalidate } = makeClient()
|
|
93
|
+
const { socket, emit } = fakeRealtime()
|
|
94
|
+
connectRealtime(client, {
|
|
95
|
+
realtime: socket,
|
|
96
|
+
doctype: ['Task', 'Invoice'],
|
|
97
|
+
events: ['doc_update'],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
emit('list_update', { doctype: 'Task' })
|
|
101
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
102
|
+
emit('doc_update', { doctype: 'Invoice', name: 'INV-2' })
|
|
103
|
+
expect(invalidate).toHaveBeenCalledWith({
|
|
104
|
+
queryKey: ['Invoice', 'INV-2'],
|
|
105
|
+
exact: true,
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('ignores payloads that are not change events', () => {
|
|
110
|
+
const { client, invalidate } = makeClient()
|
|
111
|
+
const { socket, emit } = fakeRealtime()
|
|
112
|
+
const onEvent = vi.fn()
|
|
113
|
+
connectRealtime(client, { realtime: socket, doctype: 'Task' }, { onEvent })
|
|
114
|
+
|
|
115
|
+
emit('list_update', 'ping')
|
|
116
|
+
emit('list_update', { name: 'no-doctype' })
|
|
117
|
+
emit('list_update', null)
|
|
118
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
119
|
+
expect(onEvent).not.toHaveBeenCalled()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('stops listening on close, leaving the socket itself alone', () => {
|
|
123
|
+
const { client, invalidate } = makeClient()
|
|
124
|
+
const { socket, emit } = fakeRealtime()
|
|
125
|
+
const connection = connectRealtime(client, {
|
|
126
|
+
realtime: socket,
|
|
127
|
+
doctype: 'Task',
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
connection.close()
|
|
131
|
+
emit('list_update', { doctype: 'Task' })
|
|
132
|
+
expect(invalidate).not.toHaveBeenCalled()
|
|
133
|
+
expect(socket.connected()).toBe(false)
|
|
134
|
+
})
|
|
135
|
+
})
|