@asteby/metacore-runtime-react 47.1.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.
Files changed (190) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/dist/action-modal-dispatcher.d.ts +17 -3
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +80 -13
  5. package/dist/action-priority.d.ts +34 -0
  6. package/dist/action-priority.d.ts.map +1 -0
  7. package/dist/action-priority.js +60 -0
  8. package/dist/business/catalog-product-search.d.ts +32 -0
  9. package/dist/business/catalog-product-search.d.ts.map +1 -0
  10. package/dist/business/catalog-product-search.js +71 -0
  11. package/dist/business/catalog.d.ts.map +1 -1
  12. package/dist/business/catalog.js +2 -0
  13. package/dist/business/document-editor-model.d.ts +119 -0
  14. package/dist/business/document-editor-model.d.ts.map +1 -0
  15. package/dist/business/document-editor-model.js +317 -0
  16. package/dist/business/document-editor-panels.d.ts +64 -0
  17. package/dist/business/document-editor-panels.d.ts.map +1 -0
  18. package/dist/business/document-editor-panels.js +58 -0
  19. package/dist/business/document-editor.d.ts +27 -0
  20. package/dist/business/document-editor.d.ts.map +1 -0
  21. package/dist/business/document-editor.js +493 -0
  22. package/dist/business/document-lines.d.ts.map +1 -1
  23. package/dist/business/document-lines.js +5 -1
  24. package/dist/business/index.d.ts +5 -0
  25. package/dist/business/index.d.ts.map +1 -1
  26. package/dist/business/index.js +5 -0
  27. package/dist/business/line-items.d.ts +16 -1
  28. package/dist/business/line-items.d.ts.map +1 -1
  29. package/dist/business/line-items.js +14 -2
  30. package/dist/business/payment-allocator.d.ts +19 -0
  31. package/dist/business/payment-allocator.d.ts.map +1 -0
  32. package/dist/business/payment-allocator.js +31 -0
  33. package/dist/business/product-search.d.ts +4 -0
  34. package/dist/business/product-search.d.ts.map +1 -1
  35. package/dist/dialogs/dynamic-record.d.ts.map +1 -1
  36. package/dist/dialogs/dynamic-record.js +14 -7
  37. package/dist/document/document-page.d.ts.map +1 -1
  38. package/dist/document/document-page.js +8 -1
  39. package/dist/document/evaluate.d.ts.map +1 -1
  40. package/dist/document/evaluate.js +17 -1
  41. package/dist/document/index.d.ts +1 -0
  42. package/dist/document/index.d.ts.map +1 -1
  43. package/dist/document/index.js +1 -0
  44. package/dist/document/secondary-bar.d.ts +20 -0
  45. package/dist/document/secondary-bar.d.ts.map +1 -0
  46. package/dist/document/secondary-bar.js +44 -0
  47. package/dist/document/types.d.ts +8 -0
  48. package/dist/document/types.d.ts.map +1 -1
  49. package/dist/document-form-dialog.d.ts +31 -2
  50. package/dist/document-form-dialog.d.ts.map +1 -1
  51. package/dist/document-form-dialog.js +46 -11
  52. package/dist/dynamic-columns-helpers.d.ts +9 -0
  53. package/dist/dynamic-columns-helpers.d.ts.map +1 -1
  54. package/dist/dynamic-columns-helpers.js +19 -0
  55. package/dist/dynamic-columns.d.ts +9 -13
  56. package/dist/dynamic-columns.d.ts.map +1 -1
  57. package/dist/dynamic-columns.js +27 -21
  58. package/dist/dynamic-crud-page.d.ts +18 -0
  59. package/dist/dynamic-crud-page.d.ts.map +1 -1
  60. package/dist/dynamic-crud-page.js +15 -6
  61. package/dist/dynamic-form-schema.d.ts.map +1 -1
  62. package/dist/dynamic-form-schema.js +15 -3
  63. package/dist/dynamic-form.d.ts +16 -1
  64. package/dist/dynamic-form.d.ts.map +1 -1
  65. package/dist/dynamic-form.js +78 -20
  66. package/dist/dynamic-kanban.d.ts +9 -3
  67. package/dist/dynamic-kanban.d.ts.map +1 -1
  68. package/dist/dynamic-kanban.js +31 -12
  69. package/dist/dynamic-relation-helpers.d.ts.map +1 -1
  70. package/dist/dynamic-relation-helpers.js +8 -0
  71. package/dist/dynamic-relation.js +1 -1
  72. package/dist/dynamic-row-actions.d.ts +8 -0
  73. package/dist/dynamic-row-actions.d.ts.map +1 -1
  74. package/dist/dynamic-row-actions.js +12 -2
  75. package/dist/dynamic-table.d.ts.map +1 -1
  76. package/dist/dynamic-table.js +64 -63
  77. package/dist/index.d.ts +7 -2
  78. package/dist/index.d.ts.map +1 -1
  79. package/dist/index.js +19 -2
  80. package/dist/installed-addons-context.d.ts +15 -0
  81. package/dist/installed-addons-context.d.ts.map +1 -0
  82. package/dist/installed-addons-context.js +39 -0
  83. package/dist/org-runtime-context.d.ts +8 -0
  84. package/dist/org-runtime-context.d.ts.map +1 -1
  85. package/dist/org-runtime-context.js +8 -0
  86. package/dist/org-runtime-provider.d.ts +3 -1
  87. package/dist/org-runtime-provider.d.ts.map +1 -1
  88. package/dist/org-runtime-provider.js +3 -3
  89. package/dist/primitives/aging.d.ts +34 -0
  90. package/dist/primitives/aging.d.ts.map +1 -0
  91. package/dist/primitives/aging.js +48 -0
  92. package/dist/primitives/allocation.d.ts +42 -0
  93. package/dist/primitives/allocation.d.ts.map +1 -0
  94. package/dist/primitives/allocation.js +59 -0
  95. package/dist/primitives/approvals.d.ts +71 -0
  96. package/dist/primitives/approvals.d.ts.map +1 -0
  97. package/dist/primitives/approvals.js +48 -0
  98. package/dist/primitives/contributions.d.ts +93 -0
  99. package/dist/primitives/contributions.d.ts.map +1 -0
  100. package/dist/primitives/contributions.js +163 -0
  101. package/dist/primitives/document-kinds.d.ts +25 -0
  102. package/dist/primitives/document-kinds.d.ts.map +1 -0
  103. package/dist/primitives/document-kinds.js +26 -0
  104. package/dist/primitives/index.d.ts +8 -0
  105. package/dist/primitives/index.d.ts.map +1 -0
  106. package/dist/primitives/index.js +23 -0
  107. package/dist/primitives/panels.d.ts +68 -0
  108. package/dist/primitives/panels.d.ts.map +1 -0
  109. package/dist/primitives/panels.js +3 -0
  110. package/dist/primitives/use-contributions.d.ts +15 -0
  111. package/dist/primitives/use-contributions.d.ts.map +1 -0
  112. package/dist/primitives/use-contributions.js +30 -0
  113. package/dist/record-actions.d.ts +21 -0
  114. package/dist/record-actions.d.ts.map +1 -0
  115. package/dist/record-actions.js +76 -0
  116. package/dist/record-prefill-bar.d.ts +11 -0
  117. package/dist/record-prefill-bar.d.ts.map +1 -0
  118. package/dist/record-prefill-bar.js +33 -0
  119. package/dist/row-actions-menu.d.ts +32 -0
  120. package/dist/row-actions-menu.d.ts.map +1 -0
  121. package/dist/row-actions-menu.js +59 -0
  122. package/dist/slot.d.ts +4 -21
  123. package/dist/slot.d.ts.map +1 -1
  124. package/dist/slot.js +14 -36
  125. package/dist/types.d.ts +134 -0
  126. package/dist/types.d.ts.map +1 -1
  127. package/package.json +3 -3
  128. package/src/__tests__/catalog-product-search.test.ts +61 -0
  129. package/src/__tests__/document-editor-model.test.ts +221 -0
  130. package/src/__tests__/document-editor.test.tsx +340 -0
  131. package/src/__tests__/document-form-dialog.test.tsx +158 -3
  132. package/src/__tests__/dynamic-form-select-edit.test.tsx +155 -0
  133. package/src/__tests__/dynamic-kanban.test.tsx +24 -3
  134. package/src/__tests__/dynamic-record-extension-label.test.tsx +88 -0
  135. package/src/__tests__/installed-addons-context.test.tsx +50 -0
  136. package/src/__tests__/line-item-totals.test.ts +28 -0
  137. package/src/__tests__/prefill-scalar-from-record.test.ts +52 -1
  138. package/src/__tests__/primitives-federated.test.tsx +156 -0
  139. package/src/__tests__/primitives.test.ts +224 -0
  140. package/src/__tests__/record-prefill-bar.test.tsx +50 -0
  141. package/src/__tests__/row-action-link-navigation.test.ts +17 -0
  142. package/src/__tests__/secondary-actions.test.tsx +261 -0
  143. package/src/action-modal-dispatcher.tsx +97 -12
  144. package/src/action-priority.ts +82 -0
  145. package/src/business/business.test.ts +1 -1
  146. package/src/business/catalog-product-search.ts +105 -0
  147. package/src/business/catalog.ts +2 -0
  148. package/src/business/document-editor-model.ts +367 -0
  149. package/src/business/document-editor-panels.tsx +164 -0
  150. package/src/business/document-editor.tsx +781 -0
  151. package/src/business/document-lines.ts +5 -1
  152. package/src/business/index.ts +5 -0
  153. package/src/business/line-items.ts +31 -2
  154. package/src/business/payment-allocator.tsx +110 -0
  155. package/src/business/product-search.ts +4 -0
  156. package/src/dialogs/dynamic-record.tsx +29 -6
  157. package/src/document/document-page.tsx +15 -0
  158. package/src/document/evaluate.ts +17 -1
  159. package/src/document/index.ts +1 -0
  160. package/src/document/secondary-bar.tsx +92 -0
  161. package/src/document/types.ts +8 -0
  162. package/src/document-form-dialog.tsx +96 -12
  163. package/src/dynamic-columns-helpers.ts +22 -0
  164. package/src/dynamic-columns.tsx +35 -62
  165. package/src/dynamic-crud-page.tsx +33 -5
  166. package/src/dynamic-form-schema.ts +15 -2
  167. package/src/dynamic-form.tsx +117 -39
  168. package/src/dynamic-kanban.tsx +34 -7
  169. package/src/dynamic-relation-helpers.ts +9 -0
  170. package/src/dynamic-relation.tsx +7 -2
  171. package/src/dynamic-row-actions.tsx +12 -2
  172. package/src/dynamic-table.tsx +3 -0
  173. package/src/index.ts +46 -1
  174. package/src/installed-addons-context.tsx +58 -0
  175. package/src/org-runtime-context.ts +10 -0
  176. package/src/org-runtime-provider.tsx +7 -2
  177. package/src/primitives/aging.ts +76 -0
  178. package/src/primitives/allocation.ts +104 -0
  179. package/src/primitives/approvals.ts +103 -0
  180. package/src/primitives/contributions.ts +255 -0
  181. package/src/primitives/document-kinds.ts +62 -0
  182. package/src/primitives/index.ts +23 -0
  183. package/src/primitives/panels.ts +59 -0
  184. package/src/primitives/use-contributions.ts +42 -0
  185. package/src/record-actions.ts +101 -0
  186. package/src/record-prefill-bar.tsx +62 -0
  187. package/src/row-action-stage-gate.test.ts +33 -0
  188. package/src/row-actions-menu.tsx +159 -0
  189. package/src/slot.tsx +18 -53
  190. package/src/types.ts +129 -0
