@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.
Files changed (86) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/business/customer-picker.d.ts +8 -2
  3. package/dist/business/customer-picker.d.ts.map +1 -1
  4. package/dist/business/customer-picker.js +30 -27
  5. package/dist/business/index.d.ts +1 -0
  6. package/dist/business/index.d.ts.map +1 -1
  7. package/dist/business/index.js +1 -0
  8. package/dist/business/line-product-cell.d.ts +8 -10
  9. package/dist/business/line-product-cell.d.ts.map +1 -1
  10. package/dist/business/line-product-cell.js +13 -90
  11. package/dist/business/product-options.d.ts +41 -0
  12. package/dist/business/product-options.d.ts.map +1 -0
  13. package/dist/business/product-options.js +58 -0
  14. package/dist/business/product-picker.d.ts +5 -1
  15. package/dist/business/product-picker.d.ts.map +1 -1
  16. package/dist/business/product-picker.js +26 -35
  17. package/dist/business/relate-documents.d.ts +1 -1
  18. package/dist/business/relate-documents.d.ts.map +1 -1
  19. package/dist/business/relate-documents.js +6 -8
  20. package/dist/business/vehicle-picker.d.ts +8 -2
  21. package/dist/business/vehicle-picker.d.ts.map +1 -1
  22. package/dist/business/vehicle-picker.js +28 -28
  23. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  24. package/dist/dialogs/dynamic-record.js +28 -43
  25. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-multi-select-field.js +36 -6
  27. package/dist/dynamic-select-field.d.ts +9 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +105 -172
  30. package/dist/entity-select.d.ts +5 -2
  31. package/dist/entity-select.d.ts.map +1 -1
  32. package/dist/entity-select.js +23 -116
  33. package/dist/index.d.ts +9 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -1
  36. package/dist/query-batch.d.ts.map +1 -1
  37. package/dist/query-batch.js +4 -0
  38. package/dist/record-picker-actions.d.ts +40 -2
  39. package/dist/record-picker-actions.d.ts.map +1 -1
  40. package/dist/record-picker-actions.js +44 -4
  41. package/dist/record-picker-dialog.d.ts +23 -0
  42. package/dist/record-picker-dialog.d.ts.map +1 -0
  43. package/dist/record-picker-dialog.js +37 -0
  44. package/dist/record-picker-option.d.ts +25 -0
  45. package/dist/record-picker-option.d.ts.map +1 -0
  46. package/dist/record-picker-option.js +55 -0
  47. package/dist/record-picker.d.ts +149 -0
  48. package/dist/record-picker.d.ts.map +1 -0
  49. package/dist/record-picker.js +372 -0
  50. package/dist/use-option-ids.d.ts +34 -0
  51. package/dist/use-option-ids.d.ts.map +1 -0
  52. package/dist/use-option-ids.js +138 -0
  53. package/dist/use-options-resolver.d.ts +4 -0
  54. package/dist/use-options-resolver.d.ts.map +1 -1
  55. package/dist/use-options-resolver.js +10 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/business-pickers.test.tsx +6 -3
  58. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  59. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  60. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  61. package/src/__tests__/edit-field-polish.test.tsx +6 -3
  62. package/src/__tests__/option-ids.test.tsx +181 -0
  63. package/src/__tests__/record-picker.test.tsx +469 -0
  64. package/src/business/customer-picker.tsx +87 -98
  65. package/src/business/index.ts +1 -0
  66. package/src/business/line-product-cell.tsx +55 -180
  67. package/src/business/product-options.tsx +119 -0
  68. package/src/business/product-picker.tsx +63 -126
  69. package/src/business/relate-documents.tsx +36 -45
  70. package/src/business/vehicle-picker.tsx +76 -99
  71. package/src/dialogs/dynamic-record.tsx +44 -97
  72. package/src/dynamic-multi-select-field.tsx +53 -15
  73. package/src/dynamic-select-field.tsx +188 -379
  74. package/src/entity-select.tsx +53 -202
  75. package/src/index.ts +35 -2
  76. package/src/query-batch.ts +3 -0
  77. package/src/record-picker-actions.tsx +101 -8
  78. package/src/record-picker-dialog.tsx +80 -0
  79. package/src/record-picker-option.tsx +96 -0
  80. package/src/record-picker.tsx +971 -0
  81. package/src/use-option-ids.ts +168 -0
  82. package/src/use-options-resolver.ts +11 -1
  83. package/dist/business/use-async-search.d.ts +0 -10
  84. package/dist/business/use-async-search.d.ts.map +0 -1
  85. package/dist/business/use-async-search.js +0 -38
  86. 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
- function optionsScope(): string {
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
- }