@asteby/metacore-runtime-react 49.5.0 → 49.7.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/CHANGELOG.md +12 -0
- package/dist/business/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +36 -6
- package/dist/dynamic-select-field.d.ts +9 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +105 -172
- package/dist/entity-select.d.ts +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +4 -0
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/dist/use-option-ids.d.ts +34 -0
- package/dist/use-option-ids.d.ts.map +1 -0
- package/dist/use-option-ids.js +138 -0
- package/dist/use-options-resolver.d.ts +4 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +10 -1
- package/package.json +1 -1
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/edit-field-polish.test.tsx +6 -3
- package/src/__tests__/option-ids.test.tsx +181 -0
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +53 -15
- package/src/dynamic-select-field.tsx +188 -379
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +35 -2
- package/src/query-batch.ts +3 -0
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/src/use-option-ids.ts +168 -0
- package/src/use-options-resolver.ts +11 -1
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- package/src/business/use-async-search.ts +0 -43
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// useResolveOptionIds — labels for values a picker already holds.
|
|
2
|
+
//
|
|
3
|
+
// A RecordPicker editing a saved record (a product with its brand) holds an id
|
|
4
|
+
// whose label is in no loaded page: options load only once the popover opens,
|
|
5
|
+
// so the trigger used to show the raw UUID unless the caller had a seed (the
|
|
6
|
+
// relation sibling the table served). This hook asks the canonical options
|
|
7
|
+
// endpoint for exactly those ids:
|
|
8
|
+
//
|
|
9
|
+
// GET /api/options/<ref>?field=id&ids=a,b
|
|
10
|
+
//
|
|
11
|
+
// - One react-query entry per (org/branch, endpoint, field, id): two pickers
|
|
12
|
+
// on the same value share it, and nothing is refetched for 30 s.
|
|
13
|
+
// - Every id requested within a few ms for the same endpoint + field travels in
|
|
14
|
+
// ONE request (a column of cells, a multi-select's chips), chunked at the
|
|
15
|
+
// kernel's 100-id cap.
|
|
16
|
+
// - A host that predates `ids` ignores the parameter and answers a first page.
|
|
17
|
+
// The answer is then filtered by id: a hit still resolves, a miss stays
|
|
18
|
+
// `unknown` (the caller shows the value as before) instead of being reported
|
|
19
|
+
// as deleted. A response that only holds requested ids is a host that
|
|
20
|
+
// understood `ids`, so a requested id absent from it is `missing`.
|
|
21
|
+
import { useContext, useMemo } from 'react'
|
|
22
|
+
import { QueryClient, QueryClientContext, useQueries } from '@tanstack/react-query'
|
|
23
|
+
import { useApi, type ApiClient } from './api-context'
|
|
24
|
+
import { onOptionsCacheInvalidated, optionsScope, projectOption, type ResolvedOption } from './use-options-resolver'
|
|
25
|
+
|
|
26
|
+
export type OptionIdResolution =
|
|
27
|
+
| { status: 'found'; option: ResolvedOption }
|
|
28
|
+
/** The host resolved the ids and this one is not there (deleted, or not visible). */
|
|
29
|
+
| { status: 'missing' }
|
|
30
|
+
/** Could not tell (old host, error): show the raw value. */
|
|
31
|
+
| { status: 'unknown' }
|
|
32
|
+
|
|
33
|
+
/** Ids the kernel accepts per request (dynamic.MaxOptionsIDs). */
|
|
34
|
+
export const OPTION_IDS_CHUNK = 100
|
|
35
|
+
const OPTION_IDS_TTL_MS = 30_000
|
|
36
|
+
const OPTION_IDS_WINDOW_MS = 5
|
|
37
|
+
const QUERY_ROOT = 'metacore-option-ids'
|
|
38
|
+
|
|
39
|
+
// Hosts without a QueryClientProvider still get dedup + cache.
|
|
40
|
+
let fallbackClient: QueryClient | null = null
|
|
41
|
+
function getFallbackClient(): QueryClient {
|
|
42
|
+
if (!fallbackClient) fallbackClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
43
|
+
return fallbackClient
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const knownClients = new Set<QueryClient>()
|
|
47
|
+
onOptionsCacheInvalidated(() => {
|
|
48
|
+
for (const client of knownClients) void client.invalidateQueries({ queryKey: [QUERY_ROOT] })
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
type Waiter = { resolve: (r: OptionIdResolution) => void }
|
|
52
|
+
type Pending = { api: ApiClient; url: string; field: string; waiters: Map<string, Waiter[]> }
|
|
53
|
+
const pendingBatches = new Map<string, Pending>()
|
|
54
|
+
|
|
55
|
+
function enqueue(api: ApiClient, scope: string, url: string, field: string, id: string): Promise<OptionIdResolution> {
|
|
56
|
+
const key = [scope, url, field].join('\n')
|
|
57
|
+
// Keyed without the client: useApi() hands each component its own wrapper
|
|
58
|
+
// of the same host client, and pickers of one ref must share the request.
|
|
59
|
+
let batch = pendingBatches.get(key)
|
|
60
|
+
if (!batch) {
|
|
61
|
+
batch = { api, url, field, waiters: new Map() }
|
|
62
|
+
pendingBatches.set(key, batch)
|
|
63
|
+
const flushing = batch
|
|
64
|
+
setTimeout(() => {
|
|
65
|
+
if (pendingBatches.get(key) === flushing) pendingBatches.delete(key)
|
|
66
|
+
void flush(flushing)
|
|
67
|
+
}, OPTION_IDS_WINDOW_MS)
|
|
68
|
+
}
|
|
69
|
+
const target = batch
|
|
70
|
+
return new Promise((resolve) => {
|
|
71
|
+
const list = target.waiters.get(id) ?? []
|
|
72
|
+
list.push({ resolve })
|
|
73
|
+
target.waiters.set(id, list)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function flush(batch: Pending): Promise<void> {
|
|
78
|
+
const ids = [...batch.waiters.keys()]
|
|
79
|
+
for (let i = 0; i < ids.length; i += OPTION_IDS_CHUNK) {
|
|
80
|
+
const chunk = ids.slice(i, i + OPTION_IDS_CHUNK)
|
|
81
|
+
const result = await fetchIds(batch.api, batch.url, batch.field, chunk)
|
|
82
|
+
for (const id of chunk) {
|
|
83
|
+
const r = result.get(id) ?? { status: 'unknown' as const }
|
|
84
|
+
for (const w of batch.waiters.get(id) ?? []) w.resolve(r)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function fetchIds(api: ApiClient, url: string, field: string, ids: string[]): Promise<Map<string, OptionIdResolution>> {
|
|
90
|
+
const out = new Map<string, OptionIdResolution>()
|
|
91
|
+
try {
|
|
92
|
+
const res = await api.get(url, { params: { field, ids: ids.join(',') } })
|
|
93
|
+
const body = (res as { data: any })?.data
|
|
94
|
+
if (!body || body.success !== true || !Array.isArray(body.data)) return out
|
|
95
|
+
const options = (body.data as unknown[]).map(projectOption)
|
|
96
|
+
const byId = new Map<string, ResolvedOption>()
|
|
97
|
+
for (const o of options) byId.set(String(o.id).toLowerCase(), o)
|
|
98
|
+
const asked = new Set(ids.map((id) => id.toLowerCase()))
|
|
99
|
+
// Only requested ids back → the host honoured `ids`.
|
|
100
|
+
const honoured = options.every((o) => asked.has(String(o.id).toLowerCase()))
|
|
101
|
+
for (const id of ids) {
|
|
102
|
+
const hit = byId.get(id.toLowerCase())
|
|
103
|
+
if (hit) out.set(id, { status: 'found', option: hit })
|
|
104
|
+
else if (honoured) out.set(id, { status: 'missing' })
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Network / permission error: leave every id `unknown`.
|
|
108
|
+
}
|
|
109
|
+
return out
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface UseResolveOptionIdsArgs {
|
|
113
|
+
/** FK target: `/options/<ref>`. */
|
|
114
|
+
ref?: string
|
|
115
|
+
/** Explicit options endpoint (wins over `ref`), as in useOptionsResolver. */
|
|
116
|
+
endpoint?: string
|
|
117
|
+
/** `?field=`; defaults to `id` with a `ref`. */
|
|
118
|
+
field?: string
|
|
119
|
+
/** Ids to label. Pass only those not already loaded / seeded. */
|
|
120
|
+
ids: readonly (string | number)[]
|
|
121
|
+
enabled?: boolean
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface UseResolveOptionIdsResult {
|
|
125
|
+
/** Settled resolutions by id (as given, stringified). */
|
|
126
|
+
resolved: ReadonlyMap<string, OptionIdResolution>
|
|
127
|
+
/** Some id is still being resolved. */
|
|
128
|
+
loading: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function useResolveOptionIds(args: UseResolveOptionIdsArgs): UseResolveOptionIdsResult {
|
|
132
|
+
const { ref, endpoint, field, ids, enabled = true } = args
|
|
133
|
+
const api = useApi()
|
|
134
|
+
const contextClient = useContext(QueryClientContext)
|
|
135
|
+
const client = contextClient ?? getFallbackClient()
|
|
136
|
+
knownClients.add(client)
|
|
137
|
+
|
|
138
|
+
const url = endpoint || (ref ? `/options/${ref}` : '')
|
|
139
|
+
const effectiveField = field || (ref ? 'id' : '')
|
|
140
|
+
const scope = optionsScope()
|
|
141
|
+
const list = useMemo(() => [...new Set(ids.map(String).filter((id) => id !== ''))], [ids.map(String).join('\u0001')])
|
|
142
|
+
const active = enabled && !!url && !!effectiveField && list.length > 0
|
|
143
|
+
|
|
144
|
+
const results = useQueries(
|
|
145
|
+
{
|
|
146
|
+
queries: (active ? list : []).map((id) => ({
|
|
147
|
+
queryKey: [QUERY_ROOT, scope, url, effectiveField, id],
|
|
148
|
+
queryFn: () => enqueue(api, scope, url, effectiveField, id),
|
|
149
|
+
staleTime: OPTION_IDS_TTL_MS,
|
|
150
|
+
retry: false,
|
|
151
|
+
refetchOnWindowFocus: false,
|
|
152
|
+
})),
|
|
153
|
+
},
|
|
154
|
+
client,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
const resolved = new Map<string, OptionIdResolution>()
|
|
158
|
+
let loading = false
|
|
159
|
+
if (active) {
|
|
160
|
+
list.forEach((id, i) => {
|
|
161
|
+
const r = results[i]
|
|
162
|
+
if (r?.data) resolved.set(id, r.data)
|
|
163
|
+
else if (r?.isError) resolved.set(id, { status: 'unknown' })
|
|
164
|
+
else loading = true
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
return { resolved, loading }
|
|
168
|
+
}
|
|
@@ -139,6 +139,15 @@ export function invalidateOptionsCache(): void {
|
|
|
139
139
|
optionsGeneration++
|
|
140
140
|
optionsCache.clear()
|
|
141
141
|
optionsInflight.clear()
|
|
142
|
+
for (const fn of invalidationListeners) fn()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const invalidationListeners = new Set<() => void>()
|
|
146
|
+
|
|
147
|
+
/** Runs `fn` on every invalidateOptionsCache (id-label lookups drop with it). */
|
|
148
|
+
export function onOptionsCacheInvalidated(fn: () => void): () => void {
|
|
149
|
+
invalidationListeners.add(fn)
|
|
150
|
+
return () => invalidationListeners.delete(fn)
|
|
142
151
|
}
|
|
143
152
|
|
|
144
153
|
export function optionsRequestKey(
|
|
@@ -152,7 +161,8 @@ export function optionsRequestKey(
|
|
|
152
161
|
return [scope, url, field, query, String(limit ?? ''), filter ?? ''].join('\n')
|
|
153
162
|
}
|
|
154
163
|
|
|
155
|
-
|
|
164
|
+
/** Org + branch of the host session: part of every options cache key. */
|
|
165
|
+
export function optionsScope(): string {
|
|
156
166
|
if (typeof localStorage === 'undefined') return ''
|
|
157
167
|
let org = ''
|
|
158
168
|
try {
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
/** Búsqueda async con debounce y cancelación. `search` debe ser estable (useCallback). */
|
|
2
|
-
export declare function useAsyncSearch<T>(query: string, search: (q: string, signal: AbortSignal) => Promise<T[]>, opts?: {
|
|
3
|
-
minChars?: number;
|
|
4
|
-
delay?: number;
|
|
5
|
-
}): {
|
|
6
|
-
results: T[];
|
|
7
|
-
loading: boolean;
|
|
8
|
-
error: boolean;
|
|
9
|
-
};
|
|
10
|
-
//# sourceMappingURL=use-async-search.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"use-async-search.d.ts","sourceRoot":"","sources":["../../src/business/use-async-search.ts"],"names":[],"mappings":"AAGA,0FAA0F;AAC1F,wBAAgB,cAAc,CAAC,CAAC,EAC5B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,EAAE,CAAC,EACxD,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,GACjD;IAAE,OAAO,EAAE,CAAC,EAAE,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,CAkCpD"}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react';
|
|
2
|
-
import { useDebouncedValue } from '../use-debounced-value';
|
|
3
|
-
/** Búsqueda async con debounce y cancelación. `search` debe ser estable (useCallback). */
|
|
4
|
-
export function useAsyncSearch(query, search, opts = {}) {
|
|
5
|
-
const { minChars = 2, delay = 250 } = opts;
|
|
6
|
-
const q = useDebouncedValue(query.trim(), delay);
|
|
7
|
-
const [results, setResults] = useState([]);
|
|
8
|
-
const [loading, setLoading] = useState(false);
|
|
9
|
-
const [error, setError] = useState(false);
|
|
10
|
-
useEffect(() => {
|
|
11
|
-
if (q.length < minChars) {
|
|
12
|
-
setResults([]);
|
|
13
|
-
setLoading(false);
|
|
14
|
-
setError(false);
|
|
15
|
-
return;
|
|
16
|
-
}
|
|
17
|
-
const ctrl = new AbortController();
|
|
18
|
-
setLoading(true);
|
|
19
|
-
setError(false);
|
|
20
|
-
search(q, ctrl.signal)
|
|
21
|
-
.then((rows) => {
|
|
22
|
-
if (!ctrl.signal.aborted)
|
|
23
|
-
setResults(rows);
|
|
24
|
-
})
|
|
25
|
-
.catch(() => {
|
|
26
|
-
if (!ctrl.signal.aborted) {
|
|
27
|
-
setResults([]);
|
|
28
|
-
setError(true);
|
|
29
|
-
}
|
|
30
|
-
})
|
|
31
|
-
.finally(() => {
|
|
32
|
-
if (!ctrl.signal.aborted)
|
|
33
|
-
setLoading(false);
|
|
34
|
-
});
|
|
35
|
-
return () => ctrl.abort();
|
|
36
|
-
}, [q, search, minChars]);
|
|
37
|
-
return { results, loading, error };
|
|
38
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { useEffect, useState } from 'react'
|
|
2
|
-
import { useDebouncedValue } from '../use-debounced-value'
|
|
3
|
-
|
|
4
|
-
/** Búsqueda async con debounce y cancelación. `search` debe ser estable (useCallback). */
|
|
5
|
-
export function useAsyncSearch<T>(
|
|
6
|
-
query: string,
|
|
7
|
-
search: (q: string, signal: AbortSignal) => Promise<T[]>,
|
|
8
|
-
opts: { minChars?: number; delay?: number } = {},
|
|
9
|
-
): { results: T[]; loading: boolean; error: boolean } {
|
|
10
|
-
const { minChars = 2, delay = 250 } = opts
|
|
11
|
-
const q = useDebouncedValue(query.trim(), delay)
|
|
12
|
-
const [results, setResults] = useState<T[]>([])
|
|
13
|
-
const [loading, setLoading] = useState(false)
|
|
14
|
-
const [error, setError] = useState(false)
|
|
15
|
-
|
|
16
|
-
useEffect(() => {
|
|
17
|
-
if (q.length < minChars) {
|
|
18
|
-
setResults([])
|
|
19
|
-
setLoading(false)
|
|
20
|
-
setError(false)
|
|
21
|
-
return
|
|
22
|
-
}
|
|
23
|
-
const ctrl = new AbortController()
|
|
24
|
-
setLoading(true)
|
|
25
|
-
setError(false)
|
|
26
|
-
search(q, ctrl.signal)
|
|
27
|
-
.then((rows) => {
|
|
28
|
-
if (!ctrl.signal.aborted) setResults(rows)
|
|
29
|
-
})
|
|
30
|
-
.catch(() => {
|
|
31
|
-
if (!ctrl.signal.aborted) {
|
|
32
|
-
setResults([])
|
|
33
|
-
setError(true)
|
|
34
|
-
}
|
|
35
|
-
})
|
|
36
|
-
.finally(() => {
|
|
37
|
-
if (!ctrl.signal.aborted) setLoading(false)
|
|
38
|
-
})
|
|
39
|
-
return () => ctrl.abort()
|
|
40
|
-
}, [q, search, minChars])
|
|
41
|
-
|
|
42
|
-
return { results, loading, error }
|
|
43
|
-
}
|