@asteby/metacore-runtime-react 48.0.0 → 49.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 +22 -0
- package/dist/action-modal-dispatcher.d.ts +17 -3
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +57 -10
- package/dist/action-priority.d.ts +34 -0
- package/dist/action-priority.d.ts.map +1 -0
- package/dist/action-priority.js +60 -0
- package/dist/business/catalog-product-search.d.ts +7 -0
- package/dist/business/catalog-product-search.d.ts.map +1 -1
- package/dist/business/catalog-product-search.js +14 -0
- package/dist/business/document-editor-model.d.ts +14 -2
- package/dist/business/document-editor-model.d.ts.map +1 -1
- package/dist/business/document-editor-model.js +42 -4
- package/dist/business/document-editor.d.ts.map +1 -1
- package/dist/business/document-editor.js +65 -17
- package/dist/business/line-items.d.ts +9 -1
- package/dist/business/line-items.d.ts.map +1 -1
- package/dist/business/line-items.js +8 -1
- package/dist/document/document-page.d.ts.map +1 -1
- package/dist/document/document-page.js +8 -1
- package/dist/document/evaluate.d.ts.map +1 -1
- package/dist/document/evaluate.js +17 -1
- package/dist/document/index.d.ts +1 -0
- package/dist/document/index.d.ts.map +1 -1
- package/dist/document/index.js +1 -0
- package/dist/document/secondary-bar.d.ts +20 -0
- package/dist/document/secondary-bar.d.ts.map +1 -0
- package/dist/document/secondary-bar.js +44 -0
- package/dist/document/types.d.ts +8 -0
- package/dist/document/types.d.ts.map +1 -1
- package/dist/document-form-dialog.d.ts.map +1 -1
- package/dist/document-form-dialog.js +7 -4
- package/dist/dynamic-columns.d.ts +1 -11
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +7 -17
- package/dist/dynamic-crud-page.d.ts +12 -0
- package/dist/dynamic-crud-page.d.ts.map +1 -1
- package/dist/dynamic-crud-page.js +3 -3
- package/dist/dynamic-row-actions.d.ts +8 -0
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +12 -2
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +64 -63
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/primitives/contributions.d.ts +14 -3
- package/dist/primitives/contributions.d.ts.map +1 -1
- package/dist/primitives/contributions.js +50 -13
- package/dist/record-actions.d.ts +21 -0
- package/dist/record-actions.d.ts.map +1 -0
- package/dist/record-actions.js +76 -0
- package/dist/row-actions-menu.d.ts +32 -0
- package/dist/row-actions-menu.d.ts.map +1 -0
- package/dist/row-actions-menu.js +59 -0
- package/dist/slot.d.ts +4 -21
- package/dist/slot.d.ts.map +1 -1
- package/dist/slot.js +14 -36
- package/dist/types.d.ts +32 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/document-editor-model.test.ts +55 -1
- package/src/__tests__/document-editor.test.tsx +80 -1
- package/src/__tests__/document-form-dialog.test.tsx +40 -0
- package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
- package/src/__tests__/row-action-link-navigation.test.ts +17 -0
- package/src/__tests__/secondary-actions.test.tsx +261 -0
- package/src/action-modal-dispatcher.tsx +58 -16
- package/src/action-priority.ts +82 -0
- package/src/business/business.test.ts +1 -1
- package/src/business/catalog-product-search.ts +18 -0
- package/src/business/document-editor-model.ts +48 -6
- package/src/business/document-editor.tsx +72 -18
- package/src/business/line-items.ts +16 -1
- package/src/document/document-page.tsx +15 -0
- package/src/document/evaluate.ts +17 -1
- package/src/document/index.ts +1 -0
- package/src/document/secondary-bar.tsx +92 -0
- package/src/document/types.ts +8 -0
- package/src/document-form-dialog.tsx +8 -4
- package/src/dynamic-columns.tsx +13 -57
- package/src/dynamic-crud-page.tsx +11 -2
- package/src/dynamic-row-actions.tsx +12 -2
- package/src/dynamic-table.tsx +3 -0
- package/src/index.ts +25 -0
- package/src/primitives/contributions.ts +59 -12
- package/src/record-actions.ts +101 -0
- package/src/row-actions-menu.tsx +159 -0
- package/src/slot.tsx +18 -53
- package/src/types.ts +28 -0
|
@@ -207,6 +207,46 @@ describe('DocumentFormDialog: buscador de catálogo por defecto', () => {
|
|
|
207
207
|
})
|
|
208
208
|
})
|
|
209
209
|
|
|
210
|
+
// Retest Pitsline 2026-10-05 (FAC-00013): el buscador de ops no trae tasa y el
|
|
211
|
+
// renglón entraba sin IVA; el producto sin tasa toma la de la org.
|
|
212
|
+
describe('DocumentFormDialog: IVA de la org con el buscador del host', () => {
|
|
213
|
+
it('producto sin tasa → IVA de la org; con tasa propia → la suya', async () => {
|
|
214
|
+
const searchProducts = vi.fn(async () => [
|
|
215
|
+
{ id: 'p1', name: 'Llanta host', price: 200 },
|
|
216
|
+
{ id: 'p2', name: 'Frontera host', price: 100, tax_rate: 0.08 },
|
|
217
|
+
])
|
|
218
|
+
const post = vi.fn().mockResolvedValue({ data: { success: true, data: { id: 'inv-1' } } })
|
|
219
|
+
const client = { get: vi.fn(), post, put: vi.fn(), delete: vi.fn() } as unknown as ApiClient
|
|
220
|
+
render(
|
|
221
|
+
<ApiProvider client={client}>
|
|
222
|
+
<OrgRuntimeProvider taxRate={0.16}>
|
|
223
|
+
<DocumentFormDialog
|
|
224
|
+
open
|
|
225
|
+
onOpenChange={vi.fn()}
|
|
226
|
+
model="customers.Invoice"
|
|
227
|
+
forms={{ ...forms, lines_field: 'items', types: [forms.types[0]] }}
|
|
228
|
+
searchProducts={searchProducts as any}
|
|
229
|
+
/>
|
|
230
|
+
</OrgRuntimeProvider>
|
|
231
|
+
</ApiProvider>,
|
|
232
|
+
)
|
|
233
|
+
fireEvent.change(document.querySelectorAll('input')[0], { target: { value: 'ACME' } })
|
|
234
|
+
fireEvent.click(screen.getByRole('button', { name: 'Siguiente' }))
|
|
235
|
+
const search = await screen.findByLabelText('Buscar producto')
|
|
236
|
+
fireEvent.change(search, { target: { value: 'host' } })
|
|
237
|
+
fireEvent.click(await waitFor(() => screen.getByRole('button', { name: /Llanta host/ }), { timeout: 2000 }))
|
|
238
|
+
fireEvent.change(await screen.findByLabelText('Buscar producto'), { target: { value: 'fron' } })
|
|
239
|
+
fireEvent.click(await waitFor(() => screen.getByRole('button', { name: /Frontera host/ }), { timeout: 2000 }))
|
|
240
|
+
fireEvent.click(screen.getByRole('button', { name: 'Crear' }))
|
|
241
|
+
await waitFor(() => expect(post).toHaveBeenCalled())
|
|
242
|
+
const body = post.mock.calls[0][1] as any
|
|
243
|
+
expect(body.items).toEqual([
|
|
244
|
+
expect.objectContaining({ product_id: 'p1', quantity: 1, unit_price: 200, tax_rate: 0.16 }),
|
|
245
|
+
expect.objectContaining({ product_id: 'p2', quantity: 1, unit_price: 100, tax_rate: 0.08 }),
|
|
246
|
+
])
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
210
250
|
describe('resolveDocumentForms', () => {
|
|
211
251
|
it('sin manifest (o sin tipos) devuelve undefined: el alta genérica sigue igual', () => {
|
|
212
252
|
expect(resolveDocumentForms(null)).toBeUndefined()
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
buildFieldDefaults,
|
|
4
|
+
readRecordPath,
|
|
5
|
+
refTargetsModel,
|
|
6
|
+
scalarDefaultFromRecord,
|
|
7
|
+
unwrapRecordScalar,
|
|
8
|
+
} from '../action-modal-dispatcher'
|
|
3
9
|
import type { ActionFieldDef } from '../types'
|
|
4
10
|
|
|
5
11
|
describe('scalarDefaultFromRecord', () => {
|
|
@@ -35,3 +41,48 @@ describe('scalarDefaultFromRecord', () => {
|
|
|
35
41
|
expect(scalarDefaultFromRecord(field, record)).toBe('11111111-1111-4111-8111-111111111111')
|
|
36
42
|
})
|
|
37
43
|
})
|
|
44
|
+
|
|
45
|
+
// Retest Pitsline 2026-10-05: «Registrar pago» desde una factura PPD con saldo
|
|
46
|
+
// abría el modal con «Factura» vacía: el campo es `invoice_id` y la fila (la
|
|
47
|
+
// factura) no tiene esa columna, tiene `id`.
|
|
48
|
+
describe('selector que apunta al modelo de la fila', () => {
|
|
49
|
+
// Campos de customers `register_payment` (target_model Invoice).
|
|
50
|
+
const registerPayment = [
|
|
51
|
+
{ key: 'customer_id', label: 'Cliente', type: 'dynamic_select', required: true, ref: 'Customer' },
|
|
52
|
+
{
|
|
53
|
+
key: 'invoice_id',
|
|
54
|
+
label: 'Factura',
|
|
55
|
+
type: 'dynamic_select',
|
|
56
|
+
ref: 'Invoice',
|
|
57
|
+
options: { source: 'Invoice', value: 'id', label: 'number' },
|
|
58
|
+
},
|
|
59
|
+
{ key: 'amount', label: 'Monto', type: 'number', required: true },
|
|
60
|
+
] as unknown as ActionFieldDef[]
|
|
61
|
+
const invoice = { id: 'inv-13', number: 'FAC-00013', customer_id: { value: 'cus-1', label: 'Demo' }, amount_due: 232 }
|
|
62
|
+
|
|
63
|
+
it('«Registrar pago» abre precargado con la factura y su cliente', () => {
|
|
64
|
+
const d = buildFieldDefaults(registerPayment, invoice, 'invoices')
|
|
65
|
+
expect(d.invoice_id).toBe('inv-13')
|
|
66
|
+
expect(d.customer_id).toBe('cus-1')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('sin el modelo de la fila no adivina', () => {
|
|
70
|
+
expect(buildFieldDefaults(registerPayment, invoice).invoice_id).toBe('')
|
|
71
|
+
expect(buildFieldDefaults(registerPayment, invoice, 'customers').invoice_id).toBe('')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('una columna propia vacía (parent_id sin padre) no se siembra con el id de la fila', () => {
|
|
75
|
+
const parent = { key: 'parent_id', label: 'Padre', type: 'dynamic_select', ref: 'Category' } as ActionFieldDef
|
|
76
|
+
expect(scalarDefaultFromRecord(parent, { id: 'cat-1', parent_id: null }, 'categories')).toBeUndefined()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('refTargetsModel tolera prefijo de addon, PascalCase y plural de tabla', () => {
|
|
80
|
+
expect(refTargetsModel('customers.Invoice', 'invoices')).toBe(true)
|
|
81
|
+
expect(refTargetsModel('Invoice', 'Invoice')).toBe(true)
|
|
82
|
+
expect(refTargetsModel('CustomerAddress', 'customer_addresses')).toBe(true)
|
|
83
|
+
expect(refTargetsModel('SalesOrder', 'sales_orders')).toBe(true)
|
|
84
|
+
expect(refTargetsModel('Invoice', 'invoice_items')).toBe(false)
|
|
85
|
+
expect(refTargetsModel(undefined, 'invoices')).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { linkNavigation } from '../dynamic-row-actions'
|
|
3
|
+
|
|
4
|
+
// «Crear desde»: el botón de la fila del origen es una acción link con query
|
|
5
|
+
// (`/m/invoices?create=invoice&from=sales_order&from_id={id}`). En `to` el
|
|
6
|
+
// router la tomaba como parte del path y `$model` quedaba "invoices?create=…".
|
|
7
|
+
describe('linkNavigation', () => {
|
|
8
|
+
it('separa la query como search', () => {
|
|
9
|
+
expect(linkNavigation('/m/invoices?create=invoice&from=sales_order&from_id=so-1')).toEqual({
|
|
10
|
+
to: '/m/invoices',
|
|
11
|
+
search: { create: 'invoice', from: 'sales_order', from_id: 'so-1' },
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
it('sin query deja el destino igual', () => {
|
|
15
|
+
expect(linkNavigation('/m/quotes/abc')).toEqual({ to: '/m/quotes/abc' })
|
|
16
|
+
})
|
|
17
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Acciones secundarias de un registro (P2 UX Pitsline): una sola primaria
|
|
4
|
+
// destacada; compartir / imprimir / correo / chat van al «Más…» de la fila y al
|
|
5
|
+
// pie del documento; lo que aporta un addon (registerRecordAction) sólo aparece
|
|
6
|
+
// con su proveedor instalado y activo. Y la infraestructura de registro es una
|
|
7
|
+
// sola: lo que un addon registra por AddonAPI (modal, slot) llega a los mismos
|
|
8
|
+
// lectores que registerFederatedModal / <Slot>.
|
|
9
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
10
|
+
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
11
|
+
|
|
12
|
+
const I18N_T = (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k
|
|
13
|
+
const USE_TRANSLATION = { t: I18N_T, i18n: { language: 'es' } }
|
|
14
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
|
|
15
|
+
|
|
16
|
+
import { Registry, registerRecordAction, type ActionMetadata } from '@asteby/metacore-sdk'
|
|
17
|
+
import { InstalledAddonsProvider } from '../installed-addons-context'
|
|
18
|
+
import {
|
|
19
|
+
classifyActionPriority,
|
|
20
|
+
isActionProviderActive,
|
|
21
|
+
resolveRecordActions,
|
|
22
|
+
splitActionsByPriority,
|
|
23
|
+
} from '../record-actions'
|
|
24
|
+
import { RowActionsMenu } from '../row-actions-menu'
|
|
25
|
+
import { DocumentSecondaryBar } from '../document/secondary-bar'
|
|
26
|
+
import { resolveActions } from '../document/evaluate'
|
|
27
|
+
import type { DocumentSpec } from '../document/types'
|
|
28
|
+
import { Slot } from '../slot'
|
|
29
|
+
import { ActionModalDispatcher } from '../action-modal-dispatcher'
|
|
30
|
+
|
|
31
|
+
const disposers: Array<() => void> = []
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
cleanup()
|
|
34
|
+
while (disposers.length) disposers.pop()!()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
const ROW = { id: 'inv-1', folio: 'A-1' }
|
|
38
|
+
const ACTIONS = [
|
|
39
|
+
{ key: 'stamp', label: 'Timbrar', icon: 'Stamp', priority: 'primary' },
|
|
40
|
+
{ key: 'cancel', label: 'Cancelar', icon: 'X' },
|
|
41
|
+
{ key: 'send_cfdi_email', label: 'Enviar por correo', icon: 'Mail' },
|
|
42
|
+
{ key: 'reprint_cfdi', label: 'Reimprimir', icon: 'Printer' },
|
|
43
|
+
{ key: 'approve', label: 'Aprobar', icon: 'Check', priority: 'primary' },
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
function shareToChat() {
|
|
47
|
+
const run = vi.fn()
|
|
48
|
+
disposers.push(
|
|
49
|
+
registerRecordAction(
|
|
50
|
+
{ id: 'team_chat.share', label: 'Enviar al chat', icon: 'MessageSquareShare', requires: { addon: 'link' }, run },
|
|
51
|
+
'host',
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
return run
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function openMenu() {
|
|
58
|
+
const trigger = screen.getByText('Abrir menú').closest('button')!
|
|
59
|
+
await act(async () => {
|
|
60
|
+
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('clasificación primaria / secundaria', () => {
|
|
65
|
+
it('convención por segmento de clave; `priority` explícito manda', () => {
|
|
66
|
+
expect(classifyActionPriority({ key: 'send_cfdi_email' })).toBe('secondary')
|
|
67
|
+
expect(classifyActionPriority({ key: 'download_acuse' })).toBe('secondary')
|
|
68
|
+
expect(classifyActionPriority({ key: 'send_whatsapp' })).toBe('secondary')
|
|
69
|
+
expect(classifyActionPriority({ key: 'send' })).toBeUndefined() // enviar cotización = flujo principal
|
|
70
|
+
expect(classifyActionPriority({ key: 'create_mailbox' })).toBeUndefined()
|
|
71
|
+
expect(classifyActionPriority({ key: 'delete' })).toBeUndefined()
|
|
72
|
+
expect(classifyActionPriority({ key: 'print_label', priority: 'primary' })).toBe('primary')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('una sola primaria: la primera que la reclama; las demás quedan como principales', () => {
|
|
76
|
+
const { primary, main, secondary } = splitActionsByPriority(ACTIONS)
|
|
77
|
+
expect(primary?.key).toBe('stamp')
|
|
78
|
+
expect(main.map((a) => a.key)).toEqual(['cancel', 'approve'])
|
|
79
|
+
expect(secondary.map((a) => a.key)).toEqual(['send_cfdi_email', 'reprint_cfdi'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('acción por capacidad sin proveedor activo no se ofrece', () => {
|
|
83
|
+
const a = { key: 'send_whatsapp', trigger: { type: 'capability', capability: 'messaging.whatsapp.send' } }
|
|
84
|
+
expect(isActionProviderActive(a, null)).toBe(true)
|
|
85
|
+
expect(isActionProviderActive(a, { addons: new Set(), capabilities: new Set() })).toBe(false)
|
|
86
|
+
expect(isActionProviderActive(a, { addons: new Set(['link']), capabilities: new Set(['messaging.whatsapp.send']) })).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('resolveRecordActions (registro de acciones aportadas)', () => {
|
|
91
|
+
const installed = (addons: string[], caps: string[] = []) => ({ addons: new Set(addons), capabilities: new Set(caps) })
|
|
92
|
+
const row = (c: Parameters<typeof registerRecordAction>[0], owner?: string) => ({ contribution: c, owner })
|
|
93
|
+
|
|
94
|
+
it('por addon, por capacidad o por dueño; modelos y expand', () => {
|
|
95
|
+
const rows = [
|
|
96
|
+
row({ id: 'a.chat', label: 'Chat', requires: { addon: 'link' }, run: () => {} }, 'host'),
|
|
97
|
+
row({ id: 'b.wa', label: 'WhatsApp', requires: { capability: 'messaging.whatsapp.send' }, run: () => {} }, 'host'),
|
|
98
|
+
row({ id: 'c.own', label: 'Propia', run: () => {} }, 'fiscal_mexico'),
|
|
99
|
+
row({ id: 'd.only', label: 'Sólo facturas', models: ['Invoice'], run: () => {} }),
|
|
100
|
+
row({ id: 'e.pdf', expand: () => [{ key: 'ticket', label: 'Ticket', run: () => {} }] }),
|
|
101
|
+
]
|
|
102
|
+
const keys = (inst: ReturnType<typeof installed> | null, model = 'Sale') =>
|
|
103
|
+
resolveRecordActions(rows, { model, record: ROW }, inst).map((r) => r.key)
|
|
104
|
+
expect(keys(installed([]))).toEqual(['e.pdf:ticket'])
|
|
105
|
+
expect(keys(installed(['link', 'fiscal_mexico'], ['messaging.whatsapp.send']))).toEqual(['a.chat', 'b.wa', 'c.own', 'e.pdf:ticket'])
|
|
106
|
+
expect(keys(installed([]), 'customers.Invoice')).toEqual(['d.only', 'e.pdf:ticket'])
|
|
107
|
+
// Sin InstalledAddonsProvider = desconocido: se muestra (el servidor revalida).
|
|
108
|
+
expect(keys(null)).toEqual(['a.chat', 'b.wa', 'c.own', 'e.pdf:ticket'])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('RowActionsMenu', () => {
|
|
113
|
+
it('«Enviar al chat» aparece sólo con el addon proveedor instalado y activo', async () => {
|
|
114
|
+
shareToChat()
|
|
115
|
+
const { unmount } = render(
|
|
116
|
+
<InstalledAddonsProvider addons={['fiscal_mexico']}>
|
|
117
|
+
<RowActionsMenu actions={[]} row={ROW} model="Invoice" />
|
|
118
|
+
</InstalledAddonsProvider>,
|
|
119
|
+
)
|
|
120
|
+
expect(screen.queryByText('Abrir menú')).toBeNull() // nada que ofrecer
|
|
121
|
+
unmount()
|
|
122
|
+
|
|
123
|
+
render(
|
|
124
|
+
<InstalledAddonsProvider addons={['fiscal_mexico', 'link']}>
|
|
125
|
+
<RowActionsMenu actions={[]} row={ROW} model="Invoice" />
|
|
126
|
+
</InstalledAddonsProvider>,
|
|
127
|
+
)
|
|
128
|
+
await openMenu()
|
|
129
|
+
expect(screen.getByText('Enviar al chat')).toBeTruthy()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('primaria única destacada; las secundarias van bajo «Más…», no en el primer nivel', async () => {
|
|
133
|
+
const run = shareToChat()
|
|
134
|
+
render(
|
|
135
|
+
<InstalledAddonsProvider addons={['link']}>
|
|
136
|
+
<RowActionsMenu actions={ACTIONS} row={ROW} model="Invoice" />
|
|
137
|
+
</InstalledAddonsProvider>,
|
|
138
|
+
)
|
|
139
|
+
await openMenu()
|
|
140
|
+
const top = [...document.querySelectorAll('[role="menu"] > [role="menuitem"]')].map(
|
|
141
|
+
(el) => el.getAttribute('data-action') ?? el.textContent,
|
|
142
|
+
)
|
|
143
|
+
expect(top).toEqual(['stamp', 'cancel', 'approve', 'Más…'])
|
|
144
|
+
expect(document.querySelectorAll('[data-action].font-semibold')).toHaveLength(1)
|
|
145
|
+
expect(document.querySelector('[data-action="stamp"]')?.className).toContain('font-semibold')
|
|
146
|
+
expect(screen.queryByText('Enviar por correo')).toBeNull()
|
|
147
|
+
expect(screen.queryByText('Enviar al chat')).toBeNull()
|
|
148
|
+
|
|
149
|
+
const more = screen.getByText('Más…')
|
|
150
|
+
await act(async () => {
|
|
151
|
+
fireEvent.keyDown(more, { key: 'ArrowRight' })
|
|
152
|
+
})
|
|
153
|
+
expect(screen.getByText('Enviar por correo')).toBeTruthy()
|
|
154
|
+
await act(async () => {
|
|
155
|
+
fireEvent.click(screen.getByText('Enviar al chat'))
|
|
156
|
+
})
|
|
157
|
+
expect(run).toHaveBeenCalledWith({ model: 'Invoice', record: ROW })
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('documento: secundarias en el pie', () => {
|
|
162
|
+
const spec: DocumentSpec = {
|
|
163
|
+
model: 'invoices',
|
|
164
|
+
title: '{{folio}}',
|
|
165
|
+
statuses: [],
|
|
166
|
+
actions: [
|
|
167
|
+
{ key: 'register_payment', label: 'Registrar pago' },
|
|
168
|
+
{ key: 'send', label: 'Enviar por correo', modelAction: 'send_cfdi_email' },
|
|
169
|
+
{ key: 'download_pdf', label: 'Descargar PDF', openUrl: '{{pdf}}' },
|
|
170
|
+
{ key: 'credit_note', label: 'Nota de crédito' },
|
|
171
|
+
{ key: 'share_link', label: 'Enlace', priority: 'secondary' },
|
|
172
|
+
],
|
|
173
|
+
layouts: [],
|
|
174
|
+
defaultLayout: {
|
|
175
|
+
primary: 'register_payment',
|
|
176
|
+
secondary: ['send', 'download_pdf', 'credit_note'],
|
|
177
|
+
more: ['share_link'],
|
|
178
|
+
},
|
|
179
|
+
tabs: [],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it('resolveActions saca compartir/correo/PDF de la cabecera al pie', () => {
|
|
183
|
+
const layout = resolveActions(spec, { record: ROW, sources: {}, derived: {} })
|
|
184
|
+
expect(layout.primary?.def.key).toBe('register_payment')
|
|
185
|
+
expect(layout.secondary.map((a) => a.def.key)).toEqual(['credit_note'])
|
|
186
|
+
expect(layout.more).toEqual([])
|
|
187
|
+
expect(layout.footer.map((a) => a.def.key)).toEqual(['send', 'download_pdf', 'share_link'])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('la primaria de un layout nunca se va al pie aunque sea una descarga', () => {
|
|
191
|
+
const layout = resolveActions(
|
|
192
|
+
{ ...spec, defaultLayout: { primary: 'download_pdf', secondary: ['send'] } },
|
|
193
|
+
{ record: ROW, sources: {}, derived: {} },
|
|
194
|
+
)
|
|
195
|
+
expect(layout.primary?.def.key).toBe('download_pdf')
|
|
196
|
+
expect(layout.footer.map((a) => a.def.key)).toEqual(['send'])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('DocumentSecondaryBar junta las del documento y las aportadas, filtradas por proveedor', () => {
|
|
200
|
+
shareToChat()
|
|
201
|
+
const items = [{ key: 'download_pdf', label: 'Descargar PDF', icon: 'FileDown', run: () => {} }]
|
|
202
|
+
const { rerender } = render(
|
|
203
|
+
<InstalledAddonsProvider addons={[]}>
|
|
204
|
+
<DocumentSecondaryBar items={items} model="Invoice" record={ROW} />
|
|
205
|
+
</InstalledAddonsProvider>,
|
|
206
|
+
)
|
|
207
|
+
const bar = () => document.querySelector('[data-document-secondary]')!
|
|
208
|
+
expect([...bar().querySelectorAll('[data-action]')].map((b) => b.getAttribute('data-action'))).toEqual(['download_pdf'])
|
|
209
|
+
|
|
210
|
+
rerender(
|
|
211
|
+
<InstalledAddonsProvider addons={['link']}>
|
|
212
|
+
<DocumentSecondaryBar items={items} model="Invoice" record={ROW} />
|
|
213
|
+
</InstalledAddonsProvider>,
|
|
214
|
+
)
|
|
215
|
+
expect([...bar().querySelectorAll('[data-action]')].map((b) => b.getAttribute('data-action'))).toEqual([
|
|
216
|
+
'download_pdf',
|
|
217
|
+
'team_chat.share',
|
|
218
|
+
])
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('sin nada secundario no pinta el pie', () => {
|
|
222
|
+
render(<DocumentSecondaryBar items={[]} model="Invoice" record={ROW} />)
|
|
223
|
+
expect(document.querySelector('[data-document-secondary]')).toBeNull()
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('registro único: AddonAPI → lectores de runtime-react', () => {
|
|
228
|
+
it('api.registry.registerSlot se pinta en <Slot> y se oculta si su addon no está instalado', () => {
|
|
229
|
+
const reg = new Registry()
|
|
230
|
+
const Widget = () => <span>widget de returns</span>
|
|
231
|
+
reg.scope('returns').registerSlot({ name: 'invoice.footer', component: Widget })
|
|
232
|
+
disposers.push(() => void reg.unbind('returns'))
|
|
233
|
+
const { rerender } = render(
|
|
234
|
+
<InstalledAddonsProvider addons={['returns']}>
|
|
235
|
+
<Slot name="invoice.footer" />
|
|
236
|
+
</InstalledAddonsProvider>,
|
|
237
|
+
)
|
|
238
|
+
expect(screen.getByText('widget de returns')).toBeTruthy()
|
|
239
|
+
rerender(
|
|
240
|
+
<InstalledAddonsProvider addons={[]}>
|
|
241
|
+
<Slot name="invoice.footer" />
|
|
242
|
+
</InstalledAddonsProvider>,
|
|
243
|
+
)
|
|
244
|
+
expect(screen.queryByText('widget de returns')).toBeNull()
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it('api.registry.registerModal resuelve `action.modal` por slug en el dispatcher', () => {
|
|
248
|
+
const reg = new Registry()
|
|
249
|
+
const Modal = (p: { recordId: string; close: () => void }) => <div>modal {p.recordId}</div>
|
|
250
|
+
reg.scope('fiscal_mexico').registerModal({ slug: 'fiscal_mexico.import_cfdi', component: Modal as never })
|
|
251
|
+
disposers.push(() => void reg.unbind('fiscal_mexico'))
|
|
252
|
+
const action: ActionMetadata = { key: 'import_cfdi', label: 'Importar', icon: 'Upload', modal: 'fiscal_mexico.import_cfdi' }
|
|
253
|
+
render(
|
|
254
|
+
<InstalledAddonsProvider addons={['fiscal_mexico']}>
|
|
255
|
+
<ActionModalDispatcher open onOpenChange={() => {}} action={action} model="SupplierInvoice" record={{ id: 9 }} onSuccess={() => {}} />
|
|
256
|
+
</InstalledAddonsProvider>,
|
|
257
|
+
)
|
|
258
|
+
expect(screen.getByText('modal 9')).toBeTruthy()
|
|
259
|
+
expect(screen.queryByTestId('federated-action-loading')).toBeNull()
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -84,6 +84,8 @@ import type { ActionFieldDef, TableMetadata, ColumnDefinition } from './types'
|
|
|
84
84
|
import {
|
|
85
85
|
type ActionMetadata,
|
|
86
86
|
type ActionModalProps,
|
|
87
|
+
type BridgedActionProps,
|
|
88
|
+
adaptActionProps,
|
|
87
89
|
getActionComponent,
|
|
88
90
|
subscribeActionComponents,
|
|
89
91
|
} from '@asteby/metacore-sdk'
|
|
@@ -338,8 +340,45 @@ function defaultFromRecordSpec(field: ActionFieldDef): string | string[] | undef
|
|
|
338
340
|
return f.defaultFromRecord ?? f.default_from_record
|
|
339
341
|
}
|
|
340
342
|
|
|
341
|
-
/**
|
|
342
|
-
|
|
343
|
+
/** `customers.Invoice`, `Invoice`, `invoices`, `sales_orders` → forma comparable. */
|
|
344
|
+
function modelToken(m: string): string {
|
|
345
|
+
const last = m.split('.').pop() ?? m
|
|
346
|
+
return last.replace(/[^a-z0-9]/gi, '').toLowerCase()
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* ¿`ref` (FK de un campo) apunta al modelo `model` de la fila? Tolera prefijo de
|
|
351
|
+
* addon, PascalCase vs tabla y el plural de la tabla (`Invoice` ↔ `invoices`,
|
|
352
|
+
* `CustomerAddress` ↔ `customer_addresses`).
|
|
353
|
+
*/
|
|
354
|
+
export function refTargetsModel(ref: string | undefined | null, model: string | undefined | null): boolean {
|
|
355
|
+
if (!ref || !model) return false
|
|
356
|
+
const a = modelToken(ref)
|
|
357
|
+
const b = modelToken(model)
|
|
358
|
+
if (!a || !b) return false
|
|
359
|
+
return a === b || a + 's' === b || a + 'es' === b || b + 's' === a || b + 'es' === a
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function fieldRef(field: ActionFieldDef): string | undefined {
|
|
363
|
+
if (field.ref) return field.ref
|
|
364
|
+
const opts = (field as { options?: unknown }).options
|
|
365
|
+
if (opts && !Array.isArray(opts) && typeof opts === 'object') {
|
|
366
|
+
const src = (opts as { source?: unknown }).source
|
|
367
|
+
if (typeof src === 'string' && src) return src
|
|
368
|
+
}
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Scalar seed for one action field from the row being acted on.
|
|
374
|
+
*
|
|
375
|
+
* Sin `default_from_record` declarado, un campo se siembra con la columna
|
|
376
|
+
* homónima de la fila; y un selector que apunta al MISMO modelo de la fila y
|
|
377
|
+
* que la fila no tiene como columna (`invoice_id` → Invoice en «Registrar pago»
|
|
378
|
+
* de una factura) se siembra con el id de esa fila: la acción se abrió desde
|
|
379
|
+
* ella (retest Pitsline 2026-10-05).
|
|
380
|
+
*/
|
|
381
|
+
export function scalarDefaultFromRecord(field: ActionFieldDef, record: any, model?: string): unknown {
|
|
343
382
|
if (!record) return undefined
|
|
344
383
|
const spec = defaultFromRecordSpec(field)
|
|
345
384
|
if (typeof spec === 'string') {
|
|
@@ -353,6 +392,12 @@ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): unk
|
|
|
353
392
|
} else if (field.key) {
|
|
354
393
|
const v = readRecordPath(record, field.key)
|
|
355
394
|
if (v !== undefined && v !== null && v !== '') return v
|
|
395
|
+
// Sólo si la fila NO tiene esa columna: un `parent_id` vacío de una
|
|
396
|
+
// categoría es «sin padre», no «yo mismo».
|
|
397
|
+
const id = unwrapRecordScalar(record.id)
|
|
398
|
+
if (!(field.key in record) && id !== undefined && id !== null && id !== '' && refTargetsModel(fieldRef(field), model)) {
|
|
399
|
+
return id
|
|
400
|
+
}
|
|
356
401
|
}
|
|
357
402
|
return undefined
|
|
358
403
|
}
|
|
@@ -402,17 +447,14 @@ export function ActionModalDispatcher({
|
|
|
402
447
|
}
|
|
403
448
|
|
|
404
449
|
if (federatedModal) {
|
|
405
|
-
|
|
450
|
+
// Modal por slug del store canónico: lo registró `api.registry.registerModal`
|
|
451
|
+
// (contrato recordId/payload/close) o registerFederatedModal
|
|
452
|
+
// (ActionModalProps). Recibe ambos contratos.
|
|
453
|
+
const FederatedComponent = federatedModalComponent<BridgedActionProps>(federatedModal)
|
|
406
454
|
return (
|
|
407
455
|
<Suspense fallback={<LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />}>
|
|
408
456
|
<FederatedComponent
|
|
409
|
-
|
|
410
|
-
onOpenChange={onOpenChange}
|
|
411
|
-
action={action}
|
|
412
|
-
model={model}
|
|
413
|
-
record={record}
|
|
414
|
-
endpoint={endpoint}
|
|
415
|
-
onSuccess={onSuccess}
|
|
457
|
+
{...adaptActionProps({ open, onOpenChange, action, model, record, endpoint, onSuccess })}
|
|
416
458
|
/>
|
|
417
459
|
</Suspense>
|
|
418
460
|
)
|
|
@@ -1016,11 +1058,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1016
1058
|
|
|
1017
1059
|
useEffect(() => {
|
|
1018
1060
|
if (open && action.fields) {
|
|
1019
|
-
setFormData(buildFieldDefaults(action.fields, record))
|
|
1061
|
+
setFormData(buildFieldDefaults(action.fields, record, model))
|
|
1020
1062
|
setFieldErrors({})
|
|
1021
1063
|
setFormError(undefined)
|
|
1022
1064
|
}
|
|
1023
|
-
}, [open, action.fields, record])
|
|
1065
|
+
}, [open, action.fields, record, model])
|
|
1024
1066
|
|
|
1025
1067
|
// Lines the list row does not carry: load them from the declared relation
|
|
1026
1068
|
// (see prefillRelationRequests) and seed the line-items grid.
|
|
@@ -1314,7 +1356,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
|
|
|
1314
1356
|
// buildFieldDefaults seeds formData for a set of action fields, honoring the
|
|
1315
1357
|
// same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
|
|
1316
1358
|
// wizard steps and single-page forms initialize identically.
|
|
1317
|
-
export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
|
|
1359
|
+
export function buildFieldDefaults(fields: ActionFieldDef[], record: any, model?: string): Record<string, any> {
|
|
1318
1360
|
const defaults: Record<string, any> = {}
|
|
1319
1361
|
for (const field of fields) {
|
|
1320
1362
|
if (isLineItemsField(field)) {
|
|
@@ -1326,7 +1368,7 @@ export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Recor
|
|
|
1326
1368
|
: []
|
|
1327
1369
|
continue
|
|
1328
1370
|
}
|
|
1329
|
-
const fromRecord = scalarDefaultFromRecord(field, record)
|
|
1371
|
+
const fromRecord = scalarDefaultFromRecord(field, record, model)
|
|
1330
1372
|
if (fromRecord !== undefined && fromRecord !== null && fromRecord !== '') {
|
|
1331
1373
|
defaults[field.key] = fromRecord
|
|
1332
1374
|
continue
|
|
@@ -1374,10 +1416,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
|
|
|
1374
1416
|
useEffect(() => {
|
|
1375
1417
|
if (!open) return
|
|
1376
1418
|
const allFields = steps.flatMap((s) => s.fields ?? [])
|
|
1377
|
-
setFormData(buildFieldDefaults(allFields, record))
|
|
1419
|
+
setFormData(buildFieldDefaults(allFields, record, model))
|
|
1378
1420
|
setStepIndex(0)
|
|
1379
1421
|
setFormError(undefined)
|
|
1380
|
-
}, [open, action.steps, record])
|
|
1422
|
+
}, [open, action.steps, record, model])
|
|
1381
1423
|
|
|
1382
1424
|
const updateField = (key: string, value: any) =>
|
|
1383
1425
|
setFormData((prev: Record<string, any>) => ({ ...prev, [key]: value }))
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Clasificación primaria / secundaria de acciones — puro (sin React), lo usan
|
|
2
|
+
// el menú de fila, la cabecera/pie del documento y resolveActions del
|
|
3
|
+
// DocumentPage. Ver record-actions.ts para las acciones aportadas por addons.
|
|
4
|
+
import type { ActionPriority } from '@asteby/metacore-sdk'
|
|
5
|
+
import type { InstalledAddonsValue } from './installed-addons-context'
|
|
6
|
+
|
|
7
|
+
export type { ActionPriority }
|
|
8
|
+
|
|
9
|
+
/** Lo mínimo que se lee de una acción de metadata para clasificarla. */
|
|
10
|
+
export interface PrioritizableAction {
|
|
11
|
+
key: string
|
|
12
|
+
priority?: string
|
|
13
|
+
trigger?: { type?: string; capability?: string } | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Segmentos de la clave (separados por _ . -) que marcan una acción de
|
|
17
|
+
// compartir/salida. Por segmento: `create_mailbox` no es «mail», `send` solo
|
|
18
|
+
// (enviar una cotización) sigue siendo flujo principal.
|
|
19
|
+
const SECONDARY_SEGMENTS = new Set([
|
|
20
|
+
'print',
|
|
21
|
+
'reprint',
|
|
22
|
+
'share',
|
|
23
|
+
'email',
|
|
24
|
+
'mail',
|
|
25
|
+
'whatsapp',
|
|
26
|
+
'chat',
|
|
27
|
+
'download',
|
|
28
|
+
'pdf',
|
|
29
|
+
'xml',
|
|
30
|
+
'acuse',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
const CRUD_KEYS = new Set(['view', 'edit', 'delete'])
|
|
34
|
+
|
|
35
|
+
/** "primary" | "secondary" | undefined (= acción normal del menú). */
|
|
36
|
+
export function classifyActionPriority(action: PrioritizableAction): ActionPriority | undefined {
|
|
37
|
+
if (action.priority === 'primary' || action.priority === 'secondary') return action.priority
|
|
38
|
+
if (CRUD_KEYS.has(action.key)) return undefined
|
|
39
|
+
return action.key
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.split(/[_.-]+/)
|
|
42
|
+
.some((seg) => SECONDARY_SEGMENTS.has(seg))
|
|
43
|
+
? 'secondary'
|
|
44
|
+
: undefined
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SplitActions<A> {
|
|
48
|
+
/** La única destacada (la primera que se declara `primary`). */
|
|
49
|
+
primary?: A
|
|
50
|
+
/** El resto de acciones principales, en orden. */
|
|
51
|
+
main: A[]
|
|
52
|
+
/** Compartir / imprimir / correo / chat… */
|
|
53
|
+
secondary: A[]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Separa primaria, principales y secundarias conservando el orden. Si varias
|
|
58
|
+
* reclaman `primary`, sólo la primera lo es; las demás quedan como principales.
|
|
59
|
+
*/
|
|
60
|
+
export function splitActionsByPriority<A extends PrioritizableAction>(actions: readonly A[]): SplitActions<A> {
|
|
61
|
+
let primary: A | undefined
|
|
62
|
+
const main: A[] = []
|
|
63
|
+
const secondary: A[] = []
|
|
64
|
+
for (const a of actions) {
|
|
65
|
+
const p = classifyActionPriority(a)
|
|
66
|
+
if (p === 'secondary') secondary.push(a)
|
|
67
|
+
else if (p === 'primary' && !primary) primary = a
|
|
68
|
+
else main.push(a)
|
|
69
|
+
}
|
|
70
|
+
return { primary, main, secondary }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Una acción que despacha por capacidad (`trigger.type: "capability"`) sólo se
|
|
75
|
+
* ofrece si algún addon instalado la provee. Sin provider = desconocido → se
|
|
76
|
+
* muestra (el servidor revalida igual).
|
|
77
|
+
*/
|
|
78
|
+
export function isActionProviderActive(action: PrioritizableAction, installed: InstalledAddonsValue | null): boolean {
|
|
79
|
+
const t = action.trigger
|
|
80
|
+
if (!installed || !t || t.type !== 'capability' || !t.capability) return true
|
|
81
|
+
return installed.capabilities.has(t.capability)
|
|
82
|
+
}
|
|
@@ -94,7 +94,7 @@ describe('line items', () => {
|
|
|
94
94
|
makeLine({ description: ' Llanta ', quantity: '2' as unknown as number, unit_price: '' as unknown as number, sku: '', lot: ' L1 ' }),
|
|
95
95
|
makeLine({ kind: 'note', description: 'Nota' }),
|
|
96
96
|
])
|
|
97
|
-
expect(out[0]).toEqual({ position: 1, kind: 'item', description: 'Llanta', quantity: 2, unit_price: 0, discount: 0, tax_rate: 0, lot: 'L1' })
|
|
97
|
+
expect(out[0]).toEqual({ position: 1, kind: 'item', description: 'Llanta', quantity: 2, unit_price: 0, discount: 0, tax_rate: 0, lot: 'L1', subtotal: 0, tax_amount: 0 })
|
|
98
98
|
expect(out[1]).toEqual({ position: 2, kind: 'note', description: 'Nota' })
|
|
99
99
|
expect(out[0]).not.toHaveProperty('key')
|
|
100
100
|
expect(out[0]).not.toHaveProperty('catalog_price')
|
|
@@ -85,3 +85,21 @@ export function createCatalogProductSearch(api: ApiLike, opts: CatalogProductSea
|
|
|
85
85
|
.map((r) => catalogRecordToProduct(r as Record<string, any>, opts.defaultTaxRate))
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Buscador del host (`searchProducts`) con el IVA de la org como respaldo: un
|
|
91
|
+
* producto que no trae su tasa entra con la de la org en vez de 0 (retest
|
|
92
|
+
* Pitsline 2026-10-05: el buscador de ops no devolvía tasa y el renglón de la
|
|
93
|
+
* factura quedaba sin IVA). Sin tasa de org, el buscador queda tal cual.
|
|
94
|
+
*/
|
|
95
|
+
export function withDefaultTaxRate<Q, S>(
|
|
96
|
+
search: (query: Q, signal: S) => Promise<ProductResult[]>,
|
|
97
|
+
defaultTaxRate: number | undefined,
|
|
98
|
+
): (query: Q, signal: S) => Promise<ProductResult[]> {
|
|
99
|
+
if (defaultTaxRate == null) return search
|
|
100
|
+
return async (query, signal) => {
|
|
101
|
+
const rows = await search(query, signal)
|
|
102
|
+
return rows.map((p) => (p.tax_rate == null ? { ...p, tax_rate: defaultTaxRate } : p))
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|