@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.
- package/CHANGELOG.md +12 -0
- 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 +36 -6
- package/dist/dynamic-select-field.d.ts +9 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +105 -172
- 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 +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +4 -0
- 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/dist/use-option-ids.d.ts +34 -0
- package/dist/use-option-ids.d.ts.map +1 -0
- package/dist/use-option-ids.js +138 -0
- package/dist/use-options-resolver.d.ts +4 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +10 -1
- package/package.json +1 -1
- package/src/__tests__/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__/edit-field-polish.test.tsx +6 -3
- package/src/__tests__/option-ids.test.tsx +181 -0
- package/src/__tests__/record-picker.test.tsx +469 -0
- 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 +53 -15
- package/src/dynamic-select-field.tsx +188 -379
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +35 -2
- package/src/query-batch.ts +3 -0
- 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/src/use-option-ids.ts +168 -0
- package/src/use-options-resolver.ts +11 -1
- 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
|
|
@@ -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
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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
|
-
|
|
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 {
|
|
44
|
-
import {
|
|
45
|
-
import {
|
|
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
|
-
|
|
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 =
|
|
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)
|
|
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.
|
|
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
|
|
251
|
-
//
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
(
|
|
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
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
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
|
-
<
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
|
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>
|
|
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
|
|