@asteby/metacore-runtime-react 46.4.0 → 47.1.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 (123) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/action-modal-dispatcher.d.ts +10 -1
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +107 -19
  5. package/dist/audit-info.d.ts +54 -0
  6. package/dist/audit-info.d.ts.map +1 -0
  7. package/dist/audit-info.js +142 -0
  8. package/dist/business/catalog.d.ts.map +1 -1
  9. package/dist/business/catalog.js +24 -0
  10. package/dist/business/index.d.ts +3 -0
  11. package/dist/business/index.d.ts.map +1 -1
  12. package/dist/business/index.js +3 -0
  13. package/dist/business/print-send-dialog.d.ts +27 -0
  14. package/dist/business/print-send-dialog.d.ts.map +1 -0
  15. package/dist/business/print-send-dialog.js +55 -0
  16. package/dist/business/relate-documents.d.ts +41 -0
  17. package/dist/business/relate-documents.d.ts.map +1 -0
  18. package/dist/business/relate-documents.js +42 -0
  19. package/dist/business/vehicle-picker.d.ts +36 -0
  20. package/dist/business/vehicle-picker.d.ts.map +1 -0
  21. package/dist/business/vehicle-picker.js +61 -0
  22. package/dist/cfdi-stamp-panel.d.ts +29 -0
  23. package/dist/cfdi-stamp-panel.d.ts.map +1 -0
  24. package/dist/cfdi-stamp-panel.js +53 -0
  25. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  26. package/dist/dialogs/dynamic-record.js +10 -1
  27. package/dist/document-form-dialog.d.ts +23 -0
  28. package/dist/document-form-dialog.d.ts.map +1 -0
  29. package/dist/document-form-dialog.js +196 -0
  30. package/dist/dynamic-crud-page.d.ts +6 -0
  31. package/dist/dynamic-crud-page.d.ts.map +1 -1
  32. package/dist/dynamic-crud-page.js +4 -2
  33. package/dist/dynamic-form.d.ts.map +1 -1
  34. package/dist/dynamic-form.js +3 -0
  35. package/dist/dynamic-kanban.d.ts.map +1 -1
  36. package/dist/dynamic-kanban.js +11 -2
  37. package/dist/dynamic-line-items.d.ts.map +1 -1
  38. package/dist/dynamic-line-items.js +3 -0
  39. package/dist/dynamic-multi-select-field.d.ts.map +1 -1
  40. package/dist/dynamic-multi-select-field.js +2 -0
  41. package/dist/dynamic-select-field.d.ts.map +1 -1
  42. package/dist/dynamic-select-field.js +4 -0
  43. package/dist/dynamic-table.d.ts.map +1 -1
  44. package/dist/dynamic-table.js +6 -2
  45. package/dist/index.d.ts +8 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -0
  48. package/dist/nav-permissions.d.ts +19 -0
  49. package/dist/nav-permissions.d.ts.map +1 -0
  50. package/dist/nav-permissions.js +51 -0
  51. package/dist/navigation-builder.d.ts +6 -0
  52. package/dist/navigation-builder.d.ts.map +1 -1
  53. package/dist/navigation-builder.js +26 -1
  54. package/dist/option-filter.d.ts +31 -0
  55. package/dist/option-filter.d.ts.map +1 -0
  56. package/dist/option-filter.js +49 -0
  57. package/dist/permissions-manager.d.ts +9 -0
  58. package/dist/permissions-manager.d.ts.map +1 -1
  59. package/dist/permissions-manager.js +50 -17
  60. package/dist/print-document-button.d.ts.map +1 -1
  61. package/dist/print-document-button.js +9 -1
  62. package/dist/record-mutation-events.d.ts +14 -0
  63. package/dist/record-mutation-events.d.ts.map +1 -0
  64. package/dist/record-mutation-events.js +37 -0
  65. package/dist/types.d.ts +86 -0
  66. package/dist/types.d.ts.map +1 -1
  67. package/dist/use-options-resolver.d.ts +8 -0
  68. package/dist/use-options-resolver.d.ts.map +1 -1
  69. package/dist/use-options-resolver.js +8 -2
  70. package/dist/use-print-document.d.ts +15 -1
  71. package/dist/use-print-document.d.ts.map +1 -1
  72. package/dist/use-print-document.js +52 -1
  73. package/dist/use-record-mutation-tick.d.ts +6 -0
  74. package/dist/use-record-mutation-tick.d.ts.map +1 -0
  75. package/dist/use-record-mutation-tick.js +11 -0
  76. package/package.json +3 -3
  77. package/src/__tests__/action-modal-feedback.test.tsx +116 -0
  78. package/src/__tests__/action-modal-grid-layout.test.tsx +1 -1
  79. package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
  80. package/src/__tests__/audit-info.test.tsx +103 -0
  81. package/src/__tests__/business-pickers.test.tsx +157 -0
  82. package/src/__tests__/dependent-options.test.tsx +12 -5
  83. package/src/__tests__/document-form-dialog.test.tsx +190 -0
  84. package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
  85. package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
  86. package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
  87. package/src/__tests__/nav-permissions.test.tsx +76 -0
  88. package/src/__tests__/option-filter.test.tsx +115 -0
  89. package/src/__tests__/permissions-manager.test.tsx +21 -4
  90. package/src/__tests__/structured-view-value.test.tsx +5 -1
  91. package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
  92. package/src/__tests__/wizard-action-modal.test.tsx +1 -1
  93. package/src/action-modal-dispatcher.tsx +124 -6
  94. package/src/audit-info.tsx +219 -0
  95. package/src/business/catalog.ts +24 -0
  96. package/src/business/index.ts +3 -0
  97. package/src/business/print-send-dialog.tsx +175 -0
  98. package/src/business/relate-documents.tsx +182 -0
  99. package/src/business/vehicle-picker.tsx +188 -0
  100. package/src/cfdi-stamp-panel.tsx +146 -0
  101. package/src/dialogs/dynamic-record.tsx +18 -0
  102. package/src/document-form-dialog.tsx +357 -0
  103. package/src/dropdown-menu-entry.test.ts +23 -0
  104. package/src/dynamic-crud-page.tsx +21 -2
  105. package/src/dynamic-form.tsx +3 -0
  106. package/src/dynamic-kanban.tsx +10 -4
  107. package/src/dynamic-line-items.tsx +3 -0
  108. package/src/dynamic-multi-select-field.tsx +2 -0
  109. package/src/dynamic-select-field.tsx +4 -0
  110. package/src/dynamic-table.tsx +6 -2
  111. package/src/index.ts +41 -0
  112. package/src/nav-permissions.ts +58 -0
  113. package/src/navigation-builder.tsx +27 -1
  114. package/src/option-filter.ts +88 -0
  115. package/src/permissions-manager.tsx +39 -26
  116. package/src/print-document-button.tsx +9 -1
  117. package/src/record-mutation-events.ts +50 -0
  118. package/src/types.ts +90 -0
  119. package/src/use-options-resolver.ts +19 -1
  120. package/src/use-print-document.ts +66 -3
  121. package/src/use-record-mutation-tick.ts +12 -0
  122. package/vitest.config.ts +1 -0
  123. package/vitest.setup.ts +7 -0
