@asteby/metacore-runtime-react 42.0.0 → 43.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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/api-context.d.ts.map +1 -1
  3. package/dist/api-context.js +6 -4
  4. package/dist/dynamic-icon.d.ts.map +1 -1
  5. package/dist/dynamic-icon.js +9 -13
  6. package/dist/dynamic-kanban.d.ts +83 -6
  7. package/dist/dynamic-kanban.d.ts.map +1 -1
  8. package/dist/dynamic-kanban.js +647 -146
  9. package/dist/dynamic-table.d.ts +1 -1
  10. package/dist/dynamic-table.d.ts.map +1 -1
  11. package/dist/dynamic-table.js +14 -1
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/license/license-gate.d.ts +0 -21
  16. package/dist/license/license-gate.d.ts.map +1 -1
  17. package/dist/license/license-gate.js +5 -2
  18. package/dist/portal-drag-overlay.d.ts +5 -0
  19. package/dist/portal-drag-overlay.d.ts.map +1 -0
  20. package/dist/portal-drag-overlay.js +8 -0
  21. package/dist/query-batch.d.ts.map +1 -1
  22. package/dist/query-batch.js +6 -3
  23. package/dist/sidebar-layout-manager.js +3 -2
  24. package/dist/use-flip-animation.d.ts +6 -0
  25. package/dist/use-flip-animation.d.ts.map +1 -1
  26. package/dist/use-flip-animation.js +11 -4
  27. package/package.json +4 -3
  28. package/src/__tests__/dynamic-icon-slot.test.tsx +50 -0
  29. package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +2 -1
  30. package/src/__tests__/dynamic-kanban-pipelines-pro.test.tsx +388 -0
  31. package/src/__tests__/dynamic-kanban.test.tsx +15 -2
  32. package/src/__tests__/image-cell-lucide.test.tsx +7 -5
  33. package/src/api-context.tsx +7 -5
  34. package/src/dynamic-icon.tsx +12 -16
  35. package/src/dynamic-kanban.tsx +950 -262
  36. package/src/dynamic-table.tsx +16 -2
  37. package/src/index.ts +6 -0
  38. package/src/license/license-gate.tsx +6 -2
  39. package/src/portal-drag-overlay.tsx +20 -0
  40. package/src/query-batch.ts +6 -3
  41. package/src/sidebar-layout-manager.tsx +3 -3
  42. package/src/use-flip-animation.ts +16 -3
