@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api-context.d.ts +9 -1
  3. package/dist/api-context.d.ts.map +1 -1
  4. package/dist/api-context.js +6 -4
  5. package/dist/business/customer-picker.d.ts +8 -2
  6. package/dist/business/customer-picker.d.ts.map +1 -1
  7. package/dist/business/customer-picker.js +30 -27
  8. package/dist/business/index.d.ts +1 -0
  9. package/dist/business/index.d.ts.map +1 -1
  10. package/dist/business/index.js +1 -0
  11. package/dist/business/line-product-cell.d.ts +8 -10
  12. package/dist/business/line-product-cell.d.ts.map +1 -1
  13. package/dist/business/line-product-cell.js +13 -90
  14. package/dist/business/product-options.d.ts +41 -0
  15. package/dist/business/product-options.d.ts.map +1 -0
  16. package/dist/business/product-options.js +58 -0
  17. package/dist/business/product-picker.d.ts +5 -1
  18. package/dist/business/product-picker.d.ts.map +1 -1
  19. package/dist/business/product-picker.js +26 -35
  20. package/dist/business/relate-documents.d.ts +1 -1
  21. package/dist/business/relate-documents.d.ts.map +1 -1
  22. package/dist/business/relate-documents.js +6 -8
  23. package/dist/business/vehicle-picker.d.ts +8 -2
  24. package/dist/business/vehicle-picker.d.ts.map +1 -1
  25. package/dist/business/vehicle-picker.js +28 -28
  26. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  27. package/dist/dialogs/dynamic-record.js +28 -43
  28. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-multi-select-field.js +17 -5
  30. package/dist/dynamic-select-field.d.ts +7 -23
  31. package/dist/dynamic-select-field.d.ts.map +1 -1
  32. package/dist/dynamic-select-field.js +67 -161
  33. package/dist/dynamic-table.d.ts +31 -1
  34. package/dist/dynamic-table.d.ts.map +1 -1
  35. package/dist/dynamic-table.js +37 -4
  36. package/dist/entity-select.d.ts +5 -2
  37. package/dist/entity-select.d.ts.map +1 -1
  38. package/dist/entity-select.js +23 -116
  39. package/dist/index.d.ts +7 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -1
  42. package/dist/query-batch.d.ts.map +1 -1
  43. package/dist/query-batch.js +17 -1
  44. package/dist/record-picker-actions.d.ts +40 -2
  45. package/dist/record-picker-actions.d.ts.map +1 -1
  46. package/dist/record-picker-actions.js +44 -4
  47. package/dist/record-picker-dialog.d.ts +23 -0
  48. package/dist/record-picker-dialog.d.ts.map +1 -0
  49. package/dist/record-picker-dialog.js +37 -0
  50. package/dist/record-picker-option.d.ts +25 -0
  51. package/dist/record-picker-option.d.ts.map +1 -0
  52. package/dist/record-picker-option.js +55 -0
  53. package/dist/record-picker.d.ts +149 -0
  54. package/dist/record-picker.d.ts.map +1 -0
  55. package/dist/record-picker.js +372 -0
  56. package/package.json +1 -1
  57. package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
  58. package/src/__tests__/business-pickers.test.tsx +6 -3
  59. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  60. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  61. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  62. package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
  63. package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
  64. package/src/__tests__/record-picker.test.tsx +469 -0
  65. package/src/api-context.tsx +19 -4
  66. package/src/business/customer-picker.tsx +87 -98
  67. package/src/business/index.ts +1 -0
  68. package/src/business/line-product-cell.tsx +55 -180
  69. package/src/business/product-options.tsx +119 -0
  70. package/src/business/product-picker.tsx +63 -126
  71. package/src/business/relate-documents.tsx +36 -45
  72. package/src/business/vehicle-picker.tsx +76 -99
  73. package/src/dialogs/dynamic-record.tsx +44 -97
  74. package/src/dynamic-multi-select-field.tsx +39 -13
  75. package/src/dynamic-select-field.tsx +143 -368
  76. package/src/dynamic-table.tsx +91 -4
  77. package/src/entity-select.tsx +53 -202
  78. package/src/index.ts +27 -2
  79. package/src/query-batch.ts +18 -1
  80. package/src/record-picker-actions.tsx +101 -8
  81. package/src/record-picker-dialog.tsx +80 -0
  82. package/src/record-picker-option.tsx +96 -0
  83. package/src/record-picker.tsx +971 -0
  84. package/dist/business/use-async-search.d.ts +0 -10
  85. package/dist/business/use-async-search.d.ts.map +0 -1
  86. package/dist/business/use-async-search.js +0 -38
  87. package/src/business/use-async-search.ts +0 -43