@@ -0,0 +1,157 @@
1
+ // @vitest-environment happy-dom
2
+ // #1025 VehiclePicker, #1026 RelateDocuments, #1027 PrintSendDialog.
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ vi.mock('react-i18next', () => ({
7
+ useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
8
+ }))
9
+
10
+ import { VehiclePicker, type VehicleResult } from '../business/vehicle-picker'
11
+ import { RelateDocuments, serializeRelatedDocuments, type RelatedDocument } from '../business/relate-documents'
12
+ import { PrintSendDialog } from '../business/print-send-dialog'
13
+ import { PermissionsProvider } from '../permissions-context'
14
+ import { ApiProvider, type ApiClient } from '../api-context'
15
+ import { BUSINESS_COMPONENTS } from '../business/catalog'
16
+ import * as runtime from '../index'
17
+
18
+ afterEach(cleanup)
19
+
20
+ const apiStub = (): ApiClient =>
21
+ ({
22
+ get: vi.fn().mockResolvedValue({ data: {} }),
23
+ post: vi.fn().mockResolvedValue({ data: { data: { id: 'v9', plate: 'NEW-001', vin: 'VIN9' } } }),
24
+ put: vi.fn(),
25
+ delete: vi.fn(),
26
+ }) as unknown as ApiClient
27
+
28
+ describe('exports', () => {
29
+ it('se exportan desde el runtime y están en el catálogo', () => {
30
+ expect(runtime.VehiclePicker).toBeTypeOf('function')
31
+ expect(runtime.RelateDocuments).toBeTypeOf('function')
32
+ expect(runtime.PrintSendDialog).toBeTypeOf('function')
33
+ const names = BUSINESS_COMPONENTS.map((c) => c.name)
34
+ expect(names).toEqual(expect.arrayContaining(['VehiclePicker', 'RelateDocuments', 'PrintSendDialog']))
35
+ })
36
+ })
37
+
38
+ describe('VehiclePicker', () => {
39
+ const veh: VehicleResult = { id: 'v1', plate: 'ABC-123', vin: '1HGCM82633A004352', make: 'Nissan', model: 'Versa', year: 2019 }
40
+
41
+ it('busca por placa o VIN y elige el resultado', async () => {
42
+ const search = vi.fn().mockResolvedValue([veh])
43
+ const onChange = vi.fn()
44
+ render(
45
+ <ApiProvider client={apiStub()}>
46
+ <VehiclePicker value={null} onChange={onChange} search={search} />
47
+ </ApiProvider>,
48
+ )
49
+ fireEvent.change(screen.getByPlaceholderText('Placa o VIN'), { target: { value: 'ABC' } })
50
+ const opt = await screen.findByRole('option')
51
+ expect(search.mock.calls[0][0]).toBe('ABC')
52
+ fireEvent.click(opt.querySelector('button')!)
53
+ expect(onChange).toHaveBeenCalledWith(veh)
54
+ })
55
+
56
+ it('muestra el vehículo elegido y permite quitarlo', () => {
57
+ const onChange = vi.fn()
58
+ render(
59
+ <ApiProvider client={apiStub()}>
60
+ <VehiclePicker value={veh} onChange={onChange} search={vi.fn()} />
61
+ </ApiProvider>,
62
+ )
63
+ expect(screen.getByText('ABC-123')).toBeTruthy()
64
+ fireEvent.click(screen.getByRole('button', { name: 'Quitar vehículo' }))
65
+ expect(onChange).toHaveBeenCalledWith(null)
66
+ })
67
+
68
+ it('alta rápida: sin PermissionsProvider ofrece crear; con permiso denegado, no', () => {
69
+ const { unmount } = render(
70
+ <ApiProvider client={apiStub()}>
71
+ <VehiclePicker value={null} onChange={vi.fn()} search={vi.fn()} />
72
+ </ApiProvider>,
73
+ )
74
+ expect(screen.getByRole('button', { name: 'Nuevo vehículo' })).toBeTruthy()
75
+ unmount()
76
+ render(
77
+ <ApiProvider client={apiStub()}>
78
+ <PermissionsProvider permissions={[]} isAdmin={false}>
79
+ <VehiclePicker value={null} onChange={vi.fn()} search={vi.fn()} />
80
+ </PermissionsProvider>
81
+ </ApiProvider>,
82
+ )
83
+ expect(screen.queryByRole('button', { name: 'Nuevo vehículo' })).toBeNull()
84
+ })
85
+ })
86
+
87
+ describe('RelateDocuments', () => {
88
+ const types = [
89
+ { value: '01', label: 'Nota de crédito' },
90
+ { value: '04', label: 'Sustitución' },
91
+ ]
92
+
93
+ it('agrega varios documentos con tipo de relación y serializa solo datos', async () => {
94
+ const search = vi.fn().mockResolvedValue([
95
+ { id: 'd1', uuid: 'UUID-1', folio: 'A-1' },
96
+ { id: 'd2', uuid: 'UUID-2', folio: 'A-2' },
97
+ ])
98
+ let value: RelatedDocument[] = []
99
+ const onChange = vi.fn((v: RelatedDocument[]) => {
100
+ value = v
101
+ })
102
+ const { rerender } = render(<RelateDocuments value={value} onChange={onChange} relationTypes={types} search={search} />)
103
+ fireEvent.change(screen.getByPlaceholderText('Buscar por folio o UUID'), { target: { value: 'A-' } })
104
+ const list = await screen.findByRole('listbox')
105
+ fireEvent.click(list.querySelectorAll('button')[0])
106
+ expect(onChange).toHaveBeenLastCalledWith([
107
+ expect.objectContaining({ related_document_id: 'd1', relation_type: '01', uuid: 'UUID-1', folio: 'A-1' }),
108
+ ])
109
+ rerender(<RelateDocuments value={value} onChange={onChange} relationTypes={types} search={search} />)
110
+ expect(screen.getAllByText('UUID-1').length).toBeGreaterThan(0)
111
+ // Cambiar el tipo de la relación existente.
112
+ fireEvent.change(screen.getByLabelText('Tipo de relación'), { target: { value: '04' } })
113
+ expect(onChange.mock.lastCall![0][0].relation_type).toBe('04')
114
+ expect(serializeRelatedDocuments(value)).toEqual([{ related_document_id: 'd1', relation_type: '04' }])
115
+ })
116
+
117
+ it('respeta max y no duplica', () => {
118
+ const value: RelatedDocument[] = [{ related_document_id: 'd1', relation_type: '01' }]
119
+ render(<RelateDocuments value={value} onChange={vi.fn()} relationTypes={types} search={vi.fn()} max={1} />)
120
+ expect(screen.queryByPlaceholderText('Buscar por folio o UUID')).toBeNull()
121
+ })
122
+ })
123
+
124
+ describe('PrintSendDialog', () => {
125
+ const doc = { folio: 'COT-0004', title: 'Cotización', pdfUrl: 'https://files.example/c.pdf', email: 'a@b.mx', phone: '5550001' }
126
+
127
+ it('imprimir abre la URL de PDF del host', () => {
128
+ const open = vi.spyOn(window, 'open').mockReturnValue(null)
129
+ render(<PrintSendDialog open onOpenChange={vi.fn()} document={doc} />)
130
+ fireEvent.click(screen.getByRole('button', { name: /Imprimir/ }))
131
+ expect(open).toHaveBeenCalledWith('https://files.example/c.pdf', '_blank', 'noopener,noreferrer')
132
+ open.mockRestore()
133
+ })
134
+
135
+ it('enviar llama onSend con canal y destino; no hay integración propia', async () => {
136
+ const onSend = vi.fn().mockResolvedValue(undefined)
137
+ render(<PrintSendDialog open onOpenChange={vi.fn()} document={doc} onSend={onSend} />)
138
+ expect((screen.getByLabelText('Destino') as HTMLInputElement).value).toBe('a@b.mx')
139
+ fireEvent.click(screen.getByRole('button', { name: /WhatsApp/ }))
140
+ await waitFor(() => expect((screen.getByLabelText('Destino') as HTMLInputElement).value).toBe('5550001'))
141
+ fireEvent.click(screen.getByRole('button', { name: 'Enviar' }))
142
+ await waitFor(() => expect(onSend).toHaveBeenCalledWith('whatsapp', '5550001', doc))
143
+ expect(await screen.findByText('Enviado.')).toBeTruthy()
144
+ })
145
+
146
+ it('un fallo de onSend se muestra en el diálogo', async () => {
147
+ const onSend = vi.fn().mockRejectedValue(new Error('Proveedor caído'))
148
+ render(<PrintSendDialog open onOpenChange={vi.fn()} document={doc} onSend={onSend} />)
149
+ fireEvent.click(screen.getByRole('button', { name: 'Enviar' }))
150
+ expect((await screen.findByRole('alert')).textContent).toContain('Proveedor caído')
151
+ })
152
+
153
+ it('sin onSend solo ofrece imprimir', () => {
154
+ render(<PrintSendDialog open onOpenChange={vi.fn()} document={doc} />)
155
+ expect(screen.queryByRole('button', { name: 'Enviar' })).toBeNull()
156
+ })
157
+ })
@@ -9,7 +9,7 @@
9
9
  // header field is empty, and once the header field has a value the options
