@asteby/metacore-runtime-react 49.4.0 → 49.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/api-context.d.ts +9 -1
  3. package/dist/api-context.d.ts.map +1 -1
  4. package/dist/api-context.js +6 -4
  5. package/dist/business/customer-picker.d.ts +8 -2
  6. package/dist/business/customer-picker.d.ts.map +1 -1
  7. package/dist/business/customer-picker.js +30 -27
  8. package/dist/business/index.d.ts +1 -0
  9. package/dist/business/index.d.ts.map +1 -1
  10. package/dist/business/index.js +1 -0
  11. package/dist/business/line-product-cell.d.ts +8 -10
  12. package/dist/business/line-product-cell.d.ts.map +1 -1
  13. package/dist/business/line-product-cell.js +13 -90
  14. package/dist/business/product-options.d.ts +41 -0
  15. package/dist/business/product-options.d.ts.map +1 -0
  16. package/dist/business/product-options.js +58 -0
  17. package/dist/business/product-picker.d.ts +5 -1
  18. package/dist/business/product-picker.d.ts.map +1 -1
  19. package/dist/business/product-picker.js +26 -35
  20. package/dist/business/relate-documents.d.ts +1 -1
  21. package/dist/business/relate-documents.d.ts.map +1 -1
  22. package/dist/business/relate-documents.js +6 -8
  23. package/dist/business/vehicle-picker.d.ts +8 -2
  24. package/dist/business/vehicle-picker.d.ts.map +1 -1
  25. package/dist/business/vehicle-picker.js +28 -28
  26. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  27. package/dist/dialogs/dynamic-record.js +28 -43
  28. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  29. package/dist/dynamic-multi-select-field.js +17 -5
  30. package/dist/dynamic-select-field.d.ts +7 -23
  31. package/dist/dynamic-select-field.d.ts.map +1 -1
  32. package/dist/dynamic-select-field.js +67 -161
  33. package/dist/dynamic-table.d.ts +31 -1
  34. package/dist/dynamic-table.d.ts.map +1 -1
  35. package/dist/dynamic-table.js +37 -4
  36. package/dist/entity-select.d.ts +5 -2
  37. package/dist/entity-select.d.ts.map +1 -1
  38. package/dist/entity-select.js +23 -116
  39. package/dist/index.d.ts +7 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +8 -1
  42. package/dist/query-batch.d.ts.map +1 -1
  43. package/dist/query-batch.js +17 -1
  44. package/dist/record-picker-actions.d.ts +40 -2
  45. package/dist/record-picker-actions.d.ts.map +1 -1
  46. package/dist/record-picker-actions.js +44 -4
  47. package/dist/record-picker-dialog.d.ts +23 -0
  48. package/dist/record-picker-dialog.d.ts.map +1 -0
  49. package/dist/record-picker-dialog.js +37 -0
  50. package/dist/record-picker-option.d.ts +25 -0
  51. package/dist/record-picker-option.d.ts.map +1 -0
  52. package/dist/record-picker-option.js +55 -0
  53. package/dist/record-picker.d.ts +149 -0
  54. package/dist/record-picker.d.ts.map +1 -0
  55. package/dist/record-picker.js +372 -0
  56. package/package.json +1 -1
  57. package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
  58. package/src/__tests__/business-pickers.test.tsx +6 -3
  59. package/src/__tests__/document-editor-inline-selects.test.tsx +2 -1
  60. package/src/__tests__/document-form-dialog.test.tsx +4 -3
  61. package/src/__tests__/document-lines-grid.test.tsx +2 -1
  62. package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
  63. package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
  64. package/src/__tests__/record-picker.test.tsx +469 -0
  65. package/src/api-context.tsx +19 -4
  66. package/src/business/customer-picker.tsx +87 -98
  67. package/src/business/index.ts +1 -0
  68. package/src/business/line-product-cell.tsx +55 -180
  69. package/src/business/product-options.tsx +119 -0
  70. package/src/business/product-picker.tsx +63 -126
  71. package/src/business/relate-documents.tsx +36 -45
  72. package/src/business/vehicle-picker.tsx +76 -99
  73. package/src/dialogs/dynamic-record.tsx +44 -97
  74. package/src/dynamic-multi-select-field.tsx +39 -13
  75. package/src/dynamic-select-field.tsx +143 -368
  76. package/src/dynamic-table.tsx +91 -4
  77. package/src/entity-select.tsx +53 -202
  78. package/src/index.ts +27 -2
  79. package/src/query-batch.ts +18 -1
  80. package/src/record-picker-actions.tsx +101 -8
  81. package/src/record-picker-dialog.tsx +80 -0
  82. package/src/record-picker-option.tsx +96 -0
  83. package/src/record-picker.tsx +971 -0
  84. package/dist/business/use-async-search.d.ts +0 -10
  85. package/dist/business/use-async-search.d.ts.map +0 -1
  86. package/dist/business/use-async-search.js +0 -38
  87. package/src/business/use-async-search.ts +0 -43
