@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/business/document-editor-model.d.ts +27 -2
  3. package/dist/business/document-editor-model.d.ts.map +1 -1
  4. package/dist/business/document-editor-model.js +53 -3
  5. package/dist/business/document-editor.d.ts +7 -2
  6. package/dist/business/document-editor.d.ts.map +1 -1
  7. package/dist/business/document-editor.js +116 -21
  8. package/dist/business/document-lines.d.ts +21 -1
  9. package/dist/business/document-lines.d.ts.map +1 -1
  10. package/dist/business/document-lines.js +69 -7
  11. package/dist/business/line-items-editor.d.ts +21 -1
  12. package/dist/business/line-items-editor.d.ts.map +1 -1
  13. package/dist/business/line-items-editor.js +74 -15
  14. package/dist/business/line-items.d.ts +28 -0
  15. package/dist/business/line-items.d.ts.map +1 -1
  16. package/dist/business/line-items.js +1 -0
  17. package/dist/business/line-product-cell.d.ts +6 -0
  18. package/dist/business/line-product-cell.d.ts.map +1 -1
  19. package/dist/business/line-product-cell.js +9 -3
  20. package/dist/business/product-options.js +1 -1
  21. package/dist/dialogs/dynamic-record.js +1 -1
  22. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  23. package/dist/dynamic-multi-select-field.js +22 -4
  24. package/dist/dynamic-select-field.d.ts +2 -0
  25. package/dist/dynamic-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-select-field.js +42 -15
  27. package/dist/field-grid.d.ts +4 -3
  28. package/dist/field-grid.d.ts.map +1 -1
  29. package/dist/field-grid.js +1 -1
  30. package/dist/index.d.ts +2 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +2 -0
  33. package/dist/query-batch.d.ts.map +1 -1
  34. package/dist/query-batch.js +4 -0
  35. package/dist/record-picker.d.ts +2 -0
  36. package/dist/record-picker.d.ts.map +1 -1
  37. package/dist/record-picker.js +11 -4
  38. package/dist/use-option-ids.d.ts +34 -0
  39. package/dist/use-option-ids.d.ts.map +1 -0
  40. package/dist/use-option-ids.js +138 -0
  41. package/dist/use-options-resolver.d.ts +4 -0
  42. package/dist/use-options-resolver.d.ts.map +1 -1
  43. package/dist/use-options-resolver.js +10 -1
  44. package/package.json +1 -1
  45. package/src/__tests__/document-editor-inline-selects.test.tsx +11 -4
  46. package/src/__tests__/edit-field-polish.test.tsx +6 -3
  47. package/src/__tests__/line-row-create-edit.test.tsx +312 -0
  48. package/src/__tests__/option-ids.test.tsx +181 -0
  49. package/src/business/document-editor-model.ts +60 -2
  50. package/src/business/document-editor.tsx +132 -17
  51. package/src/business/document-lines.ts +79 -7
  52. package/src/business/line-items-editor.tsx +158 -30
  53. package/src/business/line-items.ts +31 -0
  54. package/src/business/line-product-cell.tsx +32 -5
  55. package/src/business/product-options.tsx +1 -1
  56. package/src/dialogs/dynamic-record.tsx +1 -1
  57. package/src/dynamic-multi-select-field.tsx +20 -8
  58. package/src/dynamic-select-field.tsx +51 -17
  59. package/src/field-grid.tsx +5 -4
  60. package/src/index.ts +8 -0
  61. package/src/query-batch.ts +3 -0
  62. package/src/record-picker.tsx +16 -3
  63. package/src/use-option-ids.ts +168 -0
  64. 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="22rem"
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.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
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={t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}
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="block truncate font-medium">{productHitLabel(hit)}</span>
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-xs font-medium text-muted-foreground uppercase tracking-wide">
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 selectedItems = useMemo(
49
- () =>
50
- selected
51
- .map((id) => options.find((o) => String(o.id) === id))
52
- .filter((o): o is ResolvedOption => !!o),
53
- [selected, options],
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-mode caveat: resolving an EXISTING value's label requires the id to be
22
- // in a fetched page (we match by id against loaded options, else show the raw
23
- // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
24
- // case — start empty and never hit this.
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
- // optionsConfig.source → `/options/<source>`. Else searchEndpoint only
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; a readonly cell fetches eagerly so its label
184
- // resolves to the name. Blocked cascades and static lists never fetch.
185
- enabled: !useStatic && (open || readonly) && !blockedByDependency,
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 selectedOption =
212
- (picked && String(picked.id) === String(value) ? picked : null) ??
213
- options.find((o) => String(o.id) === String(value)) ??
214
- (seedOption && String(seedOption.id) === String(value) ? seedOption : null) ??
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
- const selectedLabel = selectedOption?.label ?? (value ? String(value) : '')
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
- {/* Never flash the raw id while the eager fetch resolves. */}
300
- {selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
334
+ {selectedOption?.label ?? ph('—')}
301
335
  </span>
302
336
  </>
303
337
  )
@@ -42,15 +42,16 @@ export function FieldCell({
42
42
  export function FieldLabel({
43
43
  htmlFor,
44
44
  required,
45
- tone = 'caps',
45
+ tone = 'sentence',
46
46
  children,
47
47
  }: {
48
48
  htmlFor?: string
49
49
  required?: boolean
50
50
  /**
51
- * `caps` (default): small uppercase eyebrow of the CRUD modals. `sentence`:
52
- * sentence-case label for dense editors (DocumentEditor) where a column of
53
- * uppercase labels reads as shouting.
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,
@@ -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],
@@ -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 className="max-h-72 overflow-y-auto overflow-x-hidden overscroll-contain" data-slot="record-picker-list">
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="truncate">{getLabel(item)}</span>
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: `max(var(--radix-popover-trigger-width), ${minListWidth})`,
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
- 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 {