@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
@@ -1,4 +1,5 @@
1
1
  // DynamicSelectField — async, searchable single-select for declarative forms.
2
+ // A configuration of the shared <RecordPicker> (record-picker.tsx).
2
3
  //
3
4
  // This is the declarative answer to "I don't want to type a raw FK UUID".
4
5
  // Instead of a plain <select> that dumps every option (RefSelect) or a free
@@ -17,36 +18,28 @@
17
18
  // 1. field.ref → /options/<ref>?field=id (canonical, preferred)
18
19
  // 2. field.searchEndpoint→ used verbatim as the options endpoint (escape hatch)
19
20
  //
20
- // Edit-mode caveat: resolving an EXISTING value's label requires the id to be
21
- // in a fetched page (we match by id against loaded options, else show the raw
22
- // value). A dedicated `?ids=` lookup is a follow-up; create flows — the common
23
- // 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)".
24
26
  import { useEffect, useRef, useState } from 'react'
25
27
  import { getOptionFilter } from './option-filter'
26
28
  import { useTranslation } from 'react-i18next'
27
- import {
28
- Badge,
29
- Button,
30
- Command,
31
- CommandGroup,
32
- CommandInput,
33
- CommandItem,
34
- CommandList,
35
- Popover,
36
- PopoverContent,
37
- PopoverTrigger,
38
- InitialsAvatar,
39
- } from '@asteby/metacore-ui/primitives'
40
- import { Check, ChevronsUpDown, Loader2, ScanLine } from 'lucide-react'
41
- import { resolveColorCss } from '@asteby/metacore-ui/lib'
29
+ import { Badge, Button } from '@asteby/metacore-ui/primitives'
30
+ import { ScanLine } from 'lucide-react'
42
31
  import { BarcodeScanner } from './barcode-scanner'
43
- import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
44
- import { DynamicIcon, isLucideIconName } from './dynamic-icon'
45
- import { useDebouncedValue } from './use-debounced-value'
32
+ import { RecordPicker } from './record-picker'
33
+ import { OptionLead } from './record-picker-option'
34
+ import { recordLabel, requestRecordCreate, requestRecordEdit, withSearchPrefill } from './record-picker-actions'
46
35
  import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
36
+ import { useResolveOptionIds } from './use-option-ids'
37
+ import { useDebouncedValue } from './use-debounced-value'
47
38
  import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
48
39
  import type { ActionFieldDef } from './types'
49
40
 
41
+ export { OptionLead, OptionThumb } from './record-picker-option'
42
+
50
43
  /**
51
44
  * Default hint shown when a cascading picker's depended-on field is still
52
45
  * empty. Domain-neutral on purpose; a caller may override it per field via
@@ -54,97 +47,8 @@ import type { ActionFieldDef } from './types'
54
47
  */
55
48
  export const DEFAULT_DEPENDS_HINT = 'Selecciona primero el campo del que depende'
56
49
 
