@asteby/metacore-runtime-react 43.0.0 → 45.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 (37) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/action-modal-dispatcher.d.ts +2 -0
  3. package/dist/action-modal-dispatcher.d.ts.map +1 -1
  4. package/dist/action-modal-dispatcher.js +74 -8
  5. package/dist/dynamic-columns.d.ts +11 -0
  6. package/dist/dynamic-columns.d.ts.map +1 -1
  7. package/dist/dynamic-columns.js +13 -1
  8. package/dist/dynamic-kanban.d.ts.map +1 -1
  9. package/dist/dynamic-kanban.js +4 -5
  10. package/dist/dynamic-row-actions.d.ts.map +1 -1
  11. package/dist/dynamic-row-actions.js +23 -16
  12. package/dist/federated-action-loader.d.ts +18 -0
  13. package/dist/federated-action-loader.d.ts.map +1 -0
  14. package/dist/federated-action-loader.js +34 -0
  15. package/dist/index.d.ts +3 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +3 -1
  18. package/dist/model-action-toolbar.d.ts.map +1 -1
  19. package/dist/model-action-toolbar.js +19 -15
  20. package/dist/requires-addon.d.ts +37 -0
  21. package/dist/requires-addon.d.ts.map +1 -0
  22. package/dist/requires-addon.js +104 -0
  23. package/dist/types.d.ts +27 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/package.json +3 -3
  26. package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
  27. package/src/__tests__/federated-action-modal.test.tsx +315 -0
  28. package/src/__tests__/requires-addon.test.tsx +201 -0
  29. package/src/action-modal-dispatcher.tsx +102 -10
  30. package/src/dynamic-columns.tsx +36 -6
  31. package/src/dynamic-kanban.tsx +6 -7
  32. package/src/dynamic-row-actions.tsx +26 -16
  33. package/src/federated-action-loader.ts +40 -0
  34. package/src/index.ts +13 -0
  35. package/src/model-action-toolbar.tsx +31 -15
  36. package/src/requires-addon.tsx +160 -0
  37. package/src/types.ts +28 -0
