@asteby/metacore-runtime-react 49.7.0 → 49.9.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 (74) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/business/document-editor-model.d.ts +27 -2
  3. package/dist/business/document-editor-model.d.ts.map +1 -1
  4. package/dist/business/document-editor-model.js +53 -3
  5. package/dist/business/document-editor.d.ts +7 -2
  6. package/dist/business/document-editor.d.ts.map +1 -1
  7. package/dist/business/document-editor.js +121 -22
  8. package/dist/business/document-lines.d.ts +21 -1
  9. package/dist/business/document-lines.d.ts.map +1 -1
  10. package/dist/business/document-lines.js +69 -7
  11. package/dist/business/index.d.ts +1 -0
  12. package/dist/business/index.d.ts.map +1 -1
  13. package/dist/business/index.js +1 -0
  14. package/dist/business/line-items-editor.d.ts +21 -1
  15. package/dist/business/line-items-editor.d.ts.map +1 -1
  16. package/dist/business/line-items-editor.js +74 -15
  17. package/dist/business/line-items.d.ts +28 -0
  18. package/dist/business/line-items.d.ts.map +1 -1
  19. package/dist/business/line-items.js +1 -0
  20. package/dist/business/line-product-cell.d.ts +6 -0
  21. package/dist/business/line-product-cell.d.ts.map +1 -1
  22. package/dist/business/line-product-cell.js +9 -3
  23. package/dist/business/product-display.d.ts +18 -0
  24. package/dist/business/product-display.d.ts.map +1 -0
  25. package/dist/business/product-display.js +40 -0
  26. package/dist/business/product-options.d.ts +6 -1
  27. package/dist/business/product-options.d.ts.map +1 -1
  28. package/dist/business/product-options.js +11 -2
  29. package/dist/business/product-search.d.ts +8 -0
  30. package/dist/business/product-search.d.ts.map +1 -1
  31. package/dist/dialogs/dynamic-record.js +1 -1
  32. package/dist/dynamic-select-field.d.ts +7 -1
  33. package/dist/dynamic-select-field.d.ts.map +1 -1
  34. package/dist/dynamic-select-field.js +18 -4
  35. package/dist/field-grid.d.ts +4 -3
  36. package/dist/field-grid.d.ts.map +1 -1
  37. package/dist/field-grid.js +1 -1
  38. package/dist/index.d.ts +2 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/option-display.d.ts +68 -0
  42. package/dist/option-display.d.ts.map +1 -0
  43. package/dist/option-display.js +135 -0
  44. package/dist/query-batch.d.ts +1 -1
  45. package/dist/query-batch.d.ts.map +1 -1
  46. package/dist/query-batch.js +3 -1
  47. package/dist/record-picker.d.ts +2 -0
  48. package/dist/record-picker.d.ts.map +1 -1
  49. package/dist/record-picker.js +11 -4
  50. package/dist/use-options-resolver.d.ts +16 -1
  51. package/dist/use-options-resolver.d.ts.map +1 -1
  52. package/dist/use-options-resolver.js +38 -6
  53. package/package.json +1 -1
  54. package/src/__tests__/document-editor-inline-selects.test.tsx +11 -4
  55. package/src/__tests__/line-row-create-edit.test.tsx +312 -0
  56. package/src/__tests__/option-display.test.tsx +241 -0
  57. package/src/business/document-editor-model.ts +60 -2
  58. package/src/business/document-editor.tsx +143 -19
  59. package/src/business/document-lines.ts +79 -7
  60. package/src/business/index.ts +1 -0
  61. package/src/business/line-items-editor.tsx +158 -30
  62. package/src/business/line-items.ts +31 -0
  63. package/src/business/line-product-cell.tsx +32 -5
  64. package/src/business/product-display.ts +62 -0
  65. package/src/business/product-options.tsx +11 -2
  66. package/src/business/product-search.ts +8 -0
  67. package/src/dialogs/dynamic-record.tsx +1 -1
  68. package/src/dynamic-select-field.tsx +45 -1
  69. package/src/field-grid.tsx +5 -4
  70. package/src/index.ts +10 -0
  71. package/src/option-display.tsx +255 -0
  72. package/src/query-batch.ts +2 -0
  73. package/src/record-picker.tsx +16 -3
  74. package/src/use-options-resolver.ts +49 -4
@@ -47,10 +47,17 @@ describe('DocumentLinesGrid — select de producto en la celda', () => {
47
47
  const input = screen.getByRole('combobox', { name: 'Buscar producto' })
48
48
  const td = input.closest('td')!
49
49
  expect(td).toBeTruthy()
50
- // Ocupa solo su celda: no hay colSpan sobre toda la fila.
51
- expect(td.colSpan).toBe(1)
52
- expect(td.closest('tr')!.getAttribute('data-slot')).toBe('line-draft-row')
53
- expect(td.closest('tr')!.children.length).toBeGreaterThan(1)
50
+ // No es una barra a todo lo ancho: el renglón vacío conserva las celdas
51
+ // de «Importe» y acciones; el buscador usa el ancho de las columnas que
52
+ // ese renglón aún no llena (cantidad, precio…), así no se corta.
53
+ const row = td.closest('tr')!
54
+ expect(row.getAttribute('data-slot')).toBe('line-draft-row')
55
+ const columns = document.querySelectorAll('[data-slot="line-items-editor"] thead th').length
56
+ expect(td.colSpan).toBe(columns - 2)
57
+ expect(row.children.length).toBe(3)
58
+ // Placeholder corto que cabe; el detalle va como ayuda.
59
+ expect(input.getAttribute('placeholder')).toBe('Buscar producto…')
60
+ expect(input.getAttribute('title')).toMatch(/SKU o código de barras/)
54
61
  })
