@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.
- package/CHANGELOG.md +27 -0
- package/dist/action-modal-dispatcher.d.ts +2 -0
- package/dist/action-modal-dispatcher.d.ts.map +1 -1
- package/dist/action-modal-dispatcher.js +74 -8
- package/dist/dynamic-columns.d.ts +11 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +13 -1
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +4 -5
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +23 -16
- package/dist/federated-action-loader.d.ts +18 -0
- package/dist/federated-action-loader.d.ts.map +1 -0
- package/dist/federated-action-loader.js +34 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +19 -15
- package/dist/requires-addon.d.ts +37 -0
- package/dist/requires-addon.d.ts.map +1 -0
- package/dist/requires-addon.js +104 -0
- package/dist/types.d.ts +27 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
- package/src/__tests__/federated-action-modal.test.tsx +315 -0
- package/src/__tests__/requires-addon.test.tsx +201 -0
- package/src/action-modal-dispatcher.tsx +102 -10
- package/src/dynamic-columns.tsx +36 -6
- package/src/dynamic-kanban.tsx +6 -7
- package/src/dynamic-row-actions.tsx +26 -16
- package/src/federated-action-loader.ts +40 -0
- package/src/index.ts +13 -0
- package/src/model-action-toolbar.tsx +31 -15
- package/src/requires-addon.tsx +160 -0
- 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 →
|
|
4
|
-
//
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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"`).
|
|
307
|
-
//
|
|
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
|
-
<
|
|
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,
|