@asteby/metacore-runtime-react 39.2.5 → 39.2.7
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/CHANGELOG.md +14 -0
- package/dist/activity-value-renderer.js +5 -5
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +5 -6
- package/dist/dynamic-icon.d.ts +2 -0
- package/dist/dynamic-icon.d.ts.map +1 -1
- package/dist/dynamic-icon.js +36 -12
- package/dist/icon-picker-field.js +2 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/query-batch.d.ts +26 -0
- package/dist/query-batch.d.ts.map +1 -0
- package/dist/query-batch.js +109 -0
- package/dist/use-options-resolver.d.ts +1 -10
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +116 -48
- package/package.json +3 -3
- package/src/__tests__/use-options-resolver.test.ts +9 -1
- package/src/activity-value-renderer.tsx +6 -6
- package/src/dynamic-columns.tsx +7 -8
- package/src/dynamic-icon.tsx +44 -12
- package/src/icon-picker-field.tsx +2 -2
- package/src/index.ts +7 -0
- package/src/query-batch.test.ts +24 -0
- package/src/query-batch.ts +136 -0
- package/src/use-options-resolver.ts +130 -44
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// free; legacy callers that still ship `searchEndpoint` keep working.
|
|
13
13
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
14
14
|
import { useApi } from './api-context'
|
|
15
|
+
import { loadQueryPart, optionsBatchToken, optionsModelFromUrl } from './query-batch'
|
|
15
16
|
|
|
16
17
|
export interface ResolvedOption {
|
|
17
18
|
/** Canonical id (server-side primary key). */
|
|
@@ -100,11 +101,58 @@ export interface UseOptionsResolverResult {
|
|
|
100
101
|
* `/api/options/:model?field=…` endpoint. Returns the v0.9.0 envelope
|
|
101
102
|
* `{ data, meta: { type, count } }` projected into a stable shape.
|
|
102
103
|
*
|
|
103
|
-
* The hook
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
104
|
+
* The hook does NOT debounce `query` (callers should pass it post-debounce).
|
|
105
|
+
* Identical lookups share one in-flight request and a 30s cache, so a column
|
|
106
|
+
* of reference cells does not fan out one request per row. The key includes
|
|
107
|
+
* the org and branch from the host session.
|
|
107
108
|
*/
|
|
109
|
+
const OPTIONS_TTL_MS = 30_000
|
|
110
|
+
|
|
111
|
+
type OptionsPayload = { options: ResolvedOption[]; meta: OptionsMeta }
|
|
112
|
+
|
|
113
|
+
const optionsCache = new Map<string, { payload: OptionsPayload; at: number }>()
|
|
114
|
+
const optionsInflight = new Map<string, Promise<OptionsPayload>>()
|
|
115
|
+
const optionsEpoch = new Map<string, number>()
|
|
116
|
+
|
|
117
|
+
export function optionsRequestKey(
|
|
118
|
+
scope: string,
|
|
119
|
+
url: string,
|
|
120
|
+
field: string,
|
|
121
|
+
query: string,
|
|
122
|
+
limit: number | undefined,
|
|
123
|
+
filter: string | undefined,
|
|
124
|
+
): string {
|
|
125
|
+
return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function optionsScope(): string {
|
|
129
|
+
if (typeof localStorage === 'undefined') return ''
|
|
130
|
+
let org = ''
|
|
131
|
+
try {
|
|
132
|
+
const raw = localStorage.getItem('auth_user')
|
|
133
|
+
if (raw) org = String((JSON.parse(raw) as { organization_id?: string }).organization_id ?? '')
|
|
134
|
+
} catch {
|
|
135
|
+
org = ''
|
|
136
|
+
}
|
|
137
|
+
const branch = localStorage.getItem('active_branch_id') ?? ''
|
|
138
|
+
return `${org}|${branch}`
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readOptionsEnvelope(body: any): OptionsPayload {
|
|
142
|
+
const rawOptions: any[] = Array.isArray(body?.data) ? body.data : []
|
|
143
|
+
const metaPayload =
|
|
144
|
+
body?.meta && typeof body.meta === 'object'
|
|
145
|
+
? body.meta
|
|
146
|
+
: { type: body?.type, count: rawOptions.length }
|
|
147
|
+
return {
|
|
148
|
+
options: rawOptions.map(projectOption),
|
|
149
|
+
meta: {
|
|
150
|
+
type: metaPayload?.type ?? 'dynamic',
|
|
151
|
+
count: typeof metaPayload?.count === 'number' ? metaPayload.count : rawOptions.length,
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
108
156
|
export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsResolverResult {
|
|
109
157
|
const {
|
|
110
158
|
modelKey,
|
|
@@ -146,9 +194,7 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
146
194
|
return ''
|
|
147
195
|
}, [fieldKey, ref])
|
|
148
196
|
|
|
149
|
-
|
|
150
|
-
// previous one — matters for typeahead callers passing changing `query`.
|
|
151
|
-
const abortRef = useRef<AbortController | null>(null)
|
|
197
|
+
const seenRefresh = useRef(0)
|
|
152
198
|
|
|
153
199
|
useEffect(() => {
|
|
154
200
|
if (!enabled || !url || !effectiveField) {
|
|
@@ -158,61 +204,101 @@ export function useOptionsResolver(args: UseOptionsResolverArgs): UseOptionsReso
|
|
|
158
204
|
setError(null)
|
|
159
205
|
return
|
|
160
206
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
207
|
+
|
|
208
|
+
const key = optionsRequestKey(
|
|
209
|
+
optionsScope(),
|
|
210
|
+
url,
|
|
211
|
+
effectiveField,
|
|
212
|
+
query ?? '',
|
|
213
|
+
limit,
|
|
214
|
+
filterValue,
|
|
215
|
+
)
|
|
216
|
+
if (refreshKey !== seenRefresh.current) {
|
|
217
|
+
seenRefresh.current = refreshKey
|
|
218
|
+
optionsEpoch.set(key, (optionsEpoch.get(key) ?? 0) + 1)
|
|
219
|
+
optionsCache.delete(key)
|
|
220
|
+
optionsInflight.delete(key)
|
|
221
|
+
}
|
|
222
|
+
const epoch = optionsEpoch.get(key) ?? 0
|
|
223
|
+
const cached = optionsCache.get(key)
|
|
224
|
+
if (cached && Date.now() - cached.at < OPTIONS_TTL_MS) {
|
|
225
|
+
setOptions(cached.payload.options)
|
|
226
|
+
setMeta(cached.payload.meta)
|
|
227
|
+
setLoading(false)
|
|
228
|
+
setError(null)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
165
231
|
|
|
166
232
|
setLoading(true)
|
|
167
233
|
setError(null)
|
|
168
234
|
|
|
169
|
-
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
235
|
+
let pending = optionsInflight.get(key)
|
|
236
|
+
if (!pending) {
|
|
237
|
+
const params: Record<string, string | number> = { field: effectiveField }
|
|
238
|
+
if (query) params.q = query
|
|
239
|
+
if (typeof limit === 'number' && limit > 0) params.limit = limit
|
|
240
|
+
if (filterValue) params.filter_value = filterValue
|
|
241
|
+
const model = optionsModelFromUrl(url)
|
|
242
|
+
pending = (async () => {
|
|
243
|
+
if (model) {
|
|
244
|
+
try {
|
|
245
|
+
const part = await loadQueryPart(
|
|
246
|
+
api,
|
|
247
|
+
optionsBatchToken(model, effectiveField, query ?? '', limit, filterValue),
|
|
248
|
+
)
|
|
249
|
+
if (!part.success) {
|
|
250
|
+
throw new Error(part.message || 'options resolver: unsuccessful response')
|
|
251
|
+
}
|
|
252
|
+
const payload = readOptionsEnvelope({
|
|
253
|
+
success: true,
|
|
254
|
+
data: part.data,
|
|
255
|
+
meta: part.meta,
|
|
256
|
+
})
|
|
257
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
258
|
+
optionsCache.set(key, { payload, at: Date.now() })
|
|
259
|
+
}
|
|
260
|
+
return payload
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const msg = err instanceof Error ? err.message : ''
|
|
263
|
+
const batchMiss = msg === 'batch unavailable' || msg === 'batch part missing'
|
|
264
|
+
if (!batchMiss) throw err
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
const res = await api.get(url, { params })
|
|
180
268
|
const body = (res as { data: any }).data
|
|
181
269
|
if (!body || body.success !== true) {
|
|
182
270
|
throw new Error(body?.message || 'options resolver: unsuccessful response')
|
|
183
271
|
}
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
: rawOptions.length,
|
|
202
|
-
})
|
|
272
|
+
const payload = readOptionsEnvelope(body)
|
|
273
|
+
if ((optionsEpoch.get(key) ?? 0) === epoch) {
|
|
274
|
+
optionsCache.set(key, { payload, at: Date.now() })
|
|
275
|
+
}
|
|
276
|
+
return payload
|
|
277
|
+
})().finally(() => {
|
|
278
|
+
if (optionsInflight.get(key) === pending) optionsInflight.delete(key)
|
|
279
|
+
})
|
|
280
|
+
optionsInflight.set(key, pending)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let cancelled = false
|
|
284
|
+
pending
|
|
285
|
+
.then((payload) => {
|
|
286
|
+
if (cancelled) return
|
|
287
|
+
setOptions(payload.options)
|
|
288
|
+
setMeta(payload.meta)
|
|
203
289
|
})
|
|
204
290
|
.catch((err: any) => {
|
|
205
|
-
if (
|
|
291
|
+
if (cancelled) return
|
|
206
292
|
setError(err instanceof Error ? err : new Error(String(err)))
|
|
207
293
|
setOptions([])
|
|
208
294
|
setMeta(null)
|
|
209
295
|
})
|
|
210
296
|
.finally(() => {
|
|
211
|
-
if (!
|
|
297
|
+
if (!cancelled) setLoading(false)
|
|
212
298
|
})
|
|
213
299
|
|
|
214
300
|
return () => {
|
|
215
|
-
|
|
301
|
+
cancelled = true
|
|
216
302
|
}
|
|
217
303
|
}, [api, url, effectiveField, query, limit, enabled, filterValue, refreshKey])
|
|
218
304
|
|