@asteby/metacore-runtime-react 49.5.0 → 49.7.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 (86) hide show
  1. package/CHANGELOG.md +12 -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 +36 -6
  27. package/dist/dynamic-select-field.d.ts +9 -23
  28. package/dist/dynamic-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-select-field.js +105 -172
  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 +9 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +10 -1
  36. package/dist/query-batch.d.ts.map +1 -1
  37. package/dist/query-batch.js +4 -0
  38. package/dist/record-picker-actions.d.ts +40 -2
  39. package/dist/record-picker-actions.d.ts.map +1 -1
  40. package/dist/record-picker-actions.js +44 -4
  41. package/dist/record-picker-dialog.d.ts +23 -0
  42. package/dist/record-picker-dialog.d.ts.map +1 -0
  43. package/dist/record-picker-dialog.js +37 -0
  44. package/dist/record-picker-option.d.ts +25 -0
  45. package/dist/record-picker-option.d.ts.map +1 -0
  46. package/dist/record-picker-option.js +55 -0
  47. package/dist/record-picker.d.ts +149 -0
  48. package/dist/record-picker.d.ts.map +1 -0
  49. package/dist/record-picker.js +372 -0
  50. package/dist/use-option-ids.d.ts +34 -0
  51. package/dist/use-option-ids.d.ts.map +1 -0
  52. package/dist/use-option-ids.js +138 -0
  53. package/dist/use-options-resolver.d.ts +4 -0
  54. package/dist/use-options-resolver.d.ts.map +1 -1
  55. package/dist/use-options-resolver.js +10 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/business-pickers.test.tsx +6 -3
  58. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  59. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  60. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  61. package/src/__tests__/edit-field-polish.test.tsx +6 -3
  62. package/src/__tests__/option-ids.test.tsx +181 -0
  63. package/src/__tests__/record-picker.test.tsx +469 -0
  64. package/src/business/customer-picker.tsx +87 -98
  65. package/src/business/index.ts +1 -0
  66. package/src/business/line-product-cell.tsx +55 -180
  67. package/src/business/product-options.tsx +119 -0
  68. package/src/business/product-picker.tsx +63 -126
  69. package/src/business/relate-documents.tsx +36 -45
  70. package/src/business/vehicle-picker.tsx +76 -99
  71. package/src/dialogs/dynamic-record.tsx +44 -97
  72. package/src/dynamic-multi-select-field.tsx +53 -15
  73. package/src/dynamic-select-field.tsx +188 -379
  74. package/src/entity-select.tsx +53 -202
  75. package/src/index.ts +35 -2
  76. package/src/query-batch.ts +3 -0
  77. package/src/record-picker-actions.tsx +101 -8
  78. package/src/record-picker-dialog.tsx +80 -0
  79. package/src/record-picker-option.tsx +96 -0
  80. package/src/record-picker.tsx +971 -0
  81. package/src/use-option-ids.ts +168 -0
  82. package/src/use-options-resolver.ts +11 -1
  83. package/dist/business/use-async-search.d.ts +0 -10
  84. package/dist/business/use-async-search.d.ts.map +0 -1
  85. package/dist/business/use-async-search.js +0 -38
  86. package/src/business/use-async-search.ts +0 -43
@@ -95,7 +95,8 @@ describe('DocumentLinesGrid — select de producto en la celda', () => {
95
95
  expect(cell.closest('tr')!.getAttribute('data-line-key')).toBe(free.key)
96
96
  fireEvent.change(cell, { target: { value: '205' } })
97
97
  const option = await waitFor(() => screen.getByRole('option', { name: /Llanta 205/ }), { timeout: 2000 })
98
- fireEvent.click(within(option).getByRole('button'))
98
+ // La opción (role=option) es el elemento interactivo del RecordPicker.
99
+ fireEvent.click(option)
99
100
  expect(state()).toHaveLength(1)
100
101
  expect(state()[0]).toMatchObject({ key: free.key, product_id: 'p1', unit_price: 1500, sku: 'LL-205' })
101
102
  })
@@ -169,7 +169,8 @@ describe('DocumentFormDialog: buscador de catálogo por defecto', () => {
169
169
  fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
170
170
  fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
171
171
  fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: query } })
