@asteby/metacore-runtime-react 49.4.0 → 49.6.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 +14 -0
- package/dist/api-context.d.ts +9 -1
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/business/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +17 -5
- package/dist/dynamic-select-field.d.ts +7 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +67 -161
- package/dist/dynamic-table.d.ts +31 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +37 -4
- package/dist/entity-select.d.ts +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/api-context.tsx +19 -4
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +39 -13
- package/src/dynamic-select-field.tsx +143 -368
- package/src/dynamic-table.tsx +91 -4
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +27 -2
- package/src/query-batch.ts +18 -1
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- package/src/business/use-async-search.ts +0 -43
|
@@ -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
|
|
@@ -24,29 +25,19 @@
|
|
|
24
25
|
import { useEffect, useRef, useState } from 'react'
|
|
25
26
|
import { getOptionFilter } from './option-filter'
|
|
26
27
|
import { useTranslation } from 'react-i18next'
|
|
27
|
-
import {
|
|
28
|
-
|
|
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'
|
|
28
|
+
import { Badge, Button } from '@asteby/metacore-ui/primitives'
|
|
29
|
+
import { ScanLine } from 'lucide-react'
|
|
42
30
|
import { BarcodeScanner } from './barcode-scanner'
|
|
43
|
-
import {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
31
|
+
import { RecordPicker } from './record-picker'
|
|
32
|
+
import { OptionLead } from './record-picker-option'
|
|
33
|
+
import { recordLabel, requestRecordCreate, requestRecordEdit, withSearchPrefill } from './record-picker-actions'
|
|
46
34
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
35
|
+
import { useDebouncedValue } from './use-debounced-value'
|
|
47
36
|
import { getDependsOn, getFieldRef, resolveOptionsSource } from './dynamic-form-schema'
|
|
48
37
|
import type { ActionFieldDef } from './types'
|
|
49
38
|
|
|
39
|
+
export { OptionLead, OptionThumb } from './record-picker-option'
|
|
40
|
+
|
|
50
41
|
/**
|
|
51
42
|
* Default hint shown when a cascading picker's depended-on field is still
|
|
52
43
|
* empty. Domain-neutral on purpose; a caller may override it per field via
|
|
@@ -54,98 +45,6 @@ import type { ActionFieldDef } from './types'
|
|
|
54
45
|
*/
|
|
55
46
|
export const DEFAULT_DEPENDS_HINT = 'Selecciona primero el campo del que depende'
|
|
56
47
|
|
|
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
|
-
}
|
|
148
|
-
|
|
149
48
|
export interface DynamicSelectFieldProps {
|
|
150
49
|
field: ActionFieldDef
|
|
151
50
|
value: any
|
|
@@ -212,6 +111,12 @@ export interface DynamicSelectFieldProps {
|
|
|
212
111
|
invalid?: boolean
|
|
213
112
|
}
|
|
214
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Declarative `dynamic_select` (manifest `ref` / `optionsConfig.source`) on top
|
|
116
|
+
* of the shared {@link RecordPicker}: options from the canonical options
|
|
117
|
+
* endpoint (`useOptionsResolver`: option_filter, options[].when keep-value,
|
|
118
|
+
* cascade `filter_value`), inline create/edit through the host modal events.
|
|
119
|
+
*/
|
|
215
120
|
export function DynamicSelectField({
|
|
216
121
|
field,
|
|
217
122
|
value,
|
|
@@ -234,35 +139,28 @@ export function DynamicSelectField({
|
|
|
234
139
|
const [open, setOpen] = useState(false)
|
|
235
140
|
const [search, setSearch] = useState('')
|
|
236
141
|
const [scanOpen, setScanOpen] = useState(false)
|
|
237
|
-
const debounced =
|
|
142
|
+
const debounced = useDebouncedValue(search, 250)
|
|
238
143
|
|
|
239
144
|
// 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)
|
|
241
|
-
//
|
|
242
|
-
//
|
|
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.
|
|
145
|
+
// botón aparece SIEMPRE que el campo lo declara (igual que el POS). El
|
|
146
|
+
// BarcodeScanner ya degrada con un mensaje cuando no hay cámara. Un código
|
|
147
|
+
// escaneado alimenta la búsqueda y abre el picker.
|
|
245
148
|
const scanEnabled = !!(field.scan ?? field.scannable)
|
|
246
149
|
const handleScanDetected = (code: string) => {
|
|
247
150
|
setSearch(code)
|
|
248
151
|
setOpen(true)
|
|
249
152
|
}
|
|
250
|
-
// Remember the
|
|
251
|
-
//
|
|
153
|
+
// Remember the option the user actually picked so the trigger shows a name
|
|
154
|
+
// (not a UUID) without a round-trip.
|
|
252
155
|
const [picked, setPicked] = useState<ResolvedOption | null>(null)
|
|
253
156
|
|
|
254
|
-
// Tolerate the snake_case `source`/`relation` aliases the
|
|
255
|
-
// for the FK target, not just camelCase `ref`.
|
|
157
|
+
// Tolerate the snake_case `source`/`relation` aliases for the FK target.
|
|
256
158
|
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.
|
|
159
|
+
// An `optionsConfig.source` (dependent/scoped picker) wins over `ref`.
|
|
261
160
|
const source = resolveOptionsSource(field)
|
|
262
161
|
|
|
263
162
|
// Cascade: a `dependsOn` field whose value is still empty leaves this
|
|
264
|
-
// picker disabled until the parent is set.
|
|
265
|
-
// value the caller threaded from the form context.
|
|
163
|
+
// picker disabled until the parent is set.
|
|
266
164
|
const dependsOn = getDependsOn(field)
|
|
267
165
|
const scope = dependsValue ? String(dependsValue) : ''
|
|
268
166
|
const blockedByDependency = !!dependsOn && scope === ''
|
|
@@ -275,21 +173,15 @@ export function DynamicSelectField({
|
|
|
275
173
|
fieldKey: source.fieldKey,
|
|
276
174
|
ref: source.ref,
|
|
277
175
|
// optionsConfig.source → `/options/<source>`. Else searchEndpoint only
|
|
278
|
-
// drives the URL when there's no ref
|
|
279
|
-
// kernel-derived path and wins.
|
|
176
|
+
// drives the URL when there's no ref (ref is canonical and wins).
|
|
280
177
|
endpoint: source.endpoint ?? (source.ref ? undefined : field.searchEndpoint),
|
|
281
178
|
query: debounced,
|
|
282
179
|
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
180
|
filterValue: dependsOn ? scope : undefined,
|
|
286
181
|
optionFilter,
|
|
287
182
|
keepValue: value,
|
|
288
|
-
//
|
|
289
|
-
//
|
|
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.
|
|
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.
|
|
293
185
|
enabled: !useStatic && (open || readonly) && !blockedByDependency,
|
|
294
186
|
})
|
|
295
187
|
|
|
@@ -305,8 +197,7 @@ export function DynamicSelectField({
|
|
|
305
197
|
: fetchedOptions
|
|
306
198
|
const loading = useStatic ? false : fetchLoading
|
|
307
199
|
|
|
308
|
-
//
|
|
309
|
-
// belongs to the new scope, so clear the selection (skip the initial mount).
|
|
200
|
+
// A new cascade scope invalidates the selection (skip the initial mount).
|
|
310
201
|
const prevScopeRef = useRef<string>(scope)
|
|
311
202
|
useEffect(() => {
|
|
312
203
|
if (!dependsOn) return
|
|
@@ -317,21 +208,13 @@ export function DynamicSelectField({
|
|
|
317
208
|
}
|
|
318
209
|
}, [dependsOn, scope, value, onChange])
|
|
319
210
|
|
|
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
211
|
const selectedOption =
|
|
324
212
|
(picked && String(picked.id) === String(value) ? picked : null) ??
|
|
325
213
|
options.find((o) => String(o.id) === String(value)) ??
|
|
326
214
|
(seedOption && String(seedOption.id) === String(value) ? seedOption : null) ??
|
|
327
215
|
null
|
|
328
|
-
|
|
329
216
|
const selectedLabel = selectedOption?.label ?? (value ? String(value) : '')
|
|
330
217
|
|
|
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).
|
|
334
|
-
|
|
335
218
|
const handlePick = (opt: ResolvedOption) => {
|
|
336
219
|
setPicked(opt)
|
|
337
220
|
onChange(String(opt.id))
|
|
@@ -340,238 +223,130 @@ export function DynamicSelectField({
|
|
|
340
223
|
setSearch('')
|
|
341
224
|
}
|
|
342
225
|
|
|
343
|
-
|
|
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
|
-
}
|
|
226
|
+
const toOption = (id: string, label: string): ResolvedOption => ({ id, value: id, label, name: label })
|
|
369
227
|
|
|
370
|
-
|
|
371
|
-
|
|
228
|
+
// Inline create / edit: the REFERENCED model's real modal, rendered by the
|
|
229
|
+
// host (RecordCreateBridge) via decoupled window events — no host import.
|
|
230
|
+
const openCreate = (query: string) => {
|
|
231
|
+
if (!fieldRef) return
|
|
372
232
|
setOpen(false)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
handlePick({ id, value: id, label, name: label })
|
|
387
|
-
}
|
|
388
|
-
},
|
|
389
|
-
},
|
|
390
|
-
}),
|
|
391
|
-
)
|
|
233
|
+
requestRecordCreate({
|
|
234
|
+
model: fieldRef,
|
|
235
|
+
// The searched text seeds the new record's name; explicit
|
|
236
|
+
// createDefaults win. Keys that aren't fields are ignored.
|
|
237
|
+
defaults: withSearchPrefill(query, 'name', createDefaults),
|
|
238
|
+
lockedFields: createLockedFields,
|
|
239
|
+
onCreated: (rec: any) => {
|
|
240
|
+
if (rec && rec.id != null) {
|
|
241
|
+
const id = String(rec.id)
|
|
242
|
+
handlePick(toOption(id, recordLabel(rec) ?? id))
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
})
|
|
392
246
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
)
|
|
247
|
+
const openEdit = () => {
|
|
248
|
+
if (!fieldRef || !value) return
|
|
249
|
+
setOpen(false)
|
|
250
|
+
const id = String(value)
|
|
251
|
+
requestRecordEdit({
|
|
252
|
+
model: fieldRef,
|
|
253
|
+
recordId: id,
|
|
254
|
+
onSaved: (rec: any) => {
|
|
255
|
+
const label = recordLabel(rec)
|
|
256
|
+
if (label != null) setPicked({ ...(selectedOption ?? toOption(id, label)), id, value: id, label, name: label })
|
|
257
|
+
},
|
|
258
|
+
})
|
|
419
259
|
}
|
|
420
260
|
|
|
421
|
-
|
|
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)
|
|
261
|
+
const canMutate = !!fieldRef && !hideCreate && !useStatic && !blockedByDependency
|
|
427
262
|
const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? ''
|
|
263
|
+
const badge = (opt: ResolvedOption) =>
|
|
264
|
+
opt.description ? (
|
|
265
|
+
<Badge variant="secondary" className="shrink-0 font-normal tabular-nums">
|
|
266
|
+
{opt.description}
|
|
267
|
+
</Badge>
|
|
268
|
+
) : null
|
|
269
|
+
|
|
428
270
|
return (
|
|
429
|
-
<
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
271
|
+
<RecordPicker<ResolvedOption>
|
|
272
|
+
slot="dynamic-select"
|
|
273
|
+
id={field.key}
|
|
274
|
+
items={options}
|
|
275
|
+
loading={loading}
|
|
276
|
+
getKey={(o) => String(o.id)}
|
|
277
|
+
getLabel={(o) => o.label}
|
|
278
|
+
value={value}
|
|
279
|
+
selected={selectedOption}
|
|
280
|
+
onSelect={handlePick}
|
|
281
|
+
query={search}
|
|
282
|
+
onQueryChange={setSearch}
|
|
283
|
+
open={open && !blockedByDependency}
|
|
284
|
+
onOpenChange={(o) => {
|
|
285
|
+
if (!blockedByDependency) setOpen(o)
|
|
286
|
+
}}
|
|
287
|
+
disabled={blockedByDependency}
|
|
288
|
+
readOnly={readonly}
|
|
289
|
+
invalid={invalid}
|
|
290
|
+
renderLead={(o, where) => <OptionLead option={o} size={where === 'option' ? 24 : 20} />}
|
|
291
|
+
getDescription={descriptionAsBadge ? undefined : (o) => o.description}
|
|
292
|
+
renderTrailing={descriptionAsBadge ? (o) => badge(o) : undefined}
|
|
293
|
+
renderValue={() => {
|
|
294
|
+
if (readonly) {
|
|
295
|
+
return (
|
|
296
|
+
<>
|
|
297
|
+
{selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
|
|
298
|
+
<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('—'))}
|
|
301
|
+
</span>
|
|
302
|
+
</>
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
return (
|
|
306
|
+
<>
|
|
307
|
+
{value && selectedOption ? <OptionLead option={selectedOption} size={20} /> : null}
|
|
453
308
|
<span className={'min-w-0 flex-1 truncate ' + (selectedLabel ? '' : 'text-muted-foreground')}>
|
|
454
|
-
{blockedByDependency
|
|
455
|
-
? (dependsHint || DEFAULT_DEPENDS_HINT)
|
|
456
|
-
: selectedLabel || ph('Buscar…')}
|
|
309
|
+
{blockedByDependency ? dependsHint || DEFAULT_DEPENDS_HINT : selectedLabel || ph('Buscar…')}
|
|
457
310
|
</span>
|
|
458
|
-
{descriptionAsBadge && selectedOption
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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>
|
|
311
|
+
{descriptionAsBadge && selectedOption ? badge(selectedOption) : null}
|
|
312
|
+
</>
|
|
313
|
+
)
|
|
314
|
+
}}
|
|
315
|
+
placeholder={ph('Buscar…')}
|
|
316
|
+
searchPlaceholder={ph('Buscar…')}
|
|
317
|
+
emptyText={debounced ? 'Sin resultados' : useStatic ? 'Sin opciones' : 'Sin resultados — escribe para filtrar'}
|
|
318
|
+
entityLabel={fieldName}
|
|
319
|
+
onCreate={canMutate ? openCreate : undefined}
|
|
320
|
+
onEdit={canMutate ? openEdit : undefined}
|
|
321
|
+
triggerProps={{
|
|
322
|
+
'data-depends-blocked': blockedByDependency ? '' : undefined,
|
|
323
|
+
}}
|
|
324
|
+
after={
|
|
325
|
+
scanEnabled ? (
|
|
326
|
+
<>
|
|
327
|
+
<Button
|
|
328
|
+
type="button"
|
|
329
|
+
variant="outline"
|
|
330
|
+
size="icon"
|
|
331
|
+
className="size-9 shrink-0"
|
|
332
|
+
onClick={() => setScanOpen(true)}
|
|
333
|
+
title="Escanear con la cámara"
|
|
334
|
+
aria-label="Escanear código de barras con la cámara"
|
|
335
|
+
>
|
|
336
|
+
<ScanLine className="size-4" />
|
|
337
|
+
</Button>
|
|
338
|
+
<BarcodeScanner
|
|
339
|
+
open={scanOpen}
|
|
340
|
+
onClose={() => setScanOpen(false)}
|
|
341
|
+
onDetected={handleScanDetected}
|
|
342
|
+
continuous={false}
|
|
343
|
+
position="fixed"
|
|
344
|
+
title={`Escanear ${field.label ?? ''}`.trim()}
|
|
345
|
+
/>
|
|
346
|
+
</>
|
|
347
|
+
) : null
|
|
348
|
+
}
|
|
349
|
+
/>
|
|
575
350
|
)
|
|
576
351
|
}
|
|
577
352
|
|