@asteby/metacore-runtime-react 49.3.1 → 49.4.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 +6 -0
- package/dist/business/document-editor-panels.d.ts +2 -1
- package/dist/business/document-editor-panels.d.ts.map +1 -1
- package/dist/business/document-editor-panels.js +6 -6
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +23 -9
- 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-items-editor.d.ts +6 -1
- package/dist/business/line-items-editor.d.ts.map +1 -1
- package/dist/business/line-items-editor.js +109 -83
- package/dist/business/line-product-cell.d.ts +32 -0
- package/dist/business/line-product-cell.d.ts.map +1 -0
- package/dist/business/line-product-cell.js +100 -0
- package/dist/dynamic-select-field.d.ts +2 -1
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +49 -19
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +13 -11
- package/dist/field-grid.d.ts +7 -1
- package/dist/field-grid.d.ts.map +1 -1
- package/dist/field-grid.js +4 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/record-picker-actions.d.ts +27 -0
- package/dist/record-picker-actions.d.ts.map +1 -0
- package/dist/record-picker-actions.js +32 -0
- package/package.json +1 -1
- package/src/__tests__/document-editor-inline-selects.test.tsx +202 -0
- package/src/business/document-editor-panels.tsx +13 -10
- package/src/business/document-editor.tsx +72 -20
- package/src/business/index.ts +1 -0
- package/src/business/line-items-editor.tsx +229 -174
- package/src/business/line-product-cell.tsx +219 -0
- package/src/dynamic-select-field.tsx +51 -22
- package/src/entity-select.tsx +17 -39
- package/src/field-grid.tsx +15 -1
- package/src/index.ts +2 -0
- package/src/record-picker-actions.tsx +81 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// LineProductCell — buscador de producto DENTRO de la celda «Descripción» de un
|
|
2
|
+
// renglón (patrón de editor de facturas pro: Odoo/Holded/Linear). Ocupa solo su
|
|
3
|
+
// celda; la lista de resultados va en un portal (Popover de Radix anclado al
|
|
4
|
+
// input): nunca la recorta el overflow de la tabla ni del modal, queda por
|
|
5
|
+
// encima del diálogo y se abre hacia arriba si abajo no hay espacio.
|
|
6
|
+
//
|
|
7
|
+
// Teclado: ↑/↓ recorren, Enter elige (el grid pasa el foco a «Cant.»), Esc
|
|
8
|
+
// cierra, Tab cierra y avanza normal. El texto escrito sin elegir queda como
|
|
9
|
+
// descripción libre (el renglón libre sigue funcionando igual).
|
|
10
|
+
import { forwardRef, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react'
|
|
11
|
+
import { useTranslation } from 'react-i18next'
|
|
12
|
+
import { Loader2, Search } from 'lucide-react'
|
|
13
|
+
import { Badge, Input, Popover, PopoverAnchor, PopoverContent } from '@asteby/metacore-ui/primitives'
|
|
14
|
+
import { useFormatter } from './format'
|
|
15
|
+
import { useAsyncSearch } from './use-async-search'
|
|
16
|
+
import { useDebouncedValue } from '../use-debounced-value'
|
|
17
|
+
import { availableStock, parseProductQuery, type ProductQuery, type ProductResult, type ProductVariant } from './product-search'
|
|
18
|
+
|
|
19
|
+
export type LineProductSearch = (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal) => Promise<ProductResult[]>
|
|
20
|
+
|
|
21
|
+
export interface LineProductHit {
|
|
22
|
+
product: ProductResult
|
|
23
|
+
variant?: ProductVariant
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LineProductCellProps {
|
|
27
|
+
search: LineProductSearch
|
|
28
|
+
/** Texto de la celda (la descripción del renglón, o el borrador del renglón vacío). */
|
|
29
|
+
text: string
|
|
30
|
+
onTextChange: (text: string) => void
|
|
31
|
+
onPick: (product: ProductResult, variant?: ProductVariant) => void
|
|
32
|
+
/**
|
|
33
|
+
* Teclas que el buscador no consume (lista cerrada): el grid aplica sus
|
|
34
|
+
* comandos (Enter agrega, Supr en celda vacía borra…).
|
|
35
|
+
*/
|
|
36
|
+
onKeyDownClosed?: (e: KeyboardEvent<HTMLInputElement>) => void
|
|
37
|
+
warehouseId?: string
|
|
38
|
+
currency?: string
|
|
39
|
+
placeholder?: string
|
|
40
|
+
ariaLabel: string
|
|
41
|
+
disabled?: boolean
|
|
42
|
+
invalid?: boolean
|
|
43
|
+
className?: string
|
|
44
|
+
/** Atributos de datos para que el grid ubique la celda (foco por teclado). */
|
|
45
|
+
dataCell?: string
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const LineProductCell = forwardRef<HTMLInputElement, LineProductCellProps>(function LineProductCell(
|
|
49
|
+
{ search, text, onTextChange, onPick, onKeyDownClosed, warehouseId, currency, placeholder, ariaLabel, disabled, invalid, className, dataCell },
|
|
50
|
+
ref,
|
|
51
|
+
) {
|
|
52
|
+
const { t } = useTranslation()
|
|
53
|
+
const fmt = useFormatter({ currency })
|
|
54
|
+
const listId = useId()
|
|
55
|
+
const anchorRef = useRef<HTMLDivElement>(null)
|
|
56
|
+
// Solo se abre mientras se escribe: enfocar un renglón libre ya capturado no
|
|
57
|
+
// despliega «sin resultados» encima de la tabla.
|
|
58
|
+
const [typing, setTyping] = useState(false)
|
|
59
|
+
const [active, setActive] = useState(0)
|
|
60
|
+
const parsed = parseProductQuery(text)
|
|
61
|
+
const run = useMemo(
|
|
62
|
+
() => (q: string, signal: AbortSignal) => {
|
|
63
|
+
const p = parseProductQuery(q)
|
|
64
|
+
return p.kind === 'empty' ? Promise.resolve([]) : search(p, signal)
|
|
65
|
+
},
|
|
66
|
+
[search],
|
|
67
|
+
)
|
|
68
|
+
const minChars = parsed.kind === 'barcode' ? 8 : 2
|
|
69
|
+
const delay = parsed.kind === 'barcode' ? 0 : 250
|
|
70
|
+
const { results, loading: fetching } = useAsyncSearch(typing ? text : '', run, { minChars, delay })
|
|
71
|
+
// Mientras el debounce no alcanza lo escrito, la lista dice «Buscando…» y no
|
|
72
|
+
// un «sin coincidencias» prematuro.
|
|
73
|
+
const settled = useDebouncedValue(text.trim(), delay) === text.trim()
|
|
74
|
+
const loading = fetching || !settled
|
|
75
|
+
const flat = useMemo<LineProductHit[]>(
|
|
76
|
+
() =>
|
|
77
|
+
results.flatMap((p) =>
|
|
78
|
+
(p.variants?.length ?? 0) > 0 ? p.variants!.map((v) => ({ product: p, variant: v })) : [{ product: p }],
|
|
79
|
+
),
|
|
80
|
+
[results],
|
|
81
|
+
)
|
|
82
|
+
const queryReady = typing && text.trim().length >= minChars
|
|
83
|
+
const open = queryReady && (loading || flat.length > 0 || text.trim().length >= 2)
|
|
84
|
+
const hasHits = flat.length > 0
|
|
85
|
+
|
|
86
|
+
const close = () => {
|
|
87
|
+
setTyping(false)
|
|
88
|
+
setActive(0)
|
|
89
|
+
}
|
|
90
|
+
const choose = (idx: number) => {
|
|
91
|
+
const hit = flat[idx]
|
|
92
|
+
if (!hit) return
|
|
93
|
+
close()
|
|
94
|
+
onPick(hit.product, hit.variant)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
98
|
+
if (open && hasHits) {
|
|
99
|
+
if (e.key === 'ArrowDown') {
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
setActive((n) => Math.min(flat.length - 1, n + 1))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
if (e.key === 'ArrowUp') {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
setActive((n) => Math.max(0, n - 1))
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
if (e.key === 'Enter') {
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
choose(active)
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (open && e.key === 'Escape') {
|
|
116
|
+
// Cierra la lista sin cerrar el diálogo que la contiene.
|
|
117
|
+
e.preventDefault()
|
|
118
|
+
e.stopPropagation()
|
|
119
|
+
close()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
if (e.key === 'Tab') close()
|
|
123
|
+
onKeyDownClosed?.(e)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<Popover open={open} onOpenChange={(o) => !o && close()}>
|
|
128
|
+
<PopoverAnchor asChild>
|
|
129
|
+
<div ref={anchorRef} className="relative w-full min-w-0" data-slot="line-product-cell">
|
|
130
|
+
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
|
131
|
+
<Input
|
|
132
|
+
ref={ref}
|
|
133
|
+
value={text}
|
|
134
|
+
disabled={disabled}
|
|
135
|
+
role="combobox"
|
|
136
|
+
aria-autocomplete="list"
|
|
137
|
+
aria-expanded={open}
|
|
138
|
+
aria-controls={open ? listId : undefined}
|
|
139
|
+
aria-activedescendant={open && hasHits ? `${listId}-${active}` : undefined}
|
|
140
|
+
aria-label={ariaLabel}
|
|
141
|
+
aria-invalid={invalid || undefined}
|
|
142
|
+
autoComplete="off"
|
|
143
|
+
data-cell={dataCell}
|
|
144
|
+
placeholder={placeholder ?? t('lineItems.searchPlaceholder', { defaultValue: 'Producto, medida (205/55R16), SKU o código de barras' })}
|
|
145
|
+
className={'h-8 pl-8 ' + (className ?? '')}
|
|
146
|
+
onChange={(e) => {
|
|
147
|
+
onTextChange(e.target.value)
|
|
148
|
+
setTyping(true)
|
|
149
|
+
setActive(0)
|
|
150
|
+
}}
|
|
151
|
+
onBlur={() => close()}
|
|
152
|
+
onKeyDown={onKeyDown}
|
|
153
|
+
/>
|
|
154
|
+
</div>
|
|
155
|
+
</PopoverAnchor>
|
|
156
|
+
<PopoverContent
|
|
157
|
+
align="start"
|
|
158
|
+
sideOffset={4}
|
|
159
|
+
collisionPadding={8}
|
|
160
|
+
className="p-1"
|
|
161
|
+
data-slot="line-product-results"
|
|
162
|
+
// El foco se queda en la celda: se sigue escribiendo mientras se ve la lista.
|
|
163
|
+
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
164
|
+
onCloseAutoFocus={(e) => e.preventDefault()}
|
|
165
|
+
onInteractOutside={(e) => {
|
|
166
|
+
if (anchorRef.current?.contains(e.target as Node)) e.preventDefault()
|
|
167
|
+
}}
|
|
168
|
+
style={{ width: 'max(var(--radix-popover-trigger-width), 22rem)', maxWidth: 'calc(100vw - 1rem)' }}
|
|
169
|
+
>
|
|
170
|
+
{hasHits ? (
|
|
171
|
+
<ul id={listId} role="listbox" aria-label={ariaLabel} className="max-h-72 overflow-auto">
|
|
172
|
+
{flat.map((hit, idx) => {
|
|
173
|
+
const price = hit.variant?.price ?? hit.product.price
|
|
174
|
+
const stock = availableStock(hit.variant ?? hit.product, warehouseId)
|
|
175
|
+
const meta = [hit.variant?.sku ?? hit.product.sku, hit.product.tire?.normalized].filter(Boolean).join(' · ')
|
|
176
|
+
return (
|
|
177
|
+
<li key={`${hit.product.id}:${hit.variant?.id ?? ''}`} id={`${listId}-${idx}`} role="option" aria-selected={idx === active}>
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
tabIndex={-1}
|
|
181
|
+
className={
|
|
182
|
+
'flex w-full items-center justify-between gap-3 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent hover:text-accent-foreground ' +
|
|
183
|
+
(idx === active ? 'bg-accent text-accent-foreground' : '')
|
|
184
|
+
}
|
|
185
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
186
|
+
onMouseEnter={() => setActive(idx)}
|
|
187
|
+
onClick={() => choose(idx)}
|
|
188
|
+
>
|
|
189
|
+
<span className="min-w-0">
|
|
190
|
+
<span className="block truncate font-medium">{hit.variant ? `${hit.product.name} · ${hit.variant.label}` : hit.product.name}</span>
|
|
191
|
+
{meta && <span className="block truncate text-xs text-muted-foreground">{meta}</span>}
|
|
192
|
+
</span>
|
|
193
|
+
<span className="flex shrink-0 items-center gap-2">
|
|
194
|
+
{stock != null && (
|
|
195
|
+
<Badge variant="secondary" className="font-normal tabular-nums">
|
|
196
|
+
{stock}
|
|
197
|
+
</Badge>
|
|
198
|
+
)}
|
|
199
|
+
{price != null && <span className="tabular-nums">{fmt.money(price)}</span>}
|
|
200
|
+
</span>
|
|
201
|
+
</button>
|
|
202
|
+
</li>
|
|
203
|
+
)
|
|
204
|
+
})}
|
|
205
|
+
</ul>
|
|
206
|
+
) : loading ? (
|
|
207
|
+
<p className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
|
208
|
+
<Loader2 className="size-4 animate-spin" aria-hidden />
|
|
209
|
+
{t('common.searching', { defaultValue: 'Buscando…' })}
|
|
210
|
+
</p>
|
|
211
|
+
) : (
|
|
212
|
+
<p className="px-2 py-3 text-sm text-muted-foreground">
|
|
213
|
+
{t('lineItems.noProduct', { defaultValue: 'Sin coincidencias. Se usará como descripción libre.' })}
|
|
214
|
+
</p>
|
|
215
|
+
)}
|
|
216
|
+
</PopoverContent>
|
|
217
|
+
</Popover>
|
|
218
|
+
)
|
|
219
|
+
})
|
|
@@ -28,7 +28,6 @@ import {
|
|
|
28
28
|
Badge,
|
|
29
29
|
Button,
|
|
30
30
|
Command,
|
|
31
|
-
CommandEmpty,
|
|
32
31
|
CommandGroup,
|
|
33
32
|
CommandInput,
|
|
34
33
|
CommandItem,
|
|
@@ -38,9 +37,10 @@ import {
|
|
|
38
37
|
PopoverTrigger,
|
|
39
38
|
InitialsAvatar,
|
|
40
39
|
} from '@asteby/metacore-ui/primitives'
|
|
41
|
-
import { Check, ChevronsUpDown, Loader2,
|
|
40
|
+
import { Check, ChevronsUpDown, Loader2, ScanLine } from 'lucide-react'
|
|
42
41
|
import { resolveColorCss } from '@asteby/metacore-ui/lib'
|
|
43
42
|
import { BarcodeScanner } from './barcode-scanner'
|
|
43
|
+
import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
|
|
44
44
|
import { DynamicIcon, isLucideIconName } from './dynamic-icon'
|
|
45
45
|
import { useDebouncedValue } from './use-debounced-value'
|
|
46
46
|
import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
|
|
@@ -190,7 +190,8 @@ export interface DynamicSelectFieldProps {
|
|
|
190
190
|
*/
|
|
191
191
|
descriptionAsBadge?: boolean
|
|
192
192
|
/**
|
|
193
|
-
* Hide the inline "+"
|
|
193
|
+
* Hide the inline create/edit affordances (joined "+" / pencil and the
|
|
194
|
+
* "Crear …" list footer) even when `field.ref` is set.
|
|
194
195
|
* Useful for static / filtered lists where creating a new record is not
|
|
195
196
|
* meaningful in context.
|
|
196
197
|
*/
|
|
@@ -344,8 +345,31 @@ export function DynamicSelectField({
|
|
|
344
345
|
// via a decoupled window event the host listens for. On success the host
|
|
345
346
|
// hands back the new record and we select it immediately. No host import →
|
|
346
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
|
+
}
|
|
369
|
+
|
|
347
370
|
const openCreate = () => {
|
|
348
371
|
if (!fieldRef || typeof window === 'undefined') return
|
|
372
|
+
setOpen(false)
|
|
349
373
|
window.dispatchEvent(
|
|
350
374
|
new CustomEvent('metacore:create-record', {
|
|
351
375
|
detail: {
|
|
@@ -398,8 +422,12 @@ export function DynamicSelectField({
|
|
|
398
422
|
// to the cell. Without min-w-0 the combobox+button row sizes to its content
|
|
399
423
|
// (the long empty-state placeholder) and overflows the column, pushing the
|
|
400
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)
|
|
427
|
+
const fieldName = field.label ? t(field.label, { defaultValue: field.label }) : fieldRef ?? ''
|
|
401
428
|
return (
|
|
402
|
-
<div className="flex w-full min-w-0 items-center gap-1.5">
|
|
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">
|
|
403
431
|
<Popover open={open && !blockedByDependency} onOpenChange={(o: boolean) => { if (!blockedByDependency) setOpen(o) }}>
|
|
404
432
|
<PopoverTrigger asChild>
|
|
405
433
|
<Button
|
|
@@ -412,6 +440,7 @@ export function DynamicSelectField({
|
|
|
412
440
|
aria-invalid={invalid || undefined}
|
|
413
441
|
className={
|
|
414
442
|
'min-w-0 flex-1 justify-between font-normal' +
|
|
443
|
+
(joined ? ` ${JOINED_TRIGGER_CLASS}` : '') +
|
|
415
444
|
(invalid ? ' border-destructive ring-1 ring-destructive/30' : '')
|
|
416
445
|
}
|
|
417
446
|
data-empty={!value}
|
|
@@ -438,9 +467,13 @@ export function DynamicSelectField({
|
|
|
438
467
|
<PopoverContent
|
|
439
468
|
className="p-0"
|
|
440
469
|
align="start"
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
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)' }}
|
|
444
477
|
>
|
|
445
478
|
<Command shouldFilter={false}>
|
|
446
479
|
<CommandInput
|
|
@@ -456,7 +489,9 @@ export function DynamicSelectField({
|
|
|
456
489
|
</div>
|
|
457
490
|
)}
|
|
458
491
|
{!loading && options.length === 0 && (
|
|
459
|
-
<CommandEmpty
|
|
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">
|
|
460
495
|
{useStatic
|
|
461
496
|
? debounced
|
|
462
497
|
? 'Sin resultados'
|
|
@@ -464,7 +499,7 @@ export function DynamicSelectField({
|
|
|
464
499
|
: debounced
|
|
465
500
|
? 'Sin resultados'
|
|
466
501
|
: 'Sin resultados — escribe para filtrar'}
|
|
467
|
-
</
|
|
502
|
+
</div>
|
|
468
503
|
)}
|
|
469
504
|
{!loading && options.length > 0 && (
|
|
470
505
|
<CommandGroup className="max-h-64 overflow-auto">
|
|
@@ -502,10 +537,17 @@ export function DynamicSelectField({
|
|
|
502
537
|
})}
|
|
503
538
|
</CommandGroup>
|
|
504
539
|
)}
|
|
540
|
+
{canMutate && !loading && (
|
|
541
|
+
<PickerCreateItem label={fieldName} onSelect={openCreate} />
|
|
542
|
+
)}
|
|
505
543
|
</CommandList>
|
|
506
544
|
</Command>
|
|
507
545
|
</PopoverContent>
|
|
508
546
|
</Popover>
|
|
547
|
+
{canMutate && !blockedByDependency && (
|
|
548
|
+
<RecordPickerAction hasValue={!!value} label={fieldName} onCreate={openCreate} onEdit={openEdit} />
|
|
549
|
+
)}
|
|
550
|
+
</div>
|
|
509
551
|
{scanEnabled && (
|
|
510
552
|
<Button
|
|
511
553
|
type="button"
|
|
@@ -519,19 +561,6 @@ export function DynamicSelectField({
|
|
|
519
561
|
<ScanLine className="size-4" />
|
|
520
562
|
</Button>
|
|
521
563
|
)}
|
|
522
|
-
{fieldRef && !hideCreate && !useStatic && (
|
|
523
|
-
<Button
|
|
524
|
-
type="button"
|
|
525
|
-
variant="outline"
|
|
526
|
-
size="icon"
|
|
527
|
-
className="size-9 shrink-0"
|
|
528
|
-
onClick={openCreate}
|
|
529
|
-
title={`Crear ${field.label ?? fieldRef}`}
|
|
530
|
-
aria-label={`Crear ${field.label ?? fieldRef}`}
|
|
531
|
-
>
|
|
532
|
-
<Plus className="size-4" />
|
|
533
|
-
</Button>
|
|
534
|
-
)}
|
|
535
564
|
{scanEnabled && (
|
|
536
565
|
<BarcodeScanner
|
|
537
566
|
open={scanOpen}
|
package/src/entity-select.tsx
CHANGED
|
@@ -15,11 +15,10 @@
|
|
|
15
15
|
// code is needed. This lives in the SDK so POS, purchases and any future addon
|
|
16
16
|
// share ONE implementation instead of each re-porting a bespoke picker.
|
|
17
17
|
import { useCallback, useEffect, useState } from 'react'
|
|
18
|
-
import { Search, X,
|
|
18
|
+
import { Search, X, type LucideIcon } from 'lucide-react'
|
|
19
19
|
import {
|
|
20
20
|
Button,
|
|
21
21
|
Command,
|
|
22
|
-
CommandEmpty,
|
|
23
22
|
CommandGroup,
|
|
24
23
|
CommandInput,
|
|
25
24
|
CommandItem,
|
|
@@ -29,6 +28,7 @@ import {
|
|
|
29
28
|
PopoverTrigger,
|
|
30
29
|
} from '@asteby/metacore-ui'
|
|
31
30
|
import { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
31
|
+
import { JOINED_TRIGGER_CLASS, PickerCreateItem, RecordPickerAction, hasRecordPickerAction } from './record-picker-actions'
|
|
32
32
|
import { useCan } from './permissions-context'
|
|
33
33
|
import { useApi } from './api-context'
|
|
34
34
|
|
|
@@ -172,14 +172,14 @@ export function EntitySelect({
|
|
|
172
172
|
onSelect(null, null)
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
const openCreate = (
|
|
176
|
-
|
|
175
|
+
const openCreate = () => {
|
|
176
|
+
setOpen(false)
|
|
177
177
|
setDialogRecordId(undefined)
|
|
178
178
|
setDialogOpen(true)
|
|
179
179
|
}
|
|
180
|
-
const openEdit = (
|
|
181
|
-
e.stopPropagation()
|
|
180
|
+
const openEdit = () => {
|
|
182
181
|
if (!value) return
|
|
182
|
+
setOpen(false)
|
|
183
183
|
setDialogRecordId(value)
|
|
184
184
|
setDialogOpen(true)
|
|
185
185
|
}
|
|
@@ -198,14 +198,18 @@ export function EntitySelect({
|
|
|
198
198
|
onSelect(id, lbl)
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
const onCreate = mayCreate ? openCreate : undefined
|
|
202
|
+
const onEdit = mayEdit ? openEdit : undefined
|
|
203
|
+
const joined = hasRecordPickerAction(!!value, onCreate, onEdit)
|
|
204
|
+
|
|
201
205
|
return (
|
|
202
|
-
<div className="flex items-center
|
|
206
|
+
<div className="flex items-center" data-slot="entity-select">
|
|
203
207
|
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
|
204
208
|
<PopoverTrigger asChild>
|
|
205
209
|
<Button
|
|
206
210
|
variant="outline"
|
|
207
211
|
disabled={disabled}
|
|
208
|
-
className=
|
|
212
|
+
className={'w-full min-w-0 flex-1 justify-start gap-2 font-normal' + (joined ? ` ${JOINED_TRIGGER_CLASS}` : '')}
|
|
209
213
|
>
|
|
210
214
|
{Icon && <Icon className="text-muted-foreground size-4 shrink-0" />}
|
|
211
215
|
<span className="flex-1 truncate text-left">{label ?? placeholder}</span>
|
|
@@ -228,7 +232,8 @@ export function EntitySelect({
|
|
|
228
232
|
<PopoverContent
|
|
229
233
|
className="p-0"
|
|
230
234
|
align="start"
|
|
231
|
-
|
|
235
|
+
collisionPadding={8}
|
|
236
|
+
style={{ width: 'max(var(--radix-popover-trigger-width), 14rem)', maxWidth: 'calc(100vw - 1rem)' }}
|
|
232
237
|
>
|
|
233
238
|
<Command shouldFilter={false}>
|
|
234
239
|
<CommandInput
|
|
@@ -244,7 +249,7 @@ export function EntitySelect({
|
|
|
244
249
|
)}
|
|
245
250
|
{!isLoading &&
|
|
246
251
|
searchTerm.length >= minChars &&
|
|
247
|
-
results.length === 0 && <
|
|
252
|
+
results.length === 0 && <div className="text-muted-foreground py-6 text-center text-sm">{emptyText}</div>}
|
|
248
253
|
{!isLoading && results.length > 0 && (
|
|
249
254
|
<CommandGroup className="max-h-64 overflow-auto">
|
|
250
255
|
{results.map((row) => (
|
|
@@ -270,41 +275,14 @@ export function EntitySelect({
|
|
|
270
275
|
<span className="text-xs">Escribe al menos 2 caracteres</span>
|
|
271
276
|
</div>
|
|
272
277
|
)}
|
|
278
|
+
{onCreate && !isLoading && <PickerCreateItem label={model} onSelect={openCreate} />}
|
|
273
279
|
</CommandList>
|
|
274
280
|
</Command>
|
|
275
281
|
</PopoverContent>
|
|
276
282
|
</Popover>
|
|
277
283
|
|
|
278
284
|
{/* Selected → edit (pencil); empty → create (+). Each gated by perms. */}
|
|
279
|
-
{value
|
|
280
|
-
? mayEdit && (
|
|
281
|
-
<Button
|
|
282
|
-
type="button"
|
|
283
|
-
variant="outline"
|
|
284
|
-
size="icon"
|
|
285
|
-
disabled={disabled}
|
|
286
|
-
onClick={openEdit}
|
|
287
|
-
aria-label="Editar"
|
|
288
|
-
title="Editar"
|
|
289
|
-
className="shrink-0"
|
|
290
|
-
>
|
|
291
|
-
<Pencil className="size-4" />
|
|
292
|
-
</Button>
|
|
293
|
-
)
|
|
294
|
-
: mayCreate && (
|
|
295
|
-
<Button
|
|
296
|
-
type="button"
|
|
297
|
-
variant="outline"
|
|
298
|
-
size="icon"
|
|
299
|
-
disabled={disabled}
|
|
300
|
-
onClick={openCreate}
|
|
301
|
-
aria-label="Crear"
|
|
302
|
-
title="Crear"
|
|
303
|
-
className="shrink-0"
|
|
304
|
-
>
|
|
305
|
-
<Plus className="size-4" />
|
|
306
|
-
</Button>
|
|
307
|
-
)}
|
|
285
|
+
<RecordPickerAction hasValue={!!value} label={model} onCreate={onCreate} onEdit={onEdit} disabled={disabled} />
|
|
308
286
|
|
|
309
287
|
{dialogOpen && (
|
|
310
288
|
<CreateRecordDialog
|
package/src/field-grid.tsx
CHANGED
|
@@ -42,14 +42,28 @@ export function FieldCell({
|
|
|
42
42
|
export function FieldLabel({
|
|
43
43
|
htmlFor,
|
|
44
44
|
required,
|
|
45
|
+
tone = 'caps',
|
|
45
46
|
children,
|
|
46
47
|
}: {
|
|
47
48
|
htmlFor?: string
|
|
48
49
|
required?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* `caps` (default): small uppercase eyebrow of the CRUD modals. `sentence`:
|
|
52
|
+
* sentence-case label for dense editors (DocumentEditor) where a column of
|
|
53
|
+
* uppercase labels reads as shouting.
|
|
54
|
+
*/
|
|
55
|
+
tone?: 'caps' | 'sentence'
|
|
49
56
|
children: ReactNode
|
|
50
57
|
}) {
|
|
51
58
|
return (
|
|
52
|
-
<Label
|
|
59
|
+
<Label
|
|
60
|
+
htmlFor={htmlFor}
|
|
61
|
+
className={
|
|
62
|
+
tone === 'sentence'
|
|
63
|
+
? 'text-sm font-medium text-foreground/80'
|
|
64
|
+
: 'text-xs font-medium uppercase tracking-wide text-muted-foreground'
|
|
65
|
+
}
|
|
66
|
+
>
|
|
53
67
|
{children}
|
|
54
68
|
{required && <span className="ml-0.5 text-destructive">*</span>}
|
|
55
69
|
</Label>
|
package/src/index.ts
CHANGED
|
@@ -475,6 +475,8 @@ export type { DynamicRecordDialogProps, FieldDef, FieldOption, GetImageUrl } fro
|
|
|
475
475
|
export { CreateRecordDialog } from './dialogs/create-record-dialog'
|
|
476
476
|
export { EntitySelect } from './entity-select'
|
|
477
477
|
export type { EntitySelectProps, EntitySelectOption } from './entity-select'
|
|
478
|
+
export { RecordPickerAction, PickerCreateItem, JOINED_TRIGGER_CLASS, hasRecordPickerAction } from './record-picker-actions'
|
|
479
|
+
export type { RecordPickerActionProps } from './record-picker-actions'
|
|
478
480
|
export { ViewRecordDialog } from './dialogs/view-record-dialog'
|
|
479
481
|
export type {
|
|
480
482
|
ModelKey,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// RecordPickerActions — create/edit affordances shared by every "pick a related
|
|
2
|
+
// record" control (DynamicSelectField for declarative forms, EntitySelect for
|
|
3
|
+
// hand-written screens). ONE implementation of the same contract:
|
|
4
|
+
//
|
|
5
|
+
// - nothing selected → "+" joined to the trigger: opens the model's CREATE
|
|
6
|
+
// modal and the new record ends up selected;
|
|
7
|
+
// - a record selected → pencil joined to the trigger: opens THAT record's
|
|
8
|
+
// EDIT modal and the label refreshes on save;
|
|
9
|
+
// - "Crear …" at the foot of the result list, so creating is one step away
|
|
10
|
+
// from a search with no hits.
|
|
11
|
+
//
|
|
12
|
+
// The action renders as a segment of the trigger (shared border, no gap) so the
|
|
13
|
+
// control reads as a single field instead of a select plus a loose button.
|
|
14
|
+
import type { ReactNode } from 'react'
|
|
15
|
+
import { Pencil, Plus } from 'lucide-react'
|
|
16
|
+
import { Button, CommandGroup, CommandItem, CommandSeparator } from '@asteby/metacore-ui/primitives'
|
|
17
|
+
|
|
18
|
+
/** Classes for a trigger that has a joined action segment on its right. */
|
|
19
|
+
export const JOINED_TRIGGER_CLASS = 'rounded-r-none focus-visible:z-10'
|
|
20
|
+
|
|
21
|
+
export interface RecordPickerActionProps {
|
|
22
|
+
/** Current value: drives create (+) vs edit (pencil). */
|
|
23
|
+
hasValue: boolean
|
|
24
|
+
/** Human label of the referenced model / field, for the accessible name. */
|
|
25
|
+
label: string
|
|
26
|
+
onCreate?: () => void
|
|
27
|
+
onEdit?: () => void
|
|
28
|
+
disabled?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Joined trailing segment: pencil when there is a value (and `onEdit`), "+"
|
|
33
|
+
* otherwise (and `onCreate`). Renders nothing when neither applies — callers
|
|
34
|
+
* should then drop {@link JOINED_TRIGGER_CLASS} from the trigger.
|
|
35
|
+
*/
|
|
36
|
+
export function RecordPickerAction({ hasValue, label, onCreate, onEdit, disabled }: RecordPickerActionProps) {
|
|
37
|
+
const edit = hasValue && !!onEdit
|
|
38
|
+
const create = !hasValue && !!onCreate
|
|
39
|
+
if (!edit && !create) return null
|
|
40
|
+
const name = edit ? `Editar ${label}` : `Crear ${label}`
|
|
41
|
+
return (
|
|
42
|
+
<Button
|
|
43
|
+
type="button"
|
|
44
|
+
variant="outline"
|
|
45
|
+
size="icon"
|
|
46
|
+
disabled={disabled}
|
|
47
|
+
onClick={(e) => {
|
|
48
|
+
e.stopPropagation()
|
|
49
|
+
if (edit) onEdit?.()
|
|
50
|
+
else onCreate?.()
|
|
51
|
+
}}
|
|
52
|
+
aria-label={name}
|
|
53
|
+
title={name}
|
|
54
|
+
data-slot="record-picker-action"
|
|
55
|
+
data-action={edit ? 'edit' : 'create'}
|
|
56
|
+
className="-ml-px size-9 shrink-0 rounded-l-none text-muted-foreground hover:text-foreground focus-visible:z-10"
|
|
57
|
+
>
|
|
58
|
+
{edit ? <Pencil className="size-4" /> : <Plus className="size-4" />}
|
|
59
|
+
</Button>
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when a picker will render a joined action for the given state. */
|
|
64
|
+
export function hasRecordPickerAction(hasValue: boolean, onCreate?: unknown, onEdit?: unknown): boolean {
|
|
65
|
+
return hasValue ? !!onEdit : !!onCreate
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** "Crear …" row at the foot of a Command list. */
|
|
69
|
+
export function PickerCreateItem({ label, onSelect, children }: { label: string; onSelect: () => void; children?: ReactNode }) {
|
|
70
|
+
return (
|
|
71
|
+
<>
|
|
72
|
+
<CommandSeparator />
|
|
73
|
+
<CommandGroup>
|
|
74
|
+
<CommandItem value="__create__" onSelect={onSelect} data-slot="picker-create" className="text-primary">
|
|
75
|
+
<Plus className="mr-2 size-4 shrink-0" />
|
|
76
|
+
<span className="truncate">{children ?? `Crear ${label}`}</span>
|
|
77
|
+
</CommandItem>
|
|
78
|
+
</CommandGroup>
|
|
79
|
+
</>
|
|
80
|
+
)
|
|
81
|
+
}
|