57
- /**
58
- * Small square thumbnail for an option's `image`. When the option has no image
59
- * it falls back to a deterministic initials avatar (shared `InitialsAvatar`)
60
- * derived from `name`, so an imageless reference reads as a colored badge rather
61
- * than an empty placeholder — and rows/triggers stay aligned. `size` is in
62
- * pixels (kept small — 20–24px — so the picker reads as a list, not a gallery).
63
- * Inline style for the box dimensions: arbitrary Tailwind classes from a
64
- * federated addon don't always survive the host's class scan.
65
- */
66
- export function OptionThumb({
67
- image,
68
- name,
69
- size = 20,
70
- }: {
71
- image?: string | null
72
- name?: string | null
73
- size?: number
74
- }) {
75
- const [broken, setBroken] = useState(false)
76
- const box = { width: size, height: size }
77
- // Missing or 404 image (bare avatar filenames often 404) → initials, never
78
- // an invisible box that looks like "no avatar".
79
- if (!image || broken) {
80
- return <InitialsAvatar name={name} size={size} rounded="sm" tone="neutral" />
81
- }
82
- // A lucide icon name stored where an image url/path is expected (the `icon`
83
- // form widget's icon mode) → render the glyph instead of a broken <img>.
84
- if (isLucideIconName(image)) {
85
- return (
86
- <span className="flex shrink-0 items-center justify-center rounded-sm bg-muted" style={box} aria-hidden>
87
- <DynamicIcon name={image} className="size-4" />
88
- </span>
89
- )
90
- }
91
- return (
92
- <img
93
- src={image}
94
- alt=""
95
- aria-hidden
96
- loading="lazy"
97
- className="shrink-0 rounded-sm object-cover"
98
- style={box}
99
- onError={() => setBroken(true)}
100
- />
101
- )
102
- }
103
-
104
- /**
105
- * Leading visual for an option: a photo thumbnail (FK relations with an image),
106
- * else a declared icon, else a color dot (enum/status options with a color).
107
- * Returns null when the option carries none, so plain text options stay plain.
108
- */
109
- export function OptionLead({
110
- option,
111
- size = 20,
112
- }: {
113
- option?: Pick<ResolvedOption, 'image' | 'color' | 'icon' | 'label'> | null
114
- size?: number
115
- }) {
116
- if (!option) return null
117
- if (option.image) return <OptionThumb image={option.image} name={option.label} size={size} />
118
- if (option.icon) {
119
- return (
120
- <span
121
- className="flex shrink-0 items-center justify-center"
122
- style={{ width: size, height: size, color: option.color ? resolveColorCss(option.color) : undefined }}
123
- aria-hidden
124
- >
125
- <DynamicIcon name={option.icon} className="size-4" />
126
- </span>
127
- )
128
- }
129
- if (option.color) {
130
- return (
131
- <span
132
- className="shrink-0 rounded-full"
133
- style={{ width: Math.round(size * 0.5), height: Math.round(size * 0.5), background: resolveColorCss(option.color) }}
134
- aria-hidden
135
- />
136
- )
137
- }
138
- // No image/icon/color: an imageless reference option. Show its initials
139
- // (shared InitialsAvatar) so it stays visually aligned with the sibling
140
- // options that DO carry a thumbnail, rather than a blank gap.
141
- if (option.label) return <InitialsAvatar name={option.label} size={size} rounded="sm" tone="neutral" />
142
- return null
143
- }
144
-
145
- function useDebounced<T>(value: T, ms: number): T {
146
- return useDebouncedValue(value, ms)
147
- }
50
+ /** Label of a held value whose record no longer exists (or is not visible). */
51
+ export const DELETED_RECORD_LABEL = '(registro eliminado)'
148
52
 
