@asteby/metacore-runtime-react 46.3.0 → 47.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/dist/action-modal-dispatcher.d.ts +10 -1
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +107 -19
- package/dist/business/catalog.d.ts.map +1 -1
- package/dist/business/catalog.js +24 -0
- package/dist/business/index.d.ts +3 -0
- package/dist/business/index.d.ts.map +1 -1
- package/dist/business/index.js +3 -0
- package/dist/business/print-send-dialog.d.ts +27 -0
- package/dist/business/print-send-dialog.d.ts.map +1 -0
- package/dist/business/print-send-dialog.js +55 -0
- package/dist/business/relate-documents.d.ts +41 -0
- package/dist/business/relate-documents.d.ts.map +1 -0
- package/dist/business/relate-documents.js +42 -0
- package/dist/business/vehicle-picker.d.ts +36 -0
- package/dist/business/vehicle-picker.d.ts.map +1 -0
- package/dist/business/vehicle-picker.js +61 -0
- package/dist/cfdi-stamp-panel.d.ts +29 -0
- package/dist/cfdi-stamp-panel.d.ts.map +1 -0
- package/dist/cfdi-stamp-panel.js +53 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +5 -0
- package/dist/document-form-dialog.d.ts +23 -0
- package/dist/document-form-dialog.d.ts.map +1 -0
- package/dist/document-form-dialog.js +196 -0
- package/dist/dynamic-crud-page.d.ts +6 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +17 -7
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +3 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/list-primary-action.d.ts +31 -0
- package/dist/list-primary-action.d.ts.map +1 -0
- package/dist/list-primary-action.js +35 -0
- package/dist/model-action-toolbar.d.ts +7 -1
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +10 -3
- package/dist/model-extension-registry.d.ts +7 -0
- package/dist/model-extension-registry.d.ts.map +1 -1
- package/dist/nav-permissions.d.ts +19 -0
- package/dist/nav-permissions.d.ts.map +1 -0
- package/dist/nav-permissions.js +51 -0
- package/dist/navigation-builder.d.ts +6 -0
- package/dist/navigation-builder.d.ts.map +1 -1
- package/dist/navigation-builder.js +26 -1
- package/dist/permissions-manager.d.ts +9 -0
- package/dist/permissions-manager.d.ts.map +1 -1
- package/dist/permissions-manager.js +50 -17
- package/dist/print-document-button.d.ts.map +1 -1
- package/dist/print-document-button.js +9 -1
- package/dist/record-mutation-events.d.ts +14 -0
- package/dist/record-mutation-events.d.ts.map +1 -0
- package/dist/record-mutation-events.js +37 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-print-document.d.ts +15 -1
- package/dist/use-print-document.d.ts.map +1 -1
- package/dist/use-print-document.js +52 -1
- package/dist/use-record-mutation-tick.d.ts +6 -0
- package/dist/use-record-mutation-tick.d.ts.map +1 -0
- package/dist/use-record-mutation-tick.js +11 -0
- package/package.json +3 -3
- package/src/__tests__/action-modal-feedback.test.tsx +116 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/list-primary-action.test.tsx +157 -0
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/business/catalog.ts +24 -0
- package/src/business/index.ts +3 -0
- package/src/business/print-send-dialog.tsx +175 -0
- package/src/business/relate-documents.tsx +182 -0
- package/src/business/vehicle-picker.tsx +188 -0
- package/src/cfdi-stamp-panel.tsx +146 -0
- package/src/dialogs/dynamic-record.tsx +5 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dynamic-crud-page.tsx +36 -7
- package/src/dynamic-kanban.tsx +3 -1
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +36 -0
- package/src/list-primary-action.ts +71 -0
- package/src/model-action-toolbar.tsx +18 -3
- package/src/model-extension-registry.tsx +7 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/permissions-manager.tsx +39 -26
- package/src/print-document-button.tsx +9 -1
- package/src/record-mutation-events.ts +50 -0
- package/src/types.ts +63 -0
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -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
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { cleanup, render, screen } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
6
|
+
useNavigate: () => () => {},
|
|
7
|
+
}))
|
|
8
|
+
vi.mock('react-i18next', () => {
|
|
9
|
+
const i18n = { language: 'es' }
|
|
10
|
+
const t = (k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? k
|
|
11
|
+
return { useTranslation: () => ({ t, i18n }) }
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
import { resolveListPrimaryAction } from '../list-primary-action'
|
|
15
|
+
import { ModelActionToolbar } from '../model-action-toolbar'
|
|
16
|
+
import { DynamicCRUDPage } from '../dynamic-crud-page'
|
|
17
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
18
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
19
|
+
import { clearModelExtensions, registerModelExtension } from '../model-extension-registry'
|
|
20
|
+
import type { ActionDefinition, TableMetadata } from '../types'
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
cleanup()
|
|
24
|
+
clearModelExtensions()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const act = (key: string, extra: Partial<ActionDefinition> = {}): ActionDefinition =>
|
|
28
|
+
({ key, name: key, label: key, icon: 'Plus', ...extra }) as ActionDefinition
|
|
29
|
+
|
|
30
|
+
describe('resolveListPrimaryAction', () => {
|
|
31
|
+
it('con CRUD y sin acciones deja el Crear genérico', () => {
|
|
32
|
+
const r = resolveListPrimaryAction({ enableCRUD: true, actions: [] })
|
|
33
|
+
expect(r.showGenericCreate).toBe(true)
|
|
34
|
+
expect(r.primaryAction).toBeUndefined()
|
|
35
|
+
expect(r.conflict).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('replaces_create oculta el genérico y esa acción es la primaria', () => {
|
|
39
|
+
const quote = act('nueva_cotizacion', { placement: 'table', replaces_create: true, label: 'Nueva cotización' })
|
|
40
|
+
const r = resolveListPrimaryAction({ enableCRUD: true, actions: [quote] })
|
|
41
|
+
expect(r.showGenericCreate).toBe(false)
|
|
42
|
+
expect(r.primaryAction?.key).toBe('nueva_cotizacion')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('create_mode hidden y canCreate false ocultan el genérico', () => {
|
|
46
|
+
expect(resolveListPrimaryAction({ enableCRUD: true, createMode: 'hidden' }).showGenericCreate).toBe(false)
|
|
47
|
+
expect(resolveListPrimaryAction({ enableCRUD: true, canCreate: false }).showGenericCreate).toBe(false)
|
|
48
|
+
expect(resolveListPrimaryAction({ enableCRUD: true, createMode: 'action' }).showGenericCreate).toBe(false)
|
|
49
|
+
expect(resolveListPrimaryAction({ enableCRUD: false }).showGenericCreate).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('dos placement create: una sola primaria, la primera o primaryActionKey', () => {
|
|
53
|
+
const a = act('a', { placement: 'create' })
|
|
54
|
+
const b = act('b', { placement: 'create' })
|
|
55
|
+
const r = resolveListPrimaryAction({ enableCRUD: true, actions: [a, b] })
|
|
56
|
+
expect(r.conflict).toBe(true)
|
|
57
|
+
expect(r.showGenericCreate).toBe(false)
|
|
58
|
+
expect(r.primaryAction?.key).toBe('a')
|
|
59
|
+
expect(r.secondaryActions.map((x) => x.key)).toEqual(['b'])
|
|
60
|
+
const picked = resolveListPrimaryAction({ enableCRUD: true, actions: [a, b], primaryActionKey: 'b' })
|
|
61
|
+
expect(picked.primaryAction?.key).toBe('b')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('una sola data-primary', () => {
|
|
66
|
+
it('dos acciones create pintan un solo botón primario', () => {
|
|
67
|
+
render(
|
|
68
|
+
<ApiProvider client={stubApi()}>
|
|
69
|
+
<ModelActionToolbar
|
|
70
|
+
model="Pedido"
|
|
71
|
+
actions={[
|
|
72
|
+
act('a', { placement: 'create', label: 'Crear A' }),
|
|
73
|
+
act('b', { placement: 'create', label: 'Crear B' }),
|
|
74
|
+
]}
|
|
75
|
+
/>
|
|
76
|
+
</ApiProvider>,
|
|
77
|
+
)
|
|
78
|
+
expect(document.querySelectorAll('[data-primary="true"]')).toHaveLength(1)
|
|
79
|
+
expect(screen.getByText('Crear A').closest('button')?.getAttribute('data-primary')).toBe('true')
|
|
80
|
+
expect(screen.getByText('Crear B').closest('button')?.getAttribute('data-primary')).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('replaces_create quita el Crear genérico de la página', async () => {
|
|
84
|
+
const model = 'cotizaciones'
|
|
85
|
+
const meta = metaFor(model, {
|
|
86
|
+
actions: [act('nueva', { placement: 'table', replaces_create: true, label: 'Nueva cotización' })],
|
|
87
|
+
})
|
|
88
|
+
useMetadataCache.getState().setMetadata(model, meta)
|
|
89
|
+
render(
|
|
90
|
+
<ApiProvider client={stubApi(meta)}>
|
|
91
|
+
<DynamicCRUDPage model={model} />
|
|
92
|
+
</ApiProvider>,
|
|
93
|
+
)
|
|
94
|
+
expect(await screen.findByText('Nueva cotización')).toBeTruthy()
|
|
95
|
+
expect(screen.queryByText(/New Cotizacione/)).toBeNull()
|
|
96
|
+
expect(document.querySelectorAll('[data-primary="true"]')).toHaveLength(1)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('create_mode hidden quita el Crear genérico', async () => {
|
|
100
|
+
const model = 'facturas'
|
|
101
|
+
const meta = metaFor(model, { create_mode: 'hidden', actions: [] })
|
|
102
|
+
useMetadataCache.getState().setMetadata(model, meta)
|
|
103
|
+
render(
|
|
104
|
+
<ApiProvider client={stubApi(meta)}>
|
|
105
|
+
<DynamicCRUDPage model={model} />
|
|
106
|
+
</ApiProvider>,
|
|
107
|
+
)
|
|
108
|
+
expect(await screen.findByText('Facturas')).toBeTruthy()
|
|
109
|
+
expect(screen.queryByText(/New /)).toBeNull()
|
|
110
|
+
expect(document.querySelectorAll('[data-primary="true"]')).toHaveLength(0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('la extensión createMode hidden también lo quita', async () => {
|
|
114
|
+
const model = 'notas'
|
|
115
|
+
registerModelExtension(model, { createMode: 'hidden' })
|
|
116
|
+
const meta = metaFor(model)
|
|
117
|
+
useMetadataCache.getState().setMetadata(model, meta)
|
|
118
|
+
render(
|
|
119
|
+
<ApiProvider client={stubApi(meta)}>
|
|
120
|
+
<DynamicCRUDPage model={model} />
|
|
121
|
+
</ApiProvider>,
|
|
122
|
+
)
|
|
123
|
+
expect(await screen.findByText('Notas')).toBeTruthy()
|
|
124
|
+
expect(screen.queryByText(/New /)).toBeNull()
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
function metaFor(model: string, over: Partial<TableMetadata> = {}): TableMetadata {
|
|
129
|
+
void model
|
|
130
|
+
return {
|
|
131
|
+
title: over.title ?? (model === 'cotizaciones' ? 'Cotizaciones' : model === 'facturas' ? 'Facturas' : 'Notas'),
|
|
132
|
+
endpoint: '/data/x',
|
|
133
|
+
columns: [],
|
|
134
|
+
actions: [],
|
|
135
|
+
perPageOptions: [10],
|
|
136
|
+
defaultPerPage: 10,
|
|
137
|
+
searchPlaceholder: 'Buscar',
|
|
138
|
+
enableCRUDActions: true,
|
|
139
|
+
hasActions: false,
|
|
140
|
+
canExport: false,
|
|
141
|
+
canImport: false,
|
|
142
|
+
...over,
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function stubApi(meta?: TableMetadata): ApiClient {
|
|
147
|
+
const ok = (data: unknown) => ({ data: { success: true, data, meta: { total: 0 } } })
|
|
148
|
+
return {
|
|
149
|
+
get: vi.fn(async (url: string) => {
|
|
150
|
+
if (url.startsWith('/metadata/table/')) return ok(meta ?? { actions: [] })
|
|
151
|
+
return ok([])
|
|
152
|
+
}),
|
|
153
|
+
post: vi.fn(async () => ok(null)),
|
|
154
|
+
put: vi.fn(async () => ok(null)),
|
|
155
|
+
delete: vi.fn(async () => ok(null)),
|
|
156
|
+
}
|
|
157
|
+
}
|