@@ -0,0 +1,388 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicKanban "pipelines pro": the pure helpers behind the optimistic move,
4
+ // the lane totals, the card fields and the keyboard drag, plus the board
5
+ // behaviour they add up to — lanes a card can't enter are marked while it is
6
+ // lifted and refuse the drop (no request, no toast), a rejected move returns
7
+ // the card to its lane, lane headers show the declared totals in the org
8
+ // currency, and empty card fields are left out. dnd-kit is mocked down to a
9
+ // passthrough that captures the board's drag callbacks (happy-dom can't
10
+ // simulate a pointer drag).
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+ import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
13
+
14
+ const I18N_T = (_k: string, opts?: Record<string, any>) => {
15
+ let s: string = opts?.defaultValue ?? _k
16
+ for (const [k, v] of Object.entries(opts ?? {})) s = s.replace(`{{${k}}}`, String(v))
17
+ return s
18
+ }
19
+ const I18N = { language: 'es-MX' }
20
+ const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
21
+ vi.mock('react-i18next', () => ({
22
+ useTranslation: () => USE_TRANSLATION,
23
+ }))
24
+
25
+ vi.mock('@tanstack/react-router', () => ({
26
+ useNavigate: () => () => {},
27
+ }))
28
+
29
+ const toastError = vi.fn()
30
+ vi.mock('sonner', () => ({
31
+ toast: Object.assign(vi.fn(), { error: (...a: any[]) => toastError(...a), success: vi.fn() }),
32
+ }))
33
+
34
+ let captured: {
35
+ onDragEnd?: (e: any) => void | Promise<void>
36
+ onDragStart?: (e: any) => void
37
+ onDragOver?: (e: any) => void
38
+ } = {}
39
+ vi.mock('@dnd-kit/core', async (orig) => ({
40
+ ...((await orig()) as Record<string, unknown>),
41
+ DndContext: ({ children, onDragEnd, onDragStart, onDragOver }: any) => {
42
+ captured.onDragEnd = onDragEnd
43
+ captured.onDragStart = onDragStart
44
+ captured.onDragOver = onDragOver
45
+ return children
46
+ },
47
+ DragOverlay: ({ children }: any) => <div data-testid="drag-overlay">{children}</div>,
48
+ useDraggable: () => ({
49
+ attributes: {},
50
+ listeners: {},
51
+ setNodeRef: () => {},
52
+ isDragging: false,
53
+ }),
54
+ useDroppable: () => ({ setNodeRef: () => {}, isOver: false }),
55
+ }))
56
+ vi.mock('@dnd-kit/sortable', async (orig) => ({
57
+ ...((await orig()) as Record<string, unknown>),
58
+ SortableContext: ({ children }: any) => children,
59
+ useSortable: () => ({
60
+ setNodeRef: () => {},
61
+ setActivatorNodeRef: () => {},
62
+ attributes: {},
63
+ listeners: {},
64
+ transform: null,
65
+ transition: undefined,
66
+ isDragging: false,
67
+ isOver: false,
68
+ }),
69
+ }))
70
+
71
+ import {
72
+ DynamicKanban,
73
+ applyLaneAggregatesOnMove,
74
+ applyStageOverrides,
75
+ cardMatchesLaneQuery,
76
+ isEmptyCardValue,
77
+ laneDeltasFromOverrides,
78
+ nextLaneCoordinates,
79
+ visibleCardFields,
80
+ } from '../dynamic-kanban'
81
+ import { PortalDragOverlay } from '../portal-drag-overlay'
82
+ import { ApiProvider, type ApiClient } from '../api-context'
83
+ import { OrgRuntimeProvider } from '../org-runtime-provider'
84
+ import { useMetadataCache } from '../metadata-cache'
85
+ import type { ColumnDefinition, TableMetadata } from '../types'
86
+
87
+ const col = (key: string, extra: Partial<ColumnDefinition> = {}): ColumnDefinition =>
88
+ ({ key, label: key, type: 'text', sortable: false, filterable: false, ...extra }) as ColumnDefinition
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // pure helpers
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe('card fields', () => {
95
+ it('treats unset, blank, empty lists, false and bare uuids as empty', () => {
96
+ const c = col('channel_id')
97
+ expect(isEmptyCardValue({ channel_id: null }, c)).toBe(true)
98
+ expect(isEmptyCardValue({ channel_id: ' ' }, c)).toBe(true)
99
+ expect(isEmptyCardValue({ channel_id: [] }, c)).toBe(true)
100
+ expect(isEmptyCardValue({ channel_id: false }, c)).toBe(true)
101
+ expect(isEmptyCardValue({ channel_id: '5f0c6a1e-9a55-4c1e-9d0b-2a7f3e1c9b10' }, c)).toBe(true)
102
+ expect(isEmptyCardValue({ channel_id: 'WhatsApp' }, c)).toBe(false)
103
+ expect(isEmptyCardValue({ channel_id: 0 }, c)).toBe(false)
104
+ })
105
+
106
+ it('shows the first fields that carry a value, skipping empty ones', () => {
107
+ const cols = [col('canal'), col('cliente'), col('asignado'), col('prioridad'), col('origen')]
108
+ const card = { canal: null, cliente: 'ACME', asignado: '', prioridad: 'alta', origen: 'web' }
109
+ expect(visibleCardFields(card, cols, 3).map((c) => c.key)).toEqual([
110
+ 'cliente',
111
+ 'prioridad',
112
+ 'origen',
113
+ ])
114
+ expect(visibleCardFields({}, cols, 3)).toEqual([])
115
+ })
116
+ })
117
+
118
+ describe('optimistic overrides', () => {
119
+ const records = [
120
+ { id: 1, stage: 'backlog', amount: 100 },
121
+ { id: 2, stage: 'review', amount: '50.5' },
122
+ ]
123
+
124
+ it('paints an unconfirmed move in its destination lane without touching the records', () => {
125
+ const out = applyStageOverrides(records, { '1': 'review' }, 'stage')
126
+ expect(out[0].stage).toBe('review')
127
+ expect(records[0].stage).toBe('backlog')
128
+ expect(applyStageOverrides(records, {}, 'stage')).toBe(records)
129
+ })
130
+
131
+ it('shifts lane counts and sums by the moves in flight', () => {
132
+ const d = laneDeltasFromOverrides(records, { '1': 'review', '2': 'review' }, 'stage', ['amount'])
133
+ expect(d.backlog).toEqual({ count: -1, sums: { amount: -100 } })
134
+ expect(d.review).toEqual({ count: 1, sums: { amount: 100 } })
135
+ })
136
+
137
+ it('moves a confirmed card\'s values between lane totals', () => {
138
+ const next = applyLaneAggregatesOnMove(
139
+ { backlog: { amount: 300 }, review: { amount: 50 } },
140
+ records[0],
141
+ 'backlog',
142
+ 'review',
143
+ ['amount'],
144
+ )
145
+ expect(next).toEqual({ backlog: { amount: 200 }, review: { amount: 150 } })
146
+ })
147
+ })
148
+
149
+ describe('keyboard drag', () => {
150
+ const lanes = [
151
+ { id: 'a', left: 0, top: 100, width: 300 },
152
+ { id: 'b', left: 320, top: 100, width: 300 },
153
+ { id: 'c', left: 640, top: 100, width: 300 },
154
+ ]
155
+ it('jumps to the neighbouring lane on ← / →', () => {
156
+ const card = { left: 10, top: 180, width: 280 }
157
+ expect(nextLaneCoordinates('right', card, lanes)).toEqual({ x: 330, y: 180 })
158
+ expect(nextLaneCoordinates('left', card, lanes)).toBeUndefined()
159
+ const inB = { left: 330, top: 120, width: 280 }
160
+ expect(nextLaneCoordinates('left', inB, lanes)).toEqual({ x: 10, y: 156 })
161
+ })
162
+ })
163
+
164
+ describe('lane search', () => {
165
+ it('matches a relation by the name the card shows, not its uuid', () => {
166
+ const cols = [col('contact', { type: 'relation' } as any)]
167
+ const card = { contact: { id: 'u-1', name: 'Danny Pérez' } }
168
+ expect(cardMatchesLaneQuery(card, cols, 'danny')).toBe(true)
169
+ expect(cardMatchesLaneQuery(card, cols, 'u-1')).toBe(false)
170
+ })
171
+ })
172
+
173
+ describe('PortalDragOverlay', () => {
174
+ afterEach(cleanup)
175
+ it('renders into <body>, outside a transformed / glass ancestor', () => {
176
+ const { container } = render(
177
+ <div style={{ backdropFilter: 'blur(8px)', transform: 'translateX(10px)' }}>
178
+ <PortalDragOverlay>
179
+ <span>lifted</span>
180
+ </PortalDragOverlay>
181
+ </div>,
182
+ )
183
+ const overlay = screen.getByTestId('drag-overlay')
184
+ expect(container.contains(overlay)).toBe(false)
185
+ expect(overlay.parentElement).toBe(document.body)
186
+ })
187
+ })
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // board behaviour
191
+ // ---------------------------------------------------------------------------
192
+
193
+ const STAGES = [
194
+ { key: 'nuevo', label: 'Nuevo', color: 'blue', order: 0 },
195
+ { key: 'abierto', label: 'Abierto', color: 'violet', order: 1 },
196
+ { key: 'esperando', label: 'Esperando', color: 'amber', order: 2 },
197
+ { key: 'resuelto', label: 'Resuelto', color: 'green', order: 3 },
198
+ ]
199
+
200
+ function meta(): TableMetadata {
201
+ return {
202
+ title: 'Bandeja',
203
+ endpoint: '/data/conversations',
204
+ view_type: 'kanban',
205
+ group_by: 'status',
206
+ stages: STAGES,
207
+ transitions: [
208
+ { from: 'nuevo', to: 'abierto' },
209
+ { from: 'abierto', to: 'esperando' },
210
+ { from: 'esperando', to: 'abierto' },
211
+ { from: 'abierto', to: 'resuelto' },
212
+ ],
213
+ columns: [
214
+ { key: 'title', label: 'Contacto', type: 'text', sortable: true, filterable: false, searchable: true },
215
+ { key: 'channel', label: 'Canal', type: 'text', sortable: false, filterable: false },
216
+ {
217
+ key: 'amount',
218
+ label: 'Monto',
219
+ type: 'number',
220
+ cellStyle: 'currency',
221
+ styleConfig: { aggregate: 'sum' },
222
+ sortable: false,
223
+ filterable: false,
224
+ },
225
+ {
226
+ key: 'status',
227
+ label: 'Estado',
228
+ type: 'status',
229
+ sortable: false,
230
+ filterable: true,
231
+ options: STAGES.map((s) => ({ value: s.key, label: s.label, color: s.color })),
232
+ },
233
+ ],
234
+ actions: [],
235
+ perPageOptions: [50],
236
+ defaultPerPage: 50,
237
+ searchPlaceholder: 'Buscar...',
238
+ enableCRUDActions: true,
239
+ hasActions: false,
240
+ } as TableMetadata
241
+ }
242
+
243
+ const CARDS = [
244
+ { id: 1, title: 'Danny', channel: null, amount: 100, status: 'abierto' },
245
+ { id: 2, title: 'Ana', channel: 'WhatsApp', amount: 40, status: 'esperando' },
246
+ ]
247
+
248
+ function fakeApi(over: Partial<ApiClient> = {}): ApiClient {
249
+ const ok = (data: unknown, meta?: unknown) => ({ data: { success: true, data, meta } })
250
+ // A tiny server: a PUT persists, so the lane figures re-read after a move
251
+ // reflect it.
252
+ const db = CARDS.map((c) => ({ ...c }))
253
+ return {
254
+ get: vi.fn(async (url: string, config?: any) => {
255
+ if (url.startsWith('/metadata/table/')) return ok(meta())
256
+ if (url.startsWith('/stage-layout')) return ok({ model: 'conversations', stage_order: null })
257
+ if (url.startsWith('/custom-stages') || url.startsWith('/stage-')) return { data: { success: false } }
258
+ const stage = config?.params?.f_status
259
+ if (url.endsWith('/aggregate')) {
260
+ const amount = db.filter((c) => c.status === stage).reduce((a, c) => a + c.amount, 0)
261
+ return ok({ amount })
262
+ }
263
+ const rows = stage ? db.filter((c) => c.status === stage) : db
264
+ return ok(rows, { total: rows.length })
265
+ }),
266
+ post: vi.fn(async () => ok(null)),
267
+ put: vi.fn(async (url: string, body: any) => {
268
+ const row = db.find((c) => url.endsWith(`/${c.id}`))
269
+ if (row) Object.assign(row, body)
270
+ return ok(row ?? null)
271
+ }),
272
+ delete: vi.fn(async () => ok(null)),
273
+ ...over,
274
+ }
275
+ }
276
+
277
+ const laneOf = (text: string) =>
278
+ screen.getByText(text).closest('[data-stage]')?.getAttribute('data-stage')
279
+
280
+ function renderBoard(api: ApiClient) {
281
+ useMetadataCache.getState().setMetadata('conversations', meta())
282
+ return render(
283
+ <OrgRuntimeProvider currency="MXN">
284
+ <ApiProvider client={api}>
285
+ <DynamicKanban model="conversations" />
286
+ </ApiProvider>
287
+ </OrgRuntimeProvider>,
288
+ )
289
+ }
290
+
291
+ beforeEach(() => {
292
+ captured = {}
293
+ toastError.mockReset()
294
+ })
295
+ afterEach(cleanup)
296
+
297
+ describe('DynamicKanban pipelines', () => {
298
+ it('marks the lanes a lifted card cannot enter and refuses a drop there', async () => {
299
+ const put = vi.fn(async () => ({ data: { success: true, data: null } }))
300
+ renderBoard(fakeApi({ put }))
301
+ await screen.findByText('Danny')
302
+
303
+ act(() => {
304
+ captured.onDragStart!({ active: { id: '1', data: { current: { type: 'card' } } } })
305
+ })
306
+ const drop = (key: string) =>
307
+ document.querySelector(`[data-stage="${key}"]`)?.getAttribute('data-drop')
308
+ // abierto → nuevo is not declared; abierto → esperando/resuelto are.
309
+ expect(drop('nuevo')).toBe('blocked')
310
+ expect(drop('esperando')).toBeNull()
311
+ expect(drop('resuelto')).toBeNull()
312
+ expect(drop('abierto')).toBeNull()
313
+
314
+ act(() => {
315
+ captured.onDragOver!({ active: { id: '1' }, over: { id: 'nuevo' } })
316
+ })
317
+ expect(screen.getByTestId('lane-blocked-nuevo')).toBeTruthy()
318
+
319
+ await act(async () => {
320
+ await captured.onDragEnd!({
321
+ active: { id: '1', data: { current: { type: 'card' } } },
322
+ over: { id: 'nuevo' },
323
+ })
324
+ })
325
+ expect(put).not.toHaveBeenCalled()
326
+ expect(toastError).not.toHaveBeenCalled()
327
+ expect(laneOf('Danny')).toBe('abierto')
328
+ expect(drop('nuevo')).toBeNull()
329
+ })
330
+
331
+ it('moves a card at once and sends it back when the server refuses', async () => {
332
+ let reject!: (e: unknown) => void
333
+ const put = vi.fn(
334
+ () =>
335
+ new Promise((_res, rej) => {
336
+ reject = rej
337
+ }),
338
+ )
339
+ renderBoard(fakeApi({ put: put as any }))
340
+ await screen.findByText('Danny')
341
+
342
+ await act(async () => {
343
+ await captured.onDragEnd!({
344
+ active: { id: '1', data: { current: { type: 'card' } } },
345
+ over: { id: 'resuelto' },
346
+ })
347
+ })
348
+ expect(put.mock.calls[0]).toEqual(['/data/conversations/1', { status: 'resuelto' }])
349
+ // Optimistic: already in the destination while the request is in flight.
350
+ await waitFor(() => expect(laneOf('Danny')).toBe('resuelto'))
351
+
352
+ await act(async () => {
353
+ reject({ response: { data: { message: 'bloqueado por una regla' } } })
354
+ })
355
+ await waitFor(() => expect(laneOf('Danny')).toBe('abierto'))
356
+ expect(toastError).toHaveBeenCalledWith(
357
+ 'No se pudo mover la tarjeta: bloqueado por una regla',
358
+ )
359
+ })
360
+
361
+ it('shows each lane\'s declared total in the org currency and follows a move', async () => {
362
+ renderBoard(fakeApi())
363
+ await screen.findByText('Danny')
364
+ const total = (key: string) =>
365
+ screen.queryByTestId(`lane-total-${key}-amount`)?.textContent?.replace(/\s/g, ' ')
366
+ await waitFor(() => expect(total('abierto')).toMatch(/100\.00/))
367
+ expect(total('abierto')).toMatch(/\$/)
368
+ expect(total('esperando')).toMatch(/40\.00/)
369
+
370
+ await act(async () => {
371
+ await captured.onDragEnd!({
372
+ active: { id: '1', data: { current: { type: 'card' } } },
373
+ over: { id: 'esperando' },
374
+ })
375
+ })
376
+ await waitFor(() => expect(total('esperando')).toMatch(/140\.00/))
377
+ expect(total('abierto')).toMatch(/0\.00/)
378
+ })
379
+
380
+ it('leaves out a card field with no value instead of a "Canal: —" row', async () => {
381
+ renderBoard(fakeApi())
382
+ const danny = (await screen.findByText('Danny')).closest('[data-card-id]') as HTMLElement
383
+ const ana = screen.getByText('Ana').closest('[data-card-id]') as HTMLElement
384
+ expect(danny.textContent).not.toContain('Canal')
385
+ expect(ana.textContent).toContain('Canal')
386
+ expect(ana.textContent).toContain('WhatsApp')
387
+ })
388
+ })
@@ -171,9 +171,22 @@ describe('isTransitionAllowed', () => {
171
171
  expect(isTransitionAllowed(TRANSITIONS, 'done', 'done')).toBe(true)
172
172
  })