@@ -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
+ })
@@ -15,7 +15,7 @@
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
17
  import { emitRecordMutation } from './record-mutation-events'
18
- import { useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
18
+ import { Suspense, useState, useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from 'react'
19
19
  import { useTranslation } from 'react-i18next'
20
20
  import {
21
21
  Dialog,
@@ -84,10 +84,15 @@ 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'
90
92
  import { requestFederatedAction } from './federated-action-loader'
93
+ import { useInstalledAddons } from './installed-addons-context'
94
+ import { federatedModalComponent, resolveFederatedModal } from './primitives/contributions'
95
+ import { installedPredicate, useContributionsVersion } from './primitives/use-contributions'
91
96
 
92
97
  export type { ActionMetadata, ActionModalProps }
93
98
 
@@ -335,8 +340,45 @@ function defaultFromRecordSpec(field: ActionFieldDef): string | string[] | undef
335
340
  return f.defaultFromRecord ?? f.default_from_record
336
341
  }
337
342
 
338
- /** Scalar seed for one action field from the row being acted on. */
339
- export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): unknown {
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 {
340
382
  if (!record) return undefined
341
383
  const spec = defaultFromRecordSpec(field)
342
384
  if (typeof spec === 'string') {
@@ -350,6 +392,12 @@ export function scalarDefaultFromRecord(field: ActionFieldDef, record: any): unk
350
392
  } else if (field.key) {
351
393
  const v = readRecordPath(record, field.key)
352
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
+ }
353
401
  }
354
402
  return undefined
355
403
  }
@@ -375,6 +423,15 @@ export function ActionModalDispatcher({
375
423
  const readComponent = () => getActionComponent(model, action.key)
376
424
  const CustomComponent = useSyncExternalStore(subscribeActionComponents, readComponent, readComponent)
377
425
 
426
+ // Modal federado declarativo (registerFederatedModal). Si el addon dueño
427
+ // del slug consta como NO instalado, el `modal` se ignora y se usa el
428
+ // formulario genérico de la acción de inmediato (sin esperar el timeout).
429
+ useContributionsVersion()
430
+ const installed = useInstalledAddons()
431
+ const slug = action.modal || ''
432
+ const federatedModal = slug ? resolveFederatedModal(slug, installedPredicate(installed)) : null
433
+ const modalAddonMissing = !!slug && !federatedModal && !!installed && !installed.addons.has(slug.split('.')[0])
434
+
378
435
  if (CustomComponent) {
379
436
  return (
380
437
  <CustomComponent
@@ -389,10 +446,25 @@ export function ActionModalDispatcher({
389
446
  )
390
447
  }
391
448
 
449
+ if (federatedModal) {
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)
454
+ return (
455
+ <Suspense fallback={<LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />}>
456
+ <FederatedComponent
457
+ {...adaptActionProps({ open, onOpenChange, action, model, record, endpoint, onSuccess })}
458
+ />
459
+ </Suspense>
460
+ )
461
+ }
462
+
392
463
  // Declarative custom slot (`modal: "addon.action"`). Wait for the remote,
393
464
  // then a hard error — confirm/fields generics look "fine" and hide a
394
- // missing remote.
395
- if (action.modal) {
465
+ // missing remote. Exception: the slug's addon is known NOT installed — the
466
+ // remote will never come, so fall through to the generic form now.
467
+ if (action.modal && !modalAddonMissing) {
396
468
  return (
397
469
  <PendingCustomActionModal
398
470
  open={open}
@@ -479,7 +551,6 @@ function PendingCustomActionModal({
479
551
  action: ActionMetadata
480
552
  model: string
481
553
  }) {
482
- const { t } = useTranslation()
483
554
  const slug = action.modal || ''
484
555
  const [timedOut, setTimedOut] = useState(false)
485
556
 
@@ -522,6 +593,20 @@ function PendingCustomActionModal({
522
593
  return <MissingCustomActionModal open={open} onOpenChange={onOpenChange} action={action} model={model} />
523
594
  }
524
595
 
596
+ return <LoadingActionDialog open={open} onOpenChange={onOpenChange} action={action} />
597
+ }
598
+
599
+ /** Diálogo «Cargando…» mientras llega el componente federado de la acción. */
600
+ function LoadingActionDialog({
601
+ open,
602
+ onOpenChange,
603
+ action,
604
+ }: {
605
+ open: boolean
606
+ onOpenChange: (open: boolean) => void
607
+ action: ActionMetadata
608
+ }) {
609
+ const { t } = useTranslation()
525
610
  return (
526
611
  <Dialog open={open} onOpenChange={onOpenChange}>
527
612
  <DialogContent>
@@ -973,11 +1058,11 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
973
1058
 
974
1059
  useEffect(() => {
975
1060
  if (open && action.fields) {
976
- setFormData(buildFieldDefaults(action.fields, record))
1061
+ setFormData(buildFieldDefaults(action.fields, record, model))
977
1062
  setFieldErrors({})
978
1063
  setFormError(undefined)
979
1064
  }
980
- }, [open, action.fields, record])
1065
+ }, [open, action.fields, record, model])
981
1066
 
982
1067
  // Lines the list row does not carry: load them from the declared relation
983
1068
  // (see prefillRelationRequests) and seed the line-items grid.
@@ -1271,7 +1356,7 @@ function GenericActionModal({ open, onOpenChange, action, model, record, endpoin
1271
1356
  // buildFieldDefaults seeds formData for a set of action fields, honoring the
1272
1357
  // same line-items prefill spec + boolean/empty rules GenericActionModal uses, so
1273
1358
  // wizard steps and single-page forms initialize identically.
1274
- export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Record<string, any> {
1359
+ export function buildFieldDefaults(fields: ActionFieldDef[], record: any, model?: string): Record<string, any> {
1275
1360
  const defaults: Record<string, any> = {}
1276
1361
  for (const field of fields) {
1277
1362
  if (isLineItemsField(field)) {
@@ -1283,7 +1368,7 @@ export function buildFieldDefaults(fields: ActionFieldDef[], record: any): Recor
1283
1368
  : []
1284
1369
  continue
1285
1370
  }
1286
- const fromRecord = scalarDefaultFromRecord(field, record)
1371
+ const fromRecord = scalarDefaultFromRecord(field, record, model)
1287
1372
  if (fromRecord !== undefined && fromRecord !== null && fromRecord !== '') {
1288
1373
  defaults[field.key] = fromRecord
1289
1374
  continue
@@ -1331,10 +1416,10 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
1331
1416
  useEffect(() => {
1332
1417
  if (!open) return
1333
1418
  const allFields = steps.flatMap((s) => s.fields ?? [])
1334
- setFormData(buildFieldDefaults(allFields, record))
1419
+ setFormData(buildFieldDefaults(allFields, record, model))
1335
1420
  setStepIndex(0)
1336
1421
  setFormError(undefined)
1337
- }, [open, action.steps, record])
1422
+ }, [open, action.steps, record, model])
1338
1423
 
1339
1424
  const updateField = (key: string, value: any) =>
1340
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')
@@ -0,0 +1,105 @@
1
+ // createCatalogProductSearch — buscador de productos POR DEFECTO para
2
+ // DocumentLinesGrid cuando el host no inyecta uno (`searchProducts`).
3
+ //
4
+ // Problema que corrige: DynamicCRUDPage monta DocumentFormDialog SIN
5
+ // `searchProducts`, así que el paso de renglones de «Crear factura» solo ofrecía
6
+ // «Renglón libre» (descripción a mano, precio 0, IVA 0) y la factura se guardaba
7
+ // en $0. Con esto, cualquier documento declarativo busca en el catálogo.
8
+ //
9
+ // Lee el listado canónico GET /data/<model>?search=&per_page= (el mismo que usa
10
+ // DynamicTable). No usa /options: su envelope solo trae id/label, sin precio.
11
+ import type { ProductQuery, ProductResult } from './product-search'
12
+
13
+ interface ApiLike {
14
+ get: (url: string, cfg?: { params?: Record<string, unknown>; signal?: AbortSignal }) => Promise<{ data: any }>
15
+ }
16
+
17
+ export interface CatalogProductSearchOptions {
18
+ /** Modelo de catálogo. Default `products.Product`. */
19
+ model?: string
20
+ /** Endpoint de listado. Default `/data/<model>`. */
21
+ endpoint?: string
22
+ limit?: number
23
+ /** Tasa de impuesto cuando el producto no trae la suya (config fiscal de la org; p. ej. 0.16). */
24
+ defaultTaxRate?: number
25
+ }
26
+
27
+ const num = (v: unknown): number | undefined => {
28
+ const n = typeof v === 'string' && v.trim() !== '' ? Number(v) : typeof v === 'number' ? v : NaN
29
+ return Number.isFinite(n) ? n : undefined
30
+ }
31
+
32
+ const str = (v: unknown): string | undefined => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
33
+
34
+ /** Tasa como fracción: acepta 0.16 o 16. */
35
+ const rate = (v: unknown): number | undefined => {
36
+ const n = num(v)
37
+ if (n == null || n < 0) return undefined
38
+ return n > 1 ? n / 100 : n
39
+ }
40
+
41
+ const bag = (v: unknown): Record<string, unknown> => {
42
+ if (typeof v === 'string') {
43
+ try {
44
+ const parsed = JSON.parse(v)
45
+ return parsed && typeof parsed === 'object' ? parsed : {}
46
+ } catch {
47
+ return {}
48
+ }
49
+ }
50
+ return v && typeof v === 'object' ? (v as Record<string, unknown>) : {}
51
+ }
52
+
53
+ /** Proyección pura registro del catálogo → ProductResult (exportada para tests). */
54
+ export function catalogRecordToProduct(rec: Record<string, any>, defaultTaxRate?: number): ProductResult {
55
+ // Campos de extensión de otros addons (p. ej. claves SAT de fiscal_mexico).
56
+ const extensions = Object.fromEntries(
57
+ Object.entries(bag(rec.fiscal_data)).filter(([, v]) => typeof v === 'string' && v !== ''),
58
+ ) as Record<string, string>
59
+ const pt = String(rec.product_type ?? '')
60
+ return {
61
+ id: String(rec.id ?? rec.value ?? ''),
62
+ name: String(rec.name ?? rec.label ?? ''),
63
+ sku: str(rec.sku),
64
+ barcode: str(rec.barcode),
65
+ kind: pt === 'service' ? 'service' : 'product',
66
+ price: num(rec.unit_price ?? rec.sale_price ?? rec.price),
67
+ cost: num(rec.cost_price ?? rec.cost),
68
+ tax_rate: rate(rec.tax_rate) ?? defaultTaxRate,
69
+ unit: str(rec.unit_of_measure) ?? str(rec.unit),
70
+ extensions: Object.keys(extensions).length ? extensions : undefined,
71
+ }
72
+ }
73
+
74
+ export function createCatalogProductSearch(api: ApiLike, opts: CatalogProductSearchOptions = {}) {
75
+ const model = opts.model ?? 'products.Product'
76
+ const url = opts.endpoint ?? `/data/${model}`
77
+ const limit = opts.limit ?? 20
78
+ return async (query: Exclude<ProductQuery, { kind: 'empty' }>, signal: AbortSignal): Promise<ProductResult[]> => {
79
+ const search = query.kind === 'barcode' ? query.barcode : query.raw.trim()
80
+ const res = await api.get(url, { params: { search, per_page: limit }, signal })
81
+ const rows: unknown = res?.data?.data
82
+ if (!Array.isArray(rows)) return []
83
+ return rows
84
+ .filter((r) => r && typeof r === 'object' && (r as Record<string, unknown>).is_active !== false)
85
+ .map((r) => catalogRecordToProduct(r as Record<string, any>, opts.defaultTaxRate))
86
+ }
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
+
@@ -51,6 +51,8 @@ export const BUSINESS_COMPONENTS: readonly BusinessComponentSpec[] = [
51
51
  'applyProductToLine',
52
52
  'addProductLine',
53
53
  'lineGridKeyCommand',
54
+ 'createCatalogProductSearch',
55
+ 'catalogRecordToProduct',
54
56
  ],
55
57
  },
56
58
  {