@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
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.
|
|
30
|
-
"@asteby/metacore-ui": "^2.
|
|
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.
|
|
54
|
-
"@asteby/metacore-ui": "2.
|
|
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
|
|
5
|
-
// ConfirmActionDialog (even if confirm/confirmMessage
|
|
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
|
|
174
|
+
it('is unrestricted when the metadata carries no transitions', () => {
|
|
175
175
|
expect(isTransitionAllowed(undefined, 'done', 'backlog')).toBe(true)
|
|
176
|
-
|
|
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', () => {
|