@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +44 -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/api-context.d.ts.map +1 -1
  6. package/dist/api-context.js +6 -4
  7. package/dist/dynamic-icon.d.ts.map +1 -1
  8. package/dist/dynamic-icon.js +9 -13
  9. package/dist/dynamic-kanban.d.ts +83 -6
  10. package/dist/dynamic-kanban.d.ts.map +1 -1
  11. package/dist/dynamic-kanban.js +647 -146
  12. package/dist/dynamic-row-actions.d.ts.map +1 -1
  13. package/dist/dynamic-row-actions.js +11 -15
  14. package/dist/dynamic-table.d.ts +1 -1
  15. package/dist/dynamic-table.d.ts.map +1 -1
  16. package/dist/dynamic-table.js +14 -1
  17. package/dist/federated-action-loader.d.ts +18 -0
  18. package/dist/federated-action-loader.d.ts.map +1 -0
  19. package/dist/federated-action-loader.js +34 -0
  20. package/dist/index.d.ts +4 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +4 -2
  23. package/dist/license/license-gate.d.ts +0 -21
  24. package/dist/license/license-gate.d.ts.map +1 -1
  25. package/dist/license/license-gate.js +5 -2
  26. package/dist/model-action-toolbar.d.ts.map +1 -1
  27. package/dist/model-action-toolbar.js +3 -12
  28. package/dist/portal-drag-overlay.d.ts +5 -0
  29. package/dist/portal-drag-overlay.d.ts.map +1 -0
  30. package/dist/portal-drag-overlay.js +8 -0
  31. package/dist/query-batch.d.ts.map +1 -1
  32. package/dist/query-batch.js +6 -3
  33. package/dist/sidebar-layout-manager.js +3 -2
  34. package/dist/types.d.ts +4 -0
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/use-flip-animation.d.ts +6 -0
  37. package/dist/use-flip-animation.d.ts.map +1 -1
  38. package/dist/use-flip-animation.js +11 -4
  39. package/package.json +6 -5
  40. package/src/__tests__/action-modal-missing-custom.test.tsx +12 -4
  41. package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
  42. package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
  43. package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
  44. package/src/__tests__/dynamic-kanban.test.tsx +15 -2
  45. package/src/__tests__/federated-action-modal.test.tsx +315 -0
  46. package/src/__tests__/image-cell-lucide.test.tsx +7 -5
  47. package/src/action-modal-dispatcher.tsx +102 -10
  48. package/src/api-context.tsx +7 -5
  49. package/src/dynamic-icon.tsx +12 -16
  50. package/src/dynamic-kanban.tsx +950 -262
  51. package/src/dynamic-row-actions.tsx +11 -15
  52. package/src/dynamic-table.tsx +16 -2
  53. package/src/federated-action-loader.ts +40 -0
  54. package/src/index.ts +8 -0
  55. package/src/license/license-gate.tsx +6 -2
  56. package/src/model-action-toolbar.tsx +3 -12
  57. package/src/portal-drag-overlay.tsx +20 -0
  58. package/src/query-batch.ts +6 -3
  59. package/src/sidebar-layout-manager.tsx +3 -3
  60. package/src/types.ts +4 -0
  61. 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
- expect(container.querySelector('svg')).toBeTruthy()
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
- expect(container.querySelector('svg')).toBeTruthy()
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 → 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,
@@ -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
- get: (url, config) => batchGet(ctx, url, config),
94
- post: (url, body, config) =>
95
- isWrite('post', url) ? mutating(ctx.post(url, body, config)) : ctx.post(url, body, config),
96
- put: (url, body, config) => mutating(ctx.put(url, body, config)),
97
- delete: (url, config) => mutating(ctx.delete(url, config)),
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
 
@@ -1,7 +1,9 @@
1
1
  // DynamicIcon resolves a lucide glyph by name without pulling the full icon
2
- // set into the shared chunk. Each glyph is its own dynamic import.
3
- import { lazy, Suspense, useMemo, type ComponentType } from 'react'
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) as IconName
29
- return dynamicIconImports[kebab]
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
- const Icon = useMemo(() => {
35
- if (!resolved) return null
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