@@ -0,0 +1,315 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Federated action modals are deterministic: an action that declares
4
+ // `modal: "<addon>.<action>"` shows a loading state while the remote loads,
5
+ // swaps to the registered component when it lands, and errors after the
6
+ // timeout — it never renders the generic ConfirmActionDialog.
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, render, screen } from '@testing-library/react'
9
+
10
+ // Stable identities: a fresh `t` per render spins the record dialog's effects
11
+ // into an infinite render loop (same as dynamic-kanban.test).
12
+ const USE_TRANSLATION = {
13
+ t: (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k,
14
+ i18n: { language: 'es' },
15
+ }
16
+ vi.mock('react-i18next', () => ({
17
+ useTranslation: () => USE_TRANSLATION,
18
+ }))
19
+
20
+ vi.mock('@tanstack/react-router', () => ({
21
+ useNavigate: () => () => {},
22
+ }))
23
+
24
+ import { ActionModalDispatcher, FEDERATED_ACTION_MODAL_TIMEOUT_MS } from '../action-modal-dispatcher'
25
+ import { setFederatedActionLoader } from '../federated-action-loader'
26
+ import { useDynamicRowActions } from '../dynamic-row-actions'
27
+ import { ModelActionToolbar } from '../model-action-toolbar'
28
+ import { ApiProvider, type ApiClient } from '../api-context'
29
+ import type { ActionDefinition, TableMetadata } from '../types'
30
+ import {
31
+ registerActionComponent,
32
+ unregisterActionComponent,
33
+ type ActionMetadata,
34
+ type ActionModalProps,
35
+ } from '@asteby/metacore-sdk'
36
+
37
+ afterEach(() => {
38
+ cleanup()
39
+ vi.useRealTimers()
40
+ vi.restoreAllMocks()
41
+ setFederatedActionLoader(null)
42
+ unregisterActionComponent('Order', 'return')
43
+ unregisterActionComponent('Device', 'create_and_connect')
44
+ unregisterActionComponent('Invoice', 'register_payment')
45
+ })
46
+
47
+ const noopApi: ApiClient = {
48
+ get: vi.fn().mockResolvedValue({ data: {} }),
49
+ post: vi.fn().mockResolvedValue({ data: { success: true } }),
50
+ delete: vi.fn().mockResolvedValue({ data: { success: true } }),
51
+ } as unknown as ApiClient
52
+
53
+ const returnAction: ActionMetadata = {
54
+ key: 'return',
55
+ label: 'Devolver',
56
+ icon: 'Undo2',
57
+ modal: 'returns.return',
58
+ confirm: true,
59
+ confirmMessage: 'Devolver?',
60
+ executable: true,
61
+ }
62
+
63
+ function ReturnModal({ action }: ActionModalProps) {
64
+ return <div data-testid="return-modal">{action.modal}</div>
65
+ }
66
+
67
+ function renderDispatcher(action: ActionMetadata = returnAction) {
68
+ return render(
69
+ <ApiProvider client={noopApi}>
70
+ <ActionModalDispatcher
71
+ open
72
+ onOpenChange={() => {}}
73
+ action={action}
74
+ model="Order"
75
+ record={{ id: '1' } as any}
76
+ endpoint="/data/Order"
77
+ onSuccess={() => {}}
78
+ />
79
+ </ApiProvider>,
80
+ )
81
+ }
82
+
83
+ describe('ActionModalDispatcher with a declared federated modal', () => {
84
+ it('shows loading, then the component once it registers late — never the confirm', () => {
85
+ renderDispatcher()
86
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
87
+ expect(screen.queryByText('Devolver?')).toBeNull()
88
+
89
+ act(() => {
90
+ registerActionComponent('Order', 'return', ReturnModal as never)
91
+ })
92
+ expect(screen.getByTestId('return-modal').textContent).toBe('returns.return')
93
+ expect(screen.queryByTestId('federated-action-loading')).toBeNull()
94
+ expect(screen.queryByText('Devolver?')).toBeNull()
95
+ })
96
+
97
+ it('shows MissingCustomActionModal after the timeout when nothing registers', () => {
98
+ vi.useFakeTimers()
99
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {})
100
+ renderDispatcher()
101
+ act(() => {
102
+ vi.advanceTimersByTime(FEDERATED_ACTION_MODAL_TIMEOUT_MS - 1)
103
+ })
104
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
105
+ expect(screen.queryByText('No se pudo cargar el formulario')).toBeNull()
106
+
107
+ act(() => {
108
+ vi.advanceTimersByTime(1)
109
+ })
110
+ expect(screen.getByText('No se pudo cargar el formulario')).toBeTruthy()
111
+ expect(screen.queryByText('Devolver?')).toBeNull()
112
+ expect(err).toHaveBeenCalledWith(
113
+ expect.stringContaining('did not register in time'),
114
+ expect.objectContaining({ model: 'Order', action: 'return', modal: 'returns.return' }),
115
+ )
116
+ })
117
+
118
+ it('still asks the host and times out on a slug without a valid prefix', () => {
119
+ vi.useFakeTimers()
120
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
121
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {})
122
+ const loader = vi.fn()
123
+ setFederatedActionLoader(loader)
124
+ renderDispatcher({ ...returnAction, modal: 'noprefix' })
125
+ expect(loader).toHaveBeenCalledWith({ model: 'Order', actionKey: 'return', modal: 'noprefix' })
126
+ expect(warn).toHaveBeenCalled()
127
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
128
+
129
+ act(() => {
130
+ vi.advanceTimersByTime(FEDERATED_ACTION_MODAL_TIMEOUT_MS)
131
+ })
132
+ expect(screen.getByText('No se pudo cargar el formulario')).toBeTruthy()
133
+ expect(screen.queryByText('Devolver?')).toBeNull()
134
+ expect(err).toHaveBeenCalledWith(
135
+ expect.stringContaining('did not register in time'),
136
+ expect.objectContaining({ model: 'Order', action: 'return', modal: 'noprefix' }),
137
+ )
138
+ })
139
+
140
+ it('asks the host loader for the remote and re-asks while waiting', () => {
141
+ vi.useFakeTimers()
142
+ vi.spyOn(console, 'error').mockImplementation(() => {})
143
+ const loader = vi.fn()
144
+ setFederatedActionLoader(loader)
145
+ renderDispatcher()
146
+ expect(loader).toHaveBeenCalledTimes(1)
147
+ expect(loader).toHaveBeenCalledWith({ model: 'Order', actionKey: 'return', modal: 'returns.return' })
148
+
149
+ act(() => {
150
+ vi.advanceTimersByTime(4_000)
151
+ })
152
+ expect(loader.mock.calls.length).toBeGreaterThan(1)
153
+
154
+ // Registration stops the retries.
155
+ act(() => {
156
+ registerActionComponent('Order', 'return', ReturnModal as never)
157
+ })
158
+ const calls = loader.mock.calls.length
159
+ act(() => {
160
+ vi.advanceTimersByTime(10_000)
161
+ })
162
+ expect(loader.mock.calls.length).toBe(calls)
163
+ expect(screen.getByTestId('return-modal')).toBeTruthy()
164
+ })
165
+ })
166
+
167
+ describe('useDynamicRowActions', () => {
168
+ function Harness({ metadata, actionKey }: { metadata: TableMetadata; actionKey: string }) {
169
+ const { handleInternalAction, dialogs } = useDynamicRowActions({
170
+ model: 'Order',
171
+ metadata,
172
+ onRefresh: () => {},
173
+ })
174
+ return (
175
+ <>
176
+ <button onClick={() => handleInternalAction(actionKey, { id: '1' })}>run</button>
177
+ {dialogs}
178
+ </>
179
+ )
180
+ }
181
+
182
+ function renderHarness(action: Record<string, unknown>) {
183
+ const metadata = { actions: [action] } as unknown as TableMetadata
184
+ render(
185
+ <ApiProvider client={noopApi}>
186
+ <Harness metadata={metadata} actionKey={action.key as string} />
187
+ </ApiProvider>,
188
+ )
189
+ act(() => {
190
+ screen.getByText('run').click()
191
+ })
192
+ }
193
+
194
+ it('keeps `modal` on the action it hands the dispatcher', () => {
195
+ registerActionComponent('Order', 'return', ReturnModal as never)
196
+ renderHarness({ ...returnAction, name: 'return', class: '', type: 'custom' })
197
+ expect(screen.getByTestId('return-modal').textContent).toBe('returns.return')
198
+ expect(screen.queryByText('Devolver?')).toBeNull()
199
+ })
200
+
201
+ it('honors a host-injected `modal` override on another addon\'s confirm action', () => {
202
+ // customers declares Invoice.register_payment as a plain confirm; the
203
+ // optional collections addon overrides it and the host injects `modal`.
204
+ function PaymentModal({ action }: ActionModalProps) {
205
+ return <div data-testid="payment-modal">{action.modal}</div>
206
+ }
207
+ const override = {
208
+ key: 'register_payment',
209
+ label: 'Registrar pago',
210
+ icon: 'Wallet',
211
+ type: 'custom',
212
+ confirmMessage: 'Registrar pago?',
213
+ executable: true,
214
+ modal: 'collections.register_payment',
215
+ }
216
+ const metadata = { actions: [override] } as unknown as TableMetadata
217
+ function InvoiceHarness() {
218
+ const { handleInternalAction, dialogs } = useDynamicRowActions({
219
+ model: 'Invoice',
220
+ metadata,
221
+ onRefresh: () => {},
222
+ })
223
+ return (
224
+ <>
225
+ <button onClick={() => handleInternalAction('register_payment', { id: '1' })}>pay</button>
226
+ {dialogs}
227
+ </>
228
+ )
229
+ }
230
+ render(
231
+ <ApiProvider client={noopApi}>
232
+ <InvoiceHarness />
233
+ </ApiProvider>,
234
+ )
235
+ act(() => {
236
+ screen.getByText('pay').click()
237
+ })
238
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
239
+ expect(screen.queryByText('Registrar pago?')).toBeNull()
240
+ act(() => {
241
+ registerActionComponent('Invoice', 'register_payment', PaymentModal as never, 'collections')
242
+ })
243
+ expect(screen.getByTestId('payment-modal').textContent).toBe('collections.register_payment')
244
+ })
245
+
246
+ it('opens the dispatcher for a modal-only action (no fields/confirm/executable)', () => {
247
+ renderHarness({ key: 'return', label: 'Devolver', icon: 'Undo2', modal: 'returns.return', type: 'custom' })
248
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
249
+ })
250
+ })
251
+
252
+ describe('ModelActionToolbar', () => {
253
+ // Prod case: link-inbox's Device.create_and_connect ("Conectar WhatsApp")
254
+ // is a `placement: "create"` toolbar action with `modal`. The toolbar used
255
+ // to drop `modal`, open the generic confirm and POST the action with no
256
+ // record → "action payload missing the device record id".
257
+ it('placement create + modal: loading, then the late-registered component — never the confirm', () => {
258
+ vi.useFakeTimers()
259
+ const api = {
260
+ get: vi.fn().mockResolvedValue({ data: {} }),
261
+ post: vi.fn().mockResolvedValue({ data: { success: true } }),
262
+ } as unknown as ApiClient
263
+ const onChange = vi.fn()
264
+ const loader = vi.fn()
265
+ setFederatedActionLoader(loader)
266
+ const connect = {
267
+ key: 'create_and_connect',
268
+ name: 'create_and_connect',
269
+ label: 'Conectar WhatsApp',
270
+ icon: 'Plus',
271
+ class: '',
272
+ type: 'custom',
273
+ placement: 'create',
274
+ modal: 'link_inbox.create_and_connect',
275
+ confirm: true,
276
+ confirmMessage: 'Conectar?',
277
+ executable: true,
278
+ } as ActionDefinition
279
+ render(
280
+ <ApiProvider client={api}>
281
+ <ModelActionToolbar model="Device" actions={[connect]} onChange={onChange} />
282
+ </ApiProvider>,
283
+ )
284
+ act(() => {
285
+ screen.getByText('Conectar WhatsApp').click()
286
+ })
287
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
288
+ expect(loader).toHaveBeenCalledWith({
289
+ model: 'Device',
290
+ actionKey: 'create_and_connect',
291
+ modal: 'link_inbox.create_and_connect',
292
+ })
293
+
294
+ // The remote is slow: still loading, no confirm, nothing executed.
295
+ act(() => {
296
+ vi.advanceTimersByTime(5_000)
297
+ })
298
+ expect(screen.getByTestId('federated-action-loading')).toBeTruthy()
299
+ expect(screen.queryByText('Conectar?')).toBeNull()
300
+ expect(screen.queryByRole('alertdialog')).toBeNull()
301
+
302
+ function ConnectModal({ action }: ActionModalProps) {
303
+ return <div data-testid="connect-modal">{action.modal}</div>
304
+ }
305
+ act(() => {
306
+ registerActionComponent('Device', 'create_and_connect', ConnectModal as never, 'link_inbox')
307
+ })
308
+ expect(screen.getByTestId('connect-modal').textContent).toBe('link_inbox.create_and_connect')
309
+ expect(screen.queryByTestId('federated-action-loading')).toBeNull()
310
+ expect(screen.queryByText('Conectar?')).toBeNull()
311
+ expect(screen.queryByRole('alertdialog')).toBeNull()
312
+ expect(api.post).not.toHaveBeenCalled()
313
+ expect(onChange).not.toHaveBeenCalled()
314
+ })
315
+ })
@@ -0,0 +1,201 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // requiresAddon: an action gated by a missing optional addon stays visible but
4
+ // locked. A click must NOT reach the dispatcher / federated modal / backend; it
5
+ // opens the "requires addon" dialog whose "Instalar" hands the key to the host
6
+ // handler, or routes to /marketplace/<key> through the SDK router.
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+ import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react'
9
+
10
+ const I18N_T = (_k: string, opts?: { defaultValue?: string; [k: string]: unknown }) =>
11
+ (opts?.defaultValue ?? _k).replace(/\{\{(\w+)\}\}/g, (_: string, v: string) => String(opts?.[v] ?? ''))
12
+ const USE_TRANSLATION = { t: I18N_T, i18n: { language: 'es' } }
13
+ vi.mock('react-i18next', () => ({
14
+ useTranslation: () => USE_TRANSLATION,
15
+ }))
16
+
17
+ const navigate = vi.fn()
18
+ vi.mock('@tanstack/react-router', () => ({
19
+ useNavigate: () => navigate,
20
+ }))
21
+
22
+ // The dispatcher must never mount for a gated action.
23
+ const dispatcherSpy = vi.fn()
24
+ vi.mock('../action-modal-dispatcher', () => ({
25
+ ActionModalDispatcher: (props: unknown) => {
26
+ dispatcherSpy(props)
27
+ return <div data-testid="dispatcher" />
28
+ },
29
+ }))
30
+
31
+ import { useDynamicRowActions } from '../dynamic-row-actions'
32
+ import { ModelActionToolbar } from '../model-action-toolbar'
33
+ import { RowActionMenuItem } from '../dynamic-columns'
34
+ import { ApiProvider } from '../api-context'
35
+ import { resolveRequiresAddon, setAddonInstallHandler } from '../requires-addon'
36
+ import type { ActionDefinition, TableMetadata } from '../types'
37
+ import { DropdownMenu, DropdownMenuContent } from '@asteby/metacore-ui/primitives'
38
+
39
+ const post = vi.fn(async () => ({ data: { success: true } }))
40
+ const api = {
41
+ get: async () => ({ data: { data: {} } }),
42
+ post,
43
+ put: async () => ({ data: { success: true } }),
44
+ delete: async () => ({ data: { success: true } }),
45
+ } as never
46
+
47
+ const WIRE_REQUIREMENT = {
48
+ key: 'connector_whatsapp',
49
+ name: 'Conector WhatsApp',
50
+ reason: 'Necesitas el conector para vincular un número.',
51
+ }
52
+
53
+ // Exactly as ops serves it: snake_case, plus a federated modal + executable so
54
+ // that, without the gate, the click would open the dispatcher.
55
+ const gatedRowAction = {
56
+ key: 'connect_device',
57
+ name: 'connect_device',
58
+ label: 'Conectar WhatsApp',
59
+ icon: 'QrCode',
60
+ class: '',
61
+ type: 'custom',
62
+ modal: 'link_inbox.connect_device',
63
+ executable: true,
64
+ requires_addon: WIRE_REQUIREMENT,
65
+ } as unknown as ActionDefinition
66
+
67
+ const metadata = { title: 'Devices', actions: [gatedRowAction] } as unknown as TableMetadata
68
+
69
+ function Wrapper({ children }: { children: React.ReactNode }) {
70
+ return <ApiProvider client={api}>{children}</ApiProvider>
71
+ }
72
+
73
+ beforeEach(() => {
74
+ post.mockClear()
75
+ navigate.mockClear()
76
+ dispatcherSpy.mockClear()
77
+ setAddonInstallHandler(null)
78
+ })
79
+ afterEach(cleanup)
80
+
81
+ describe('resolveRequiresAddon', () => {
82
+ it('normalizes the snake_case requires_addon served by the host', () => {
83
+ expect(resolveRequiresAddon({ requires_addon: WIRE_REQUIREMENT })).toEqual(WIRE_REQUIREMENT)
84
+ })
85
+ it('reads camelCase requiresAddon', () => {
86
+ expect(resolveRequiresAddon({ requiresAddon: { key: 'x' } })).toEqual({ key: 'x' })
87
+ })
88
+ it('treats absent / keyless requirements as runnable', () => {
89
+ expect(resolveRequiresAddon({ key: 'a' })).toBeNull()
90
+ expect(resolveRequiresAddon({ requires_addon: { name: 'Sin key' } })).toBeNull()
91
+ expect(resolveRequiresAddon(undefined)).toBeNull()
92
+ })
93
+ })
94
+
95
+ function RowHarness() {
96
+ const { handleInternalAction, dialogs } = useDynamicRowActions({
97
+ model: 'Device',
98
+ metadata,
99
+ onRefresh: () => {},
100
+ })
101
+ return (
102
+ <>
103
+ <button onClick={() => handleInternalAction('connect_device', { id: 'r1' })}>run</button>
104
+ {dialogs}
105
+ </>
106
+ )
107
+ }
108
+
109
+ describe('row actions (table / kanban shared handler)', () => {
110
+ it('does not dispatch nor post; opens the requires-addon dialog', async () => {
111
+ const onAction = vi.fn()
112
+ const { result } = renderHook(
113
+ () => useDynamicRowActions({ model: 'Device', metadata, onAction, onRefresh: () => {} }),
114
+ { wrapper: Wrapper },
115
+ )
116
+ await act(() => result.current.handleInternalAction('connect_device', { id: 'r1' }))
117
+ expect(onAction).not.toHaveBeenCalled()
118
+
119
+ render(<RowHarness />, { wrapper: Wrapper })
120
+ await act(async () => fireEvent.click(screen.getByText('run')))
121
+
122
+ expect(screen.getByText('Esta acción requiere «Conector WhatsApp»')).toBeTruthy()
123
+ expect(screen.getByText(WIRE_REQUIREMENT.reason)).toBeTruthy()
124
+ expect(screen.queryByTestId('dispatcher')).toBeNull()
125
+ expect(dispatcherSpy).not.toHaveBeenCalled()
126
+ expect(post).not.toHaveBeenCalled()
127
+ })
128
+
129
+ it('Instalar invokes the host handler with the addon key', async () => {
130
+ const handler = vi.fn()
131
+ setAddonInstallHandler(handler)
132
+ render(<RowHarness />, { wrapper: Wrapper })
133
+ await act(async () => fireEvent.click(screen.getByText('run')))
134
+ await act(async () => fireEvent.click(screen.getByText('Instalar «Conector WhatsApp»')))
135
+ expect(handler).toHaveBeenCalledWith('connector_whatsapp')
136
+ expect(navigate).not.toHaveBeenCalled()
137
+ })
138
+
139
+ it('without a handler navigates through the router to /marketplace/<key>', async () => {
140
+ render(<RowHarness />, { wrapper: Wrapper })
141
+ await act(async () => fireEvent.click(screen.getByText('run')))
142
+ await act(async () => fireEvent.click(screen.getByText('Instalar «Conector WhatsApp»')))
143
+ expect(navigate).toHaveBeenCalledWith({ to: '/marketplace/connector_whatsapp' })
144
+ })
145
+
146
+ it('menu entry stays visible with a lock and the addon name as tooltip', () => {
147
+ render(
148
+ <DropdownMenu open>
149
+ <DropdownMenuContent>
150
+ <RowActionMenuItem action={gatedRowAction} label="Conectar WhatsApp" onSelect={() => {}} />
151
+ </DropdownMenuContent>
152
+ </DropdownMenu>,
153
+ )
154
+ const item = screen.getByText('Conectar WhatsApp').closest('[data-requires-addon]')
155
+ expect(item?.getAttribute('data-requires-addon')).toBe('connector_whatsapp')
156
+ expect(item?.getAttribute('title')).toBe('Requiere «Conector WhatsApp»')
157
+ })
158
+ })
159
+
160
+ describe('ModelActionToolbar', () => {
161
+ const toolbarAction = {
162
+ ...gatedRowAction,
163
+ placement: 'table',
164
+ requires_addon: undefined,
165
+ requiresAddon: { key: 'connector_whatsapp', name: 'Conector WhatsApp' },
166
+ } as unknown as ActionDefinition
167
+
168
+ it('renders the action locked and opens the dialog instead of the dispatcher', async () => {
169
+ const intent = vi.fn()
170
+ render(
171
+ <Wrapper>
172
+ <ModelActionToolbar model="Device" actions={[toolbarAction]} onActionIntent={intent} />
173
+ </Wrapper>,
174
+ )
175
+ const button = screen.getByText('Conectar WhatsApp').closest('button')!
176
+ expect(button.getAttribute('title')).toBe('Requiere «Conector WhatsApp»')
177
+ expect(button.querySelector('[data-requires-addon="connector_whatsapp"]')).toBeTruthy()
178
+
179
+ await act(async () => fireEvent.click(button))
180
+ expect(screen.getByText('Esta acción requiere «Conector WhatsApp»')).toBeTruthy()
181
+ // No reason from the host → generic copy.
182
+ expect(screen.getByText('Instala «Conector WhatsApp» para habilitar esta acción.')).toBeTruthy()
183
+ expect(dispatcherSpy).not.toHaveBeenCalled()
184
+ expect(intent).not.toHaveBeenCalled()
185
+ expect(post).not.toHaveBeenCalled()
186
+
187
+ await act(async () => fireEvent.click(screen.getByText('Instalar «Conector WhatsApp»')))
188
+ expect(navigate).toHaveBeenCalledWith({ to: '/marketplace/connector_whatsapp' })
189
+ })
190
+
191
+ it('an ungated action still opens the dispatcher', async () => {
192
+ const plain = { ...toolbarAction, key: 'other', label: 'Otra', requiresAddon: undefined } as ActionDefinition
193
+ render(
194
+ <Wrapper>
195
+ <ModelActionToolbar model="Device" actions={[plain]} />
196
+ </Wrapper>,
197
+ )
198
+ await act(async () => fireEvent.click(screen.getByText('Otra')))
199
+ expect(dispatcherSpy).toHaveBeenCalled()
200
+ })
201
+ })
@@ -1,7 +1,11 @@
1
1
  // ActionModalDispatcher — renders the right modal for a custom action:
