@asteby/metacore-runtime-react 49.4.0 → 49.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/dist/api-context.d.ts +9 -1
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/business/customer-picker.d.ts +8 -2
- package/dist/business/customer-picker.d.ts.map +1 -1
- package/dist/business/customer-picker.js +30 -27
- package/dist/business/index.d.ts +1 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +1 -0
- package/dist/business/line-product-cell.d.ts +8 -10
- package/dist/business/line-product-cell.d.ts.map +1 -1
- package/dist/business/line-product-cell.js +13 -90
- package/dist/business/product-options.d.ts +41 -0
- package/dist/business/product-options.d.ts.map +1 -0
- package/dist/business/product-options.js +58 -0
- package/dist/business/product-picker.d.ts +5 -1
- package/dist/business/product-picker.d.ts.map +1 -1
- package/dist/business/product-picker.js +26 -35
- package/dist/business/relate-documents.d.ts +1 -1
- package/dist/business/relate-documents.d.ts.map +1 -1
- package/dist/business/relate-documents.js +6 -8
- package/dist/business/vehicle-picker.d.ts +8 -2
- package/dist/business/vehicle-picker.d.ts.map +1 -1
- package/dist/business/vehicle-picker.js +28 -28
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +28 -43
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +17 -5
- package/dist/dynamic-select-field.d.ts +7 -23
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +67 -161
- package/dist/dynamic-table.d.ts +31 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +37 -4
- package/dist/entity-select.d.ts +5 -2
- package/dist/entity-select.d.ts.map +1 -1
- package/dist/entity-select.js +23 -116
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/dist/record-picker-actions.d.ts +40 -2
- package/dist/record-picker-actions.d.ts.map +1 -1
- package/dist/record-picker-actions.js +44 -4
- package/dist/record-picker-dialog.d.ts +23 -0
- package/dist/record-picker-dialog.d.ts.map +1 -0
- package/dist/record-picker-dialog.js +37 -0
- package/dist/record-picker-option.d.ts +25 -0
- package/dist/record-picker-option.d.ts.map +1 -0
- package/dist/record-picker-option.js +55 -0
- package/dist/record-picker.d.ts +149 -0
- package/dist/record-picker.d.ts.map +1 -0
- package/dist/record-picker.js +372 -0
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/business-pickers.test.tsx +6 -3
- package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
- package/src/__tests__/document-form-dialog.test.tsx +4 -3
- package/src/__tests__/document-lines-grid.test.tsx +2 -1
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/__tests__/record-picker.test.tsx +469 -0
- package/src/api-context.tsx +19 -4
- package/src/business/customer-picker.tsx +87 -98
- package/src/business/index.ts +1 -0
- package/src/business/line-product-cell.tsx +55 -180
- package/src/business/product-options.tsx +119 -0
- package/src/business/product-picker.tsx +63 -126
- package/src/business/relate-documents.tsx +36 -45
- package/src/business/vehicle-picker.tsx +76 -99
- package/src/dialogs/dynamic-record.tsx +44 -97
- package/src/dynamic-multi-select-field.tsx +39 -13
- package/src/dynamic-select-field.tsx +143 -368
- package/src/dynamic-table.tsx +91 -4
- package/src/entity-select.tsx +53 -202
- package/src/index.ts +27 -2
- package/src/query-batch.ts +18 -1
- package/src/record-picker-actions.tsx +101 -8
- package/src/record-picker-dialog.tsx +80 -0
- package/src/record-picker-option.tsx +96 -0
- package/src/record-picker.tsx +971 -0
- package/dist/business/use-async-search.d.ts +0 -10
- package/dist/business/use-async-search.d.ts.map +0 -1
- package/dist/business/use-async-search.js +0 -38
- package/src/business/use-async-search.ts +0 -43
|
@@ -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
|
+
})
|
package/src/api-context.tsx
CHANGED
|
@@ -15,13 +15,22 @@ export interface ApiClient {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
const ApiContext = createContext<ApiClient | null>(null)
|
|
18
|
+
const BatchContext = createContext<boolean>(true)
|
|
18
19
|
|
|
19
20
|
export interface ApiProviderProps {
|
|
20
21
|
client: ApiClient
|
|
22
|
+
/**
|
|
23
|
+
* Groups list, metadata and options reads into one `POST /q`. Defaults to
|
|
24
|
+
* `true`. Pass `false` when the host backend does not implement `/q`: every
|
|
25
|
+
* read then goes straight to its own GET and no POST is attempted. When
|
|
26
|
+
* left on and `/q` answers 404/405, the batch turns itself off for that
|
|
27
|
+
* client after the first failed attempt and the read falls back to GET.
|
|
28
|
+
*/
|
|
29
|
+
batch?: boolean
|
|
21
30
|
children: React.ReactNode
|
|
22
31
|
}
|
|
23
32
|
|
|
24
|
-
export function ApiProvider({ client, children }: ApiProviderProps) {
|
|
33
|
+
export function ApiProvider({ client, batch = true, children }: ApiProviderProps) {
|
|
25
34
|
// Hosts also write through their own client (native dialogs, settings
|
|
26
35
|
// screens) without going through useApi(). When the client is axios,
|
|
27
36
|
// every non-GET response drops what the runtime remembered as well.
|
|
@@ -43,7 +52,11 @@ export function ApiProvider({ client, children }: ApiProviderProps) {
|
|
|
43
52
|
)
|
|
44
53
|
return () => interceptors.eject?.(id)
|
|
45
54
|
}, [client])
|
|
46
|
-
return
|
|
55
|
+
return (
|
|
56
|
+
<ApiContext.Provider value={client}>
|
|
57
|
+
<BatchContext.Provider value={batch}>{children}</BatchContext.Provider>
|
|
58
|
+
</ApiContext.Provider>
|
|
59
|
+
)
|
|
47
60
|
}
|
|
48
61
|
|
|
49
62
|
interface AxiosLike {
|
|
@@ -82,6 +95,7 @@ function mutating<T>(request: Promise<T>): Promise<T> {
|
|
|
82
95
|
/** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
|
|
83
96
|
export function useApi(): ApiClient {
|
|
84
97
|
const ctx = useContext(ApiContext)
|
|
98
|
+
const batch = useContext(BatchContext)
|
|
85
99
|
if (!ctx) {
|
|
86
100
|
throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
|
|
87
101
|
}
|
|
@@ -92,12 +106,13 @@ export function useApi(): ApiClient {
|
|
|
92
106
|
return useMemo<ApiClient>(() => ({
|
|
93
107
|
// Each call reaches the host client with the arguments it was given —
|
|
94
108
|
// never a trailing `undefined` it didn't ask for.
|
|
95
|
-
get: (...args: Parameters<ApiClient['get']>) =>
|
|
109
|
+
get: (...args: Parameters<ApiClient['get']>) =>
|
|
110
|
+
batch ? batchGet(ctx, ...args) : ctx.get(...args),
|
|
96
111
|
post: (...args: Parameters<ApiClient['post']>) =>
|
|
97
112
|
isWrite('post', args[0]) ? mutating(ctx.post(...args)) : ctx.post(...args),
|
|
98
113
|
put: (...args: Parameters<ApiClient['put']>) => mutating(ctx.put(...args)),
|
|
99
114
|
delete: (...args: Parameters<ApiClient['delete']>) => mutating(ctx.delete(...args)),
|
|
100
|
-
}), [ctx])
|
|
115
|
+
}), [ctx, batch])
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
/** Optional branch context — hosts that support tenant branches can supply
|