@asteby/metacore-runtime-react 49.6.0 → 49.8.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/document-editor-model.d.ts +27 -2
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +53 -3
- package/dist/business/document-editor.d.ts +7 -2
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +116 -21
- package/dist/business/document-lines.d.ts +21 -1
- package/dist/business/document-lines.d.ts.map +1 -1
- package/dist/business/document-lines.js +69 -7
- package/dist/business/line-items-editor.d.ts +21 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +74 -15
- package/dist/business/line-items.d.ts +28 -0
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +1 -0
- package/dist/business/line-product-cell.d.ts +6 -0
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +9 -3
- package/dist/business/product-options.js +1 -1
- package/dist/dialogs/dynamic-record.js +1 -1
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +22 -4
- package/dist/dynamic-select-field.d.ts +2 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +42 -15
- package/dist/field-grid.d.ts +4 -3
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +4 -0
- package/dist/record-picker.d.ts +2 -0
- package/dist/record-picker.d.ts.map +1 -1
- package/dist/record-picker.js +11 -4
- 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__/document-editor-inline-selects.test.tsx +11 -4
- package/src/__tests__/edit-field-polish.test.tsx +6 -3
- package/src/__tests__/line-row-create-edit.test.tsx +312 -0
- package/src/__tests__/option-ids.test.tsx +181 -0
- package/src/business/document-editor-model.ts +60 -2
- package/src/business/document-editor.tsx +132 -17
- package/src/business/document-lines.ts +79 -7
- package/src/business/line-items-editor.tsx +158 -30
- package/src/business/line-items.ts +31 -0
- package/src/business/line-product-cell.tsx +32 -5
- package/src/business/product-options.tsx +1 -1
- package/src/dialogs/dynamic-record.tsx +1 -1
- package/src/dynamic-multi-select-field.tsx +20 -8
- package/src/dynamic-select-field.tsx +51 -17
- package/src/field-grid.tsx +5 -4
- package/src/index.ts +8 -0
- package/src/query-batch.ts +3 -0
- package/src/record-picker.tsx +16 -3
- package/src/use-option-ids.ts +168 -0
- package/src/use-options-resolver.ts +11 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// guardado de renglones fallaba (PIT-018) por mandar al API cadenas vacías y
|
|
4
4
|
// claves de UI; `serializeLineItems` es la única puerta de salida hacia el backend.
|
|
5
5
|
import { roundMoney, toAmount } from './format'
|
|
6
|
+
import type { ProductVariant } from './product-search'
|
|
6
7
|
|
|
7
8
|
export type LineItemKind = 'item' | 'section' | 'note'
|
|
8
9
|
|
|
@@ -50,6 +51,36 @@ export interface LineItem {
|
|
|
50
51
|
* del host) — el SDK no conoce sus claves.
|
|
51
52
|
*/
|
|
52
53
|
extensions?: Record<string, string>
|
|
54
|
+
/**
|
|
55
|
+
* Valores que el catálogo escribió en el renglón la última vez que se
|
|
56
|
+
* aplicó el producto (elegirlo, crearlo o editarlo desde la celda). Un campo
|
|
57
|
+
* cuyo valor actual difiere de su foto es un override del usuario («dirty»)
|
|
58
|
+
* y una edición del producto no lo pisa. No se serializa.
|
|
59
|
+
*/
|
|
60
|
+
catalog?: LineCatalogSnapshot
|
|
61
|
+
/**
|
|
62
|
+
* Valores nuevos del catálogo que NO se aplicaron porque el usuario ya
|
|
63
|
+
* había cambiado ese campo a mano: la celda ofrece «aplicar». No se serializa.
|
|
64
|
+
*/
|
|
65
|
+
catalog_pending?: Partial<Pick<LineItem, CatalogLineField>>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Campos del renglón que salen del producto y el usuario puede sobrescribir. */
|
|
69
|
+
export type CatalogLineField = 'description' | 'sku' | 'unit_price' | 'tax_rate' | 'unit'
|
|
70
|
+
|
|
71
|
+
export const CATALOG_LINE_FIELDS: readonly CatalogLineField[] = ['description', 'sku', 'unit_price', 'tax_rate', 'unit']
|
|
72
|
+
|
|
73
|
+
/** Foto de lo que el catálogo escribió en el renglón (por campo) y de qué producto/variante. */
|
|
74
|
+
export interface LineCatalogSnapshot {
|
|
75
|
+
description?: string
|
|
76
|
+
sku?: string
|
|
77
|
+
unit_price?: number
|
|
78
|
+
tax_rate?: number
|
|
79
|
+
unit?: string
|
|
80
|
+
/** Id del producto del catálogo (el registro que se edita), aunque el renglón lleve una variante. */
|
|
81
|
+
product_ref?: string
|
|
82
|
+
/** Variante elegida: al refrescar se vuelve a aplicar la misma. */
|
|
83
|
+
variant?: ProductVariant
|
|
53
84
|
}
|
|
54
85
|
|
|
55
86
|
/** Qué hacer si `quantity > available`. */
|
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
|
|
9
9
|
// cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
|
|
10
10
|
// descripción libre (el renglón libre sigue funcionando igual).
|
|
11
|
+
//
|
|
12
|
+
// Con `onCreate`, la celda ofrece crear el producto como las apps top: «+» unido
|
|
13
|
+
// al buscador y «Crear producto «texto»» al pie de la lista, prellenado con lo
|
|
14
|
+
// buscado. El grid llena el renglón con el producto nuevo al guardarlo.
|
|
11
15
|
import { forwardRef, useState, type KeyboardEvent } from 'react'
|
|
12
16
|
import { useTranslation } from 'react-i18next'
|
|
13
17
|
import { RecordPicker } from '../record-picker'
|
|
@@ -47,10 +51,16 @@ export interface LineProductCellProps {
|
|
|
47
51
|
className?: string
|
|
48
52
|
/** Atributos de datos para que el grid ubique la celda (foco por teclado). */
|
|
49
53
|
dataCell?: string
|
|
54
|
+
/** Alta del producto desde la celda, prellenada con lo buscado. */
|
|
55
|
+
onCreate?: (query: string) => void
|
|
56
|
+
/** Nombre del modelo para «Crear …» (default «producto»). */
|
|
57
|
+
entityLabel?: string
|
|
58
|
+
/** Detalle de qué se puede buscar: tooltip del buscador y estado sin coincidencias. */
|
|
59
|
+
hint?: string
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps>(function LineProductCell(
|
|
53
|
-
{ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell },
|
|
63
|
+
{ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell, onCreate, entityLabel, hint },
|
|
54
64
|
ref,
|
|
55
65
|
) {
|
|
56
66
|
const { t } = useTranslation()
|
|
@@ -58,6 +68,8 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
|
|
|
58
68
|
// capturado no despliega «sin resultados» encima de la tabla.
|
|
59
69
|
const [typing, setTyping] = useState(false)
|
|
60
70
|
const { hits, loading, minChars } = useProductSearch(typing ? text : '', search, typing)
|
|
71
|
+
const entity = entityLabel ?? t('lineItems.productEntity', { defaultValue: 'producto' })
|
|
72
|
+
const searchHint = hint ?? t('lineItems.searchHint', { defaultValue: 'Busca por nombre, SKU o código de barras' })
|
|
61
73
|
|
|
62
74
|
return (
|
|
63
75
|
<RecordPicker<ProductHit>
|
|
@@ -66,7 +78,8 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
|
|
|
66
78
|
freeText
|
|
67
79
|
anchorSlot="line-product-cell"
|
|
68
80
|
contentSlot="line-product-results"
|
|
69
|
-
minListWidth="
|
|
81
|
+
minListWidth="26.25rem"
|
|
82
|
+
maxListWidth="35rem"
|
|
70
83
|
items={hits}
|
|
71
84
|
loading={loading}
|
|
72
85
|
minChars={minChars}
|
|
@@ -85,10 +98,24 @@ export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps
|
|
|
85
98
|
disabled={disabled}
|
|
86
99
|
invalid={invalid}
|
|
87
100
|
triggerClassName={className}
|
|
88
|
-
triggerProps={{ 'data-cell': dataCell }}
|
|
89
|
-
placeholder={placeholder ?? t('lineItems.
|
|
101
|
+
triggerProps={{ 'data-cell': dataCell, title: searchHint }}
|
|
102
|
+
placeholder={placeholder ?? t('lineItems.searchPlaceholderShort', { defaultValue: 'Buscar producto…' })}
|
|
90
103
|
loadingText={t('common.searching', { defaultValue: 'Buscando…' })}
|
|
91
|
-
emptyText={
|
|
104
|
+
emptyText={
|
|
105
|
+
<span className="block space-y-1">
|
|
106
|
+
<span className="block">{t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}</span>
|
|
107
|
+
<span className="block text-xs">{searchHint}</span>
|
|
108
|
+
</span>
|
|
109
|
+
}
|
|
110
|
+
onCreate={onCreate}
|
|
111
|
+
entityLabel={entity}
|
|
112
|
+
createLabel={t('lineItems.createProduct', { defaultValue: 'Crear {{entity}}', entity })}
|
|
113
|
+
createFooterLabel={(q) => (
|
|
114
|
+
<>
|
|
115
|
+
{t('lineItems.createProduct', { defaultValue: 'Crear {{entity}}', entity })}
|
|
116
|
+
{q ? <span className="text-muted-foreground"> «{q}»</span> : null}
|
|
117
|
+
</>
|
|
118
|
+
)}
|
|
92
119
|
/>
|
|
93
120
|
)
|
|
94
121
|
})
|
|
@@ -108,7 +108,7 @@ export function ProductHitRow({ hit, warehouseId, currency, compact }: { hit: Pr
|
|
|
108
108
|
return (
|
|
109
109
|
<span className="flex w-full min-w-0 items-center justify-between gap-3">
|
|
110
110
|
<span className="min-w-0">
|
|
111
|
-
<span className="
|
|
111
|
+
<span className="line-clamp-2 break-words font-medium" title={productHitLabel(hit)}>{productHitLabel(hit)}</span>
|
|
112
112
|
{meta && <span className="block truncate text-xs text-muted-foreground">{meta}</span>}
|
|
113
113
|
</span>
|
|
114
114
|
<span className="flex shrink-0 items-center gap-2">
|
|
@@ -1334,7 +1334,7 @@ function FieldRow({ field, record, value, mode, onChange, error, locked }: Field
|
|
|
1334
1334
|
|
|
1335
1335
|
return (
|
|
1336
1336
|
<div className="flex flex-col gap-1.5" data-aby-field={field.key}>
|
|
1337
|
-
<Label className="text-
|
|
1337
|
+
<Label className="text-sm font-medium text-foreground/80">
|
|
1338
1338
|
{localizeFieldLabel(field.label, t)}
|
|
1339
1339
|
{field.required && mode !== 'view' && !isEditReadonly && (
|
|
1340
1340
|
<span className="text-destructive ml-0.5">*</span>
|
|
@@ -17,11 +17,17 @@
|
|
|
17
17
|
// (segments, tags, categories — tens, not thousands of rows); a field with a
|
|
18
18
|
// genuinely large option set should keep using a single dynamic_select per
|
|
19
19
|
// value instead.
|
|
20
|
+
//
|
|
21
|
+
// Selected ids outside that page (or hidden by an option_filter) are labelled
|
|
22
|
+
// with ONE `?ids=` lookup (useResolveOptionIds): a chip reads "Cargando…" while
|
|
23
|
+
// it resolves and "(registro eliminado)" when the record is gone — never the id.
|
|
20
24
|
import { useMemo, useState } from 'react'
|
|
21
25
|
import { getOptionFilter } from './option-filter'
|
|
22
26
|
import { RecordPicker } from './record-picker'
|
|
23
27
|
import { OptionLead } from './record-picker-option'
|
|
24
28
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
29
|
+
import { useResolveOptionIds } from './use-option-ids'
|
|
30
|
+
import { DELETED_RECORD_LABEL } from './dynamic-select-field'
|
|
25
31
|
import { getFieldRef } from './dynamic-form-schema'
|
|
26
32
|
import type { ActionFieldDef } from './types'
|
|
27
33
|
|
|
@@ -35,7 +41,7 @@ export interface DynamicMultiSelectFieldProps {
|
|
|
35
41
|
export function DynamicMultiSelectField({ field, value, onChange }: DynamicMultiSelectFieldProps) {
|
|
36
42
|
const ref = getFieldRef(field)
|
|
37
43
|
const [query, setQuery] = useState('')
|
|
38
|
-
const { options, loading } = useOptionsResolver({
|
|
44
|
+
const { options, loading, meta, error } = useOptionsResolver({
|
|
39
45
|
modelKey: '',
|
|
40
46
|
fieldKey: 'id',
|
|
41
47
|
ref,
|
|
@@ -45,13 +51,19 @@ export function DynamicMultiSelectField({ field, value, onChange }: DynamicMulti
|
|
|
45
51
|
})
|
|
46
52
|
|
|
47
53
|
const selected = useMemo(() => (Array.isArray(value) ? value.map(String) : []), [value])
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
const endpoint = !ref && field.searchEndpoint ? field.searchEndpoint : undefined
|
|
55
|
+
// Ask for the ids the loaded page does not cover — only once it is in.
|
|
56
|
+
const pageSettled = !loading && (meta !== null || error !== null)
|
|
57
|
+
const unresolved = pageSettled ? selected.filter((id) => !options.some((o) => String(o.id) === id)) : []
|
|
58
|
+
const { resolved } = useResolveOptionIds({ ref, endpoint, field: 'id', ids: unresolved, enabled: unresolved.length > 0 })
|
|
59
|
+
const selectedItems = selected.map((id): ResolvedOption => {
|
|
60
|
+
const loaded = options.find((o) => String(o.id) === id)
|
|
61
|
+
if (loaded) return loaded
|
|
62
|
+
const r = pageSettled ? resolved.get(id) : undefined
|
|
63
|
+
if (r?.status === 'found') return r.option
|
|
64
|
+
const label = !r ? 'Cargando…' : r.status === 'missing' ? DELETED_RECORD_LABEL : id
|
|
65
|
+
return { id, value: id, label, name: label }
|
|
66
|
+
})
|
|
55
67
|
const shown = useMemo(() => {
|
|
56
68
|
const q = query.trim().toLowerCase()
|
|
57
69
|
return q ? options.filter((o) => String(o.label ?? '').toLowerCase().includes(q)) : options
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
// 1. field.ref → /options/<ref>?field=id (canonical, preferred)
|
|
19
19
|
// 2. field.searchEndpoint→ used verbatim as the options endpoint (escape hatch)
|
|
20
20
|
//
|
|
21
|
-
// Edit
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
21
|
+
// Edit mode: an EXISTING value is labelled from (in order) the option the user
|
|
22
|
+
// just picked, the loaded page, the caller's `seedOption`, and otherwise one
|
|
23
|
+
// `?ids=` lookup (useResolveOptionIds — batched and shared with every picker of
|
|
24
|
+
// the same ref on screen). While it resolves the trigger reads "Cargando…",
|
|
25
|
+
// never the raw id; an id that no longer exists reads "(registro eliminado)".
|
|
25
26
|
import { useEffect, useRef, useState } from 'react'
|
|
26
27
|
import { getOptionFilter } from './option-filter'
|
|
27
28
|
import { useTranslation } from 'react-i18next'
|
|
@@ -32,6 +33,7 @@ import { RecordPicker } from './record-picker'
|
|
|
32
33
|
import { OptionLead } from './record-picker-option'
|
|
33
34
|
import { recordLabel, requestRecordCreate, requestRecordEdit, withSearchPrefill } from './record-picker-actions'
|
|
34
35
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
36
|
+
import { useResolveOptionIds } from './use-option-ids'
|
|
35
37
|
import { useDebouncedValue } from './use-debounced-value'
|
|
36
38
|
import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
|
|
37
39
|
import type { ActionFieldDef } from './types'
|
|
@@ -45,6 +47,9 @@ export { OptionLead, OptionThumb } from './record-picker-option'
|
|
|
45
47
|
*/
|
|
46
48
|
export const DEFAULT_DEPENDS_HINT = 'Selecciona primero el campo del que depende'
|
|
47
49
|
|
|
50
|
+
/** Label of a held value whose record no longer exists (or is not visible). */
|
|
51
|
+
export const DELETED_RECORD_LABEL = '(registro eliminado)'
|
|
52
|
+
|
|
48
53
|
export interface DynamicSelectFieldProps {
|
|
49
54
|
field: ActionFieldDef
|
|
50
55
|
value: any
|
|
@@ -168,21 +173,23 @@ export function DynamicSelectField({
|
|
|
168
173
|
const useStatic = Array.isArray(staticOptions)
|
|
169
174
|
const optionFilter = getOptionFilter(field)
|
|
170
175
|
|
|
176
|
+
// optionsConfig.source → `/options/<source>`. Else searchEndpoint only
|
|
177
|
+
// drives the URL when there's no ref (ref is canonical and wins).
|
|
178
|
+
const optionsEndpoint = source.endpoint ?? (source.ref ? undefined : field.searchEndpoint)
|
|
171
179
|
const { options: fetchedOptions, loading: fetchLoading } = useOptionsResolver({
|
|
172
180
|
modelKey: '',
|
|
173
181
|
fieldKey: source.fieldKey,
|
|
174
182
|
ref: source.ref,
|
|
175
|
-
|
|
176
|
-
// drives the URL when there's no ref (ref is canonical and wins).
|
|
177
|
-
endpoint: source.endpoint ?? (source.ref ? undefined : field.searchEndpoint),
|
|
183
|
+
endpoint: optionsEndpoint,
|
|
178
184
|
query: debounced,
|
|
179
185
|
limit: 20,
|
|
180
186
|
filterValue: dependsOn ? scope : undefined,
|
|
181
187
|
optionFilter,
|
|
182
188
|
keepValue: value,
|
|
183
|
-
// Fetch only while open
|
|
184
|
-
//
|
|
185
|
-
|
|
189
|
+
// Fetch only while open (a held value's label comes from the `?ids=`
|
|
190
|
+
// lookup below, readonly included). Blocked cascades and static lists
|
|
191
|
+
// never fetch.
|
|
192
|
+
enabled: !useStatic && open && !blockedByDependency,
|
|
186
193
|
})
|
|
187
194
|
|
|
188
195
|
const options = useStatic
|
|
@@ -208,12 +215,37 @@ export function DynamicSelectField({
|
|
|
208
215
|
}
|
|
209
216
|
}, [dependsOn, scope, value, onChange])
|
|
210
217
|
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
(
|
|
218
|
+
const valueKey = value != null && value !== '' ? String(value) : ''
|
|
219
|
+
const knownOption =
|
|
220
|
+
(picked && String(picked.id) === valueKey ? picked : null) ??
|
|
221
|
+
options.find((o) => String(o.id) === valueKey) ??
|
|
222
|
+
(seedOption && String(seedOption.id) === valueKey ? seedOption : null) ??
|
|
215
223
|
null
|
|
216
|
-
|
|
224
|
+
// A held value nobody has labelled yet: one `?ids=` lookup (no popover).
|
|
225
|
+
const needsLookup = !!valueKey && !knownOption && !useStatic
|
|
226
|
+
const { resolved, loading: idsLoading } = useResolveOptionIds({
|
|
227
|
+
ref: source.ref,
|
|
228
|
+
endpoint: optionsEndpoint,
|
|
229
|
+
field: source.fieldKey,
|
|
230
|
+
ids: needsLookup ? [valueKey] : [],
|
|
231
|
+
enabled: needsLookup,
|
|
232
|
+
})
|
|
233
|
+
const resolution = needsLookup ? resolved.get(valueKey) : undefined
|
|
234
|
+
const selectedOption = knownOption ?? (resolution?.status === 'found' ? resolution.option : null)
|
|
235
|
+
const valueResolving = needsLookup && !selectedOption && idsLoading
|
|
236
|
+
const valueMissing = needsLookup && !selectedOption && resolution?.status === 'missing'
|
|
237
|
+
const selectedLabel =
|
|
238
|
+
selectedOption?.label ?? (valueResolving ? '' : valueMissing ? DELETED_RECORD_LABEL : valueKey)
|
|
239
|
+
const resolvingNode = (
|
|
240
|
+
<span className="min-w-0 flex-1 truncate animate-pulse text-muted-foreground" data-slot="record-picker-value-loading">
|
|
241
|
+
Cargando…
|
|
242
|
+
</span>
|
|
243
|
+
)
|
|
244
|
+
const missingNode = (
|
|
245
|
+
<span className="min-w-0 flex-1 truncate italic text-muted-foreground" data-slot="record-picker-value-missing">
|
|
246
|
+
{DELETED_RECORD_LABEL}
|
|
247
|
+
</span>
|
|
248
|
+
)
|
|
217
249
|
|
|
218
250
|
const handlePick = (opt: ResolvedOption) => {
|
|
219
251
|
setPicked(opt)
|
|
@@ -291,13 +323,15 @@ export function DynamicSelectField({
|
|
|
291
323
|
getDescription={descriptionAsBadge ? undefined : (o) => o.description}
|
|
292
324
|
renderTrailing={descriptionAsBadge ? (o) => badge(o) : undefined}
|
|
293
325
|
renderValue={() => {
|
|
326
|
+
// Never flash the raw id while the label resolves.
|
|
327
|
+
if (valueResolving) return resolvingNode
|
|
328
|
+
if (valueMissing) return missingNode
|
|
294
329
|
if (readonly) {
|
|
295
330
|
return (
|
|
296
331
|
<>
|
|
297
332
|
{selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
|
|
298
333
|
<span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
|
|
299
|
-
{
|
|
300
|
-
{selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
|
|
334
|
+
{selectedOption?.label ?? ph('—')}
|
|
301
335
|
</span>
|
|
302
336
|
</>
|
|
303
337
|
)
|
package/src/field-grid.tsx
CHANGED
|
@@ -42,15 +42,16 @@ export function FieldCell({
|
|
|
42
42
|
export function FieldLabel({
|
|
43
43
|
htmlFor,
|
|
44
44
|
required,
|
|
45
|
-
tone = '
|
|
45
|
+
tone = 'sentence',
|
|
46
46
|
children,
|
|
47
47
|
}: {
|
|
48
48
|
htmlFor?: string
|
|
49
49
|
required?: boolean
|
|
50
50
|
/**
|
|
51
|
-
* `
|
|
52
|
-
*
|
|
53
|
-
* uppercase labels
|
|
51
|
+
* `sentence` (default): sentence-case label — every form of the runtime
|
|
52
|
+
* (CRUD modal, action modal, DocumentEditor) reads the same and a column of
|
|
53
|
+
* uppercase labels no longer shouts. `caps`: small uppercase eyebrow, for
|
|
54
|
+
* callers that still want it.
|
|
54
55
|
*/
|
|
55
56
|
tone?: 'caps' | 'sentence'
|
|
56
57
|
children: ReactNode
|
package/src/index.ts
CHANGED
|
@@ -588,6 +588,14 @@ export {
|
|
|
588
588
|
type UseOptionsResolverArgs,
|
|
589
589
|
type UseOptionsResolverResult,
|
|
590
590
|
} from './use-options-resolver'
|
|
591
|
+
export {
|
|
592
|
+
useResolveOptionIds,
|
|
593
|
+
OPTION_IDS_CHUNK,
|
|
594
|
+
type OptionIdResolution,
|
|
595
|
+
type UseResolveOptionIdsArgs,
|
|
596
|
+
type UseResolveOptionIdsResult,
|
|
597
|
+
} from './use-option-ids'
|
|
598
|
+
export { DELETED_RECORD_LABEL } from './dynamic-select-field'
|
|
591
599
|
export {
|
|
592
600
|
setOrgConfigBridge,
|
|
593
601
|
getOrgConfigBridge,
|
package/src/query-batch.ts
CHANGED
|
@@ -375,6 +375,9 @@ export function tokenForGet(url: string, params?: Record<string, unknown>): stri
|
|
|
375
375
|
if (value != null && value !== '') search.set(key, String(value))
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
|
+
// An `?ids=` resolve is not a page: the `o:` token has no slot for it, so
|
|
379
|
+
// it goes as its own GET instead of being turned into a first-page read.
|
|
380
|
+
if (search.has('ids')) return null
|
|
378
381
|
const limit = Number(search.get('limit'))
|
|
379
382
|
return optionsBatchToken(
|
|
380
383
|
opt[1],
|
package/src/record-picker.tsx
CHANGED
|
@@ -268,6 +268,8 @@ export interface RecordPickerProps<T> {
|
|
|
268
268
|
inputRef?: Ref<HTMLInputElement>
|
|
269
269
|
/** Floor for the list width (CSS length). Default 14rem. */
|
|
270
270
|
minListWidth?: string
|
|
271
|
+
/** Ceiling for the list width (CSS length), e.g. under a very wide trigger. Default: none. */
|
|
272
|
+
maxListWidth?: string
|
|
271
273
|
/** Rendered after the joined group (e.g. a scan button). */
|
|
272
274
|
after?: ReactNode
|
|
273
275
|
/** Rendered under the control (badges, hints). */
|
|
@@ -351,6 +353,7 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
351
353
|
triggerProps,
|
|
352
354
|
inputRef,
|
|
353
355
|
minListWidth = '14rem',
|
|
356
|
+
maxListWidth,
|
|
354
357
|
after,
|
|
355
358
|
below,
|
|
356
359
|
beforeList,
|
|
@@ -555,7 +558,15 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
555
558
|
)
|
|
556
559
|
|
|
557
560
|
const list = (
|
|
558
|
-
<div
|
|
561
|
+
<div
|
|
562
|
+
className="overflow-y-auto overflow-x-hidden overscroll-contain"
|
|
563
|
+
data-slot="record-picker-list"
|
|
564
|
+
// Never taller than the room Radix measured on the side it opened
|
|
565
|
+
// (flipped above the field near the bottom of a modal it used to
|
|
566
|
+
// spill past the top of the viewport): 18rem at most, minus the
|
|
567
|
+
// search box in button mode; the list scrolls inside.
|
|
568
|
+
style={{ maxHeight: `min(18rem, calc(var(--radix-popover-content-available-height, 18rem) - ${isInput ? '2px' : '2.75rem'}))` }}
|
|
569
|
+
>
|
|
559
570
|
{beforeList}
|
|
560
571
|
{status}
|
|
561
572
|
<ul
|
|
@@ -596,7 +607,7 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
596
607
|
) : null}
|
|
597
608
|
{renderLead?.(item, 'option')}
|
|
598
609
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
599
|
-
<span className="
|
|
610
|
+
<span className="line-clamp-2 break-words">{getLabel(item)}</span>
|
|
600
611
|
{desc != null && desc !== '' ? (
|
|
601
612
|
<span className="truncate text-xs text-muted-foreground">{desc}</span>
|
|
602
613
|
) : null}
|
|
@@ -636,7 +647,9 @@ export function RecordPicker<T>(props: RecordPickerProps<T>) {
|
|
|
636
647
|
)
|
|
637
648
|
|
|
638
649
|
const contentStyle = {
|
|
639
|
-
width:
|
|
650
|
+
width: maxListWidth
|
|
651
|
+
? `min(max(var(--radix-popover-trigger-width), ${minListWidth}), ${maxListWidth})`
|
|
652
|
+
: `max(var(--radix-popover-trigger-width), ${minListWidth})`,
|
|
640
653
|
maxWidth: 'calc(100vw - 1rem)',
|
|
641
654
|
zIndex: RECORD_PICKER_Z_INDEX,
|
|
642
655
|
}
|
|
@@ -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 {
|