@asteby/metacore-runtime-react 49.5.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 (73) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/business/customer-picker.d.ts +8 -2
  3. package/dist/business/customer-picker.d.ts.map +1 -1
  4. package/dist/business/customer-picker.js +30 -27
  5. package/dist/business/index.d.ts +1 -0
  6. package/dist/business/index.d.ts.map +1 -1
  7. package/dist/business/index.js +1 -0
  8. package/dist/business/line-product-cell.d.ts +8 -10
  9. package/dist/business/line-product-cell.d.ts.map +1 -1
  10. package/dist/business/line-product-cell.js +13 -90
  11. package/dist/business/product-options.d.ts +41 -0
  12. package/dist/business/product-options.d.ts.map +1 -0
  13. package/dist/business/product-options.js +58 -0
  14. package/dist/business/product-picker.d.ts +5 -1
  15. package/dist/business/product-picker.d.ts.map +1 -1
  16. package/dist/business/product-picker.js +26 -35
  17. package/dist/business/relate-documents.d.ts +1 -1
  18. package/dist/business/relate-documents.d.ts.map +1 -1
  19. package/dist/business/relate-documents.js +6 -8
  20. package/dist/business/vehicle-picker.d.ts +8 -2
  21. package/dist/business/vehicle-picker.d.ts.map +1 -1
  22. package/dist/business/vehicle-picker.js +28 -28
  23. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  24. package/dist/dialogs/dynamic-record.js +28 -43
  25. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  26. package/dist/dynamic-multi-select-field.js +17 -5
  27. package/dist/dynamic-select-field.d.ts +7 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +67 -161
  30. package/dist/entity-select.d.ts +5 -2
  31. package/dist/entity-select.d.ts.map +1 -1
  32. package/dist/entity-select.js +23 -116
  33. package/dist/index.d.ts +7 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +8 -1
  36. package/dist/record-picker-actions.d.ts +40 -2
  37. package/dist/record-picker-actions.d.ts.map +1 -1
  38. package/dist/record-picker-actions.js +44 -4
  39. package/dist/record-picker-dialog.d.ts +23 -0
  40. package/dist/record-picker-dialog.d.ts.map +1 -0
  41. package/dist/record-picker-dialog.js +37 -0
  42. package/dist/record-picker-option.d.ts +25 -0
  43. package/dist/record-picker-option.d.ts.map +1 -0
  44. package/dist/record-picker-option.js +55 -0
  45. package/dist/record-picker.d.ts +149 -0
  46. package/dist/record-picker.d.ts.map +1 -0
  47. package/dist/record-picker.js +372 -0
  48. package/package.json +1 -1
  49. package/src/__tests__/business-pickers.test.tsx +6 -3
  50. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  51. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  52. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  53. package/src/__tests__/record-picker.test.tsx +469 -0
  54. package/src/business/customer-picker.tsx +87 -98
  55. package/src/business/index.ts +1 -0
  56. package/src/business/line-product-cell.tsx +55 -180
  57. package/src/business/product-options.tsx +119 -0
  58. package/src/business/product-picker.tsx +63 -126
  59. package/src/business/relate-documents.tsx +36 -45
  60. package/src/business/vehicle-picker.tsx +76 -99
  61. package/src/dialogs/dynamic-record.tsx +44 -97
  62. package/src/dynamic-multi-select-field.tsx +39 -13
  63. package/src/dynamic-select-field.tsx +143 -368
  64. package/src/entity-select.tsx +53 -202
  65. package/src/index.ts +27 -2
  66. package/src/record-picker-actions.tsx +101 -8
  67. package/src/record-picker-dialog.tsx +80 -0
  68. package/src/record-picker-option.tsx +96 -0
  69. package/src/record-picker.tsx +971 -0
  70. package/dist/business/use-async-search.d.ts +0 -10
  71. package/dist/business/use-async-search.d.ts.map +0 -1
  72. package/dist/business/use-async-search.js +0 -38
  73. package/src/business/use-async-search.ts +0 -43