2
2
  // 1) Custom component from the SDK registry → use it
3
- // 2) action.modal set but no registered component → MissingCustomActionModal
4
- // (NEVER fall back to confirm/fields — that hides a broken federated UI)
3
+ // 2) action.modal set but no registered component → loading state while the
4
+ // host loads the remote (setFederatedActionLoader); the registry is reactive,
5
+ // so the component renders the moment it registers. Timeout →
6
+ // MissingCustomActionModal (a malformed slug still asks the host, then
7
+ // times out). NEVER falls back to confirm/fields — that
8
+ // hides a broken federated UI.
5
9
  // 3) action.fields[] / action.steps[] → GenericActionModal / WizardActionModal
6
10
  // 4) action.confirm OR action.confirmMessage → ConfirmActionDialog
7
11
  // 5) action.executable (host opened the modal; no modal/fields/confirm) →
@@ -10,7 +14,7 @@
10
14
  //
11
15
  // The host injects its axios-like client via <ApiProvider>; we no longer
12
16
  // depend on a bundler alias to `@/lib/api`.
13
- import { useState, useEffect, useMemo } from 'react'
17
+ import { useState, useEffect, useMemo, useSyncExternalStore } from 'react'
14
18
  import { useTranslation } from 'react-i18next'
15
19
  import {
16
20
  Dialog,
@@ -76,7 +80,9 @@ import {
76
80
  type ActionMetadata,
77
81
  type ActionModalProps,
78
82
  getActionComponent,
83
+ subscribeActionComponents,
79
84
  } from '@asteby/metacore-sdk'
85
+ import { requestFederatedAction } from './federated-action-loader'
80
86
 
81
87
  export type { ActionMetadata, ActionModalProps }
82
88
 
@@ -284,10 +290,10 @@ export function ActionModalDispatcher({
284
290
  endpoint,
285
291
  onSuccess,
286
292
  }: ActionModalProps) {
287
- const CustomComponent = useMemo(
288
- () => getActionComponent(model, action.key),
289
- [model, action.key],
290
- )
293
+ // Reactive read: a federated remote usually registers AFTER the first
294
+ // render, and a memoized lookup would pin the fallback forever.
295
+ const readComponent = () => getActionComponent(model, action.key)
296
+ const CustomComponent = useSyncExternalStore(subscribeActionComponents, readComponent, readComponent)
291
297
 
292
298
  if (CustomComponent) {
293
299
  return (
@@ -303,11 +309,12 @@ export function ActionModalDispatcher({
303
309
  )
304
310
  }
305
311
 
306
- // Declarative custom slot (`modal: "addon.action"`). Prefer a hard error
307
- // over confirm/fields generics — those look "fine" and hide a missing remote.
312
+ // Declarative custom slot (`modal: "addon.action"`). Wait for the remote,
313
+ // then a hard error — confirm/fields generics look "fine" and hide a
314
+ // missing remote.
308
315
  if (action.modal) {
309
316
  return (
310
- <MissingCustomActionModal
317
+ <PendingCustomActionModal
311
318
  open={open}
312
319
  onOpenChange={onOpenChange}
313
320
  action={action}
@@ -367,6 +374,91 @@ export function ActionModalDispatcher({
367
374
  return null
368
375
  }
369
376
 
377
+ /** How long an action with `modal` waits for its federated component. */
378
+ export const FEDERATED_ACTION_MODAL_TIMEOUT_MS = 20_000
379
+ /** How often the host loader is re-invoked while waiting. */
380
+ const FEDERATED_ACTION_RETRY_MS = 2_000
381
+ const MODAL_SLUG_RE = /^[\w-]+(\.[\w-]+)+$/
382
+
383
+ /**
384
+ * `modal` declared, component not registered yet: show a loading dialog, ask
385
+ * the host to load the remote (re-asking periodically), and give up with
386
+ * MissingCustomActionModal after the timeout. The dispatcher swaps this out
387
+ * for the real component as soon as the registry reports it. `modal` is
388
+ * honored wherever it came from — the addon's own manifest or a host-injected
389
+ * override from another addon that registerAction()s over it.
390
+ */
391
+ function PendingCustomActionModal({
392
+ open,
393
+ onOpenChange,
394
+ action,
395
+ model,
396
+ }: {
397
+ open: boolean
398
+ onOpenChange: (open: boolean) => void
399
+ action: ActionMetadata
400
+ model: string
401
+ }) {
402
+ const { t } = useTranslation()
403
+ const slug = action.modal || ''
404
+ const [timedOut, setTimedOut] = useState(false)
405
+
406
+ useEffect(() => {
407
+ if (!open) return
408
+ // A slug without a valid `<addon>.<name>` prefix still goes to the
409
+ // host loader (it decides which remote to load) and still times out —
410
+ // the component is looked up by (model, action.key), never by slug.
411
+ // Warn so a malformed manifest is diagnosable before the timeout.
412
+ const validSlug = MODAL_SLUG_RE.test(slug)
413
+ if (!validSlug) {
414
+ console.warn('[metacore] action modal slug has no valid "<addon>.<name>" prefix', {
415
+ model,
416
+ action: action.key,
417
+ modal: slug,
418
+ })
419
+ }
420
+ setTimedOut(false)
421
+ const req = { model, actionKey: action.key, modal: slug }
422
+ requestFederatedAction(req)
423
+ const retry = setInterval(() => requestFederatedAction(req), FEDERATED_ACTION_RETRY_MS)
424
+ const giveUp = setTimeout(() => {
425
+ clearInterval(retry)
426
+ console.error('[metacore] federated action modal did not register in time', {
427
+ model,
428
+ action: action.key,
429
+ modal: slug,
430
+ validSlug,
431
+ timeoutMs: FEDERATED_ACTION_MODAL_TIMEOUT_MS,
432
+ })
433
+ setTimedOut(true)
434
+ }, FEDERATED_ACTION_MODAL_TIMEOUT_MS)
435
+ return () => {
436
+ clearInterval(retry)
437
+ clearTimeout(giveUp)
438
+ }
439
+ }, [open, model, action.key, slug])
440
+
441
+ if (timedOut) {
442
+ return <MissingCustomActionModal open={open} onOpenChange={onOpenChange} action={action} model={model} />
443
+ }
444
+
445
+ return (
446
+ <Dialog open={open} onOpenChange={onOpenChange}>
447
+ <DialogContent>
448
+ <DialogHeader>
449
+ <DialogTitle>{t(action.label, { defaultValue: action.label })}</DialogTitle>
450
+ <DialogDescription>
451
+ {t('dynamic.action_modal_loading', { defaultValue: 'Cargando…' })}
452
+ </DialogDescription>
453
+ </DialogHeader>
454
+ <div className="flex justify-center py-6" data-testid="federated-action-loading">
455
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
456
+ </div>
457
+ </DialogContent>
458
+ </Dialog>
459
+ )
460
+ }
461
+
370
462
  /** Shown when the action declares `modal` but no federated component registered. */
371
463
  function MissingCustomActionModal({
372
464
  open,