173
173
 
174
- it('is unrestricted when no transitions are declared', () => {
174
+ it('is unrestricted when the metadata carries no transitions', () => {
175
175
  expect(isTransitionAllowed(undefined, 'done', 'backlog')).toBe(true)
176
- expect(isTransitionAllowed([], 'done', 'backlog')).toBe(true)
176
+ })
177
+
178
+ it('an empty list allows no moves between stages (kernel rule)', () => {
179
+ expect(isTransitionAllowed([], 'done', 'backlog')).toBe(false)
180
+ expect(isTransitionAllowed([], 'done', 'done')).toBe(true)
181
+ })
182
+
183
+ it('places a card that has no stage, or one outside the machine, in any stage', () => {
184
+ const keys = new Set(['backlog', 'in_progress', 'review', 'done'])
185
+ expect(isTransitionAllowed(TRANSITIONS, '', 'done', keys)).toBe(true)
186
+ expect(isTransitionAllowed(TRANSITIONS, 'legacy_open', 'done', keys)).toBe(true)
187
+ expect(isTransitionAllowed([], '', 'backlog', keys)).toBe(true)
188
+ // …but only into a stage of the machine.
189
+ expect(isTransitionAllowed(TRANSITIONS, '', 'nowhere', keys)).toBe(false)
177
190
  })
178
191
 
179
192
  it('honors wildcards on either side', () => {
@@ -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
 
@@ -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