@@ -39,12 +39,6 @@ import {
39
39
  Popover,
40
40
  PopoverContent,
41
41
  PopoverTrigger,
42
- Command,
43
- CommandEmpty,
44
- CommandGroup,
45
- CommandInput,
46
- CommandItem,
47
- CommandList,
48
42
  Calendar,
49
43
  } from '@asteby/metacore-ui/primitives'
50
44
  import { cn } from '@asteby/metacore-ui/lib'
@@ -52,12 +46,13 @@ import { toast } from 'sonner'
52
46
  import { format, parseISO } from 'date-fns'
53
47
  import { parseCalendarDate } from '../calendar-date'
54
48
  import { es } from 'date-fns/locale'
55
- import { ExternalLink, Loader2, CalendarIcon, ChevronDown, Check, Upload, X as XIcon, ScanLine } from 'lucide-react'
49
+ import { ExternalLink, Loader2, CalendarIcon, Upload, X as XIcon, ScanLine } from 'lucide-react'
56
50
  import { BarcodeScanner } from '../barcode-scanner'
57
51
  import { useApi } from '../api-context'
58
52
  import { useBranchCreateGate } from '../branch-create-gate'
59
53
  import { toastServerError, extractFieldErrors, localizeFieldIssue, localizeFieldErrorMap } from '../server-error'
60
54
  import { DynamicSelectField, OptionLead, OptionThumb } from '../dynamic-select-field'
55
+ import { RecordPicker } from '../record-picker'
61
56
  import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
62
57
  import { DynamicRelations } from '../dynamic-relations'
63
58
  import { AuditInfo, readAuditMeta } from '../audit-info'
@@ -2380,108 +2375,60 @@ const searchCache = new Map<string, any[]>()
2380
2375
 
