@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "42.0.0",
3
+ "version": "44.0.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "zod": "^4.3.0"
27
27
  },
28
28
  "peerDependencies": {
29
- "@asteby/metacore-sdk": "^3.9.0",
30
- "@asteby/metacore-ui": "^2.22.0",
29
+ "@asteby/metacore-sdk": "^3.10.0",
30
+ "@asteby/metacore-ui": "^2.23.0",
31
31
  "@tanstack/react-query": ">=5",
32
32
  "@tanstack/react-router": ">=1.100",
33
33
  "@tanstack/react-table": ">=8",
@@ -50,13 +50,14 @@
50
50
  }
51
51
  },
52
52
  "devDependencies": {
53
- "@asteby/metacore-sdk": "3.9.0",
54
- "@asteby/metacore-ui": "2.22.0",
53
+ "@asteby/metacore-sdk": "3.10.0",
54
+ "@asteby/metacore-ui": "2.23.0",
55
55
  "@tanstack/react-query": "^5.95.0",
56
56
  "@tanstack/react-router": "^1.168.0",
57
57
  "@tanstack/react-table": "^8.20.0",
58
58
  "@testing-library/react": "^16.3.2",
59
59
  "@types/react": "^19.0.0",
60
+ "@types/react-dom": "^19.0.0",
60
61
  "date-fns": "^4.1.0",
61
62
  "happy-dom": "^20.10.2",
62
63
  "i18next": "^26.0.0",
@@ -1,10 +1,11 @@
1
1
  // @vitest-environment happy-dom
2
2
  //
3
3
  // When an action declares `modal: "<slug>"` and no federated component is
4
- // registered, ActionModalDispatcher must surface an error — never the generic
5
- // ConfirmActionDialog (even if confirm/confirmMessage are also set).
4
+ // registered, ActionModalDispatcher must surface an error once the wait times
5
+ // out — never the generic ConfirmActionDialog (even if confirm/confirmMessage
6
+ // are also set).
6
7
  import { afterEach, describe, expect, it, vi } from 'vitest'
7
- import { cleanup, render, screen } from '@testing-library/react'
8
+ import { act, cleanup, render, screen } from '@testing-library/react'
8
9
 
9
10
  vi.mock('react-i18next', () => ({
10
11
  useTranslation: () => ({
@@ -12,7 +13,7 @@ vi.mock('react-i18next', () => ({
12
13
  }),
13
14
  }))
14
15
 
15
- import { ActionModalDispatcher } from '../action-modal-dispatcher'
16
+ import { ActionModalDispatcher, FEDERATED_ACTION_MODAL_TIMEOUT_MS } from '../action-modal-dispatcher'
16
17
  import { ApiProvider, type ApiClient } from '../api-context'
17
18
  import {
18
19
  registerActionComponent,
@@ -22,6 +23,7 @@ import {
22
23
 
23
24
  afterEach(() => {
24
25
  cleanup()
26
+ vi.useRealTimers()
25
27
  unregisterActionComponent('SalesOrder', 'authorize_credit')
26
28
  })
27
29
 
@@ -58,7 +60,13 @@ function renderDispatcher(action: ActionMetadata = creditAction) {
58
60
 
59
61
  describe('ActionModalDispatcher custom modal contract', () => {
60
62
  it('errors when modal is declared but no component is registered (no generic confirm)', () => {
63
+ vi.useFakeTimers()
64
+ const err = vi.spyOn(console, 'error').mockImplementation(() => {})
61
65
  renderDispatcher()
66
+ act(() => {
67
+ vi.advanceTimersByTime(FEDERATED_ACTION_MODAL_TIMEOUT_MS)
68
+ })
69
+ err.mockRestore()
62
70
  expect(screen.getByText('No se pudo cargar el formulario')).toBeTruthy()
63
71
  expect(screen.queryByText('customers.action.authorize_credit_confirm_message')).toBeNull()
64
72
  expect(screen.queryByRole('button', { name: /autorizar a crédito/i })).toBeNull()
@@ -0,0 +1,50 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicIcon in a table action ("icon + label" menu item or button): the
4
+ // label must not move when the glyph arrives. Before, the first render drew
5
+ // nothing (Suspense fallback null) and every remount — each time a row menu
6
+ // opened — suspended again on a fresh lazy(), so the icon popped in and
7
+ // pushed the text on every open.
8
+ import { afterEach, describe, expect, it } from 'vitest'
9
+ import { cleanup, render, waitFor } from '@testing-library/react'
10
+ import { DynamicIcon } from '../dynamic-icon'
11
+
12
+ afterEach(cleanup)
13
+
14
+ function ActionItem({ icon }: { icon: string }) {
15
+ return (
16
+ <div role="menuitem">
17
+ <DynamicIcon name={icon} className="mr-2 h-4 w-4" />
18
+ Facturar
19
+ </div>
20
+ )
21
+ }
22
+
23
+ describe('DynamicIcon reserva su lugar', () => {
24
+ it('el primer render ya ocupa el tamaño del ícono', () => {
25
+ const { container } = render(<ActionItem icon="ReceiptText" />)
26
+ const slot = container.querySelector('[role="menuitem"]')!.firstElementChild as HTMLElement
27
+ expect(slot).not.toBeNull()
28
+ expect(slot.getAttribute('class')).toContain('mr-2 h-4 w-4')
29
+ })
30
+
31
+ it('al volver a abrir el menú el ícono sale en el primer render', async () => {
32
+ const first = render(<ActionItem icon="FileCheck" />)
33
+ await waitFor(() => expect(first.container.querySelector('svg')).toBeTruthy())
34
+ first.unmount()
35
+
36
+ const again = render(<ActionItem icon="FileCheck" />)
37
+ expect(again.container.querySelector('svg')).toBeTruthy()
38
+ expect(again.container.querySelector('[data-glyph-pending]')).toBeNull()
39
+ })
40
+
41
+ it('resuelve nombres con dígitos como Building2', async () => {
42
+ const { container } = render(<DynamicIcon name="Building2" className="h-4 w-4" />)
43
+ await waitFor(() => expect(container.querySelector('svg')).toBeTruthy())
44
+ })
45
+
46
+ it('un nombre que no es de lucide no dibuja nada', () => {
47
+ const { container } = render(<DynamicIcon name="/uploads/x.png" className="h-4 w-4" />)
48
+ expect(container.firstChild).toBeNull()
49
+ })
50
+ })
@@ -26,7 +26,8 @@ let captured: {
26
26
  onDragEnd?: (e: any) => void | Promise<void>
27
27
  onDragStart?: (e: any) => void
28
28
  } = {}
29
- vi.mock('@dnd-kit/core', () => ({
29
+ vi.mock('@dnd-kit/core', async (orig) => ({
30
+ ...((await orig()) as Record<string, unknown>),
30
31
  DndContext: ({ children, onDragEnd, onDragStart }: any) => {
31
32
  captured.onDragEnd = onDragEnd
32
33
  captured.onDragStart = onDragStart
@@ -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', () => {