@@ -1,7 +1,7 @@
1
1
  // @vitest-environment happy-dom
2
2
  // #1025 VehiclePicker, #1026 RelateDocuments, #1027 PrintSendDialog.
3
3
  import { afterEach, describe, expect, it, vi } from 'vitest'
4
- import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
4
+ import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
5
5
 
6
6
  vi.mock('react-i18next', () => ({
7
7
  useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
@@ -49,7 +49,9 @@ describe('VehiclePicker', () => {
49
49
  fireEvent.change(screen.getByPlaceholderText('Placa o VIN'), { target: { value: 'ABC' } })
50
50
  const opt = await screen.findByRole('option')
51
51
  expect(search.mock.calls[0][0]).toBe('ABC')
52
- fireEvent.click(opt.querySelector('button')!)
52
+ // La opción misma es el elemento interactivo (RecordPicker: role=option
53
+ // sin <button> anidado, como pide ARIA para listbox).
54
+ fireEvent.click(opt)
53
55
  expect(onChange).toHaveBeenCalledWith(veh)
54
56
  })
55
57
 
@@ -102,7 +104,8 @@ describe('RelateDocuments', () => {
102
104
  const { rerender } = render(<RelateDocuments value={value} onChange={onChange} relationTypes={types} search={search} />)
103
105
  fireEvent.change(screen.getByPlaceholderText('Buscar por folio o UUID'), { target: { value: 'A-' } })
104
106
  const list = await screen.findByRole('listbox')
105
- fireEvent.click(list.querySelectorAll('button')[0])
107
+ // RecordPicker: se elige la opción (role=option), no un <button> interno.
108
+ fireEvent.click((await within(list).findAllByRole('option'))[0]!)
106
109
  expect(onChange).toHaveBeenLastCalledWith([
107
110
  expect.objectContaining({ related_document_id: 'd1', relation_type: '01', uuid: 'UUID-1', folio: 'A-1' }),
108
111
  ])
@@ -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()
@@ -0,0 +1,140 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicTable `extraBulkActions` / `hideBulkDelete`:
4
+ // 1. Host actions render in the floating bar once rows are selected and
5
+ // receive the selected rows / ids.
6
+ // 2. `clearSelection()` empties the selection.
7
+ // 3. Changing page drops the selection (classic pagination) so a bulk
8
+ // action can never target rows that are no longer visible.
9
+ // 4. `hideBulkDelete` removes the built-in "Eliminar".
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
12
+
13
+ vi.mock('@tanstack/react-router', () => ({
14
+ useNavigate: () => () => {},
15
+ }))
16
+ const I18N = {
17
+ t: (_k: string, o?: { defaultValue?: string; count?: number; total?: number }) => {
18
+ if (o?.defaultValue && o.count != null && o.total != null) {
19
+ return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
20
+ }
21
+ return o?.defaultValue ?? _k
22
+ },
23
+ i18n: { language: 'es' },
24
+ }
25
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
26
+
27
+ import { DynamicTable } from '../dynamic-table'
28
+ import { makeDefaultGetDynamicColumns } from '../dynamic-columns'
29
+ import { ApiProvider, type ApiClient } from '../api-context'
30
+ import { useMetadataCache } from '../metadata-cache'
31
+ import type { TableMetadata } from '../types'
32
+
33
+ afterEach(cleanup)
34
+
35
+ const getDynamicColumns = makeDefaultGetDynamicColumns()
36
+
37
+ function meta(): TableMetadata {
38
+ return {
39
+ title: 'Issues',
40
+ endpoint: '/data/issue',
41
+ columns: [
42
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
43
+ ],
44
+ actions: [],
45
+ perPageOptions: [10, 20, 50],
46
+ defaultPerPage: 10,
47
+ searchPlaceholder: 'Buscar...',
48
+ enableCRUDActions: false,
49
+ hasActions: false,
50
+ }
51
+ }
52
+
53
+ function fakeApi(total: number): ApiClient {
54
+ const ok = (data: unknown) => ({ data: { success: true, data, meta: { total } } })
55
+ return {
56
+ get: vi.fn(async (url: string, cfg?: any) => {
57
+ if (url.startsWith('/metadata/table/')) return ok(meta())
58
+ if (url.endsWith('/facets')) return ok([])
59
+ const p = cfg?.params?.page ?? 1
60
+ const size = cfg?.params?.per_page ?? 10
61
+ const rows: Array<{ id: number; title: string }> = []
62
+ for (let i = (p - 1) * size; i < Math.min(p * size, total); i++) rows.push({ id: i + 1, title: `Issue ${i + 1}` })
63
+ return ok(rows)
64
+ }),
65
+ post: vi.fn(async () => ok(null)),
66
+ put: vi.fn(async () => ok(null)),
67
+ delete: vi.fn(async () => ok(null)),
68
+ }
69
+ }
70
+
71
+ async function selectFirstRow() {
72
+ await screen.findAllByText('Issue 1')
73
+ const boxes = screen.getAllByRole('checkbox')
74
+ fireEvent.click(boxes[1]) // [0] is the select-all header checkbox
75
+ }
76
+
77
+ describe('DynamicTable extraBulkActions', () => {
78
+ beforeEach(() => {
79
+ localStorage.clear()
80
+ sessionStorage.clear()
81
+ useMetadataCache.getState().setMetadata('issue', meta())
82
+ })
83
+
84
+ it('renders host actions with the selection and lets them clear it', async () => {
85
+ const seen: unknown[] = []
86
+ render(
87
+ <ApiProvider client={fakeApi(5)}>
88
+ <DynamicTable
89
+ model="issue"
90
+ enableUrlSync={false} getDynamicColumns={getDynamicColumns}
91
+ extraBulkActions={({ selectedRows, selectedIds, clearSelection }) => (
92
+ <button onClick={() => { seen.push({ rows: selectedRows.length, ids: selectedIds }); clearSelection() }}>
93
+ Aprobar seleccionados
94
+ </button>
95
+ )}
96
+ />
97
+ </ApiProvider>,
98
+ )
99
+ expect(screen.queryByText('Aprobar seleccionados')).toBeNull()
100
+ await selectFirstRow()
101
+ fireEvent.click(await screen.findByText('Aprobar seleccionados'))
102
+ expect(seen).toEqual([{ rows: 1, ids: [1] }])
103
+ await waitFor(() => expect(screen.queryByText('Aprobar seleccionados')).toBeNull())
104
+ })
105
+
106
+ it('accepts a plain node and keeps the built-in delete by default', async () => {
107
+ render(
108
+ <ApiProvider client={fakeApi(5)}>
109
+ <DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} extraBulkActions={<button>Extra</button>} />
110
+ </ApiProvider>,
111
+ )
112
+ await selectFirstRow()
113
+ expect(await screen.findByText('Extra')).toBeTruthy()
114
+ expect(screen.getByText('Eliminar')).toBeTruthy()
115
+ })
116
+
117
+ it('hideBulkDelete removes the built-in delete', async () => {
118
+ render(
119
+ <ApiProvider client={fakeApi(5)}>
120
+ <DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} hideBulkDelete extraBulkActions={<button>Extra</button>} />
121
+ </ApiProvider>,
122
+ )
123
+ await selectFirstRow()
124
+ expect(await screen.findByText('Extra')).toBeTruthy()
125
+ expect(screen.queryByText('Eliminar')).toBeNull()
126
+ })
127
+
128
+ it('drops the selection when the page changes', async () => {
129
+ render(
130
+ <ApiProvider client={fakeApi(25)}>
131
+ <DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} extraBulkActions={<button>Extra</button>} />
132
+ </ApiProvider>,
133
+ )
134
+ await selectFirstRow()
135
+ expect(await screen.findByText('Extra')).toBeTruthy()
136
+ fireEvent.click(await screen.findByRole('button', { name: /siguiente|next/i }))
137
+ await screen.findAllByText('Issue 11')
138
+ await waitFor(() => expect(screen.queryByText('Extra')).toBeNull())
139
+ })
140
+ })
@@ -0,0 +1,97 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Un fallo del endpoint de lista que NO es 403 (500, red, `success: false`)
4
+ // no debe verse como "sin resultados": se muestra "No se pudieron cargar los
5
+ // datos" con "Reintentar", que vuelve a pedir la lista. `emptyState` solo
6
+ // reemplaza el vacío cuando la lista cargó bien y no hay filtros activos.
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
9
+
10
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
11
+
12
+ import { ApiProvider, type ApiClient } from '../api-context'
13
+ import { useMetadataCache } from '../metadata-cache'
14
+ import { DynamicTable } from '../dynamic-table'
15
+ import type { TableMetadata } from '../types'
16
+
17
+ afterEach(cleanup)
18
+
19
+ const meta = {
20
+ title: 'Ajustes',
21
+ endpoint: '/data/load_err',
22
+ columns: [],
23
+ actions: [],
24
+ perPageOptions: [10],
25
+ defaultPerPage: 10,
26
+ searchPlaceholder: 'Buscar...',
27
+ enableCRUDActions: false,
28
+ hasActions: false,
29
+ } as unknown as TableMetadata
30
+
31
+ type ListResult = 'error500' | 'successFalse' | 'empty'
32
+
33
+ function mount(results: ListResult[], model: string, extra: Record<string, unknown> = {}) {
34
+ useMetadataCache.getState().setMetadata(model, meta)
35
+ let listCalls = 0
36
+ const client: ApiClient = {
37
+ get: vi.fn(async (url: string) => {
38
+ if (url.startsWith('/metadata/table/')) return { data: { success: true, data: meta } }
39
+ if (url.endsWith('/facets')) return { data: { success: true, data: [] } }
40
+ const r = results[Math.min(listCalls, results.length - 1)]
41
+ listCalls++
42
+ if (r === 'error500') throw Object.assign(new Error('500'), { response: { status: 500 } })
43
+ if (r === 'successFalse') return { data: { success: false, message: 'boom' } }
44
+ return { data: { success: true, data: [], meta: { total: 0 } } }
45
+ }),
46
+ post: vi.fn(async () => ({ data: { success: true, data: null } })),
47
+ put: vi.fn(async () => ({ data: { success: true, data: null } })),
48
+ delete: vi.fn(async () => ({ data: { success: true, data: null } })),
49
+ }
50
+ render(
51
+ <ApiProvider client={client}>
52
+ <DynamicTable model={model} enableUrlSync={false} {...extra} />
53
+ </ApiProvider>,
54
+ )
55
+ return () => listCalls
56
+ }
57
+
58
+ describe('DynamicTable — fallo de la lista (no 403)', () => {
59
+ it('un 500 muestra error con Reintentar, no "No se encontraron resultados"', async () => {
60
+ vi.spyOn(console, 'error').mockImplementation(() => {})
61
+ mount(['error500'], 'lerr_a')
62
+ expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
63
+ expect(screen.queryByText('No se encontraron resultados')).toBeNull()
64
+ expect(screen.queryByText('Sin permiso para ver este módulo')).toBeNull()
65
+ })
66
+
67
+ it('`success: false` también es un error de carga', async () => {
68
+ mount(['successFalse'], 'lerr_b')
69
+ expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
70
+ })
71
+
72
+ it('Reintentar vuelve a pedir la lista y, si ahora carga, limpia el error', async () => {
73
+ vi.spyOn(console, 'error').mockImplementation(() => {})
74
+ const calls = mount(['error500', 'empty'], 'lerr_c')
75
+ const retry = (await screen.findAllByRole('button', { name: 'Reintentar' }))[0]
76
+ const before = calls()
77
+ fireEvent.click(retry)
78
+ await waitFor(() => expect(calls()).toBeGreaterThan(before))
79
+ await waitFor(() => expect(screen.queryByText('No se pudieron cargar los datos')).toBeNull())
80
+ expect((await screen.findAllByText('No se encontraron resultados')).length).toBeGreaterThan(0)
81
+ })
82
+ })
83
+
84
+ describe('DynamicTable — emptyState', () => {
85
+ it('reemplaza el vacío cuando la lista cargó bien y no hay filtros', async () => {
86
+ mount(['empty'], 'lerr_d', { emptyState: <p>Aún no hay registros</p> })
87
+ expect((await screen.findAllByText('Aún no hay registros')).length).toBeGreaterThan(0)
88
+ expect(screen.queryByText('No se encontraron resultados')).toBeNull()
89
+ })
90
+
91
+ it('no tapa un error de carga', async () => {
92
+ vi.spyOn(console, 'error').mockImplementation(() => {})
93
+ mount(['error500'], 'lerr_e', { emptyState: <p>Aún no hay registros</p> })
94
+ expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
95
+ expect(screen.queryByText('Aún no hay registros')).toBeNull()
96
+ })
97
+ })