149
53
  export interface DynamicSelectFieldProps {
150
54
  field: ActionFieldDef
@@ -212,6 +116,12 @@ export interface DynamicSelectFieldProps {
212
116
  invalid?: boolean
213
117
  }
214
118
 
119
+ /**
120
+ * Declarative `dynamic_select` (manifest `ref` / `optionsConfig.source`) on top
121
+ * of the shared {@link RecordPicker}: options from the canonical options
122
+ * endpoint (`useOptionsResolver`: option_filter, options[].when keep-value,
123
+ * cascade `filter_value`), inline create/edit through the host modal events.
124
+ */
215
125
  export function DynamicSelectField({
216
126
  field,
217
127
  value,
@@ -234,35 +144,28 @@ export function DynamicSelectField({
234
144
  const [open, setOpen] = useState(false)
235
145
  const [search, setSearch] = useState('')
236
146
  const [scanOpen, setScanOpen] = useState(false)
237
- const debounced = useDebounced(search, 250)
147
+ const debounced = useDebouncedValue(search, 250)
238
148
 
239
149
  // Escaneo por cámara para "llenar rápido": opt-in por campo (`scan`). El
240
- // botón aparece SIEMPRE que el campo lo declara (igual que el POS), sin
241
- // esconderse por `isCameraScanSupported()` — en escritorio el botón no salía
242
- // aunque el campo lo pidiera. El BarcodeScanner ya degrada con un mensaje
243
- // cuando no hay cámara. Un código escaneado alimenta la búsqueda y abre el
244
- // picker para elegir la referencia sin tipear el UUID.
150
+ // botón aparece SIEMPRE que el campo lo declara (igual que el POS). El
151
+ // BarcodeScanner ya degrada con un mensaje cuando no hay cámara. Un código
152
+ // escaneado alimenta la búsqueda y abre el picker.
245
153
  const scanEnabled = !!(field.scan ?? field.scannable)
246
154
  const handleScanDetected = (code: string) => {
247
155
  setSearch(code)
248
156
  setOpen(true)
249
157
  }
250
- // Remember the label of the option the user actually picked so the trigger
251
- // shows a name (not a UUID) without a round-trip.
158
+ // Remember the option the user actually picked so the trigger shows a name
159
+ // (not a UUID) without a round-trip.
252
160
  const [picked, setPicked] = useState<ResolvedOption | null>(null)
253
161
 
254
- // Tolerate the snake_case `source`/`relation` aliases the kernel may serve
255
- // for the FK target, not just camelCase `ref`.
162
+ // Tolerate the snake_case `source`/`relation` aliases for the FK target.
256
163
  const fieldRef = getFieldRef(field)
257
-
258
- // Options routing: an `optionsConfig.source` (dependent/scoped picker) wins
259
- // over the field's `ref` — query the SOURCE model with `field=<value>`;
260
- // otherwise keep the canonical `ref`-based resolution.
164
+ // An `optionsConfig.source` (dependent/scoped picker) wins over `ref`.
261
165
  const source = resolveOptionsSource(field)
262
166
 
263
167
  // Cascade: a `dependsOn` field whose value is still empty leaves this
264
- // picker disabled until the parent is set. `dependsValue` is the resolved
265
- // value the caller threaded from the form context.
168
+ // picker disabled until the parent is set.
266
169
  const dependsOn = getDependsOn(field)
267
170
  const scope = dependsValue ? String(dependsValue) : ''
268
171
  const blockedByDependency = !!dependsOn && scope === ''
@@ -270,27 +173,23 @@ export function DynamicSelectField({
270
173
  const useStatic = Array.isArray(staticOptions)
271
174
  const optionFilter = getOptionFilter(field)
272
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)
273
179
  const { options: fetchedOptions, loading: fetchLoading } = useOptionsResolver({
274
180
  modelKey: '',
275
181
  fieldKey: source.fieldKey,
276
182
  ref: source.ref,
277
- // optionsConfig.source → `/options/<source>`. Else searchEndpoint only
278
- // drives the URL when there's no ref — ref is the canonical,
279
- // kernel-derived path and wins.
280
- endpoint: source.endpoint ?? (source.ref ? undefined : field.searchEndpoint),
183
+ endpoint: optionsEndpoint,
281
184
  query: debounced,
282
185
  limit: 20,
283
- // Cascade scope forwarded as filter_value (only when this field
284
- // declares a dependency). Re-fetches when the parent value changes.
285
186
  filterValue: dependsOn ? scope : undefined,
286
187
  optionFilter,
287
188
  keepValue: value,
288
- // Don't fetch until the popover opens (and keep fetching as the query
289
- // changes while open). A picker blocked by an unset dependency never
290
- // fetches. A readonly cell fetches eagerly so its value's label resolves
291
- // to the name without the user ever opening it.
292
- // Static lists never hit the network.
293
- 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,
294
193
  })
295
194
 
296
195
  const options = useStatic
@@ -305,8 +204,7 @@ export function DynamicSelectField({
305
204
  : fetchedOptions
306
205
  const loading = useStatic ? false : fetchLoading
307
206
 
308
- // When the depended-on value changes, the previously-picked option no longer
309
- // belongs to the new scope, so clear the selection (skip the initial mount).
207
+ // A new cascade scope invalidates the selection (skip the initial mount).
310
208
  const prevScopeRef = useRef<string>(scope)
311
209
  useEffect(() => {
312
210
  if (!dependsOn) return
@@ -317,20 +215,37 @@ export function DynamicSelectField({
317
215
  }
318
216
  }, [dependsOn, scope, value, onChange])
319
217
 
320
- // The currently-selected option, resolved either from what the user picked
321
- // (cached in `picked`) or from the loaded page. Drives both the trigger
322
- // label and its thumbnail.
323
- const selectedOption =
324
- (picked && String(picked.id) === String(value) ? picked : null) ??
325
- options.find((o) => String(o.id) === String(value)) ??
326
- (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) ??
327
223
  null
328
-
329
- const selectedLabel = selectedOption?.label ?? (value ? String(value) : '')
330
-
331
- // Only switch the picker into "with thumbnails" mode when the data actually
332
- // carries images — a relation whose options have no `image` keeps the plain
333
- // text list it had before (no empty placeholder column).
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
+ )
334
249
 
335
250
  const handlePick = (opt: ResolvedOption) => {
336
251
  setPicked(opt)
@@ -340,238 +255,132 @@ export function DynamicSelectField({
340
255
  setSearch('')
341
256
  }
342
257
 
343
- // Inline-create: the "+" opens the REFERENCED model's own create modal (the
344
- // real one the host renders for that model — full fields, not a duplicate),
345
- // via a decoupled window event the host listens for. On success the host
346
- // hands back the new record and we select it immediately. No host import →
347
- // no circular dependency; works for ANY dynamic_select with a `ref`.
348
- // Inline-edit: with a value selected the joined pencil opens THAT record's
349
- // edit modal through the sibling `metacore:edit-record` event (the host's
350
- // RecordCreateBridge already listens for it). On save the trigger label
351
- // refreshes from the returned record.
352
- const openEdit = () => {
353
- if (!fieldRef || !value || typeof window === 'undefined') return
354
- setOpen(false)
355
- window.dispatchEvent(
356
- new CustomEvent('metacore:edit-record', {
357
- detail: {
358
- model: fieldRef,
359
- recordId: String(value),
360
- onSaved: (rec: any) => {
361
- const id = String(value)
362
- const label = rec ? rec.name ?? rec.label ?? rec.title : undefined
363
- if (label != null) setPicked({ ...(selectedOption ?? { id, value: id }), id, value: id, label: String(label), name: String(label) })
364
- },
365
- },
366
- }),
367
- )
368
- }
258
+ const toOption = (id: string, label: string): ResolvedOption => ({ id, value: id, label, name: label })
369
259
 
370
- const openCreate = () => {
371
- if (!fieldRef || typeof window === 'undefined') return
260
+ // Inline create / edit: the REFERENCED model's real modal, rendered by the
261
+ // host (RecordCreateBridge) via decoupled window events — no host import.
262
+ const openCreate = (query: string) => {
263
+ if (!fieldRef) return
372
264
  setOpen(false)
373
- window.dispatchEvent(
374
- new CustomEvent('metacore:create-record', {
375
- detail: {
376
- model: fieldRef,
377
- // Generic passthrough: the host's create modal seeds these
378
- // values and locks these fields (DynamicRecordDialog's own
379
- // defaults/lockedFields props). Undefined when unused.
380
- defaults: createDefaults,
381
- lockedFields: createLockedFields,
382
- onCreated: (rec: any) => {
383
- if (rec && rec.id != null) {
384
- const id = String(rec.id)
385
- const label = String(rec.name ?? rec.label ?? rec.title ?? rec.id)
386
- handlePick({ id, value: id, label, name: label })
387
- }
388
- },
389
- },
390
- }),
391
- )
265
+ requestRecordCreate({
266
+ model: fieldRef,
267
+ // The searched text seeds the new record's name; explicit
268
+ // createDefaults win. Keys that aren't fields are ignored.
269
+ defaults: withSearchPrefill(query, 'name', createDefaults),
270
+ lockedFields: createLockedFields,
271
+ onCreated: (rec: any) => {
272
+ if (rec && rec.id != null) {
273
+ const id = String(rec.id)
274
+ handlePick(toOption(id, recordLabel(rec) ?? id))
275
+ }
276
+ },
277
+ })
392
278
  }
393
-
394
- // Locked display: the value is fixed by context, so render the resolved
395
- // label (name) as a disabled control — no popover, no inline-create. While
396
- // the eager fetch is in flight the label falls back to the seed/raw value,
397
- // then snaps to the name once options arrive.
398
- if (readonly) {
399
- return (
400
- <Button
401
- type="button"
402
- variant="outline"
403
- role="combobox"
404
- id={field.key}
405
- disabled
406
- aria-readonly="true"
407
- className="w-full min-w-0 cursor-default justify-start font-normal opacity-100"
408
- >
409
- <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
410
- {selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
411
- <span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
412
- {/* Never flash the raw id: until the eager fetch resolves the
413
- option, show a loading hint instead of String(value). */}
414
- {selectedOption?.label ?? (loading ? 'Cargando…' : ph('—'))}
415
- </span>
416
- </span>
417
- </Button>
418
- )
279
+ const openEdit = () => {
280
+ if (!fieldRef || !value) return
281
+ setOpen(false)
282
+ const id = String(value)
283
+ requestRecordEdit({
284
+ model: fieldRef,
285
+ recordId: id,
286
+ onSaved: (rec: any) => {
287
+ const label = recordLabel(rec)
288
+ if (label != null) setPicked({ ...(selectedOption ?? toOption(id, label)), id, value: id, label, name: label })
289
+ },
290
+ })
419
291
  }
420
292
 
421
- // w-full + min-w-0: as a grid cell child, the row must be allowed to shrink
422
- // to the cell. Without min-w-0 the combobox+button row sizes to its content
423
- // (the long empty-state placeholder) and overflows the column, pushing the
424
- // "+" off-screen — it only "fit" once a short value was selected.
425
- const canMutate = !!fieldRef && !hideCreate && !useStatic
426
- const joined = canMutate && !blockedByDependency && hasRecordPickerAction(!!value, openCreate, openEdit)
293
+ const canMutate = !!fieldRef && !hideCreate && !useStatic && !blockedByDependency
427
294
  const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? ''
295
+ const badge = (opt: ResolvedOption) =>
296
+ opt.description ? (
297
+ <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
298
+ {opt.description}
299
+ </Badge>
300
+ ) : null
301
+
428
302
  return (
429
- <div className="flex w-full min-w-0 items-center gap-1.5" data-slot="dynamic-select">
430
- <div className="flex min-w-0 flex-1 items-center">
431
- <Popover open={open && !blockedByDependency} onOpenChange={(o: boolean) => { if (!blockedByDependency) setOpen(o) }}>
432
- <PopoverTrigger asChild>
433
- <Button
434
- type="button"
435
- variant="outline"
436
- role="combobox"
437
- aria-expanded={open}
438
- id={field.key}
439
- disabled={blockedByDependency}
440
- aria-invalid={invalid || undefined}
441
- className={
442
- 'min-w-0 flex-1 justify-between font-normal' +
443
- (joined ? ` ${JOINED_TRIGGER_CLASS}` : '') +
444
- (invalid ? ' border-destructive ring-1 ring-destructive/30' : '')
445
- }
446
- data-empty={!value}
447
- data-depends-blocked={blockedByDependency ? '' : undefined}
448
- >
449
- <span className="flex min-w-0 flex-1 items-center gap-2 text-left">
450
- {value && selectedOption ? (
451
- <OptionLead option={selectedOption} size={20} />
452
- ) : null}
303
+ <RecordPicker<ResolvedOption>
304
+ slot="dynamic-select"
305
+ id={field.key}
306
+ items={options}
307
+ loading={loading}
308
+ getKey={(o) => String(o.id)}
309
+ getLabel={(o) => o.label}
310
+ value={value}
311
+ selected={selectedOption}
312
+ onSelect={handlePick}
313
+ query={search}
314
+ onQueryChange={setSearch}
315
+ open={open && !blockedByDependency}
316
+ onOpenChange={(o) => {
317
+ if (!blockedByDependency) setOpen(o)
318
+ }}
319
+ disabled={blockedByDependency}
320
+ readOnly={readonly}
321
+ invalid={invalid}
322
+ renderLead={(o, where) => <OptionLead option={o} size={where === 'option' ? 24 : 20} />}
323
+ getDescription={descriptionAsBadge ? undefined : (o) => o.description}
324
+ renderTrailing={descriptionAsBadge ? (o) => badge(o) : undefined}
325
+ renderValue={() => {
326
+ // Never flash the raw id while the label resolves.
327
+ if (valueResolving) return resolvingNode
328
+ if (valueMissing) return missingNode
329
+ if (readonly) {
330
+ return (
331
+ <>
332
+ {selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
333
+ <span className={'min-w-0 flex-1 truncate ' + (selectedOption ? '' : 'text-muted-foreground')}>
334
+ {selectedOption?.label ?? ph('—')}
335
+ </span>
336
+ </>
337
+ )
338
+ }
339
+ return (
340
+ <>
341
+ {value && selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
453
342
  <span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
454
- {blockedByDependency
455
- ? (dependsHint || DEFAULT_DEPENDS_HINT)
456
- : selectedLabel || ph('Buscar…')}
343
+ {blockedByDependency ? dependsHint || DEFAULT_DEPENDS_HINT : selectedLabel || ph('Buscar…')}
457
344
  </span>
458
- {descriptionAsBadge && selectedOption?.description ? (
459
- <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
460
- {selectedOption.description}
461
- </Badge>
462
- ) : null}
463
- </span>
464
- <ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
465
- </Button>
466
- </PopoverTrigger>
467
- <PopoverContent
468
- className="p-0"
469
- align="start"
470
- // Portaled (never clipped by a table/modal overflow) and
471
- // collision-aware: flips above the trigger when there is no
472
- // room below. Match the trigger width without an arbitrary
473
- // Tailwind class (those don't always survive a consuming app's
474
- // Tailwind scan), with a floor so short cells stay readable.
475
- collisionPadding={8}
476
- style={{ width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }}
477
- >
478
- <Command shouldFilter={false}>
479
- <CommandInput
480
- placeholder={ph('Buscar…')}
481
- value={search}
482
- onValueChange={setSearch}
483
- />
484
- <CommandList>
485
- {loading && (
486
- <div className="text-muted-foreground flex items-center justify-center gap-2 py-6 text-sm">
487
- <Loader2 className="size-4 animate-spin" />
488
- Buscando…
489
- </div>
490
- )}
491
- {!loading && options.length === 0 && (
492
- // Plain block, not <CommandEmpty>: cmdk hides Empty while
493
- // any item is mounted, and the "Crear …" footer is one.
494
- <div className="text-muted-foreground py-6 text-center text-sm" data-slot="picker-empty">
495
- {useStatic
496
- ? debounced
497
- ? 'Sin resultados'
498
- : 'Sin opciones'
499
- : debounced
500
- ? 'Sin resultados'
501
- : 'Sin resultados — escribe para filtrar'}
502
- </div>
503
- )}
504
- {!loading && options.length > 0 && (
505
- <CommandGroup className="max-h-64 overflow-auto">
506
- {options.map((opt) => {
507
- const isSel = String(opt.id) === String(value)
508
- return (
509
- <CommandItem
510
- key={String(opt.id)}
511
- value={String(opt.id)}
512
- onSelect={() => handlePick(opt)}
513
- >
514
- <Check className={'mr-2 size-4 shrink-0 ' + (isSel ? 'opacity-100' : 'opacity-0')} />
515
- <OptionLead option={opt} size={24} />
516
- {descriptionAsBadge ? (
517
- <div className="ml-2 flex min-w-0 flex-1 items-center gap-2">
518
- <span className="min-w-0 flex-1 truncate">{opt.label}</span>
519
- {opt.description ? (
520
- <Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
521
- {opt.description}
522
- </Badge>
523
- ) : null}
524
- </div>
525
- ) : (
526
- <div className="ml-2 flex min-w-0 flex-col">
527
- <span className="truncate">{opt.label}</span>
528
- {opt.description && (
529
- <span className="text-muted-foreground truncate text-xs">
530
- {opt.description}
531
- </span>
532
- )}
533
- </div>
534
- )}
535
- </CommandItem>
536
- )
537
- })}
538
- </CommandGroup>
539
- )}
540
- {canMutate && !loading && (
541
- <PickerCreateItem label={fieldName} onSelect={openCreate} />
542
- )}
543
- </CommandList>
544
- </Command>
545
- </PopoverContent>
546
- </Popover>
547
- {canMutate && !blockedByDependency && (
548
- <RecordPickerAction hasValue={!!value} label={fieldName} onCreate={openCreate} onEdit={openEdit} />
549
- )}
550
- </div>
551
- {scanEnabled && (
552
- <Button
553
- type="button"
554
- variant="outline"
555
- size="icon"
556
- className="size-9 shrink-0"
557
- onClick={() => setScanOpen(true)}
558
- title="Escanear con la cámara"
559
- aria-label="Escanear código de barras con la cámara"
560
- >
561
- <ScanLine className="size-4" />
562
- </Button>
563
- )}
564
- {scanEnabled && (
565
- <BarcodeScanner
566
- open={scanOpen}
567
- onClose={() => setScanOpen(false)}
568
- onDetected={handleScanDetected}
569
- continuous={false}
570
- position="fixed"
571
- title={`Escanear ${field.label ?? ''}`.trim()}
572
- />
573
- )}
574
- </div>
345
+ {descriptionAsBadge && selectedOption ? badge(selectedOption) : null}
346
+ </>
347
+ )
348
+ }}
349
+ placeholder={ph('Buscar…')}
350
+ searchPlaceholder={ph('Buscar…')}
351
+ emptyText={debounced ? 'Sin resultados' : useStatic ? 'Sin opciones' : 'Sin resultados — escribe para filtrar'}
352
+ entityLabel={fieldName}
353
+ onCreate={canMutate ? openCreate : undefined}
354
+ onEdit={canMutate ? openEdit : undefined}
355
+ triggerProps={{
356
+ 'data-depends-blocked': blockedByDependency ? '' : undefined,
357
+ }}
358
+ after={
359
+ scanEnabled ? (
360
+ <>
361
+ <Button
362
+ type="button"
363
+ variant="outline"
364
+ size="icon"
365
+ className="size-9 shrink-0"
366
+ onClick={() => setScanOpen(true)}
367
+ title="Escanear con la cámara"
368
+ aria-label="Escanear código de barras con la cámara"
369
+ >
370
+ <ScanLine className="size-4" />
371
+ </Button>
372
+ <BarcodeScanner
373
+ open={scanOpen}
374
+ onClose={() => setScanOpen(false)}
375
+ onDetected={handleScanDetected}
376
+ continuous={false}
377
+ position="fixed"
378
+ title={`Escanear ${field.label ?? ''}`.trim()}
379
+ />
380
+ </>
381
+ ) : null
382
+ }
383
+ />
575
384
  )
576
385
  }
577
386