@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.
- package/CHANGELOG.md +38 -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/audit-info.d.ts +54 -0
- package/dist/audit-info.d.ts.map +1 -0
- package/dist/audit-info.js +142 -0
- 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 +10 -1
- 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 +4 -2
- package/dist/dynamic-form.d.ts.map +1 -1
- package/dist/dynamic-form.js +3 -0
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +11 -2
- package/dist/dynamic-line-items.d.ts.map +1 -1
- package/dist/dynamic-line-items.js +3 -0
- package/dist/dynamic-multi-select-field.d.ts.map +1 -1
- package/dist/dynamic-multi-select-field.js +2 -0
- package/dist/dynamic-select-field.d.ts.map +1 -1
- package/dist/dynamic-select-field.js +4 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +6 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- 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/option-filter.d.ts +31 -0
- package/dist/option-filter.d.ts.map +1 -0
- package/dist/option-filter.js +49 -0
- 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 +86 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-options-resolver.d.ts +8 -0
- package/dist/use-options-resolver.d.ts.map +1 -1
- package/dist/use-options-resolver.js +8 -2
- 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__/action-modal-grid-layout.test.tsx +1 -1
- package/src/__tests__/apply-sidebar-layout.test.ts +1 -1
- package/src/__tests__/audit-info.test.tsx +103 -0
- package/src/__tests__/business-pickers.test.tsx +157 -0
- package/src/__tests__/dependent-options.test.tsx +12 -5
- package/src/__tests__/document-form-dialog.test.tsx +190 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +4 -0
- package/src/__tests__/dynamic-table-mutation-refresh.test.tsx +116 -0
- package/src/__tests__/line-items-lock-rows.test.tsx +2 -1
- package/src/__tests__/nav-permissions.test.tsx +76 -0
- package/src/__tests__/option-filter.test.tsx +115 -0
- package/src/__tests__/permissions-manager.test.tsx +21 -4
- package/src/__tests__/structured-view-value.test.tsx +5 -1
- package/src/__tests__/use-print-document-feedback.test.tsx +58 -0
- package/src/__tests__/wizard-action-modal.test.tsx +1 -1
- package/src/action-modal-dispatcher.tsx +124 -6
- package/src/audit-info.tsx +219 -0
- 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 +18 -0
- package/src/document-form-dialog.tsx +357 -0
- package/src/dropdown-menu-entry.test.ts +23 -0
- package/src/dynamic-crud-page.tsx +21 -2
- package/src/dynamic-form.tsx +3 -0
- package/src/dynamic-kanban.tsx +10 -4
- package/src/dynamic-line-items.tsx +3 -0
- package/src/dynamic-multi-select-field.tsx +2 -0
- package/src/dynamic-select-field.tsx +4 -0
- package/src/dynamic-table.tsx +6 -2
- package/src/index.ts +41 -0
- package/src/nav-permissions.ts +58 -0
- package/src/navigation-builder.tsx +27 -1
- package/src/option-filter.ts +88 -0
- 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 +90 -0
- package/src/use-options-resolver.ts +19 -1
- package/src/use-print-document.ts +66 -3
- package/src/use-record-mutation-tick.ts +12 -0
- package/vitest.config.ts +1 -0
- package/vitest.setup.ts +7 -0
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// `option_filter` / `optionFilter` on a relation picker hides options client-side
|
|
4
|
+
// (PIT-059: cancelled invoices must not show up in the payments selector).
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
6
|
+
import { act, cleanup, render, screen, waitFor, within } from '@testing-library/react'
|
|
7
|
+
import { applyOptionFilter, getOptionFilter, optionPassesRule } from '../option-filter'
|
|
8
|
+
import { projectOption } from '../use-options-resolver'
|
|
9
|
+
import { DynamicSelectField } from '../dynamic-select-field'
|
|
10
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
11
|
+
import type { ActionFieldDef } from '../types'
|
|
12
|
+
|
|
13
|
+
vi.mock('react-i18next', () => ({
|
|
14
|
+
useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
afterEach(cleanup)
|
|
18
|
+
|
|
19
|
+
const invoices = [
|
|
20
|
+
{ id: 'i1', label: 'FAC-1', status: 'timbrada', total: 100 },
|
|
21
|
+
{ id: 'i2', label: 'FAC-2', status: 'Cancelada', total: 50 },
|
|
22
|
+
{ id: 'i3', label: 'FAC-3', status: 'pendiente', total: 70 },
|
|
23
|
+
{ id: 'i4', label: 'FAC-4' },
|
|
24
|
+
].map(projectOption)
|
|
25
|
+
|
|
26
|
+
describe('getOptionFilter', () => {
|
|
27
|
+
it('is empty when the field declares none (retrocompat)', () => {
|
|
28
|
+
expect(getOptionFilter({ key: 'x' })).toEqual([])
|
|
29
|
+
expect(getOptionFilter(undefined)).toEqual([])
|
|
30
|
+
})
|
|
31
|
+
it('reads camelCase and snake_case, one rule or a list', () => {
|
|
32
|
+
const rule = { field: 'status', not_in: ['cancelada'] }
|
|
33
|
+
expect(getOptionFilter({ optionFilter: rule })).toEqual([rule])
|
|
34
|
+
expect(getOptionFilter({ option_filter: [rule] })).toEqual([rule])
|
|
35
|
+
})
|
|
36
|
+
it('ignores malformed rules', () => {
|
|
37
|
+
expect(getOptionFilter({ option_filter: [{ nope: 1 }, null, { field: '' }] })).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('applyOptionFilter', () => {
|
|
42
|
+
it('returns the same array with no rules', () => {
|
|
43
|
+
expect(applyOptionFilter(invoices, [])).toBe(invoices)
|
|
44
|
+
})
|
|
45
|
+
it('not_in hides cancelled invoices (case-insensitive) and keeps options without the column', () => {
|
|
46
|
+
const out = applyOptionFilter(invoices, [{ field: 'status', not_in: ['cancelada'] }])
|
|
47
|
+
expect(out.map((o) => o.id)).toEqual(['i1', 'i3', 'i4'])
|
|
48
|
+
})
|
|
49
|
+
it('notEquals alias and equals / in need the column to exist', () => {
|
|
50
|
+
expect(applyOptionFilter(invoices, [{ field: 'status', notEquals: 'cancelada' }]).map((o) => o.id)).toEqual(['i1', 'i3', 'i4'])
|
|
51
|
+
expect(applyOptionFilter(invoices, [{ field: 'status', equals: 'timbrada' }]).map((o) => o.id)).toEqual(['i1'])
|
|
52
|
+
expect(applyOptionFilter(invoices, [{ field: 'status', in: ['timbrada', 'pendiente'] }]).map((o) => o.id)).toEqual(['i1', 'i3'])
|
|
53
|
+
})
|
|
54
|
+
it('combines rules with AND', () => {
|
|
55
|
+
const out = applyOptionFilter(invoices, [
|
|
56
|
+
{ field: 'status', not_in: ['cancelada'] },
|
|
57
|
+
{ field: 'status', not_equals: 'pendiente' },
|
|
58
|
+
])
|
|
59
|
+
expect(out.map((o) => o.id)).toEqual(['i1', 'i4'])
|
|
60
|
+
})
|
|
61
|
+
it('never hides the current selection', () => {
|
|
62
|
+
const out = applyOptionFilter(invoices, [{ field: 'status', not_in: ['cancelada'] }], 'i2')
|
|
63
|
+
expect(out.map((o) => o.id)).toEqual(['i1', 'i2', 'i3', 'i4'])
|
|
64
|
+
})
|
|
65
|
+
it('tests standard keys too (label)', () => {
|
|
66
|
+
expect(optionPassesRule(invoices[0], { field: 'label', equals: 'fac-1' })).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('DynamicSelectField with option_filter', () => {
|
|
71
|
+
const field = (extra: Partial<ActionFieldDef> = {}): ActionFieldDef => ({
|
|
72
|
+
key: 'invoice_id',
|
|
73
|
+
label: 'Factura',
|
|
74
|
+
type: 'dynamic_select',
|
|
75
|
+
ref: 'invoices.Invoice',
|
|
76
|
+
...extra,
|
|
77
|
+
})
|
|
78
|
+
const api = () => {
|
|
79
|
+
const get = vi.fn(async () => ({
|
|
80
|
+
data: {
|
|
81
|
+
success: true,
|
|
82
|
+
data: [
|
|
83
|
+
{ id: 'i1', label: 'FAC-1', status: 'timbrada' },
|
|
84
|
+
{ id: 'i2', label: 'FAC-2', status: 'cancelada' },
|
|
85
|
+
],
|
|
86
|
+
meta: { type: 'dynamic', count: 2 },
|
|
87
|
+
},
|
|
88
|
+
}))
|
|
89
|
+
const post = vi.fn(async () => ({ data: { success: false } }))
|
|
90
|
+
return { get, post } as unknown as ApiClient
|
|
91
|
+
}
|
|
92
|
+
const open = async (f: ActionFieldDef) => {
|
|
93
|
+
render(
|
|
94
|
+
<ApiProvider client={api()}>
|
|
95
|
+
<DynamicSelectField field={f} value="" onChange={() => {}} />
|
|
96
|
+
</ApiProvider>,
|
|
97
|
+
)
|
|
98
|
+
await act(async () => {
|
|
99
|
+
screen.getByRole('combobox').click()
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
it('lists every option without a filter', async () => {
|
|
104
|
+
await open(field())
|
|
105
|
+
await waitFor(() => expect(screen.getByText('FAC-2')).toBeTruthy())
|
|
106
|
+
expect(screen.getByText('FAC-1')).toBeTruthy()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('hides the cancelled invoice when the manifest declares option_filter', async () => {
|
|
110
|
+
await open(field({ option_filter: { field: 'status', not_in: ['cancelada'] } }))
|
|
111
|
+
await waitFor(() => expect(screen.getByText('FAC-1')).toBeTruthy())
|
|
112
|
+
expect(screen.queryByText('FAC-2')).toBeNull()
|
|
113
|
+
expect(within(document.body).queryAllByText('FAC-2')).toHaveLength(0)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -249,9 +249,25 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
|
|
|
249
249
|
expect(screen.getByRole('option', { name: /Pedidos POS/ })).toBeTruthy()
|
|
250
250
|
expect(screen.getByRole('option', { name: /Terminal/ })).toBeTruthy()
|
|
251
251
|
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
expect(screen.
|
|
252
|
+
// Los generales son un grupo sintético del combobox (#1021), no cards
|
|
253
|
+
// sueltas junto a la tarjeta del rol.
|
|
254
|
+
expect(screen.getByRole('option', { name: /Permisos generales/ })).toBeTruthy()
|
|
255
|
+
expect(screen.queryByText('Trabajar fuera de horario')).toBeNull()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('«Permisos generales» se edita en la misma grilla de dos columnas y cuenta en el total', async () => {
|
|
259
|
+
const props = makeProps()
|
|
260
|
+
render(<PermissionsManager {...props} />)
|
|
261
|
+
await waitFor(() => expect(moduleTrigger().textContent).toMatch(/Usuarios/))
|
|
262
|
+
|
|
263
|
+
await selectModule(/Permisos generales/)
|
|
264
|
+
expect(await screen.findByText('Trabajar fuera de horario')).toBeTruthy()
|
|
265
|
+
expect(screen.getByText('0/1')).toBeTruthy()
|
|
266
|
+
const box = screen.getByRole('checkbox', { name: /Trabajar fuera de horario/ })
|
|
267
|
+
// Misma grilla que las acciones de un módulo.
|
|
268
|
+
expect(box.closest('div.grid')?.className).toContain('sm:grid-cols-2')
|
|
269
|
+
fireEvent.click(box)
|
|
270
|
+
expect(screen.getByText('1/1')).toBeTruthy()
|
|
255
271
|
})
|
|
256
272
|
|
|
257
273
|
it('CERO acordeones: el módulo es un combobox, no un folder colapsable', async () => {
|
|
@@ -304,7 +320,8 @@ describe('PermissionsManager (módulo como combobox agrupado)', () => {
|
|
|
304
320
|
await selectModule(/Pedidos POS/)
|
|
305
321
|
await screen.findByText('Pagar')
|
|
306
322
|
fireEvent.click(screen.getByRole('checkbox', { name: /Pagar/ }))
|
|
307
|
-
|
|
323
|
+
await selectModule(/Permisos generales/)
|
|
324
|
+
fireEvent.click(await screen.findByRole('checkbox', { name: /Trabajar fuera de horario/ }))
|
|
308
325
|
|
|
309
326
|
expect(screen.getByText('Cambios sin guardar')).toBeTruthy()
|
|
310
327
|
fireEvent.click(screen.getByRole('button', { name: /Guardar permisos/ }))
|
|
@@ -82,7 +82,11 @@ describe('detail-view jsonb line-items (ViewValue → inline CollectionCell)', (
|
|
|
82
82
|
record={{}}
|
|
83
83
|
/>
|
|
84
84
|
)
|
|
85
|
-
|
|
85
|
+
// Keys are humanized (the addon-declared label is the localized source);
|
|
86
|
+
// each pair renders as a <dt>/<dd>, never "[object Object]".
|
|
87
|
+
expect(screen.getByText('Price').tagName).toBe('DT')
|
|
88
|
+
expect(screen.getByText('10').tagName).toBe('DD')
|
|
89
|
+
expect(screen.getByText('Quantity')).toBeTruthy()
|
|
86
90
|
expect(container.textContent).not.toContain('[object Object]')
|
|
87
91
|
})
|
|
88
92
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
// «Descargar Factura CFDI» no daba señal alguna: ahora hay progreso, éxito y
|
|
3
|
+
// el motivo del servidor si falla (la promesa sigue rechazando).
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
import { cleanup, renderHook } from '@testing-library/react'
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
|
|
8
|
+
const toast = vi.hoisted(() => ({
|
|
9
|
+
loading: vi.fn(() => 'tid'),
|
|
10
|
+
success: vi.fn(),
|
|
11
|
+
error: vi.fn(),
|
|
12
|
+
dismiss: vi.fn(),
|
|
13
|
+
}))
|
|
14
|
+
vi.mock('sonner', () => ({ toast }))
|
|
15
|
+
|
|
16
|
+
import { usePrintDocument, documentErrorMessage } from '../use-print-document'
|
|
17
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
cleanup()
|
|
21
|
+
vi.clearAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const wrap = (client: ApiClient) => ({ children }: { children: ReactNode }) => <ApiProvider client={client}>{children}</ApiProvider>
|
|
25
|
+
|
|
26
|
+
describe('usePrintDocument feedback', () => {
|
|
27
|
+
it('descarga: toast de progreso y de éxito', async () => {
|
|
28
|
+
URL.createObjectURL = vi.fn(() => 'blob:x')
|
|
29
|
+
const client = { get: vi.fn().mockResolvedValue({ data: new Blob(['x']), headers: {} }) } as unknown as ApiClient
|
|
30
|
+
const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
|
|
31
|
+
await result.current({ model: 'Invoice', id: '1', key: 'cfdi', mode: 'download' })
|
|
32
|
+
expect(toast.loading).toHaveBeenCalled()
|
|
33
|
+
expect(toast.success).toHaveBeenCalledWith('Documento descargado')
|
|
34
|
+
expect(toast.dismiss).toHaveBeenCalled()
|
|
35
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('fallo: muestra el motivo del servidor (cuerpo Blob) y rechaza', async () => {
|
|
39
|
+
const body = new Blob([JSON.stringify({ message: 'La factura aún no está timbrada' })], { type: 'application/json' })
|
|
40
|
+
const client = { get: vi.fn().mockRejectedValue({ response: { status: 409, data: body } }) } as unknown as ApiClient
|
|
41
|
+
const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
|
|
42
|
+
await expect(result.current({ model: 'Invoice', id: '1', key: 'cfdi', mode: 'download' })).rejects.toBeTruthy()
|
|
43
|
+
expect(toast.error).toHaveBeenCalledWith('La factura aún no está timbrada')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('feedback:false no muestra nada; error:false deja el error al llamador', async () => {
|
|
47
|
+
const client = { get: vi.fn().mockRejectedValue({ response: { status: 500, data: null } }) } as unknown as ApiClient
|
|
48
|
+
const { result } = renderHook(() => usePrintDocument(), { wrapper: wrap(client) })
|
|
49
|
+
await expect(result.current({ model: 'I', id: '1', key: 'k', mode: 'open', feedback: false })).rejects.toBeTruthy()
|
|
50
|
+
await expect(result.current({ model: 'I', id: '1', key: 'k', mode: 'open', feedback: { progress: true, error: false } })).rejects.toBeTruthy()
|
|
51
|
+
expect(toast.error).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('documentErrorMessage cae a un mensaje genérico', async () => {
|
|
55
|
+
expect(await documentErrorMessage(new Error('x'))).toMatch(/No se pudo generar/)
|
|
56
|
+
expect(await documentErrorMessage({ response: { status: 404 } })).toMatch(/no está disponible/)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -14,7 +14,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
|
14
14
|
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
15
15
|
|
|
16
16
|
vi.mock('react-i18next', () => ({
|
|
17
|
-
useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k }),
|
|
17
|
+
useTranslation: () => ({ t: (k: string, o?: any) => o?.defaultValue ?? k, i18n: { language: 'es' } }),
|
|
18
18
|
}))
|
|
19
19
|
|
|
20
20
|
import { ActionModalDispatcher } from '../action-modal-dispatcher'
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
//
|
|
15
15
|
// The host injects its axios-like client via <ApiProvider>; we no longer
|
|
16
16
|
// depend on a bundler alias to `@/lib/api`.
|
|
17
|
-
import {
|
|
17
|
+
import { emitRecordMutation } from './record-mutation-events'
|
|
18
|
+
import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
|
|
18
19
|
import { useTranslation } from 'react-i18next'
|
|
19
20
|
import {
|
|
20
21
|
Dialog,
|
|
@@ -44,7 +45,9 @@ import {
|
|
|
44
45
|
import { Loader2 } from 'lucide-react'
|
|
45
46
|
import { ProcessStepper } from '@asteby/metacore-ui/wizard'
|
|
46
47
|
import { toast } from 'sonner'
|
|
47
|
-
import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
|
|
48
|
+
import { toastServerError, toastServerSuccess, extractFieldErrors, extractServerError, localizeFieldErrorMap } from './server-error'
|
|
49
|
+
import { FormErrorBanner } from './business/feedback'
|
|
50
|
+
import { CfdiStampResultDialog, extractStampResult, type CfdiStampResult } from './cfdi-stamp-panel'
|
|
48
51
|
import { useBranchCreateGate } from './branch-create-gate'
|
|
49
52
|
import type { Translate } from './server-error'
|
|
50
53
|
import { validateValues, bagHasErrors } from './validator'
|
|
@@ -358,8 +361,15 @@ export function ActionModalDispatcher({
|
|
|
358
361
|
model,
|
|
359
362
|
record,
|
|
360
363
|
endpoint,
|
|
361
|
-
onSuccess,
|
|
364
|
+
onSuccess: onSuccessProp,
|
|
362
365
|
}: ActionModalProps) {
|
|
366
|
+
// Every successful action may have created/updated/deleted a record of
|
|
367
|
+
// `model` (federated component, wizard, generic form or confirmation), so
|
|
368
|
+
// announce it: mounted lists of the model reload on their own (#1020).
|
|
369
|
+
const onSuccess = useCallback(() => {
|
|
370
|
+
emitRecordMutation(model, 'update')
|
|
371
|
+
onSuccessProp?.()
|
|
372
|
+
}, [model, onSuccessProp])
|
|
363
373
|
// Reactive read: a federated remote usually registers AFTER the first
|
|
364
374
|
// render, and a memoized lookup would pin the fallback forever.
|
|
365
375
|
const readComponent = () => getActionComponent(model, action.key)
|
|
@@ -794,10 +804,34 @@ function toastActionError(
|
|
|
794
804
|
toastServerError(err, { t, language })
|
|
795
805
|
}
|
|
796
806
|
|
|
807
|
+
/** Text for the in-dialog error banner of a failed action (PIT-046, #1023): a
|
|
808
|
+
* 422 with per-field `errors` -> "Revisa los campos" + each localized line; any
|
|
809
|
+
* other failure (500, network) -> the server's own message and cause. Unlike
|
|
810
|
+
* the toast it stays on screen until the next attempt. */
|
|
811
|
+
export function actionErrorBannerMessage(
|
|
812
|
+
err: unknown,
|
|
813
|
+
fields: readonly ActionFieldDef[] | undefined,
|
|
814
|
+
t: Translate,
|
|
815
|
+
language?: string,
|
|
816
|
+
): string {
|
|
817
|
+
const localized = localizeActionFieldErrors(err, fields, t, language)
|
|
818
|
+
if (localized) {
|
|
819
|
+
const head = t('validation.failed', { defaultValue: validationCatalog(language).failed })
|
|
820
|
+
const lines = Object.values(localized).filter(Boolean)
|
|
821
|
+
return lines.length ? `${head}: ${lines.join(' · ')}` : head
|
|
822
|
+
}
|
|
823
|
+
const fallback = t('common.error', { defaultValue: 'Algo salió mal. Intenta de nuevo.' })
|
|
824
|
+
const ex = extractServerError(err, fallback)
|
|
825
|
+
const title = t(ex.title, { defaultValue: ex.title })
|
|
826
|
+
return ex.description && ex.description !== title ? `${title} — ${ex.description}` : title
|
|
827
|
+
}
|
|
828
|
+
|
|
797
829
|
function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoint, onSuccess }: ActionModalProps) {
|
|
798
830
|
const { t, i18n } = useTranslation()
|
|
799
831
|
const api = useApi()
|
|
800
832
|
const [executing, setExecuting] = useState(false)
|
|
833
|
+
const [formError, setFormError] = useState<string | undefined>()
|
|
834
|
+
const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
|
|
801
835
|
// Actions listed in the model's `reason_required.actions` (cancel, void…)
|
|
802
836
|
// answer 422 errors.reason without a motive; the prompt asks and retries.
|
|
803
837
|
const reasonPrompt = useReasonPrompt()
|
|
@@ -809,6 +843,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
809
843
|
|
|
810
844
|
const execute = async () => {
|
|
811
845
|
setExecuting(true)
|
|
846
|
+
setFormError(undefined)
|
|
812
847
|
try {
|
|
813
848
|
const url = buildActionUrl(endpoint, model, record.id, action.key)
|
|
814
849
|
// Supervisor PIN first (action.supervisorPolicy); null = cancelled.
|
|
@@ -823,18 +858,41 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
823
858
|
if (!res) return
|
|
824
859
|
if (res.data.success) {
|
|
825
860
|
toastServerSuccess(res.data, { t })
|
|
861
|
+
const result = extractStampResult(res.data)
|
|
862
|
+
if (result) {
|
|
863
|
+
setStamp(result)
|
|
864
|
+
return
|
|
865
|
+
}
|
|
826
866
|
onOpenChange(false)
|
|
827
867
|
onSuccess()
|
|
828
868
|
} else {
|
|
869
|
+
setFormError(actionErrorBannerMessage({ response: { data: res.data } }, action.fields, t, i18n.language))
|
|
829
870
|
toastActionError({ response: { data: res.data } }, action.fields, t, i18n.language)
|
|
830
871
|
}
|
|
831
872
|
} catch (err: any) {
|
|
873
|
+
setFormError(actionErrorBannerMessage(err, action.fields, t, i18n.language))
|
|
832
874
|
toastActionError(err, action.fields, t, i18n.language)
|
|
833
875
|
} finally {
|
|
834
876
|
setExecuting(false)
|
|
835
877
|
}
|
|
836
878
|
}
|
|
837
879
|
|
|
880
|
+
if (stamp) {
|
|
881
|
+
return (
|
|
882
|
+
<CfdiStampResultDialog
|
|
883
|
+
open={open}
|
|
884
|
+
result={stamp}
|
|
885
|
+
title={label}
|
|
886
|
+
onOpenChange={(o) => {
|
|
887
|
+
if (o) return
|
|
888
|
+
setStamp(undefined)
|
|
889
|
+
onOpenChange(false)
|
|
890
|
+
onSuccess()
|
|
891
|
+
}}
|
|
892
|
+
/>
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
|
|
838
896
|
return (
|
|
839
897
|
<>
|
|
840
898
|
{reasonPrompt.dialog}
|
|
@@ -850,6 +908,7 @@ function ConfirmActionDialog({ open, onOpenChange, action, model, record, endpoi
|
|
|
850
908
|
</AlertDialogDescription>
|
|
851
909
|
</AlertDialogHeader>
|
|
852
910
|
{record ? <RecordPreview model={model} record={record} /> : null}
|
|
911
|
+
<FormErrorBanner message={formError} />
|
|
853
912
|
<AlertDialogFooter>
|
|
854
913
|
<AlertDialogCancel disabled={executing}>{t('common.cancel')}</AlertDialogCancel>
|
|
855
914
|
<AlertDialogAction
|
|
@@ -881,6 +940,10 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
881
940
|
const [executing, setExecuting] = useState(false)
|
|
882
941
|
// Per-field validation errors (localized), shown inline under each input.
|
|
883
942
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
|
|
943
|
+
// Persistent in-dialog banner for a failed submit (422 / 500) — the toast
|
|
944
|
+
// alone vanished and, for some failures, only the console had the cause.
|
|
945
|
+
const [formError, setFormError] = useState<string | undefined>()
|
|
946
|
+
const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
|
|
884
947
|
// Related records to surface BELOW the form, as read-only context for the
|
|
885
948
|
// record being acted on — e.g. the reception history of a transfer while
|
|
886
949
|
// receiving against it. Sourced from the model's metadata.relations (the
|
|
@@ -912,6 +975,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
912
975
|
if (open && action.fields) {
|
|
913
976
|
setFormData(buildFieldDefaults(action.fields, record))
|
|
914
977
|
setFieldErrors({})
|
|
978
|
+
setFormError(undefined)
|
|
915
979
|
}
|
|
916
980
|
}, [open, action.fields, record])
|
|
917
981
|
|
|
@@ -999,6 +1063,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
999
1063
|
|
|
1000
1064
|
const lang = i18n.language
|
|
1001
1065
|
const handleActionError = (err: unknown) => {
|
|
1066
|
+
setFormError(actionErrorBannerMessage(err, action.fields, t, lang))
|
|
1002
1067
|
const labels = labelsForValidationFields(action.fields, t)
|
|
1003
1068
|
const localized = localizeActionFieldErrors(err, action.fields, t, lang)
|
|
1004
1069
|
if (localized) {
|
|
@@ -1035,6 +1100,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1035
1100
|
}
|
|
1036
1101
|
}
|
|
1037
1102
|
setFieldErrors({})
|
|
1103
|
+
setFormError(undefined)
|
|
1038
1104
|
setExecuting(true)
|
|
1039
1105
|
try {
|
|
1040
1106
|
let payload: Record<string, any> = { ...formData }
|
|
@@ -1069,6 +1135,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1069
1135
|
if (!res) return
|
|
1070
1136
|
if (res.data.success) {
|
|
1071
1137
|
toastServerSuccess(res.data, { t })
|
|
1138
|
+
const result = extractStampResult(res.data)
|
|
1139
|
+
if (result) {
|
|
1140
|
+
setStamp(result)
|
|
1141
|
+
return
|
|
1142
|
+
}
|
|
1072
1143
|
onOpenChange(false)
|
|
1073
1144
|
onSuccess()
|
|
1074
1145
|
} else {
|
|
@@ -1102,6 +1173,22 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1102
1173
|
? '820px'
|
|
1103
1174
|
: undefined
|
|
1104
1175
|
|
|
1176
|
+
if (stamp) {
|
|
1177
|
+
return (
|
|
1178
|
+
<CfdiStampResultDialog
|
|
1179
|
+
open={open}
|
|
1180
|
+
result={stamp}
|
|
1181
|
+
title={tl(action.label)}
|
|
1182
|
+
onOpenChange={(o) => {
|
|
1183
|
+
if (o) return
|
|
1184
|
+
setStamp(undefined)
|
|
1185
|
+
onOpenChange(false)
|
|
1186
|
+
onSuccess()
|
|
1187
|
+
}}
|
|
1188
|
+
/>
|
|
1189
|
+
)
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1105
1192
|
return (
|
|
1106
1193
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1107
1194
|
{/* Sticky header + footer, scrollable body: the form can grow tall
|
|
@@ -1127,6 +1214,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1127
1214
|
(in FieldCell) keeps a long select/input value from blowing the
|
|
1128
1215
|
grid past the dialog and spawning a horizontal scrollbar. */}
|
|
1129
1216
|
<div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
|
|
1217
|
+
<FormErrorBanner message={formError} className="mb-3" />
|
|
1130
1218
|
<FieldGrid>
|
|
1131
1219
|
{action.fields?.map((field) => {
|
|
1132
1220
|
const fullWidth =
|
|
@@ -1183,7 +1271,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1183
1271
|
// buildFieldDefaults seeds formData for a set of action fields, honoring the
|
|
1184
1272
|
// same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
|
|
1185
1273
|
// wizard steps and single-page forms initialize identically.
|
|
1186
|
-
function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
|
|
1274
|
+
export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
|
|
1187
1275
|
const defaults: Record<string, any> = {}
|
|
1188
1276
|
for (const field of fields) {
|
|
1189
1277
|
if (isLineItemsField(field)) {
|
|
@@ -1235,6 +1323,8 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1235
1323
|
const [stepIndex, setStepIndex] = useState(0)
|
|
1236
1324
|
const [formData, setFormData] = useState<Record<string, any>>({})
|
|
1237
1325
|
const [executing, setExecuting] = useState(false)
|
|
1326
|
+
const [formError, setFormError] = useState<string | undefined>()
|
|
1327
|
+
const [stamp, setStamp] = useState<CfdiStampResult | undefined>()
|
|
1238
1328
|
|
|
1239
1329
|
// Reset to the first step and seed defaults for EVERY step's fields whenever
|
|
1240
1330
|
// the modal (re)opens, so accumulated values from a prior run don't leak.
|
|
@@ -1243,6 +1333,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1243
1333
|
const allFields = steps.flatMap((s) => s.fields ?? [])
|
|
1244
1334
|
setFormData(buildFieldDefaults(allFields, record))
|
|
1245
1335
|
setStepIndex(0)
|
|
1336
|
+
setFormError(undefined)
|
|
1246
1337
|
}, [open, action.steps, record])
|
|
1247
1338
|
|
|
1248
1339
|
const updateField = (key: string, value: any) =>
|
|
@@ -1284,6 +1375,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1284
1375
|
}
|
|
1285
1376
|
}
|
|
1286
1377
|
setExecuting(true)
|
|
1378
|
+
setFormError(undefined)
|
|
1287
1379
|
try {
|
|
1288
1380
|
const url = buildActionUrl(endpoint, model, record?.id, action.key)
|
|
1289
1381
|
const auth = await supervisor.authorize(
|
|
@@ -1297,14 +1389,22 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1297
1389
|
request: (reason) => api.post(url, withApproval(reason ? { ...formData, reason } : formData, auth)),
|
|
1298
1390
|
})
|
|
1299
1391
|
if (!res) return
|
|
1392
|
+
const allFields = steps.flatMap(s => s.fields ?? [])
|
|
1300
1393
|
if (res.data.success) {
|
|
1301
1394
|
toastServerSuccess(res.data, { t })
|
|
1395
|
+
const result = extractStampResult(res.data)
|
|
1396
|
+
if (result) {
|
|
1397
|
+
setStamp(result)
|
|
1398
|
+
return
|
|
1399
|
+
}
|
|
1302
1400
|
onOpenChange(false)
|
|
1303
1401
|
onSuccess()
|
|
1304
1402
|
} else {
|
|
1305
|
-
|
|
1403
|
+
setFormError(actionErrorBannerMessage({ response: { data: res.data } }, allFields, t, i18n.language))
|
|
1404
|
+
toastActionError({ response: { data: res.data } }, allFields, t, i18n.language)
|
|
1306
1405
|
}
|
|
1307
1406
|
} catch (err: any) {
|
|
1407
|
+
setFormError(actionErrorBannerMessage(err, steps.flatMap(s => s.fields ?? []), t, i18n.language))
|
|
1308
1408
|
toastActionError(err, steps.flatMap(s => s.fields ?? []), t, i18n.language)
|
|
1309
1409
|
} finally {
|
|
1310
1410
|
setExecuting(false)
|
|
@@ -1313,6 +1413,22 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1313
1413
|
|
|
1314
1414
|
if (steps.length === 0) return null
|
|
1315
1415
|
|
|
1416
|
+
if (stamp) {
|
|
1417
|
+
return (
|
|
1418
|
+
<CfdiStampResultDialog
|
|
1419
|
+
open={open}
|
|
1420
|
+
result={stamp}
|
|
1421
|
+
title={tl(action.label)}
|
|
1422
|
+
onOpenChange={(o) => {
|
|
1423
|
+
if (o) return
|
|
1424
|
+
setStamp(undefined)
|
|
1425
|
+
onOpenChange(false)
|
|
1426
|
+
onSuccess()
|
|
1427
|
+
}}
|
|
1428
|
+
/>
|
|
1429
|
+
)
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1316
1432
|
return (
|
|
1317
1433
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
1318
1434
|
<DialogContent
|
|
@@ -1347,6 +1463,7 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1347
1463
|
persist in the one formData object, so navigating back and
|
|
1348
1464
|
forth keeps entries intact. */}
|
|
1349
1465
|
<div className="-mx-1 min-h-0 flex-1 overflow-y-auto px-1 py-4">
|
|
1466
|
+
<FormErrorBanner message={formError} className="mb-3" />
|
|
1350
1467
|
<FieldGrid>
|
|
1351
1468
|
{stepFields.map((field) => {
|
|
1352
1469
|
const fullWidth =
|
|
@@ -1423,7 +1540,8 @@ function seedOptionFromRecord(
|
|
|
1423
1540
|
}
|
|
1424
1541
|
}
|
|
1425
1542
|
|
|
1426
|
-
|
|
1543
|
+
/** Renders one action field (same widgets as the action modals). Shared with DocumentFormDialog. */
|
|
1544
|
+
export function renderField(
|
|
1427
1545
|
field: ActionFieldDef,
|
|
1428
1546
|
value: any,
|
|
1429
1547
|
onChange: (value: any) => void,
|