55
62
 
56
63
  it('la lista sale en un portal fuera del contenedor con overflow y Enter elige y pasa a «Cant.»', async () => {
@@ -0,0 +1,312 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Renglón del DocumentEditor «como las apps top»:
4
+ // - crear el producto desde la celda («Crear producto «texto»» / «+»): el
5
+ // producto nuevo entra en ESE renglón y lo llena igual que al elegirlo;
6
+ // - editar el producto del renglón (lápiz en la celda): el renglón toma los
7
+ // valores nuevos salvo lo que el usuario sobrescribió, con aviso «aplicar»;
8
+ // - la tarjeta de la contraparte con etiquetas y catálogos legibles;
9
+ // - placeholder corto y unidad con su etiqueta (nunca la clave cruda).
10
+ import { useState } from 'react'
11
+ import { afterEach, describe, expect, it, vi } from 'vitest'
12
+ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
13
+
14
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
15
+ vi.mock('react-i18next', () => {
16
+ const t = (k: string, o?: Record<string, any>) => {
17
+ let s: string = o?.defaultValue ?? k
18
+ for (const [key, v] of Object.entries(o ?? {})) s = s.replace(`{{${key}}}`, String(v))
19
+ return s
20
+ }
21
+ const value = { t, i18n: { language: 'es' } }
22
+ return { useTranslation: () => value }
23
+ })
24
+
25
+ import { DocumentLinesGrid } from '../business/line-items-editor'
26
+ import { DocumentEditor } from '../business/document-editor'
27
+ import { makeLine, serializeLineItems, type LineItem } from '../business/line-items'
28
+ import { applyCatalogPending, applyProductToLine, lineOverrides, refreshLineFromProduct } from '../business/document-lines'
29
+ import { fieldDisplayMeta, formatOptionValue, linesFromSource, partySummaryRows } from '../business/document-editor-model'
30
+ import type { ProductResult } from '../business/product-search'
31
+ import { ApiProvider, type ApiClient } from '../api-context'
32
+ import type { DocumentFormType } from '../types'
33
+
34
+ afterEach(cleanup)
35
+
36
+ const tire: ProductResult = { id: 'p1', name: 'Llanta 205/55R16', sku: 'LL-205', price: 1500, tax_rate: 0.16, unit: 'unit' }
37
+
38
+ function Grid(props: { initial?: LineItem[]; onCreateProduct?: any; onEditProduct?: any; unitLabel?: (u: string) => string | undefined }) {
39
+ const [lines, setLines] = useState(props.initial ?? [])
40
+ return (
41
+ <>
42
+ <DocumentLinesGrid
43
+ value={lines}
44
+ onChange={setLines}
45
+ search={vi.fn(async () => [])}
46
+ columns={['discount', 'tax']}
47
+ currency="MXN"
48
+ onCreateProduct={props.onCreateProduct}
49
+ onEditProduct={props.onEditProduct}
50
+ unitLabel={props.unitLabel}
51
+ />
52
+ <output data-testid="state">{JSON.stringify(lines)}</output>
53
+ </>
54
+ )
55
+ }
56
+ const state = () => JSON.parse(screen.getByTestId('state').textContent ?? '[]') as LineItem[]
57
+
58
+ async function typeAndCreate(input: HTMLElement, text: string) {
59
+ input.focus()
60
+ fireEvent.change(input, { target: { value: text } })
61
+ const create = await waitFor(() => {
62
+ const el = document.querySelector('[data-slot="picker-create"]') as HTMLElement | null
63
+ if (!el) throw new Error('sin pie «Crear»')
64
+ return el
65
+ }, { timeout: 2000 })
66
+ expect(create.textContent).toContain('Crear producto')
67
+ expect(create.textContent).toContain(`«${text}»`)
68
+ fireEvent.click(create)
69
+ }
70
+
71
+ describe('renglón — crear producto desde la celda', () => {
72
+ it('el renglón vacío del final: «Crear producto «texto»» abre el alta prellenada y el producto nuevo llena el renglón', async () => {
73
+ let resolve!: (p: ProductResult) => void
74
+ const onCreate = vi.fn(() => new Promise<ProductResult>((r) => (resolve = r)))
75
+ render(<Grid onCreateProduct={onCreate} unitLabel={(u) => (u === 'unit' ? 'pza' : undefined)} />)
76
+ await typeAndCreate(screen.getByRole('combobox', { name: 'Buscar producto' }), 'Llanta nueva')
77
+ expect(onCreate).toHaveBeenCalledWith('Llanta nueva')
78
+ // Mientras el modal está abierto no se agrega nada.
79
+ expect(state()).toHaveLength(0)
80
+ await act(async () => resolve({ ...tire, id: 'p9', name: 'Llanta nueva', sku: 'LL-NEW', price: 2450 }))
81
+ await waitFor(() => expect(state()).toHaveLength(1))
82
+ expect(state()[0]).toMatchObject({ product_id: 'p9', description: 'Llanta nueva', sku: 'LL-NEW', unit_price: 2450, catalog_price: 2450, tax_rate: 0.16, unit: 'unit', quantity: 1 })
83
+ // La unidad con su etiqueta, nunca la clave cruda.
84
+ const row = document.querySelector(`[data-line-key="${state()[0].key}"]`)!
85
+ expect(row.textContent).toContain('LL-NEW · pza')
86
+ expect(row.textContent).not.toContain('· unit')
87
+ await waitFor(() => expect((document.activeElement as HTMLElement | null)?.getAttribute('data-cell')).toBe('quantity'))
88
+ })
89
+
90
+ it('un renglón libre (sin producto): el producto creado queda en ESE renglón, no en uno nuevo', async () => {
91
+ const free = makeLine({ description: '', quantity: 3 })
92
+ const onCreate = vi.fn(async () => ({ ...tire, id: 'p7', name: 'Rin 16' }))
93
+ render(<Grid initial={[free]} onCreateProduct={onCreate} />)
94
+ const cell = screen.getAllByRole('combobox', { name: 'Descripción' })[0]
95
+ await typeAndCreate(cell, 'Rin 16')
96
+ await waitFor(() => expect(state()[0].product_id).toBe('p7'))
97
+ expect(state()).toHaveLength(1)
98
+ expect(state()[0]).toMatchObject({ key: free.key, description: 'Rin 16', quantity: 3, unit_price: 1500 })
99
+ })
100
+
101
+ it('el «+» unido al buscador también abre el alta', async () => {
102
+ const onCreate = vi.fn(() => new Promise<ProductResult>(() => {}))
103
+ render(<Grid onCreateProduct={onCreate} />)
104
+ const draft = document.querySelector('[data-slot="line-draft-row"]') as HTMLElement
105
+ fireEvent.click(within(draft).getByRole('button', { name: 'Crear producto' }))
106
+ expect(onCreate).toHaveBeenCalledWith('')
107
+ })
108
+
109
+ it('sin `onCreateProduct` no hay pie ni «+» (otros usos del grid quedan igual)', () => {
110
+ render(<Grid />)
111
+ const draft = document.querySelector('[data-slot="line-draft-row"]') as HTMLElement
112
+ expect(within(draft).queryByRole('button', { name: 'Crear producto' })).toBeNull()
113
+ })
114
+ })
115
+
116
+ describe('renglón — editar el producto desde la celda', () => {
117
+ it('lápiz accesible por teclado; al guardar actualiza lo que sigue igual al catálogo y respeta el precio capturado con aviso «aplicar»', async () => {
118
+ const picked = applyProductToLine(makeLine({ quantity: 2 }), tire)
119
+ let resolve!: (p: ProductResult) => void
120
+ const onEdit = vi.fn(() => new Promise<ProductResult>((r) => (resolve = r)))
121
+ render(<Grid initial={[picked]} onEditProduct={onEdit} />)
122
+ // El usuario cambia el precio a mano (override).
123
+ const price = screen.getByRole('textbox', { name: 'Precio' })
124
+ fireEvent.change(price, { target: { value: '1400' } })
125
+ const pencil = screen.getByRole('button', { name: 'Editar producto' })
126
+ expect(pencil.tagName).toBe('BUTTON')
127
+ expect(pencil.getAttribute('tabindex')).not.toBe('-1')
128
+ fireEvent.click(pencil)
129
+ expect(onEdit).toHaveBeenCalledWith(expect.objectContaining({ product_id: 'p1' }))
130
+ await act(async () => resolve({ ...tire, name: 'Llanta 205/55R16 XL', sku: 'LL-205XL', price: 1650, tax_rate: 0.08, unit: 'set' }))
131
+ await waitFor(() => expect(state()[0].description).toBe('Llanta 205/55R16 XL'))
132
+ const l = state()[0]
133
+ expect(l).toMatchObject({ sku: 'LL-205XL', tax_rate: 0.08, unit: 'set', catalog_price: 1650, quantity: 2 })
134
+ // Override respetado.
135
+ expect(l.unit_price).toBe(1400)
136
+ const notice = document.querySelector('[data-slot="catalog-pending"]') as HTMLElement
137
+ expect(notice.textContent).toMatch(/Precio de catálogo cambió a .*1,650\.00/)
138
+ fireEvent.click(within(notice).getByRole('button', { name: 'aplicar' }))
139
+ await waitFor(() => expect(state()[0].unit_price).toBe(1650))
140
+ expect(document.querySelector('[data-slot="catalog-pending"]')).toBeNull()
141
+ })
142
+
143
+ it('sin overrides no hay aviso: todo el renglón toma el catálogo nuevo', async () => {
144
+ const picked = applyProductToLine(makeLine(), tire)
145
+ render(<Grid initial={[picked]} onEditProduct={vi.fn(async () => ({ ...tire, price: 1800 }))} />)
146
+ fireEvent.click(screen.getByRole('button', { name: 'Editar producto' }))
147
+ await waitFor(() => expect(state()[0].unit_price).toBe(1800))
148
+ expect(document.querySelector('[data-slot="catalog-pending"]')).toBeNull()
149
+ })
150
+
151
+ it('sin `onEditProduct` no hay lápiz', () => {
152
+ render(<Grid initial={[applyProductToLine(makeLine(), tire)]} />)
153
+ expect(screen.queryByRole('button', { name: 'Editar producto' })).toBeNull()
154
+ })
155
+ })
156
+
157
+ describe('refreshLineFromProduct (puro)', () => {
158
+ it('dirty por campo: solo pisa lo que sigue igual a la foto del catálogo', () => {
159
+ const line = { ...applyProductToLine(makeLine(), tire), description: 'Llanta con nombre del cliente' }
160
+ expect([...lineOverrides(line)]).toEqual(['description'])
161
+ const next = refreshLineFromProduct(line, { ...tire, name: 'Llanta nueva', price: 1600 })
162
+ expect(next.description).toBe('Llanta con nombre del cliente')
163
+ expect(next.unit_price).toBe(1600)
164
+ expect(next.catalog_pending).toEqual({ description: 'Llanta nueva' })
165
+ // La foto avanza: el override sigue siéndolo, lo demás queda limpio.
166
+ expect([...lineOverrides(next)]).toEqual(['description'])
167
+ expect(applyCatalogPending(next).description).toBe('Llanta nueva')
168
+ })
169
+
170
+ it('renglón de un documento origen: no cambia cantidad, tope ni vínculo, ni pisa los precios del origen', () => {
171
+ const [src] = linesFromSource([{ id: 'sol-1', product_id: 'p1', product_name: 'Llanta', quantity: 4, remaining_quantity: 2, unit_price: 1300, tax_rate: 0.16 }])
172
+ const next = refreshLineFromProduct(src, { ...tire, price: 1700 })
173
+ expect(next).toMatchObject({ quantity: 2, max_quantity: 2, source_line_id: 'sol-1', unit_price: 1300, description: 'Llanta', product_id: 'p1' })
174
+ expect(next.catalog_pending?.unit_price).toBe(1700)
175
+ })
176
+
177
+ it('la foto y el aviso no viajan al backend', () => {
178
+ const next = refreshLineFromProduct({ ...applyProductToLine(makeLine(), tire), unit_price: 1 }, { ...tire, price: 2 })
179
+ const [row] = serializeLineItems([next])
180
+ expect(row).not.toHaveProperty('catalog')
181
+ expect(row).not.toHaveProperty('catalog_pending')
182
+ })
183
+ })
184
+
185
+ describe('tarjeta de la contraparte', () => {
186
+ it('etiqueta del campo, valores de catálogo «código · texto» y sin vacíos ni claves crudas', () => {
187
+ const meta = fieldDisplayMeta([
188
+ [{ key: 'tax_id', label: 'customers.field.tax_id' }, { key: 'email', label: 'Correo' }],
189
+ [{ key: 'fiscal_data.uso_cfdi', label: 'Uso del CFDI', options: [{ value: 'G03', label: 'Gastos en general' }] }],
190
+ ], (s) => (s === 'customers.field.tax_id' ? 'RFC' : s))
191
+ const rows = partySummaryRows(
192
+ { tax_id: 'TBA850312KJ9', email: ' ', fiscal_data: { uso_cfdi: 'G03', regimen: '601' } },
193
+ ['tax_id', 'email'],
194
+ {},
195
+ 6,
196
+ meta,
197
+ )
198
+ expect(rows).toEqual([
199
+ { key: 'tax_id', label: 'RFC', value: 'TBA850312KJ9' },
200
+ { key: 'fiscal_data.uso_cfdi', label: 'Uso del CFDI', value: 'G03 · Gastos en general' },
201
+ { key: 'fiscal_data.regimen', label: 'Regimen', value: '601' },
202
+ ])
203
+ })
204
+
205
+ it('formatOptionValue: la etiqueta que ya trae el código va tal cual; sin opción, el valor', () => {
206
+ expect(formatOptionValue('G03', [{ value: 'G03', label: 'G03 · Gastos en general' }])).toBe('G03 · Gastos en general')
207
+ expect(formatOptionValue('active', [{ value: 'active', label: 'Activo' }])).toBe('Activo')
208
+ expect(formatOptionValue('X', [])).toBe('X')
209
+ })
210
+ })
211
+
212
+ // ---- DocumentEditor: el puente con el host ----------------------------------
213
+
214
+ const invoice: DocumentFormType = {
215
+ key: 'invoice',
216
+ label: 'Factura',
217
+ value: 'I',
218
+ layout: 'editor',
219
+ party: { field: 'customer_id', model: 'customers.Customer', summary: ['tax_id', 'email'] },
220
+ fields: [{ key: 'customer_id', label: 'Cliente', type: 'text', required: true }],
221
+ lines: { columns: ['tax'] },
222
+ submit_label: 'Guardar factura',
223
+ }
224
+
225
+ function makeApi(routes: Record<string, (url: string, cfg?: any) => unknown>) {
226
+ const get = vi.fn(async (url: string, cfg?: any) => {
227
+ for (const [prefix, fn] of Object.entries(routes)) if (url === prefix || url.startsWith(prefix + '?')) return { data: fn(url, cfg) }
228
+ if (url.startsWith('/metadata')) throw Object.assign(new Error('404'), { response: { status: 404 } })
229
+ return { data: { data: [] } }
230
+ })
231
+ return { get, post: vi.fn(), put: vi.fn(), delete: vi.fn() } as unknown as ApiClient & { get: typeof get }
232
+ }
233
+
234
+ describe('DocumentEditor — crear/editar el producto del renglón por el host', () => {
235
+ it('crear: dispara metacore:create-record prellenado y llena el renglón con el registro leído del catálogo', async () => {
236
+ const created = { id: 'p9', name: 'Válvula nueva', sku: 'VAL-N', unit_price: 120, unit_of_measure: 'unit', is_active: true }
237
+ const api = makeApi({
238
+ '/data/products.Product': () => ({ data: [created] }),
239
+ '/metadata/modal/products.Product': () => ({ data: { fields: [{ key: 'unit_of_measure', label: 'Unidad', options: [{ value: 'unit', label: 'pza' }] }] } }),
240
+ })
241
+ const events: any[] = []
242
+ const onEvt = (e: Event) => events.push((e as CustomEvent).detail)
243
+ window.addEventListener('metacore:create-record', onEvt)
244
+ render(
245
+ <ApiProvider client={api} batch={false}>
246
+ <DocumentEditor model="customers.Invoice" forms={{ types: [invoice] }} type={invoice} onCancel={() => {}} currency="MXN" defaultTaxRate={0.16} />
247
+ </ApiProvider>,
248
+ )
249
+ await typeAndCreate(screen.getByRole('combobox', { name: 'Buscar producto' }), 'Válvula nueva')
250
+ window.removeEventListener('metacore:create-record', onEvt)
251
+ expect(events).toHaveLength(1)
252
+ expect(events[0]).toMatchObject({ model: 'products.Product', defaults: { name: 'Válvula nueva' } })
253
+ await act(async () => events[0].onCreated({ id: 'p9' }))
254
+ const row = await waitFor(() => {
255
+ const r = document.querySelector('[data-slot="editor-lines"] tr[data-line-key]') as HTMLElement | null
256
+ if (!r) throw new Error('sin renglón')
257
+ return r
258
+ })
259
+ expect((within(row).getByRole('textbox', { name: 'Descripción' }) as HTMLInputElement).value).toBe('Válvula nueva')
260
+ expect((within(row).getByRole('textbox', { name: 'Precio' }) as HTMLInputElement).value).toBe('120')
261
+ await waitFor(() => expect(row.textContent).toContain('VAL-N · pza'))
262
+ })
263
+
264
+ it('editar: el lápiz dispara metacore:edit-record con el id del producto del renglón', async () => {
265
+ const rec = { id: 'p1', name: 'Llanta', sku: 'LL', unit_price: 1500, is_active: true }
266
+ const api = makeApi({ '/data/products.Product': () => ({ data: [rec] }) })
267
+ render(
268
+ <ApiProvider client={api} batch={false}>
269
+ <DocumentEditor model="customers.Invoice" forms={{ types: [invoice] }} type={invoice} onCancel={() => {}} currency="MXN" defaultTaxRate={0.16} />
270
+ </ApiProvider>,
271
+ )
272
+ const input = screen.getByRole('combobox', { name: 'Buscar producto' })
273
+ input.focus()
274
+ fireEvent.change(input, { target: { value: 'Llan' } })
275
+ await waitFor(() => expect(screen.getAllByRole('option').length).toBeGreaterThan(0), { timeout: 2000 })
276
+ fireEvent.keyDown(input, { key: 'Enter' })
277
+ const events: any[] = []
278
+ const onEvt = (e: Event) => events.push((e as CustomEvent).detail)
279
+ window.addEventListener('metacore:edit-record', onEvt)
280
+ fireEvent.click(await screen.findByRole('button', { name: 'Editar producto' }))
281
+ window.removeEventListener('metacore:edit-record', onEvt)
282
+ expect(events[0]).toMatchObject({ model: 'products.Product', recordId: 'p1' })
283
+ rec.unit_price = 1999
284
+ rec.name = 'Llanta XL'
285
+ await act(async () => events[0].onSaved(rec))
286
+ await waitFor(() => expect((screen.getByRole('textbox', { name: 'Precio' }) as HTMLInputElement).value).toBe('1999'))
287
+ expect((screen.getByRole('textbox', { name: 'Descripción' }) as HTMLInputElement).value).toBe('Llanta XL')
288
+ })
289
+
290
+ it('tarjeta: si la clave con módulo no resuelve, usa la corta; etiquetas y catálogos legibles', async () => {
291
+ const api = makeApi({
292
+ '/data/customers.Customer': () => ({ data: [{ id: 'c1', name: 'ACME', tax_id: 'AAA010101AAA', email: '', fiscal_data: { uso_cfdi: 'G03' } }] }),
293
+ '/metadata/modal/Customer': () => ({ data: { fields: [{ key: 'tax_id', label: 'RFC' }, { key: 'fiscal_data.uso_cfdi', label: 'Uso del CFDI', options: [{ value: 'G03', label: 'Gastos en general' }] }] } }),
294
+ })
295
+ render(
296
+ <ApiProvider client={api} batch={false}>
297
+ <DocumentEditor model="customers.Invoice" forms={{ types: [invoice] }} type={invoice} onCancel={() => {}} currency="MXN" />
298
+ </ApiProvider>,
299
+ )
300
+ fireEvent.change(document.getElementById('customer_id')!, { target: { value: 'c1' } })
301
+ const card = await waitFor(() => {
302
+ const c = document.querySelector('[data-slot="party-card"]') as HTMLElement | null
303
+ if (!c || !c.textContent?.includes('RFC') || !c.textContent.includes('Gastos')) throw new Error('tarjeta sin etiquetas')
304
+ return c
305
+ })
306
+ expect(card.textContent).toContain('G03 · Gastos en general')
307
+ expect(card.textContent).not.toContain('tax_id')
308
+ expect(card.textContent).not.toContain('uso_cfdi')
309
+ // El correo vacío no aparece.
310
+ expect(within(card).queryByText(/email|Correo/i)).toBeNull()
311
+ })
312
+ })
@@ -0,0 +1,241 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Declarative option display (kernel `option_display`): the picker paints the
4
+ // row the server resolved — avatar, title, subtitle, toned metrics (price,
5
+ // stock «Agotado»), badges — and keeps the plain row when there is none.
6
+ import { afterEach, describe, expect, it, vi } from 'vitest'
7
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
8
+
9
+ vi.mock('react-i18next', () => {
10
+ const value = { t: (k: string, o?: Record<string, any>) => o?.defaultValue ?? k, i18n: { language: 'es' } }
11
+ return { useTranslation: () => value }
12
+ })
13
+
14
+ import { DynamicSelectField } from '../dynamic-select-field'
15
+ import { ApiProvider, type ApiClient } from '../api-context'
16
+ import { CurrencyContext } from '../org-runtime-context'
17
+ import { invalidateOptionsCache, projectOption, optionsRequestKey } from '../use-options-resolver'
18
+ import { optionsBatchToken } from '../query-batch'
19
+ import {
20
+ OptionDisplayRow,
21
+ formatRelativeDate,
22
+ formatTrailingValue,
23
+ getOptionDisplay,
24
+ toneVariant,
25
+ type OptionDisplayData,
26
+ } from '../option-display'
27
+ import { createFormatter } from '../business/format'
28
+ import { withOptionDisplays } from '../business/product-display'
29
+ import { ProductHitRow } from '../business/product-options'
30
+ import type { ActionFieldDef } from '../types'
31
+
32
+ afterEach(() => {
33
+ cleanup()
34
+ invalidateOptionsCache()
35
+ })
36
+
37
+ const tire: OptionDisplayData = {
38
+ title: 'Llanta Michelin Primacy 4 205/55R16 91V',
39
+ subtitle: 'MIC-2055516 · 205/55R16',
40
+ image: null,
41
+ trailing: [
42
+ { key: 'price', label: 'Precio', value: 1899.5, format: 'money' },
43
+ { key: 'stock', label: 'Disp.', value: 8, format: 'number', tone: 'success' },
44
+ ],
45
+ badges: [],
46
+ tone: 'success',
47
+ }
48
+ const oil: OptionDisplayData = {
49
+ title: 'Aceite 5W30',
50
+ subtitle: 'ACE-530',
51
+ trailing: [
52
+ { key: 'price', label: 'Precio', value: 250, format: 'money' },
53
+ { key: 'stock', label: 'Disp.', value: 0, format: 'number', tone: 'danger', text: 'Agotado' },
54
+ ],
55
+ badges: [{ text: 'Servicio', tone: 'info' }],
56
+ tone: 'danger',
57
+ dimmed: true,
58
+ }
59
+
60
+ describe('formatTrailingValue', () => {
61
+ const fmt = createFormatter({ currency: 'MXN' })
62
+ it('money with the org currency (or the item own)', () => {
63
+ expect(formatTrailingValue({ key: 'p', value: 1899.5, format: 'money' }, fmt)).toBe('$1,899.50')
64
+ expect(formatTrailingValue({ key: 'p', value: 10, format: 'money', currency: 'USD' }, fmt)).toBe('$10.00')
65
+ })
66
+ it('number, integer, percent, text', () => {
67
+ expect(formatTrailingValue({ key: 'n', value: 1234.5, format: 'number' }, fmt)).toBe('1,234.5')
68
+ expect(formatTrailingValue({ key: 'n', value: '7.6', format: 'integer' }, fmt)).toBe('8')
69
+ expect(formatTrailingValue({ key: 'n', value: 0.16, format: 'percent' }, fmt)).toBe('16%')
70
+ expect(formatTrailingValue({ key: 'n', value: 16, format: 'percent' }, fmt)).toBe('16%')
71
+ expect(formatTrailingValue({ key: 'n', value: 'ACEPTADA', format: 'text' }, fmt)).toBe('ACEPTADA')
72
+ expect(formatTrailingValue({ key: 'n', value: null, format: 'money' }, fmt)).toBe('')
73
+ })
74
+ it('relative dates', () => {
75
+ const now = new Date('2026-10-07T12:00:00Z')
76
+ expect(formatRelativeDate('2026-10-04T12:00:00Z', 'es-MX', now)).toBe('hace 3 días')
77
+ expect(formatRelativeDate('2026-10-21T12:00:00Z', 'es-MX', now)).toBe('dentro de 2 semanas')
78
+ })
79
+ it('tone → badge variant', () => {
80
+ expect(toneVariant('danger')).toBe('danger')
81
+ expect(toneVariant('neutral')).toBe('muted')
82
+ expect(toneVariant(undefined)).toBe('muted')
83
+ })
84
+ })
85
+
86
+ describe('OptionDisplayRow', () => {
87
+ it('paints title, subtitle, metrics aligned with tabular numbers and the toned chip', () => {
88
+ render(
89
+ <CurrencyContext.Provider value="MXN">
90
+ <OptionDisplayRow display={oil} />
91
+ </CurrencyContext.Provider>,
92
+ )
93
+ expect(screen.getByText('Aceite 5W30')).toBeTruthy()
94
+ expect(screen.getByText('ACE-530')).toBeTruthy()
95
+ expect(screen.getByText('$250.00').className).toContain('tabular-nums')
96
+ const chip = screen.getByText('Agotado')
97
+ expect(chip.getAttribute('data-tone')).toBe('danger')
98
+ expect(screen.getByText('Servicio')).toBeTruthy()
99
+ const row = document.querySelector('[data-slot="option-display"]')!
100
+ expect(row.getAttribute('data-dimmed')).toBe('true')
101
+ expect(document.querySelectorAll('[data-slot="option-metric"]')).toHaveLength(1)
102
+ expect(document.querySelectorAll('[data-slot="option-metric-chip"]')).toHaveLength(1)
103
+ // Title clamps to two lines instead of truncating to one.
104
+ expect(document.querySelector('[data-slot="option-display-title"]')!.className).toContain('line-clamp-2')
105
+ })
106
+ })
107
+
108
+ describe('options wire: display + context', () => {
109
+ it('projectOption keeps display and leaves it out of meta', () => {
110
+ const o = projectOption({ id: 'p1', label: 'Llanta', display: tire, sku: 'X' })
111
+ expect(getOptionDisplay(o)).toEqual(tire)
112
+ expect(o.meta).toEqual({ sku: 'X' })
113
+ expect(getOptionDisplay(projectOption({ id: 'p2', label: 'Plain' }))).toBeNull()
114
+ })
115
+ it('context is part of the cache key and the batch token', () => {
116
+ const a = optionsRequestKey('s', '/options/p', 'id', '', 20, undefined, { warehouse_id: 'W1' })
117
+ const b = optionsRequestKey('s', '/options/p', 'id', '', 20, undefined, { warehouse_id: 'W2' })
118
+ expect(a).not.toBe(b)
119
+ expect(optionsRequestKey('s', '/options/p', 'id', '', 20, undefined)).toBe(['s', '/options/p', 'id', '', '20', ''].join('\n'))
120
+ expect(optionsBatchToken('products.Product', 'id', 'll', 20, undefined, { warehouse_id: 'W1' })).toBe(
121
+ 'o:products.Product?field=id&q=ll&limit=20&ctx.warehouse_id=W1',
122
+ )
123
+ })
124
+ })
125
+
126
+ function host(rows: unknown[]) {
127
+ const get = vi.fn(async (_url: string, config?: { params?: Record<string, unknown> }) => {
128
+ const ids = config?.params?.ids
129
+ const data = typeof ids === 'string' ? rows.filter((r: any) => ids.split(',').includes(r.id)) : rows
130
+ return { data: { success: true, data, meta: { type: 'dynamic' } } }
131
+ })
132
+ const api = { get, post: vi.fn(async () => ({ data: {} })), put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
133
+ return { api, get }
134
+ }
135
+
136
+ const product: ActionFieldDef = { key: 'product_id', label: 'Producto', type: 'dynamic_select', ref: 'products.Product' }
137
+
138
+ describe('DynamicSelectField with option_display', () => {
139
+ it('lists rich rows, sends the picker context and summarizes the selection in the trigger', async () => {
140
+ const h = host([
141
+ { id: 'p1', value: 'p1', label: 'Llanta', display: tire },
142
+ { id: 'p2', value: 'p2', label: 'Aceite', display: oil },
143
+ ])
144
+ const onChange = vi.fn()
145
+ render(
146
+ <ApiProvider client={h.api}>
147
+ <DynamicSelectField field={product} value="" onChange={onChange} optionsContext={{ warehouse_id: 'W1', branch_id: '' }} />
148
+ </ApiProvider>,
149
+ )
150
+ await act(async () => {
151
+ ;(screen.getByRole('combobox') as HTMLButtonElement).click()
152
+ })
153
+ await screen.findByText('Llanta Michelin Primacy 4 205/55R16 91V')
154
+ const params = h.get.mock.calls[0][1]?.params as Record<string, unknown>
155
+ expect(params['ctx.warehouse_id']).toBe('W1')
156
+ expect('ctx.branch_id' in params).toBe(false)
157
+ expect(screen.getByText('Agotado')).toBeTruthy()
158
+ // «Agotado» is dimmed but still selectable (not blocked).
159
+ const options = screen.getAllByRole('option')
160
+ expect(options[1].getAttribute('aria-disabled')).toBeNull()
161
+ await act(async () => {
162
+ options[1].click()
163
+ })
164
+ expect(onChange).toHaveBeenCalledWith('p2')
165
+ })
166
+
167
+ it('a blocked option cannot be picked', async () => {
168
+ const h = host([{ id: 'p3', value: 'p3', label: 'Bloqueado', display: { ...oil, title: 'Bloqueado', blocked: true } }])
169
+ const onChange = vi.fn()
170
+ render(
171
+ <ApiProvider client={h.api}>
172
+ <DynamicSelectField field={product} value="" onChange={onChange} />
173
+ </ApiProvider>,
174
+ )
175
+ await act(async () => {
176
+ ;(screen.getByRole('combobox') as HTMLButtonElement).click()
177
+ })
178
+ const opt = (await screen.findByText('Bloqueado')).closest('[role="option"]') as HTMLElement
179
+ expect(opt.getAttribute('aria-disabled')).toBe('true')
180
+ await act(async () => {
181
+ opt.click()
182
+ })
183
+ expect(onChange).not.toHaveBeenCalled()
184
+ })
185
+
186
+ it('a held value shows the compact summary (title + toned metric)', async () => {
187
+ const h = host([{ id: 'p2', value: 'p2', label: 'Aceite', display: oil }])
188
+ render(
189
+ <ApiProvider client={h.api}>
190
+ <DynamicSelectField field={product} value="p2" onChange={vi.fn()} />
191
+ </ApiProvider>,
192
+ )
193
+ await waitFor(() => expect(document.querySelector('[data-slot="option-display-value"]')).toBeTruthy())
194
+ expect(screen.getByText('Aceite 5W30')).toBeTruthy()
195
+ expect(screen.getByText('Agotado')).toBeTruthy()
196
+ })
197
+
198
+ it('without display the plain row stays', async () => {
199
+ const h = host([{ id: 'b1', value: 'b1', label: 'Michelin' }])
200
+ render(
201
+ <ApiProvider client={h.api}>
202
+ <DynamicSelectField field={{ ...product, ref: 'Brand' }} value="" onChange={vi.fn()} />
203
+ </ApiProvider>,
204
+ )
205
+ await act(async () => {
206
+ ;(screen.getByRole('combobox') as HTMLButtonElement).click()
207
+ })
208
+ await screen.findByText('Michelin')
209
+ expect(document.querySelector('[data-slot="option-display"]')).toBeNull()
210
+ })
211
+ })
212
+
213
+ describe('withOptionDisplays (product line search)', () => {
214
+ const search = vi.fn(async () => [
215
+ { id: 'P1', name: 'Llanta', price: 1899.5 },
216
+ { id: 'p2', name: 'Aceite', price: 250 },
217
+ ])
218
+ it('one ids lookup with the context, displays merged by id (case-insensitive)', async () => {
219
+ const h = host([{ id: 'p1', display: tire }, { id: 'p2', display: oil }])
220
+ const wrapped = withOptionDisplays(search, h.api as any, { model: 'products.Product', context: { warehouse_id: 'W1', branch_id: null } })
221
+ const out = await wrapped({ kind: 'text', raw: 'a', text: 'a' } as any, new AbortController().signal)
222
+ expect(h.get).toHaveBeenCalledTimes(1)
223
+ expect(h.get.mock.calls[0][0]).toBe('/options/products.Product')
224
+ expect(h.get.mock.calls[0][1]?.params).toEqual({ field: 'id', ids: 'P1,p2', 'ctx.warehouse_id': 'W1' })
225
+ expect(out[1].display).toEqual(oil)
226
+ expect(out[0].price).toBe(1899.5)
227
+ })
228
+ it('a failing lookup returns the results untouched', async () => {
229
+ const api = { get: vi.fn(async () => Promise.reject(new Error('501'))) }
230
+ const out = await withOptionDisplays(search, api as any, { model: 'products.Product' })({} as any, new AbortController().signal)
231
+ expect(out.map((r) => r.display)).toEqual([undefined, undefined])
232
+ })
233
+ it('ProductHitRow paints the display when the product carries one', () => {
234
+ render(<ProductHitRow hit={{ product: { id: 'p2', name: 'Aceite', display: oil } }} currency="MXN" />)
235
+ expect(screen.getByText('Agotado')).toBeTruthy()
236
+ cleanup()
237
+ render(<ProductHitRow hit={{ product: { id: 'p2', name: 'Aceite plano', sku: 'ACE' } }} currency="MXN" />)
238
+ expect(screen.getByText('Aceite plano')).toBeTruthy()
239
+ expect(document.querySelector('[data-slot="option-display"]')).toBeNull()
240
+ })
241
+ })
@@ -178,12 +178,68 @@ export function partyDefaults(
178
178
  return out
179
179
  }
180
180
 
181
- /** Filas de la tarjeta de contraparte: resumen declarado + extensiones (máximo `limit`). */
181
+ /** Etiqueta y opciones de catálogo de un campo (de la metadata del modelo o del propio tipo). */
182
+ export interface FieldDisplayMeta {
183
+ label?: string
184
+ options?: ReadonlyArray<{ value: unknown; label?: unknown }>
185
+ }
186
+
187
+ /**
188
+ * Metadata de pantalla por clave a partir de listas de campos/columnas
189
+ * (`/metadata/modal` → fields, `/metadata/table` → columns, campos del tipo).
190
+ * La primera lista que trae etiqueta u opciones para una clave gana.
191
+ */
192
+ export function fieldDisplayMeta(
193
+ lists: ReadonlyArray<ReadonlyArray<unknown> | null | undefined>,
194
+ translate: (s: string) => string = (s) => s,
195
+ ): Record<string, FieldDisplayMeta> {
196
+ const out: Record<string, FieldDisplayMeta> = {}
197
+ for (const list of lists) {
198
+ for (const raw of list ?? []) {
199
+ const f = raw as { key?: unknown; name?: unknown; label?: unknown; options?: unknown }
200
+ const key = typeof f?.key === 'string' ? f.key : typeof f?.name === 'string' ? f.name : undefined
201
+ if (!key) continue
202
+ const cur = (out[key] ??= {})
203
+ if (!cur.label && typeof f.label === 'string' && f.label) cur.label = translate(f.label)
204
+ if (!cur.options && Array.isArray(f.options) && f.options.length > 0) {
205
+ cur.options = f.options.map((o: any) => ({ value: o?.value, label: typeof o?.label === 'string' ? translate(o.label) : o?.label }))
206
+ }
207
+ }
208
+ }
209
+ return out
210
+ }
211
+
212
+ /** `tax_id` → «Tax id»: último recurso cuando ningún metadata etiqueta la columna. */
213
+ export function humanizeKey(key: string): string {
214
+ const base = key.replace(EXTENSION_PREFIX, '').replace(/[_.]+/g, ' ').trim()
215
+ return base ? base.charAt(0).toUpperCase() + base.slice(1) : key
216
+ }
217
+
218
+ /**
219
+ * Valor de catálogo legible: `G03` con la opción «Gastos en general» →
220
+ * «G03 · Gastos en general» (si la etiqueta ya trae el código, tal cual).
221
+ */
222
+ export function formatOptionValue(value: unknown, options?: FieldDisplayMeta['options']): string {
223
+ const raw = String(value)
224
+ const opt = options?.find((o) => String(o.value) === raw)
225
+ const label = opt && typeof opt.label === 'string' ? opt.label.trim() : ''
226
+ if (!label || label === raw) return raw
227
+ if (label.includes(raw)) return label
228
+ const codeLike = /^[A-Z0-9]{1,5}$/.test(raw) && /\d/.test(raw)
229
+ return codeLike ? `${raw} · ${label}` : label
230
+ }
231
+
232
+ /**
233
+ * Filas de la tarjeta de contraparte: resumen declarado + extensiones (máximo
234
+ * `limit`). Etiqueta del campo (metadata / labels declarados, ya traducidos),
235
+ * valores de catálogo con su texto y nunca claves crudas ni vacíos.
236
+ */
182
237
  export function partySummaryRows(
183
238
  party: Record<string, any> | null,
184
239
  summary: readonly string[] = [],
185
240
  labels: Record<string, string> = {},
186
241
  limit = 6,
242
+ meta: Record<string, FieldDisplayMeta> = {},
187
243
  ): Array<{ key: string; label: string; value: string }> {
188
244
  if (!party) return []
189
245
  const keys = new Set<string>(summary)
@@ -193,7 +249,9 @@ export function partySummaryRows(
193
249
  for (const key of keys) {
194
250
  const v = readPath(party, key)
195
251
  if (!hasValue(v) || typeof v === 'object') continue
196
- rows.push({ key, label: labels[key] ?? key.replace(EXTENSION_PREFIX, ''), value: String(v) })
252
+ if (typeof v === 'string' && !v.trim()) continue
253
+ const m = meta[key]
254
+ rows.push({ key, label: labels[key] ?? m?.label ?? humanizeKey(key), value: formatOptionValue(v, m?.options) })
197
255
  }
198
256
  return rows.slice(0, limit)
199
257
  }