@@ -0,0 +1,469 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // RecordPicker — el ÚNICO selector de registro del runtime. Matriz del
4
+ // primitivo (simple/múltiple, crear con el texto buscado, editar solo con
5
+ // valor, portal fuera de overflow-hidden, teclado, estados async) y un test
6
+ // por migración clave (DynamicSelectField, EntitySelect, Customer/Product/
7
+ // Vehicle pickers, multi-select de `ref`, campo `search` legado del modal).
8
+ import { useState } from 'react'
9
+ import { afterEach, describe, expect, it, vi } from 'vitest'
10
+ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
11
+
12
+ vi.mock('react-i18next', () => {
13
+ const t = (k: string, o?: Record<string, any>) => {
14
+ let s: string = o?.defaultValue ?? k
15
+ for (const [key, v] of Object.entries(o ?? {})) s = s.replace(`{{${key}}}`, String(v))
16
+ return s
17
+ }
18
+ const value = { t, i18n: { language: 'es' } }
19
+ return { useTranslation: () => value }
20
+ })
21
+
22
+ import { RecordPicker, RECORD_PICKER_Z_INDEX, useRecordSearch } from '../record-picker'
23
+ import { DynamicSelectField } from '../dynamic-select-field'
24
+ import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
25
+ import { EntitySelect } from '../entity-select'
26
+ import { CustomerPicker, type CustomerResult } from '../business/customer-picker'
27
+ import { ProductPicker } from '../business/product-picker'
28
+ import { EditField } from '../dialogs/dynamic-record'
29
+ import { ApiProvider, type ApiClient } from '../api-context'
30
+ import type { ActionFieldDef } from '../types'
31
+ import * as runtime from '../index'
32
+
33
+ afterEach(cleanup)
34
+
35
+ interface Row {
36
+ id: string
37
+ name: string
38
+ sku?: string
39
+ }
40
+ const rows: Row[] = [
41
+ { id: 'r1', name: 'Llantera Norte', sku: 'LN' },
42
+ { id: 'r2', name: 'Llantera Sur', sku: 'LS' },
43
+ { id: 'r3', name: 'Refacciones Bajío', sku: 'RB' },
44
+ ]
45
+ const base = { getKey: (r: Row) => r.id, getLabel: (r: Row) => r.name, getDescription: (r: Row) => r.sku }
46
+
47
+ function Single(props: Partial<React.ComponentProps<typeof RecordPicker<Row>>> & { initial?: string | null }) {
48
+ const { initial = null, ...rest } = props
49
+ const [value, setValue] = useState<string | null>(initial)
50
+ return (
51
+ <>
52
+ <RecordPicker<Row>
53
+ {...base}
54
+ items={rows}
55
+ entityLabel="Proveedor"
56
+ value={value}
57
+ selected={rows.find((r) => r.id === value) ?? null}
58
+ onSelect={(r) => setValue(r.id)}
59
+ onClear={() => setValue(null)}
60
+ {...rest}
61
+ />
62
+ <output data-testid="value">{value ?? ''}</output>
63
+ </>
64
+ )
65
+ }
66
+ const valueOf = () => screen.getByTestId('value').textContent
67
+ const trigger = () => screen.getAllByRole('combobox')[0]!
68
+ const openList = async () => {
69
+ await act(async () => {
70
+ trigger().click()
71
+ })
72
+ return screen.getByRole('listbox')
73
+ }
74
+ const searchBox = () => screen.getByPlaceholderText('Buscar…') as HTMLInputElement
75
+
76
+ describe('RecordPicker — selección simple', () => {
77
+ it('abre la lista, elige y cierra; el trigger muestra la etiqueta', async () => {
78
+ render(<Single />)
79
+ expect(trigger().getAttribute('aria-expanded')).toBe('false')
80
+ const list = await openList()
81
+ expect(within(list).getAllByRole('option').map((o) => o.textContent)).toEqual(['Llantera NorteLN', 'Llantera SurLS', 'Refacciones BajíoRB'])
82
+ fireEvent.click(within(list).getByRole('option', { name: /Llantera Sur/ }))
83
+ expect(valueOf()).toBe('r2')
84
+ expect(screen.queryByRole('listbox')).toBeNull()
85
+ expect(trigger().textContent).toContain('Llantera Sur')
86
+ })
87
+
88
+ it('al reabrir resalta el valor actual y el × limpia', async () => {
89
+ render(<Single initial="r3" />)
90
+ const list = await openList()
91
+ const active = within(list).getAllByRole('option').find((o) => o.getAttribute('data-active') != null)!
92
+ expect(active.textContent).toContain('Refacciones')
93
+ expect(active.getAttribute('data-selected')).not.toBeNull()
94
+ fireEvent.keyDown(searchBox(), { key: 'Escape' })
95
+ fireEvent.click(screen.getByRole('button', { name: 'Quitar selección' }))
96
+ expect(valueOf()).toBe('')
97
+ })
98
+ })
99
+
100
+ describe('RecordPicker — selección múltiple', () => {
101
+ function Multi() {
102
+ const [ids, setIds] = useState<string[]>(['r1'])
103
+ const toggle = (r: Row) => setIds((v) => (v.includes(r.id) ? v.filter((x) => x !== r.id) : [...v, r.id]))
104
+ return (
105
+ <>
106
+ <RecordPicker<Row>
107
+ {...base}
108
+ multiple
109
+ items={rows}
110
+ value={ids}
111
+ selected={rows.filter((r) => ids.includes(r.id))}
112
+ onSelect={toggle}
113
+ onRemove={toggle}
114
+ />
115
+ <output data-testid="value">{ids.join(',')}</output>
116
+ </>
117
+ )
118
+ }
119
+
120
+ it('alterna sin cerrar, muestra chips y quita desde el chip', async () => {
121
+ render(<Multi />)
122
+ expect(document.querySelectorAll('[data-slot="record-picker-chip"]')).toHaveLength(1)
123
+ const list = await openList()
124
+ expect(list.getAttribute('aria-multiselectable')).toBe('true')
125
+ fireEvent.click(within(list).getByRole('option', { name: /Refacciones/ }))
126
+ expect(valueOf()).toBe('r1,r3')
127
+ // Sigue abierta para elegir más.
128
+ expect(screen.getByRole('listbox')).toBeTruthy()
129
+ expect(within(list).getByRole('option', { name: /Refacciones/ }).getAttribute('aria-selected')).toBe('true')
130
+ fireEvent.click(screen.getByRole('button', { name: 'Quitar Llantera Norte' }))
131
+ expect(valueOf()).toBe('r3')
132
+ })
133
+ })
134
+
135
+ describe('RecordPicker — crear / editar', () => {
136
+ it('«Crear …» al pie prellena con el texto buscado; el «+» unido abre el alta sin valor', async () => {
137
+ const onCreate = vi.fn()
138
+ render(<Single onCreate={onCreate} onEdit={vi.fn()} />)
139
+ // Sin valor: «+» unido al trigger, sin lápiz.
140
+ expect(screen.queryByRole('button', { name: 'Editar Proveedor' })).toBeNull()
141
+ expect(trigger().className).toContain('rounded-r-none')
142
+ await openList()
143
+ fireEvent.change(searchBox(), { target: { value: 'Acme Llantas' } })
144
+ const footer = document.querySelector('[data-slot="picker-create"]') as HTMLElement
145
+ expect(footer.textContent).toContain('Crear Proveedor')
146
+ expect(footer.textContent).toContain('«Acme Llantas»')
147
+ fireEvent.click(footer)
148
+ expect(onCreate).toHaveBeenCalledWith('Acme Llantas')
149
+ fireEvent.click(screen.getByRole('button', { name: 'Crear Proveedor' }))
150
+ expect(onCreate).toHaveBeenLastCalledWith('')
151
+ })
152
+
153
+ it('el lápiz solo existe con valor y abre la edición', () => {
154
+ const onEdit = vi.fn()
155
+ const { unmount } = render(<Single onEdit={onEdit} />)
156
+ expect(screen.queryByRole('button', { name: 'Editar Proveedor' })).toBeNull()
157
+ unmount()
158
+ render(<Single initial="r1" onEdit={onEdit} onCreate={vi.fn()} />)
159
+ expect(screen.queryByRole('button', { name: 'Crear Proveedor' })).toBeNull()
160
+ fireEvent.click(screen.getByRole('button', { name: 'Editar Proveedor' }))
161
+ expect(onEdit).toHaveBeenCalledTimes(1)
162
+ })
163
+ })
164
+
165
+ describe('RecordPicker — portal y capas', () => {
166
+ it('la lista sale fuera de un contenedor overflow-hidden y por encima de los diálogos', async () => {
167
+ render(
168
+ <div data-testid="clip" style={{ overflow: 'hidden', height: 40 }}>
169
+ <Single />
170
+ </div>,
171
+ )
172
+ const list = await openList()
173
+ expect(screen.getByTestId('clip').contains(list)).toBe(false)
174
+ const content = list.closest('[data-slot="record-picker-content"]') as HTMLElement
175
+ expect(content).toBeTruthy()
176
+ expect(content.style.zIndex).toBe(String(RECORD_PICKER_Z_INDEX))
177
+ expect(RECORD_PICKER_Z_INDEX).toBeGreaterThan(50)
178
+ })
179
+ })
180
+
181
+ describe('RecordPicker — teclado y ARIA', () => {
182
+ it('↓/↑/Home/End mueven, Enter elige; aria-activedescendant sigue al resaltado', async () => {
183
+ render(<Single />)
184
+ await openList()
185
+ const box = searchBox()
186
+ expect(box.getAttribute('aria-controls')).toBe(screen.getByRole('listbox').id)
187
+ fireEvent.keyDown(box, { key: 'ArrowDown' })
188
+ expect(document.getElementById(box.getAttribute('aria-activedescendant')!)!.textContent).toContain('Llantera Sur')
189
+ fireEvent.keyDown(box, { key: 'End' })
190
+ expect(document.getElementById(box.getAttribute('aria-activedescendant')!)!.textContent).toContain('Refacciones')
191
+ fireEvent.keyDown(box, { key: 'Home' })
192
+ fireEvent.keyDown(box, { key: 'ArrowDown' })
193
+ fireEvent.keyDown(box, { key: 'ArrowUp' })
194
+ fireEvent.keyDown(box, { key: 'ArrowDown' })
195
+ fireEvent.keyDown(box, { key: 'Enter' })
196
+ expect(valueOf()).toBe('r2')
197
+ })
198
+
199
+ it('Esc cierra sin elegir; ↓ en el trigger abre; teclear en el trigger busca', async () => {
200
+ render(<Single />)
201
+ await openList()
202
+ fireEvent.keyDown(searchBox(), { key: 'Escape' })
203
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
204
+ expect(valueOf()).toBe('')
205
+ fireEvent.keyDown(trigger(), { key: 'ArrowDown' })
206
+ expect(screen.getByRole('listbox')).toBeTruthy()
207
+ fireEvent.keyDown(searchBox(), { key: 'Escape' })
208
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
209
+ fireEvent.keyDown(trigger(), { key: 'r' })
210
+ expect(searchBox().value).toBe('r')
211
+ })
212
+
213
+ it('Tab cierra la lista y pasa al siguiente campo', async () => {
214
+ render(
215
+ <>
216
+ <Single />
217
+ <input aria-label="siguiente" />
218
+ </>,
219
+ )
220
+ await openList()
221
+ fireEvent.keyDown(searchBox(), { key: 'Tab' })
222
+ await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull())
223
+ await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText('siguiente')))
224
+ })
225
+
226
+ it('modo input con texto libre: Enter sin coincidencias pasa al host; Esc no burbujea al diálogo', async () => {
227
+ const unhandled = vi.fn()
228
+ const outer = vi.fn()
229
+ function Free() {
230
+ const [text, setText] = useState('')
231
+ const [open, setOpen] = useState(false)
232
+ return (
233
+ <div onKeyDown={(e) => e.key === 'Escape' && outer()}>
234
+ <RecordPicker<Row>
235
+ {...base}
236
+ trigger="input"
237
+ freeText
238
+ items={[]}
239
+ query={text}
240
+ onQueryChange={setText}
241
+ open={open}
242
+ onOpenChange={setOpen}
243
+ onSelect={() => {}}
244
+ onUnhandledKeyDown={unhandled}
245
+ ariaLabel="Descripción"
246
+ emptyText="Sin coincidencias"
247
+ />
248
+ </div>
249
+ )
250
+ }
251
+ render(<Free />)
252
+ const box = screen.getByRole('combobox', { name: 'Descripción' })
253
+ fireEvent.change(box, { target: { value: 'Mano de obra' } })
254
+ expect(screen.getByText('Sin coincidencias')).toBeTruthy()
255
+ fireEvent.keyDown(box, { key: 'Escape' })
256
+ expect(outer).not.toHaveBeenCalled()
257
+ expect(box.getAttribute('aria-expanded')).toBe('false')
258
+ fireEvent.keyDown(box, { key: 'Enter' })
259
+ expect(unhandled).toHaveBeenCalledTimes(1)
260
+ expect((box as HTMLInputElement).value).toBe('Mano de obra')
261
+ })
262
+ })
263
+
264
+ describe('RecordPicker — búsqueda async', () => {
265
+ it('debounce, «Buscando…», vacío con crear al pie y error', async () => {
266
+ const search = vi.fn(async (q: string) => {
267
+ if (q === 'falla') throw new Error('500')
268
+ return rows.filter((r) => r.name.toLowerCase().includes(q.toLowerCase()))
269
+ })
270
+ render(<Single items={undefined} search={search} minChars={2} onCreate={vi.fn()} createFooter="empty" />)
271
+ await openList()
272
+ expect(document.querySelector('[data-slot="picker-min-chars"]')).toBeTruthy()
273
+ fireEvent.change(searchBox(), { target: { value: 'll' } })
274
+ expect(screen.getByText('Buscando…')).toBeTruthy()
275
+ await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(2))
276
+ expect(search).toHaveBeenCalledTimes(1)
277
+ expect(search.mock.calls[0]![0]).toBe('ll')
278
+ // Con resultados no hay «Crear» al pie (modo `empty`).
279
+ expect(document.querySelector('[data-slot="picker-create"]')).toBeNull()
280
+ fireEvent.change(searchBox(), { target: { value: 'zzz' } })
281
+ await waitFor(() => expect(document.querySelector('[data-slot="picker-empty"]')).toBeTruthy())
282
+ expect(document.querySelector('[data-slot="picker-create"]')).toBeTruthy()
283
+ fireEvent.change(searchBox(), { target: { value: 'falla' } })
284
+ await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy())
285
+ })
286
+
287
+ it('useRecordSearch no consulta deshabilitado ni por debajo del mínimo', async () => {
288
+ const search = vi.fn(async () => rows)
289
+ function Probe({ q, enabled }: { q: string; enabled: boolean }) {
290
+ const r = useRecordSearch(q, search, { minChars: 2, delay: 0, enabled })
291
+ return <output data-testid="n">{r.results.length}</output>
292
+ }
293
+ const { rerender } = render(<Probe q="l" enabled />)
294
+ rerender(<Probe q="ll" enabled={false} />)
295
+ await new Promise((r) => setTimeout(r, 20))
296
+ expect(search).not.toHaveBeenCalled()
297
+ rerender(<Probe q="ll" enabled />)
298
+ await waitFor(() => expect(screen.getByTestId('n').textContent).toBe('3'))
299
+ })
300
+ })
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Migraciones
304
+ // ---------------------------------------------------------------------------
305
+
306
+ const capture = (name: string) => {
307
+ const seen: any[] = []
308
+ const fn = (e: Event) => seen.push((e as CustomEvent).detail)
309
+ window.addEventListener(name, fn)
310
+ return { seen, off: () => window.removeEventListener(name, fn) }
311
+ }
312
+
313
+ describe('migración: DynamicSelectField', () => {
314
+ it('es un RecordPicker: crear desde la búsqueda manda el texto como nombre al modal del host', async () => {
315
+ const api = {
316
+ get: vi.fn(async () => ({ data: { success: true, data: [{ id: 'b1', label: 'Michelin' }] } })),
317
+ post: vi.fn(),
318
+ } as unknown as ApiClient
319
+ const field: ActionFieldDef = { key: 'brand_id', label: 'Marca', type: 'dynamic_select', ref: 'Brand' }
320
+ const create = capture('metacore:create-record')
321
+ const onChange = vi.fn()
322
+ render(
323
+ <ApiProvider client={api}>
324
+ <DynamicSelectField field={field} value="" onChange={onChange} createDefaults={{ active: true }} />
325
+ </ApiProvider>,
326
+ )
327
+ await act(async () => {
328
+ screen.getByRole('combobox').click()
329
+ })
330
+ await waitFor(() => expect(screen.getByRole('option', { name: /Michelin/ })).toBeTruthy())
331
+ fireEvent.change(screen.getByPlaceholderText('Buscar…'), { target: { value: 'Pirelli' } })
332
+ fireEvent.click(document.querySelector('[data-slot="picker-create"]') as HTMLElement)
333
+ expect(create.seen[0]).toMatchObject({ model: 'Brand', defaults: { name: 'Pirelli', active: true } })
334
+ // El registro creado queda elegido.
335
+ act(() => create.seen[0].onCreated({ id: 'b9', name: 'Pirelli' }))
336
+ expect(onChange).toHaveBeenCalledWith('b9')
337
+ create.off()
338
+ })
339
+ })
340
+
341
+ describe('migración: EntitySelect', () => {
342
+ it('busca con el fetcher, elige, limpia y conserva el slot', async () => {
343
+ const fetcher = vi.fn(async () => [{ value: 's1', label: 'Llantera Norte', description: 'RFC LNO' }])
344
+ const onSelect = vi.fn()
345
+ render(
346
+ <ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
347
+ <EntitySelect model="Supplier" value={null} label={null} onSelect={onSelect} fetcher={fetcher} preload canCreate={false} />
348
+ </ApiProvider>,
349
+ )
350
+ expect(document.querySelector('[data-slot="entity-select"]')).toBeTruthy()
351
+ await act(async () => {
352
+ screen.getByRole('combobox').click()
353
+ })
354
+ fireEvent.click(await screen.findByRole('option', { name: /Llantera Norte/ }))
355
+ expect(onSelect).toHaveBeenCalledWith('s1', 'Llantera Norte')
356
+ cleanup()
357
+ render(
358
+ <ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
359
+ <EntitySelect model="Supplier" value="s1" label="Llantera Norte" onSelect={onSelect} fetcher={fetcher} canCreate={false} canEdit={false} />
360
+ </ApiProvider>,
361
+ )
362
+ expect(screen.getByRole('combobox').textContent).toContain('Llantera Norte')
363
+ fireEvent.click(screen.getByRole('button', { name: 'Quitar selección' }))
364
+ expect(onSelect).toHaveBeenLastCalledWith(null, null)
365
+ })
366
+ })
367
+
368
+ describe('migración: CustomerPicker', () => {
369
+ const c: CustomerResult = { id: 'c1', name: 'Transportes del Bajío', tax_id: 'TBA850312KJ9', balance: 1500, credit_limit: 1000 }
370
+
371
+ it('busca en el propio campo, elige, y el elegido muestra saldo y lápiz de edición', async () => {
372
+ const search = vi.fn(async () => [c])
373
+ const onChange = vi.fn()
374
+ render(
375
+ <ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
376
+ <CustomerPicker value={null} onChange={onChange} search={search} currency="MXN" />
377
+ </ApiProvider>,
378
+ )
379
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'trans' } })
380
+ fireEvent.click(await screen.findByRole('option', { name: /Transportes/ }))
381
+ expect(onChange).toHaveBeenCalledWith(c)
382
+ cleanup()
383
+ render(
384
+ <ApiProvider client={{ get: vi.fn(), post: vi.fn() } as unknown as ApiClient}>
385
+ <CustomerPicker value={c} onChange={onChange} search={search} currency="MXN" />
386
+ </ApiProvider>,
387
+ )
388
+ expect(screen.getByText('TBA850312KJ9')).toBeTruthy()
389
+ expect(screen.getByText(/Adeudo/)).toBeTruthy()
390
+ expect(screen.getByRole('button', { name: 'Editar cliente' })).toBeTruthy()
391
+ fireEvent.click(screen.getByRole('button', { name: 'Quitar cliente' }))
392
+ expect(onChange).toHaveBeenLastCalledWith(null)
393
+ })
394
+ })
395
+
396
+ describe('migración: ProductPicker', () => {
397
+ it('código de barras exacto + Enter elige la variante; sin existencia queda deshabilitado', async () => {
398
+ const product = {
399
+ id: 'p1',
400
+ name: 'Llanta 205/55R16',
401
+ price: 1500,
402
+ variants: [
403
+ { id: 'v1', label: 'Rin 16 · 91V', barcode: '7501234567890', price: 1600 },
404
+ { id: 'v2', label: 'Rin 16 · 94W', barcode: '7501234567891', stock: [{ warehouse_id: 'w1', available: 0 }] },
405
+ ],
406
+ }
407
+ const search = vi.fn(async () => [product])
408
+ const onSelect = vi.fn()
409
+ render(<ProductPicker search={search} onSelect={onSelect} allowOutOfStock={false} warehouseId="w1" />)
410
+ const box = screen.getByRole('combobox')
411
+ fireEvent.change(box, { target: { value: '7501234567890' } })
412
+ const options = await screen.findAllByRole('option')
413
+ expect(options).toHaveLength(2)
414
+ expect(options[1]!.getAttribute('aria-disabled')).toBe('true')
415
+ fireEvent.keyDown(box, { key: 'Enter' })
416
+ expect(onSelect).toHaveBeenCalledWith(product, product.variants[0])
417
+ })
418
+ })
419
+
420
+ describe('migración: multi-select de `ref` y campo `search` legado', () => {
421
+ it('DynamicMultiSelectField guarda un arreglo de ids con chips', async () => {
422
+ const api = {
423
+ get: vi.fn(async () => ({ data: { success: true, data: [{ id: 'g1', label: 'Mayoreo' }, { id: 'g2', label: 'Flotillas' }] } })),
424
+ post: vi.fn(async () => ({ data: { success: false } })),
425
+ } as unknown as ApiClient
426
+ const onChange = vi.fn()
427
+ render(
428
+ <ApiProvider client={api}>
429
+ <DynamicMultiSelectField field={{ key: 'segments', label: 'Segmentos', ref: 'Segment', multiple: true }} value={['g1']} onChange={onChange} />
430
+ </ApiProvider>,
431
+ )
432
+ await waitFor(() => expect(document.querySelector('[data-slot="record-picker-chip"]')?.textContent).toContain('Mayoreo'))
433
+ await act(async () => {
434
+ screen.getByRole('combobox').click()
435
+ })
436
+ fireEvent.click(screen.getByRole('option', { name: /Flotillas/ }))
437
+ expect(onChange).toHaveBeenCalledWith(['g1', 'g2'])
438
+ })
439
+
440
+ it('EditField `type: search` usa el RecordPicker contra su searchEndpoint', async () => {
441
+ const api = {
442
+ get: vi.fn(async () => ({ data: { data: [{ id: 'u1', name: 'Ana López' }, { id: 'u2', name: 'Beto Ruiz' }] } })),
443
+ } as unknown as ApiClient
444
+ const onChange = vi.fn()
445
+ render(
446
+ <ApiProvider client={api}>
447
+ <EditField field={{ key: 'user_id', label: 'Usuario', type: 'search', searchEndpoint: '/users/search' }} value="" onChange={onChange} />
448
+ </ApiProvider>,
449
+ )
450
+ await act(async () => {
451
+ screen.getByRole('combobox').click()
452
+ })
453
+ fireEvent.click(await screen.findByRole('option', { name: /Beto Ruiz/ }))
454
+ expect(onChange).toHaveBeenCalledWith('u2')
455
+ expect(screen.getByRole('combobox').textContent).toContain('Beto Ruiz')
456
+ })
457
+ })
458
+
459
+ describe('exports', () => {
460
+ it('RecordPicker y sus piezas son públicas; los wrappers siguen exportados', () => {
461
+ expect(runtime.RecordPicker).toBeTypeOf('function')
462
+ expect(runtime.useRecordSearch).toBeTypeOf('function')
463
+ expect(runtime.useRecordPickerDialog).toBeTypeOf('function')
464
+ expect(runtime.requestRecordCreate).toBeTypeOf('function')
465
+ for (const name of ['DynamicSelectField', 'EntitySelect', 'CustomerPicker', 'ProductPicker', 'VehiclePicker', 'LineProductCell', 'OptionLead', 'PickerCreateItem', 'RecordPickerAction'] as const) {
466
+ expect((runtime as Record<string, unknown>)[name]).toBeTruthy()
467
+ }
468
+ })
469
+ })