2381
2376
  function SearchField({ field, value, onChange }: { field: FieldDef; value: any; onChange: (val: any) => void }) {
2382
2377
  const api = useApi()
2383
- const [open, setOpen] = useState(false)
2384
- const [query, setQuery] = useState('')
2385
- const [results, setResults] = useState<any[]>([])
2386
- const [loading, setLoading] = useState(false)
2387
2378
  const [selectedLabel, setSelectedLabel] = useState('')
2379
+ const ep = field.searchEndpoint!
2388
2380
 
2389
2381
  useEffect(() => {
2390
- if (!value || !field.searchEndpoint) return
2391
- const cached = searchCache.get(field.searchEndpoint)
2382
+ if (!value || !ep) return
2383
+ const cached = searchCache.get(ep)
2392
2384
  if (cached) {
2393
2385
  const match = cached.find((item: any) => item.value === value || item.id === value)
2394
2386
  if (match) { setSelectedLabel(match.label || match.name || ''); return }
2395
2387
  }
2396
- api.get(field.searchEndpoint, { params: { search: '', limit: 50 } }).then(res => {
2388
+ api.get(ep, { params: { search: '', limit: 50 } }).then(res => {
2397
2389
  const items = extractArray(res)
2398
- searchCache.set(field.searchEndpoint!, items)
2390
+ searchCache.set(ep, items)
2399
2391
  const match = items.find((item: any) => item.value === value || item.id === value)
2400
2392
  if (match) setSelectedLabel(match.label || match.name || '')
2401
2393
  }).catch(() => {})
2402
- }, [value, field.searchEndpoint, api])
2403
-
2404
- useEffect(() => {
2405
- if (!open || !field.searchEndpoint) return
2406
- if (!query) {
2407
- const cached = searchCache.get(field.searchEndpoint)
2408
- if (cached) { setResults(cached); return }
2394
+ }, [value, ep, api])
2395
+
2396
+ // Legacy `type: search` + searchEndpoint — same <RecordPicker> as every
2397
+ // relation field; the empty query is served from the per-endpoint cache.
2398
+ const search = useCallback(async (q: string) => {
2399
+ if (!q) {
2400
+ const cached = searchCache.get(ep)
2401
+ if (cached) return cached
2409
2402
  }
2410
- setLoading(true)
2411
- const timer = setTimeout(() => {
2412
- api.get(field.searchEndpoint!, { params: { search: query, limit: 20 } }).then(res => {
2413
- const items = extractArray(res)
2414
- if (!query) searchCache.set(field.searchEndpoint!, items)
2415
- setResults(items)
2416
- }).catch(() => setResults([]))
2417
- .finally(() => setLoading(false))
2418
- }, query ? 250 : 0)
2419
- return () => clearTimeout(timer)
2420
- }, [query, open, field.searchEndpoint, api])
2421
-
2403
+ const items = extractArray(await api.get(ep, { params: { search: q, limit: 20 } }))
2404
+ if (!q) searchCache.set(ep, items)
2405
+ return items
2406
+ }, [api, ep])
2407
+
2408
+ const itemValue = (item: any) => item.value ?? item.id
2409
+ const itemLabel = (item: any): string => item.label ?? item.name ?? ''
2410
+ const what = field.label?.toLowerCase() || ''
2422
2411
  return (
2423
- <Popover open={open} onOpenChange={setOpen}>
2424
- <PopoverTrigger asChild>
2425
- <Button
2426
- variant="outline"
2427
- role="combobox"
2428
- className={cn(
2429
- "w-full justify-between font-normal h-9",
2430
- !value && "text-muted-foreground"
2431
- )}
2432
- >
2433
- <span className="truncate">{selectedLabel || `Seleccionar ${field.label?.toLowerCase() || ''}...`}</span>
2434
- <ChevronDown className="ml-auto h-4 w-4 shrink-0 opacity-50" />
2435
- </Button>
2436
- </PopoverTrigger>
2437
- <PopoverContent className="w-[--radix-popover-trigger-width] p-0" align="start" side="bottom" sideOffset={4}>
2438
- <Command shouldFilter={false}>
2439
- <CommandInput
2440
- placeholder={`Buscar ${field.label?.toLowerCase() || ''}...`}
2441
- value={query}
2442
- onValueChange={setQuery}
2443
- />
2444
- <CommandList className="max-h-[200px]">
2445
- {loading ? (
2446
- <div className="py-6 text-center text-sm">
2447
- <Loader2 className="h-4 w-4 animate-spin mx-auto mb-1 text-muted-foreground" />
2448
- <span className="text-muted-foreground text-xs">Buscando...</span>
2449
- </div>
2450
- ) : results.length === 0 ? (
2451
- <CommandEmpty>Sin resultados.</CommandEmpty>
2452
- ) : (
2453
- <CommandGroup>
2454
- {results.map((item: any) => {
2455
- const itemValue = item.value ?? item.id
2456
- const itemLabel = item.label ?? item.name ?? ''
2457
- const isSelected = value === itemValue
2458
- return (
2459
- <CommandItem
2460
- key={itemValue}
2461
- value={String(itemValue)}
2462
- onSelect={() => {
2463
- onChange(itemValue)
2464
- setSelectedLabel(itemLabel)
2465
- setOpen(false)
2466
- setQuery('')
2467
- }}
2468
- >
2469
- {isSelected && <Check className="mr-2 h-3.5 w-3.5 shrink-0 text-primary" />}
2470
- <OptionThumb image={item.image} name={itemLabel} size={20} />
2471
- <div className="flex flex-col min-w-0 ml-2">
2472
- <span className="truncate">{itemLabel}</span>
2473
- {item.description && (
2474
- <span className="text-[11px] text-muted-foreground truncate">{item.description}</span>
2475
- )}
2476
- </div>
2477
- </CommandItem>
2478
- )
2479
- })}
2480
- </CommandGroup>
2481
- )}
2482
- </CommandList>
2483
- </Command>
2484
- </PopoverContent>
2485
- </Popover>
2412
+ <RecordPicker<any>
2413
+ search={search}
2414
+ minChars={0}
2415
+ getKey={(item) => String(itemValue(item))}
2416
+ getLabel={itemLabel}
2417
+ getDescription={(item) => item.description}
2418
+ renderLead={(item) => <OptionThumb image={item.image} name={itemLabel(item)} size={20} />}
2419
+ value={value}
2420
+ selected={value ? { value, label: selectedLabel } : null}
2421
+ renderValue={() => (
2422
+ <span className={cn('min-w-0 flex-1 truncate', !selectedLabel && 'text-muted-foreground')}>
2423
+ {selectedLabel || `Seleccionar ${what}...`}
2424
+ </span>
2425
+ )}
2426
+ onSelect={(item) => {
2427
+ onChange(itemValue(item))
2428
+ setSelectedLabel(itemLabel(item))
2429
+ }}
2430
+ searchPlaceholder={`Buscar ${what}...`}
2431
+ emptyText="Sin resultados."
2432
+ />
2486
2433
  )
2487
2434
  }
@@ -12,15 +12,16 @@
12
12
  //
13
13
  // Options are resolved once (a single page, not per-keystroke) through the
14
14
  // same canonical `/api/options/<ref>?field=id` endpoint DynamicSelectField
15
- // uses — the MultiSelect primitive then filters that page client-side as the
15
+ // uses — the shared <RecordPicker multiple> then filters that page client-side as the
16
16
  // user types. That's the right tradeoff for the FK sets this targets
17
17
  // (segments, tags, categories — tens, not thousands of rows); a field with a
18
18
  // genuinely large option set should keep using a single dynamic_select per
19
19
  // value instead.
20
- import { useMemo } from 'react'
21
- import { MultiSelect } from '@asteby/metacore-ui/primitives'
20
+ import { useMemo, useState } from 'react'
22
21
  import { getOptionFilter } from './option-filter'
23
- import { useOptionsResolver } from './use-options-resolver'
22
+ import { RecordPicker } from './record-picker'
23
+ import { OptionLead } from './record-picker-option'
24
+ import { useOptionsResolver, type ResolvedOption } from './use-options-resolver'
24
25
  import { getFieldRef } from './dynamic-form-schema'
25
26
  import type { ActionFieldDef } from './types'
26
27
 
@@ -33,6 +34,7 @@ export interface DynamicMultiSelectFieldProps {
33
34
 
34
35
  export function DynamicMultiSelectField({ field, value, onChange }: DynamicMultiSelectFieldProps) {
35
36
  const ref = getFieldRef(field)
37
+ const [query, setQuery] = useState('')
36
38
  const { options, loading } = useOptionsResolver({
37
39
  modelKey: '',
38
40
  fieldKey: 'id',
@@ -43,19 +45,43 @@ export function DynamicMultiSelectField({ field, value, onChange }: DynamicMulti
43
45
  })
44
46
 
45
47
  const selected = useMemo(() => (Array.isArray(value) ? value.map(String) : []), [value])
46
- const uiOptions = useMemo(
47
- () => options.map((o) => ({ value: String(o.id), label: o.label })),
48
- [options],
48
+ const selectedItems = useMemo(
49
+ () =>
50
+ selected
51
+ .map((id) => options.find((o) => String(o.id) === id))
52
+ .filter((o): o is ResolvedOption => !!o),
53
+ [selected, options],
49
54
  )
55
+ const shown = useMemo(() => {
56
+ const q = query.trim().toLowerCase()
57
+ return q ? options.filter((o) => String(o.label ?? '').toLowerCase().includes(q)) : options
58
+ }, [options, query])
59
+
60
+ const toggle = (o: ResolvedOption) => {
61
+ const id = String(o.id)
62
+ onChange(selected.includes(id) ? selected.filter((x) => x !== id) : [...selected, id])
63
+ }
50
64
 
51
65
  return (
52
- <MultiSelect
53
- options={uiOptions}
54
- selected={selected}
55
- onChange={onChange}
56
- placeholder={loading ? 'Cargando…' : (field.placeholder || 'Seleccionar...')}
66
+ <RecordPicker<ResolvedOption>
67
+ multiple
68
+ slot="dynamic-multi-select"
69
+ id={field.key}
70
+ items={shown}
71
+ loading={loading}
72
+ getKey={(o) => String(o.id)}
73
+ getLabel={(o) => o.label}
74
+ renderLead={(o) => <OptionLead option={o} size={20} />}
75
+ getDescription={(o) => o.description}
76
+ value={selected}
77
+ selected={selectedItems}
78
+ onSelect={toggle}
79
+ onRemove={toggle}
80
+ query={query}
81
+ onQueryChange={setQuery}
82
+ placeholder={loading ? 'Cargando…' : field.placeholder || 'Seleccionar...'}
57
83
  searchPlaceholder="Buscar..."
58
- emptyMessage="Sin resultados."
84
+ emptyText="Sin resultados."
59
85
  />
60
86
  )
61
87
  }