10
10
  // request carries that value as `filter_value` and re-fetches on change.
11
11
  import { afterEach, describe, expect, it, vi } from 'vitest'
12
- import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
12
+ import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'
13
13
 
14
14
  // Identity translator so any raw i18n keys surface verbatim.
15
15
  vi.mock('react-i18next', () => ({
@@ -194,6 +194,10 @@ function LineItemsHost({ headerValue }: { headerValue: string }) {
194
194
  )
195
195
  }
196
196
 
197
+ // The editor renders a mobile card list AND a desktop table (CSS picks one), so
198
+ // scope queries to the table to hit a single picker.
199
+ const tableCombobox = () => within(document.querySelector('table') as HTMLElement).getByRole('combobox')
200
+
197
201
  describe('DynamicLineItems cascading cell', () => {
198
202
  it('disables the picker while the header field is empty', () => {
199
203
  const get = vi.fn(async () => ({
@@ -206,7 +210,7 @@ describe('DynamicLineItems cascading cell', () => {
206
210
  </ApiProvider>,
207
211
  )
208
212
  // The combobox trigger is disabled and shows the dependency hint.
209
- const trigger = screen.getByRole('combobox')
213
+ const trigger = tableCombobox()
210
214
  expect((trigger as HTMLButtonElement).disabled).toBe(true)
211
215
  expect(trigger.getAttribute('data-depends-blocked')).toBe('')
212
216
  // No options request fires while blocked.
@@ -230,7 +234,7 @@ describe('DynamicLineItems cascading cell', () => {
230
234
  )
231
235
 
232
236
  // Open the popover so the typeahead fetches.
233
- const trigger = screen.getByRole('combobox')
237
+ const trigger = tableCombobox()
234
238
  expect((trigger as HTMLButtonElement).disabled).toBe(false)
235
239
  await act(async () => {
236
240
  trigger.click()
@@ -300,7 +304,10 @@ describe('DynamicLineItems cell with optionsConfig.source', () => {
300
304
  meta: { type: 'dynamic', count: 1 },
301
305
  },
302
306
  }))
303
- const client = { get } as unknown as ApiClient
307
+ // `/options/<model>` lookups try the batched `/q` endpoint first; a client
308
+ // whose batch endpoint is unavailable falls back to the plain GET asserted here.
309
+ const post = vi.fn(async () => ({ data: { success: false } }))
310
+ const client = { get, post } as unknown as ApiClient
304
311
 
305
312
  const { rerender } = render(
306
313
  <ApiProvider client={client}>
@@ -308,7 +315,7 @@ describe('DynamicLineItems cell with optionsConfig.source', () => {
308
315
  </ApiProvider>,
309
316
  )
310
317
 
311
- const trigger = screen.getByRole('combobox')
318
+ const trigger = tableCombobox()
312
319
  expect((trigger as HTMLButtonElement).disabled).toBe(false)
313
320
  await act(async () => {
314
321
  trigger.click()
@@ -0,0 +1,190 @@
1
+ // @vitest-environment happy-dom
2
+ // FAC-12 (#1022): alta guiada por tipo de documento.
3
+ import { afterEach, describe, expect, it, vi } from 'vitest'
4
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
5
+
6
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
7
+ vi.mock('react-i18next', () => {
8
+ // Stable references: a fresh `t` per render would retrigger effects that depend on it.
9
+ const i18n = { language: 'es' }
10
+ const t = (k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? k
11
+ const value = { t, i18n }
12
+ return { useTranslation: () => value }
13
+ })
14
+
15
+ import { DocumentFormDialog, resolveDocumentForms } from '../document-form-dialog'
16
+ import { DynamicCRUDPage } from '../dynamic-crud-page'
17
+ import { useMetadataCache } from '../metadata-cache'
18
+ import { ApiProvider, type ApiClient } from '../api-context'
19
+ import type { DocumentFormsManifest } from '../types'
20
+
21
+ afterEach(cleanup)
22
+
23
+ const forms: DocumentFormsManifest = {
24
+ type_field: 'type',
25
+ types: [
26
+ {
27
+ key: 'invoice',
28
+ label: 'Factura',
29
+ description: 'Ingreso (I)',
30
+ value: 'I',
31
+ fields: [
32
+ { key: 'customer_name', label: 'Cliente', type: 'text', required: true },
33
+ { key: 'payment_method', label: 'Método de pago', type: 'text' },
34
+ ],
35
+ lines: { columns: ['tax'] },
36
+ },
37
+ {
38
+ key: 'credit_note',
39
+ label: 'Nota de crédito',
40
+ value: 'E',
41
+ fields: [
42
+ { key: 'customer_name', label: 'Cliente', type: 'text', required: true },
43
+ { key: 'origin_invoice', label: 'Factura origen', type: 'text', required: true },
44
+ ],
45
+ },
46
+ ],
47
+ }
48
+
49
+ function api(post: ApiClient['post']): ApiClient {
50
+ return { get: vi.fn().mockResolvedValue({ data: {} }), post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
51
+ }
52
+
53
+ function setup(manifest: DocumentFormsManifest, post: ApiClient['post'] = vi.fn().mockResolvedValue({ data: { success: true, data: { id: '1' } } })) {
54
+ const onOpenChange = vi.fn()
55
+ const onSaved = vi.fn()
56
+ render(
57
+ <ApiProvider client={api(post)}>
58
+ <DocumentFormDialog open onOpenChange={onOpenChange} model="fiscal_documents" forms={manifest} onSaved={onSaved} />
59
+ </ApiProvider>,
60
+ )
61
+ return { post: post as ReturnType<typeof vi.fn>, onOpenChange, onSaved }
62
+ }
63
+
64
+ describe('DocumentFormDialog', () => {
65
+ it('con dos tipos muestra tarjetas; elegir «Nota de crédito» muestra sus campos y no los de factura', () => {
66
+ setup(forms)
67
+ expect(screen.getByText('Factura')).toBeTruthy()
68
+ expect(screen.getByText('Nota de crédito')).toBeTruthy()
69
+ expect(screen.queryByText('Factura origen')).toBeNull()
70
+ fireEvent.click(screen.getByText('Nota de crédito'))
71
+ expect(screen.getByText('Factura origen')).toBeTruthy()
72
+ expect(screen.queryByText('Método de pago')).toBeNull()
73
+ })
74
+
75
+ it('un solo tipo se salta el selector', () => {
76
+ setup({ ...forms, types: [forms.types[1]] })
77
+ expect(screen.queryByRole('radiogroup')).toBeNull()
78
+ expect(screen.getByText('Factura origen')).toBeTruthy()
79
+ })
80
+
81
+ it('envía el valor del tipo y los campos al endpoint del modelo', async () => {
82
+ const { post, onSaved, onOpenChange } = setup(forms)
83
+ fireEvent.click(screen.getByText('Nota de crédito'))
84
+ const inputs = document.querySelectorAll('input')
85
+ fireEvent.change(inputs[0], { target: { value: 'ACME' } })
86
+ fireEvent.change(inputs[1], { target: { value: 'FAC-1' } })
87
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
88
+ await waitFor(() => expect(post).toHaveBeenCalled())
89
+ expect(post.mock.calls[0][0]).toBe('/dynamic/fiscal_documents')
90
+ expect(post.mock.calls[0][1]).toMatchObject({ type: 'E', customer_name: 'ACME', origin_invoice: 'FAC-1' })
91
+ await waitFor(() => expect(onSaved).toHaveBeenCalled())
92
+ expect(onOpenChange).toHaveBeenCalledWith(false)
93
+ })
94
+
95
+ it('el paso de renglones usa el editor del SDK y los serializa', async () => {
96
+ const { post } = setup({ ...forms, types: [forms.types[0]] })
97
+ fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
98
+ fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
99
+ // Sin renglones no guarda: banner en el diálogo.
100
+ await screen.findByRole('button', { name: 'Renglón libre' })
101
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
102
+ await screen.findByRole('alert')
103
+ expect(post).not.toHaveBeenCalled()
104
+ // Con un renglón válido, viajan serializados en `lines`.
105
+ fireEvent.click(screen.getByRole('button', { name: 'Renglón libre' }))
106
+ fireEvent.change(screen.getByLabelText('Descripción'), { target: { value: 'Servicio' } })
107
+ fireEvent.change(screen.getByLabelText('Precio'), { target: { value: '100' } })
108
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
109
+ await waitFor(() => expect(post).toHaveBeenCalled())
110
+ const body = post.mock.calls[0][1] as any
111
+ expect(body.type).toBe('I')
112
+ expect(body.lines).toEqual([expect.objectContaining({ description: 'Servicio', quantity: 1, unit_price: 100 })])
113
+ })
114
+
115
+ it('un 422 se muestra dentro del diálogo y no lo cierra', async () => {
116
+ const post = vi.fn().mockRejectedValue({
117
+ response: { status: 422, data: { success: false, message: 'validation failed', errors: { origin_invoice: ['La factura origen no existe'] } } },
118
+ })
119
+ const { onOpenChange, onSaved } = setup({ ...forms, types: [forms.types[1]] }, post)
120
+ const inputs = document.querySelectorAll('input')
121
+ fireEvent.change(inputs[0], { target: { value: 'ACME' } })
122
+ fireEvent.change(inputs[1], { target: { value: 'X' } })
123
+ fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
124
+ const banner = await screen.findByRole('alert')
125
+ expect(banner.textContent).toContain('La factura origen no existe')
126
+ expect(onOpenChange).not.toHaveBeenCalledWith(false)
127
+ expect(onSaved).not.toHaveBeenCalled()
128
+ })
129
+ })
130
+
131
+ describe('resolveDocumentForms', () => {
132
+ it('sin manifest (o sin tipos) devuelve undefined: el alta genérica sigue igual', () => {
133
+ expect(resolveDocumentForms(null)).toBeUndefined()
134
+ expect(resolveDocumentForms({} as any)).toBeUndefined()
135
+ expect(resolveDocumentForms({ document_forms: { types: [] } } as any)).toBeUndefined()
136
+ })
137
+ it('la prop gana sobre el metadata', () => {
138
+ expect(resolveDocumentForms({ document_forms: { types: [forms.types[0]] } } as any, forms)).toBe(forms)
139
+ expect(resolveDocumentForms({ document_forms: forms } as any)).toBe(forms)
140
+ })
141
+ })
142
+
143
+ describe('DynamicCRUDPage + document_forms', () => {
144
+ const meta = (extra: Record<string, unknown>) =>
145
+ ({
146
+ title: 'Notas de crédito',
147
+ endpoint: '/data/x',
148
+ columns: [],
149
+ actions: [],
150
+ perPageOptions: [10],
151
+ defaultPerPage: 10,
152
+ searchPlaceholder: 'Buscar',
153
+ enableCRUDActions: true,
154
+ hasActions: false,
155
+ canExport: false,
156
+ canImport: false,
157
+ ...extra,
158
+ }) as any
159
+
160
+ const mount = (m: any) => {
161
+ useMetadataCache.getState().setMetadata('fiscal_documents', m)
162
+ const client = {
163
+ get: vi.fn(async (url: string) =>
164
+ url.startsWith('/metadata/') ? { data: { success: true, data: m } } : { data: { success: true, data: [], meta: { total: 0 } } },
165
+ ),
166
+ post: vi.fn(),
167
+ put: vi.fn(),
168
+ delete: vi.fn(),
169
+ } as unknown as ApiClient
170
+ render(
171
+ <ApiProvider client={client}>
172
+ <DynamicCRUDPage model="fiscal_documents" />
173
+ </ApiProvider>,
174
+ )
175
+ }
176
+
177
+ it('con document_forms, Crear abre el selector de tipo en vez del formulario genérico', async () => {
178
+ mount(meta({ document_forms: forms }))
179
+ fireEvent.click((await screen.findByText(/New Nota/)).closest('button')!)
180
+ expect(await screen.findByRole('radiogroup')).toBeTruthy()
181
+ expect(screen.getByText('Nota de crédito')).toBeTruthy()
182
+ })
183
+
184
+ it('sin document_forms no aparece el selector (alta genérica)', async () => {
185
+ mount(meta({}))
186
+ fireEvent.click((await screen.findByText(/New Nota/)).closest('button')!)
187
+ await new Promise((r) => setTimeout(r, 50))
188
+ expect(screen.queryByRole('radiogroup')).toBeNull()
189
+ })
190
+ })
@@ -149,6 +149,10 @@ describe('DynamicTable infinite scroll', () => {
149
149
  await waitFor(() => expect(screen.getByText('30 de 45')).toBeTruthy())
150
150
  observers.forEach((o) => o.fire(true))
151
151
  await waitFor(() => expect(screen.getByText('45 de 45')).toBeTruthy())
152
+ // The sentinel leaves the viewport once everything is loaded. While it
153
+ // stays intersecting the hook deliberately chains the next page after a
154
+ // reset (viewport-fill), which would mask the collapse asserted below.
155
+ observers.forEach((o) => o.fire(false))
152
156
 
153
157
  // Change the active filters via defaultFilters (part of buildFilterParams).
154
158
  rerender(
@@ -0,0 +1,116 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicTable classic pagination ('pages', the default mode):
4
+ // 1. The pager footer renders; clicking page 2 issues a NEW query with
5
+ // page=2 and REPLACES the visible rows (no accumulation).
6
+ // 2. Changing the filters resets back to page 1.
7
+ // 3. The chosen page size persists per table in localStorage and is adopted
8
+ // on the next mount (over the model's server default).
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
11
+
12
+ vi.mock('@tanstack/react-router', () => ({
13
+ useNavigate: () => () => {},
14
+ }))
15
+ const I18N = {
16
+ t: (_k: string, o?: { defaultValue?: string; count?: number; total?: number }) => {
17
+ if (o?.defaultValue && o.count != null && o.total != null) {
18
+ return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
19
+ }
20
+ return o?.defaultValue ?? _k
21
+ },
22
+ i18n: { language: 'es' },
23
+ }
24
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
25
+
26
+ import { act } from '@testing-library/react'
27
+ import { emitRecordMutation } from '../record-mutation-events'
28
+ import { DynamicTable } from '../dynamic-table'
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
+ function meta(): TableMetadata {
36
+ return {
37
+ title: 'Issues',
38
+ endpoint: '/data/issue',
39
+ group_by: 'stage',
40
+ columns: [
41
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
42
+ ],
43
+ actions: [],
44
+ perPageOptions: [10, 20, 50],
45
+ defaultPerPage: 10,
46
+ searchPlaceholder: 'Buscar...',
47
+ enableCRUDActions: false,
48
+ hasActions: false,
49
+ }
50
+ }
51
+
52
+ function fakeApi(total: number): ApiClient {
53
+ const ok = (data: unknown) => ({ data: { success: true, data, meta: { total } } })
54
+ const page = (n: number, size: number) => {
55
+ const start = (n - 1) * size
56
+ const rows: Array<{ id: number; title: string }> = []
57
+ for (let i = start; i < Math.min(start + size, total); i++) {
58
+ rows.push({ id: i + 1, title: `Issue ${i + 1}` })
59
+ }
60
+ return rows
61
+ }
62
+ return {
63
+ get: vi.fn(async (url: string, cfg?: any) => {
64
+ if (url.startsWith('/metadata/table/')) return ok(meta())
65
+ if (url.endsWith('/facets')) return ok([])
66
+ const p = cfg?.params?.page ?? 1
67
+ const size = cfg?.params?.per_page ?? 10
68
+ return ok(page(p, size))
69
+ }),
70
+ post: vi.fn(async () => ok(null)),
71
+ put: vi.fn(async () => ok(null)),
72
+ delete: vi.fn(async () => ok(null)),
73
+ }
74
+ }
75
+
76
+ function dataCalls(api: ApiClient) {
77
+ return (api.get as any).mock.calls.filter((c: any[]) => c[0] === '/data/issue')
78
+ }
79
+
80
+ describe('DynamicTable reloads on record mutation events (#1020)', () => {
81
+ beforeEach(() => {
82
+ localStorage.clear()
83
+ sessionStorage.clear()
84
+ useMetadataCache.getState().setMetadata('issue', meta())
85
+ })
86
+
87
+ it('repeats the GET when a record of the same model (any case) is mutated', async () => {
88
+ const api = fakeApi(5)
89
+ render(
90
+ <ApiProvider client={api}>
91
+ <DynamicTable model="issue" enableUrlSync={false} />
92
+ </ApiProvider>,
93
+ )
94
+ await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(0))
95
+ await new Promise((r) => setTimeout(r, 450))
96
+ const before = dataCalls(api).length
97
+
98
+ act(() => emitRecordMutation('ISSUE', 'create'))
99
+ await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(before))
100
+ })
101
+
102
+ it('ignores mutations of other models', async () => {
103
+ const api = fakeApi(5)
104
+ render(
105
+ <ApiProvider client={api}>
106
+ <DynamicTable model="issue" enableUrlSync={false} />
107
+ </ApiProvider>,
108
+ )
109
+ await waitFor(() => expect(dataCalls(api).length).toBeGreaterThan(0))
110
+ await new Promise((r) => setTimeout(r, 450))
111
+ const before = dataCalls(api).length
112
+ act(() => emitRecordMutation('other_model', 'delete'))
113
+ await new Promise((r) => setTimeout(r, 500))
114
+ expect(dataCalls(api).length).toBe(before)
115
+ })
116
+ })
@@ -65,6 +65,7 @@ describe('DynamicLineItems — lock_rows', () => {
65
65
  it('control: sin lock_rows muestra "Agregar renglón" y un botón de borrar por fila', () => {
66
66
  render(<DynamicLineItems field={lineField()} value={rows} onChange={vi.fn()} />)
67
67
  expect(screen.getByText('Agregar renglón')).toBeTruthy()
68
- expect(screen.getAllByLabelText('Eliminar renglón')).toHaveLength(2)
68
+ // Mobile cards + desktop table both render a delete control per row.
69
+ expect(screen.getAllByLabelText('Eliminar renglón')).toHaveLength(rows.length * 2)
69
70
  })
70
71
  })
@@ -0,0 +1,76 @@
1
+ // @vitest-environment happy-dom
2
+ // PIT-044: the sidebar follows the role's effective permissions.
3
+ import { describe, expect, it } from 'vitest'
4
+ import { renderHook } from '@testing-library/react'
5
+ import type { ReactNode } from 'react'
6
+ import { PermissionsProvider, makeCan } from '../permissions-context'
7
+ import {
8
+ capabilityForNavItem,
9
+ isNavItemAllowed,
10
+ modelFromNavUrl,
11
+ useNavItemVisible,
12
+ } from '../nav-permissions'
13
+ import { filterNavigationByCapability, useNavigation, type NavItem } from '../navigation-builder'
14
+
15
+ describe('nav url → capability', () => {
16
+ it('maps /m/<model> to <model>.index and ignores other routes', () => {
17
+ expect(modelFromNavUrl('/m/POS_Orders?view=kanban')).toBe('POS_Orders')
18
+ expect(modelFromNavUrl('/app/m/stock_transfers')).toBe('stock_transfers')
19
+ expect(modelFromNavUrl('/app/activity')).toBeUndefined()
20
+ expect(capabilityForNavItem({ url: '/m/pos_orders' })).toBe('pos_orders.index')
21
+ expect(capabilityForNavItem({ url: '/m/x', requires: 'general.admin' })).toBe('general.admin')
22
+ expect(capabilityForNavItem({ url: '/app/activity' })).toBeUndefined()
23
+ })
24
+
25
+ it('allows non-model routes and gates model routes', () => {
26
+ const can = makeCan(['pos_orders.index'], false)
27
+ expect(isNavItemAllowed({ url: '/m/pos_orders' }, can)).toBe(true)
28
+ expect(isNavItemAllowed({ url: '/m/invoices' }, can)).toBe(false)
29
+ expect(isNavItemAllowed({ url: '/app/activity' }, can)).toBe(true)
30
+ })
31
+ })
32
+
33
+ describe('useNavItemVisible', () => {
34
+ it('is undefined without a provider (nothing filtered)', () => {
35
+ const { result } = renderHook(() => useNavItemVisible())
36
+ expect(result.current).toBeUndefined()
37
+ })
38
+
39
+ it('hides entries the role cannot index; admin sees all', () => {
40
+ const wrap = (perms: string[], isAdmin: boolean) =>
41
+ ({ children }: { children: ReactNode }) => (
42
+ <PermissionsProvider permissions={perms} isAdmin={isAdmin}>
43
+ {children}
44
+ </PermissionsProvider>
45
+ )
46
+ const limited = renderHook(() => useNavItemVisible(), { wrapper: wrap(['pos_orders.index'], false) })
47
+ expect(limited.result.current!({ url: '/m/pos_orders' })).toBe(true)
48
+ expect(limited.result.current!({ url: '/m/invoices' })).toBe(false)
49
+ const admin = renderHook(() => useNavItemVisible(), { wrapper: wrap([], true) })
50
+ expect(admin.result.current!({ url: '/m/invoices' })).toBe(true)
51
+ })
52
+ })
53
+
54
+ describe('navigation builder capability filter', () => {
55
+ const base: NavItem[] = [
56
+ { key: 'sales', label: 'Ventas', children: [
57
+ { key: 'orders', label: 'Pedidos', to: '/m/orders', requires: 'orders.index' },
58
+ { key: 'invoices', label: 'Facturas', to: '/m/invoices', requires: 'invoices.index' },
59
+ ] },
60
+ { key: 'admin', label: 'Admin', children: [
61
+ { key: 'roles', label: 'Roles', to: '/m/roles', requires: 'roles.index' },
62
+ ] },
63
+ { key: 'home', label: 'Inicio', to: '/' },
64
+ ]
65
+
66
+ it('drops denied items and parents left empty', () => {
67
+ const out = filterNavigationByCapability(base, makeCan(['orders.index'], false))
68
+ expect(out.map((i) => i.key)).toEqual(['sales', 'home'])
69
+ expect(out[0].children!.map((c) => c.key)).toEqual(['orders'])
70
+ })
71
+
72
+ it('useNavigation keeps everything without a provider', () => {
73
+ const { result } = renderHook(() => useNavigation(base, []))
74
+ expect(result.current).toHaveLength(3)
75
+ })
76
+ })