172
- fireEvent.click(await waitFor(() => screen.getByRole('button', { name }), { timeout: 2000 }))
172
+ // RecordPicker: la opción del producto es role="option" clicable (sin <button> anidado).
173
+ fireEvent.click(await waitFor(() => screen.getByRole('option', { name }), { timeout: 2000 }))
173
174
  }
174
175
 
175
176
  it('sin searchProducts del host busca en el catálogo y el renglón entra con precio, cantidad 1, unidad, SKU e IVA de la org', async () => {
@@ -234,9 +235,9 @@ describe('DocumentFormDialog: IVA de la org con el buscador del host', () => {
234
235
  fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
235
236
  const search = await screen.findByLabelText('Buscar producto')
236
237
  fireEvent.change(search, { target: { value: 'host' } })
237
- fireEvent.click(await waitFor(() => screen.getByRole('button', { name: /Llanta host/ }), { timeout: 2000 }))
238
+ fireEvent.click(await waitFor(() => screen.getByRole('option', { name: /Llanta host/ }), { timeout: 2000 }))
238
239
  fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: 'fron' } })
239
- fireEvent.click(await waitFor(() => screen.getByRole('button', { name: /Frontera host/ }), { timeout: 2000 }))
240
+ fireEvent.click(await waitFor(() => screen.getByRole('option', { name: /Frontera host/ }), { timeout: 2000 }))
240
241
  fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
241
242
  await waitFor(() => expect(post).toHaveBeenCalled())
242
243
  const body = post.mock.calls[0][1] as any
@@ -49,7 +49,8 @@ describe('DocumentLinesGrid', () => {
49
49
  const search = vi.fn(async () => [tire])
50
50
  render(<Harness search={search} priceSource="cost" />)
51
51
  fireEvent.change(screen.getByLabelText('Buscar producto'), { target: { value: 'llan' } })
52
- const option = await waitFor(() => screen.getByRole('button', { name: /Llanta X/ }), { timeout: 2000 })
52
+ // RecordPicker: la opción del producto es role="option" clicable (sin <button> anidado).
53
+ const option = await waitFor(() => screen.getByRole('option', { name: /Llanta X/ }), { timeout: 2000 })
53
54
  fireEvent.click(option)
54
55
  expect(state()[0]).toMatchObject({ description: 'Llanta X', sku: 'LX', quantity: 1, unit_price: 800, catalog_price: 800 })
55
56
  expect(screen.getByText(/Catálogo/)).toBeTruthy()
@@ -153,7 +153,7 @@ describe('EditField — PART 2: FK select shows the resolved label', () => {
153
153
  expect(screen.queryByText('550e8400-e29b-41d4-a716-446655440000')).toBeNull()
154
154
  })
155
155
 
156
- it('keeps the raw value as fallback when no sibling was injected', () => {
156
+ it('keeps the raw value as fallback when no sibling was injected and the host cannot resolve ids', async () => {
157
157
  render(
158
158
  <ApiProvider client={noopApi}>
159
159
  <EditField
@@ -164,7 +164,10 @@ describe('EditField — PART 2: FK select shows the resolved label', () => {
164
164
  />
165
165
  </ApiProvider>,
166
166
  )
167
- // No sibling → existing behaviour: the raw value is shown on the trigger.
168
- expect(screen.getByText('wh-xyz')).toBeTruthy()
167
+ // No sibling → an `?ids=` lookup runs first ("Cargando…", never the id);
168
+ // this host answers without resolving it, so the raw value is the
169
+ // fallback on the trigger, as before.
170
+ expect(screen.getByText('Cargando…')).toBeTruthy()
171
+ expect(await screen.findByText('wh-xyz')).toBeTruthy()
169
172
  })
170
173
  })
@@ -0,0 +1,181 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // RecordPicker editing a saved value: the label comes from ONE `?ids=` lookup
4
+ // (useResolveOptionIds) without opening the popover — batched per ref, shared
5
+ // between pickers, skipped with a seed, "(registro eliminado)" for a gone id
6
+ // and the raw value when the host predates `ids`.
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, render, screen, waitFor } from '@testing-library/react'
9
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
10
+
11
+ vi.mock('react-i18next', () => {
12
+ const value = { t: (k: string, o?: Record<string, any>) => o?.defaultValue ?? k, i18n: { language: 'es' } }
13
+ return { useTranslation: () => value }
14
+ })
15
+
16
+ import { DynamicSelectField } from '../dynamic-select-field'
17
+ import { DynamicMultiSelectField } from '../dynamic-multi-select-field'
18
+ import { ApiProvider, type ApiClient } from '../api-context'
19
+ import { invalidateOptionsCache } from '../use-options-resolver'
20
+ import type { ActionFieldDef } from '../types'
21
+
22
+ afterEach(() => {
23
+ cleanup()
24
+ invalidateOptionsCache()
25
+ })
26
+
27
+ let seq = 0
28
+ /** Fresh ids per test: the id-label cache is module-wide on purpose. */
29
+ const uid = (p: string) => `${p}-${++seq}-${Math.random().toString(36).slice(2, 8)}`
30
+
31
+ type Row = { id: string; label: string }
32
+
33
+ /** A host that understands `ids`: answers exactly the requested rows it has. */
34
+ function idsHost(rows: Row[], page: Row[] = rows) {
35
+ const get = vi.fn(async (_url: string, config?: { params?: Record<string, unknown> }) => {
36
+ const ids = config?.params?.ids
37
+ if (typeof ids === 'string') {
38
+ const want = ids.split(',')
39
+ return { data: { success: true, data: rows.filter((r) => want.includes(r.id)), meta: { type: 'dynamic' } } }
40
+ }
41
+ return { data: { success: true, data: page, meta: { type: 'dynamic' } } }
42
+ })
43
+ // batch=false: no POST /q in these tests (ids reads bypass it anyway).
44
+ const api = { get, post: vi.fn(async () => ({ data: {} })), put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
45
+ return { api, get, idsCalls: () => get.mock.calls.filter(([, c]) => typeof c?.params?.ids === 'string') }
46
+ }
47
+
48
+ const brand: ActionFieldDef = { key: 'brand_id', label: 'Marca', type: 'dynamic_select', ref: 'Brand' }
49
+
50
+ describe('RecordPicker: etiqueta de un valor guardado por ?ids=', () => {
51
+ it('sin semilla → una sola petición ids y muestra el nombre sin abrir el popover', async () => {
52
+ const id = uid('b')
53
+ const host = idsHost([{ id, label: 'Michelin' }])
54
+ render(
55
+ <ApiProvider client={host.api}>
56
+ <DynamicSelectField field={brand} value={id} onChange={vi.fn()} />
57
+ </ApiProvider>,
58
+ )
59
+ // Mientras resuelve: "Cargando…", nunca el UUID.
60
+ expect(screen.getByText('Cargando…')).toBeTruthy()
61
+ expect(screen.queryByText(id)).toBeNull()
62
+ expect(await screen.findByText('Michelin')).toBeTruthy()
63
+ expect(host.get).toHaveBeenCalledTimes(1)
64
+ expect(host.get).toHaveBeenCalledWith('/options/Brand', { params: { field: 'id', ids: id } })
65
+ expect(document.querySelector('[data-slot="record-picker-content"]')).toBeNull()
66
+ })
67
+
68
+ it('dos pickers de la misma ref (y uno repetido) → UNA petición con todos los ids', async () => {
69
+ const a = uid('b')
70
+ const b = uid('b')
71
+ const host = idsHost([
72
+ { id: a, label: 'Michelin' },
73
+ { id: b, label: 'Pirelli' },
74
+ ])
75
+ render(
76
+ <ApiProvider client={host.api}>
77
+ <DynamicSelectField field={brand} value={a} onChange={vi.fn()} />
78
+ <DynamicSelectField field={{ ...brand, key: 'alt_brand_id' }} value={b} onChange={vi.fn()} />
79
+ <DynamicSelectField field={{ ...brand, key: 'other_brand_id' }} value={a} onChange={vi.fn()} />
80
+ </ApiProvider>,
81
+ )
82
+ await waitFor(() => expect(screen.getAllByText('Michelin')).toHaveLength(2))
83
+ expect(screen.getByText('Pirelli')).toBeTruthy()
84
+ expect(host.get).toHaveBeenCalledTimes(1)
85
+ const ids = String(host.idsCalls()[0][1].params.ids).split(',').sort()
86
+ expect(ids).toEqual([a, b].sort())
87
+ })
88
+
89
+ it('con semilla → cero peticiones', async () => {
90
+ const id = uid('b')
91
+ const host = idsHost([{ id, label: 'Michelin' }])
92
+ render(
93
+ <ApiProvider client={host.api}>
94
+ <DynamicSelectField
95
+ field={brand}
96
+ value={id}
97
+ onChange={vi.fn()}
98
+ seedOption={{ id, value: id, label: 'Bridgestone', name: 'Bridgestone' }}
99
+ />
100
+ </ApiProvider>,
101
+ )
102
+ expect(screen.getByText('Bridgestone')).toBeTruthy()
103
+ await new Promise((r) => setTimeout(r, 30))
104
+ expect(host.get).not.toHaveBeenCalled()
105
+ })
106
+
107
+ it('id que ya no existe → «(registro eliminado)» sutil, no el UUID', async () => {
108
+ const id = uid('b')
109
+ const host = idsHost([])
110
+ render(
111
+ <ApiProvider client={host.api}>
112
+ <DynamicSelectField field={brand} value={id} onChange={vi.fn()} />
113
+ </ApiProvider>,
114
+ )
115
+ const missing = await screen.findByText('(registro eliminado)')
116
+ expect(missing.getAttribute('data-slot')).toBe('record-picker-value-missing')
117
+ expect(screen.queryByText(id)).toBeNull()
118
+ })
119
+
120
+ it('host sin soporte de ids (responde una página): filtra por id; si no está, muestra el valor como antes', async () => {
121
+ const known = uid('b')
122
+ const unknown = uid('b')
123
+ const page = [
124
+ { id: known, label: 'Michelin' },
125
+ { id: uid('x'), label: 'Otra' },
126
+ ]
127
+ // Ignores `ids` entirely.
128
+ const get = vi.fn(async () => ({ data: { success: true, data: page } }))
129
+ const api = { get, post: vi.fn(async () => ({ data: {} })), put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
130
+ render(
131
+ <ApiProvider client={api}>
132
+ <DynamicSelectField field={brand} value={known} onChange={vi.fn()} />
133
+ <DynamicSelectField field={{ ...brand, key: 'b2' }} value={unknown} onChange={vi.fn()} />
134
+ </ApiProvider>,
135
+ )
136
+ expect(await screen.findByText('Michelin')).toBeTruthy()
137
+ expect(await screen.findByText(unknown)).toBeTruthy()
138
+ expect(screen.queryByText('(registro eliminado)')).toBeNull()
139
+ })
140
+
141
+ it('usa el QueryClient del host cuando hay QueryClientProvider', async () => {
142
+ const id = uid('b')
143
+ const host = idsHost([{ id, label: 'Goodyear' }])
144
+ const client = new QueryClient()
145
+ render(
146
+ <QueryClientProvider client={client}>
147
+ <ApiProvider client={host.api}>
148
+ <DynamicSelectField field={brand} value={id} onChange={vi.fn()} />
149
+ </ApiProvider>
150
+ </QueryClientProvider>,
151
+ )
152
+ expect(await screen.findByText('Goodyear')).toBeTruthy()
153
+ expect(client.getQueryCache().findAll({ queryKey: ['metacore-option-ids'] })).toHaveLength(1)
154
+ })
155
+
156
+ it('múltiple: los ids fuera de la página se resuelven en una petición; el borrado se marca', async () => {
157
+ const inPage = uid('t')
158
+ const outside = uid('t')
159
+ const gone = uid('t')
160
+ const host = idsHost(
161
+ [
162
+ { id: inPage, label: 'Mayoreo' },
163
+ { id: outside, label: 'Flotillas' },
164
+ ],
165
+ [{ id: inPage, label: 'Mayoreo' }],
166
+ )
167
+ const field: ActionFieldDef = { key: 'segment_ids', label: 'Segmentos', type: 'dynamic_select', ref: 'Segment', multiple: true }
168
+ render(
169
+ <ApiProvider client={host.api} batch={false}>
170
+ <DynamicMultiSelectField field={field} value={[inPage, outside, gone]} onChange={vi.fn()} />
171
+ </ApiProvider>,
172
+ )
173
+ expect(await screen.findByText('Flotillas')).toBeTruthy()
174
+ expect(screen.getByText('Mayoreo')).toBeTruthy()
175
+ expect(await screen.findByText('(registro eliminado)')).toBeTruthy()
176
+ expect(screen.queryByText(outside)).toBeNull()
177
+ const calls = host.idsCalls()
178
+ expect(calls).toHaveLength(1)
179
+ expect(String(calls[0][1].params.ids).split(',').sort()).toEqual([outside, gone].sort())
180
+ })
181
+ })