@asteby/metacore-runtime-react 42.0.0 → 44.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 +44 -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/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/dynamic-icon.d.ts.map +1 -1
- package/dist/dynamic-icon.js +9 -13
- package/dist/dynamic-kanban.d.ts +83 -6
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +647 -146
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +11 -15
- package/dist/dynamic-table.d.ts +1 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +14 -1
- 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 +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -2
- package/dist/license/license-gate.d.ts +0 -21
- package/dist/license/license-gate.d.ts.map +1 -1
- package/dist/license/license-gate.js +5 -2
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +3 -12
- package/dist/portal-drag-overlay.d.ts +5 -0
- package/dist/portal-drag-overlay.d.ts.map +1 -0
- package/dist/portal-drag-overlay.js +8 -0
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +6 -3
- package/dist/sidebar-layout-manager.js +3 -2
- package/dist/types.d.ts +4 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/use-flip-animation.d.ts +6 -0
- package/dist/use-flip-animation.d.ts.map +1 -1
- package/dist/use-flip-animation.js +11 -4
- package/package.json +6 -5
- package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
- package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
- package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
- package/src/__tests__/dynamic-kanban.test.tsx +15 -2
- package/src/__tests__/federated-action-modal.test.tsx +315 -0
- package/src/__tests__/image-cell-lucide.test.tsx +7 -5
- package/src/action-modal-dispatcher.tsx +102 -10
- package/src/api-context.tsx +7 -5
- package/src/dynamic-icon.tsx +12 -16
- package/src/dynamic-kanban.tsx +950 -262
- package/src/dynamic-row-actions.tsx +11 -15
- package/src/dynamic-table.tsx +16 -2
- package/src/federated-action-loader.ts +40 -0
- package/src/index.ts +8 -0
- package/src/license/license-gate.tsx +6 -2
- package/src/model-action-toolbar.tsx +3 -12
- package/src/portal-drag-overlay.tsx +20 -0
- package/src/query-batch.ts +6 -3
- package/src/sidebar-layout-manager.tsx +3 -3
- package/src/types.ts +4 -0
- package/src/use-flip-animation.ts +16 -3
|
@@ -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
|
+
})
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// kebab, as stored by the `icon` form widget) renders the glyph instead of a
|
|
5
5
|
// broken <img>; real paths/urls keep rendering an <img>.
|
|
6
6
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
7
|
-
import { cleanup, render } from '@testing-library/react'
|
|
7
|
+
import { cleanup, render, waitFor } from '@testing-library/react'
|
|
8
8
|
import { ImageCell } from '../dynamic-columns'
|
|
9
9
|
import { isLucideIconName, resolveLucideIconName } from '../dynamic-icon'
|
|
10
10
|
|
|
@@ -13,15 +13,17 @@ afterEach(cleanup)
|
|
|
13
13
|
const getImageUrl = (p: string) => `/img${p}`
|
|
14
14
|
|
|
15
15
|
describe('ImageCell con nombre lucide', () => {
|
|
16
|
-
it('renderiza el ícono (svg) para un nombre PascalCase, sin <img>', () => {
|
|
16
|
+
it('renderiza el ícono (svg) para un nombre PascalCase, sin <img>', async () => {
|
|
17
17
|
const { container } = render(<ImageCell value="CreditCard" getImageUrl={getImageUrl} />)
|
|
18
|
-
|
|
18
|
+
// The glyph loads once; its box is held meanwhile.
|
|
19
|
+
await waitFor(() => expect(container.querySelector('svg')).toBeTruthy())
|
|
19
20
|
expect(container.querySelector('img')).toBeNull()
|
|
20
21
|
})
|
|
21
22
|
|
|
22
|
-
it('renderiza el ícono también para el slug kebab "credit-card"', () => {
|
|
23
|
+
it('renderiza el ícono también para el slug kebab "credit-card"', async () => {
|
|
23
24
|
const { container } = render(<ImageCell value="credit-card" getImageUrl={getImageUrl} />)
|
|
24
|
-
|
|
25
|
+
// The glyph loads once; its box is held meanwhile.
|
|
26
|
+
await waitFor(() => expect(container.querySelector('svg')).toBeTruthy())
|
|
25
27
|
expect(container.querySelector('img')).toBeNull()
|
|
26
28
|
})
|
|
27
29
|
|
|
@@ -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,
|
package/src/api-context.tsx
CHANGED
|
@@ -90,11 +90,13 @@ export function useApi(): ApiClient {
|
|
|
90
90
|
// remembered, so the refresh after a save reads the saved row. The
|
|
91
91
|
// wrapper is stable while the host client is.
|
|
92
92
|
return useMemo<ApiClient>(() => ({
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
// Each call reaches the host client with the arguments it was given —
|
|
94
|
+
// never a trailing `undefined` it didn't ask for.
|
|
95
|
+
get: (...args: Parameters<ApiClient['get']>) => batchGet(ctx, ...args),
|
|
96
|
+
post: (...args: Parameters<ApiClient['post']>) =>
|
|
97
|
+
isWrite('post', args[0]) ? mutating(ctx.post(...args)) : ctx.post(...args),
|
|
98
|
+
put: (...args: Parameters<ApiClient['put']>) => mutating(ctx.put(...args)),
|
|
99
|
+
delete: (...args: Parameters<ApiClient['delete']>) => mutating(ctx.delete(...args)),
|
|
98
100
|
}), [ctx])
|
|
99
101
|
}
|
|
100
102
|
|
package/src/dynamic-icon.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// DynamicIcon resolves a lucide glyph by name without pulling the full icon
|
|
2
|
-
// set into the shared chunk.
|
|
3
|
-
|
|
2
|
+
// set into the shared chunk. It draws through the shared glyph registry of
|
|
3
|
+
// @asteby/metacore-ui: a glyph seen before renders in the first frame, and an
|
|
4
|
+
// unseen one holds its box until it loads, so the label next to it stays put.
|
|
4
5
|
import dynamicIconImports from 'lucide-react/dynamicIconImports'
|
|
6
|
+
import { Glyph } from '@asteby/metacore-ui/icons'
|
|
5
7
|
|
|
6
8
|
export interface DynamicIconProps {
|
|
7
9
|
name: string
|
|
@@ -25,24 +27,18 @@ function kebabToPascal(kebab: string): string {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
function loaderFor(pascal: string) {
|
|
28
|
-
const kebab = pascalToKebab(pascal)
|
|
29
|
-
|
|
30
|
+
const kebab = pascalToKebab(pascal)
|
|
31
|
+
// Lucide keys split digits ("building-2"); "building2" is not one.
|
|
32
|
+
return (
|
|
33
|
+
dynamicIconImports[kebab as IconName] ??
|
|
34
|
+
dynamicIconImports[kebab.replace(/([a-z])(\d)/g, '$1-$2') as IconName]
|
|
35
|
+
)
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export function DynamicIcon({ name, className }: DynamicIconProps) {
|
|
33
39
|
const resolved = resolveLucideIconName(name)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const loader = loaderFor(resolved)
|
|
37
|
-
if (!loader) return null
|
|
38
|
-
return lazy(loader as () => Promise<{ default: ComponentType<{ className?: string }> }>)
|
|
39
|
-
}, [resolved])
|
|
40
|
-
if (!Icon) return null
|
|
41
|
-
return (
|
|
42
|
-
<Suspense fallback={null}>
|
|
43
|
-
<Icon className={className} />
|
|
44
|
-
</Suspense>
|
|
45
|
-
)
|
|
40
|
+
if (!resolved) return null
|
|
41
|
+
return <Glyph name={resolved} className={className} />
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
// resolveLucideIconName — canonical PascalCase lucide name for a value that is
|