@asteby/metacore-runtime-react 41.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.
- package/CHANGELOG.md +45 -0
- 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-table.d.ts +1 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +14 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- 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/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/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/dist/use-persisted-query.d.ts +53 -0
- package/dist/use-persisted-query.d.ts.map +1 -0
- package/dist/use-persisted-query.js +92 -0
- package/package.json +6 -5
- 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__/image-cell-lucide.test.tsx +7 -5
- package/src/__tests__/use-persisted-query.test.tsx +132 -0
- package/src/api-context.tsx +7 -5
- package/src/dynamic-icon.tsx +12 -16
- package/src/dynamic-kanban.tsx +950 -262
- package/src/dynamic-table.tsx +16 -2
- package/src/index.ts +14 -0
- package/src/license/license-gate.tsx +6 -2
- 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/use-flip-animation.ts +16 -3
- package/src/use-persisted-query.ts +152 -0
|
@@ -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', () => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// kebab, as stored by the `icon` form widget) renders the glyph instead of a
|
|
5
5
|
// broken <img>; real paths/urls keep rendering an <img>.
|
|
6
6
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
7
|
-
import { cleanup, render } from '@testing-library/react'
|
|
7
|
+
import { cleanup, render, waitFor } from '@testing-library/react'
|
|
8
8
|
import { ImageCell } from '../dynamic-columns'
|
|
9
9
|
import { isLucideIconName, resolveLucideIconName } from '../dynamic-icon'
|
|
10
10
|
|
|
@@ -13,15 +13,17 @@ afterEach(cleanup)
|
|
|
13
13
|
const getImageUrl = (p: string) => `/img${p}`
|
|
14
14
|
|
|
15
15
|
describe('ImageCell con nombre lucide', () => {
|
|
16
|
-
it('renderiza el ícono (svg) para un nombre PascalCase, sin <img>', () => {
|
|
16
|
+
it('renderiza el ícono (svg) para un nombre PascalCase, sin <img>', async () => {
|
|
17
17
|
const { container } = render(<ImageCell value="CreditCard" getImageUrl={getImageUrl} />)
|
|
18
|
-
|
|
18
|
+
// The glyph loads once; its box is held meanwhile.
|
|
19
|
+
await waitFor(() => expect(container.querySelector('svg')).toBeTruthy())
|
|
19
20
|
expect(container.querySelector('img')).toBeNull()
|
|
20
21
|
})
|
|
21
22
|
|
|
22
|
-
it('renderiza el ícono también para el slug kebab "credit-card"', () => {
|
|
23
|
+
it('renderiza el ícono también para el slug kebab "credit-card"', async () => {
|
|
23
24
|
const { container } = render(<ImageCell value="credit-card" getImageUrl={getImageUrl} />)
|
|
24
|
-
|
|
25
|
+
// The glyph loads once; its box is held meanwhile.
|
|
26
|
+
await waitFor(() => expect(container.querySelector('svg')).toBeTruthy())
|
|
25
27
|
expect(container.querySelector('img')).toBeNull()
|
|
26
28
|
})
|
|
27
29
|
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { describe, expect, it, beforeEach, vi } from 'vitest'
|
|
3
|
+
import { act, renderHook, waitFor } from '@testing-library/react'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
5
|
+
import type { ReactNode } from 'react'
|
|
6
|
+
import { createPersistedSnapshot, usePersistedQuery } from '../use-persisted-query'
|
|
7
|
+
|
|
8
|
+
type Layout = { order: string[] }
|
|
9
|
+
|
|
10
|
+
function deferred<T>() {
|
|
11
|
+
let resolve!: (v: T) => void
|
|
12
|
+
const promise = new Promise<T>((res) => {
|
|
13
|
+
resolve = res
|
|
14
|
+
})
|
|
15
|
+
return { promise, resolve }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function wrapperFor(qc: QueryClient) {
|
|
19
|
+
return ({ children }: { children: ReactNode }) => (
|
|
20
|
+
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const newClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
|
25
|
+
|
|
26
|
+
describe('createPersistedSnapshot', () => {
|
|
27
|
+
beforeEach(() => localStorage.clear())
|
|
28
|
+
|
|
29
|
+
it('round-trips per scope and version', () => {
|
|
30
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
31
|
+
const ts = Date.now() - 1000
|
|
32
|
+
snap.write('org-a', { order: ['a'] }, { ts })
|
|
33
|
+
expect(snap.read('org-a')).toEqual({ data: { order: ['a'] }, ts })
|
|
34
|
+
expect(snap.read('org-b')).toBeUndefined()
|
|
35
|
+
const v2 = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 2 })
|
|
36
|
+
expect(v2.read('org-a')).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('ignores expired and corrupt entries', () => {
|
|
40
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1, maxAgeMs: 1000 })
|
|
41
|
+
snap.write('org-a', { order: ['a'] }, { ts: Date.now() - 5000 })
|
|
42
|
+
expect(snap.read('org-a')).toBeUndefined()
|
|
43
|
+
localStorage.setItem('test:layout:v1:org-b', '{not json')
|
|
44
|
+
expect(snap.read('org-b')).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('usePersistedQuery', () => {
|
|
49
|
+
beforeEach(() => localStorage.clear())
|
|
50
|
+
|
|
51
|
+
it('paints the persisted value on the first render, then revalidates and persists', async () => {
|
|
52
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
53
|
+
snap.write('org-a', { order: ['cached'] }, { ts: Date.now() - 60_000 })
|
|
54
|
+
const net = deferred<Layout>()
|
|
55
|
+
const renders: (Layout | undefined)[] = []
|
|
56
|
+
const { result } = renderHook(
|
|
57
|
+
() => {
|
|
58
|
+
const q = usePersistedQuery({
|
|
59
|
+
queryKey: ['layout'],
|
|
60
|
+
queryFn: () => net.promise,
|
|
61
|
+
staleTime: 1000,
|
|
62
|
+
persist: { snapshot: snap, scope: 'org-a' },
|
|
63
|
+
})
|
|
64
|
+
renders.push(q.data)
|
|
65
|
+
return q
|
|
66
|
+
},
|
|
67
|
+
{ wrapper: wrapperFor(newClient()) },
|
|
68
|
+
)
|
|
69
|
+
expect(renders[0]).toEqual({ order: ['cached'] })
|
|
70
|
+
await act(async () => net.resolve({ order: ['fresh'] }))
|
|
71
|
+
await waitFor(() => expect(result.current.data).toEqual({ order: ['fresh'] }))
|
|
72
|
+
expect(snap.read('org-a')?.data).toEqual({ order: ['fresh'] })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('revalidates a seed once per load even when it is younger than staleTime', async () => {
|
|
76
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
77
|
+
snap.write('org-a', { order: ['cached'] }, { ts: Date.now() - 1000 })
|
|
78
|
+
const queryFn = vi.fn(async () => ({ order: ['changed-elsewhere'] }))
|
|
79
|
+
const { result } = renderHook(
|
|
80
|
+
() =>
|
|
81
|
+
usePersistedQuery({
|
|
82
|
+
queryKey: ['layout'],
|
|
83
|
+
queryFn,
|
|
84
|
+
staleTime: 60_000,
|
|
85
|
+
persist: { snapshot: snap, scope: 'org-a' },
|
|
86
|
+
}),
|
|
87
|
+
{ wrapper: wrapperFor(newClient()) },
|
|
88
|
+
)
|
|
89
|
+
expect(result.current.data).toEqual({ order: ['cached'] })
|
|
90
|
+
await waitFor(() => expect(result.current.data).toEqual({ order: ['changed-elsewhere'] }))
|
|
91
|
+
expect(queryFn).toHaveBeenCalledTimes(1)
|
|
92
|
+
expect(snap.read('org-a')?.data).toEqual({ order: ['changed-elsewhere'] })
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('persists the raw payload, not the selected shape, and setQueryData writes back', async () => {
|
|
96
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
97
|
+
const qc = newClient()
|
|
98
|
+
const { result } = renderHook(
|
|
99
|
+
() =>
|
|
100
|
+
usePersistedQuery({
|
|
101
|
+
queryKey: ['layout'],
|
|
102
|
+
queryFn: async () => ({ order: ['x', 'y'] }),
|
|
103
|
+
select: (d: Layout) => d.order.length,
|
|
104
|
+
persist: { snapshot: snap, scope: 'org-a' },
|
|
105
|
+
}),
|
|
106
|
+
{ wrapper: wrapperFor(qc) },
|
|
107
|
+
)
|
|
108
|
+
await waitFor(() => expect(result.current.data).toBe(2))
|
|
109
|
+
await waitFor(() => expect(snap.read('org-a')?.data).toEqual({ order: ['x', 'y'] }))
|
|
110
|
+
act(() => {
|
|
111
|
+
qc.setQueryData<Layout>(['layout'], { order: ['z'] })
|
|
112
|
+
})
|
|
113
|
+
await waitFor(() => expect(snap.read('org-a')?.data).toEqual({ order: ['z'] }))
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('does not read or write without a scope', async () => {
|
|
117
|
+
const snap = createPersistedSnapshot<Layout>({ key: 'test:layout', version: 1 })
|
|
118
|
+
snap.write('org-a', { order: ['cached'] })
|
|
119
|
+
const { result } = renderHook(
|
|
120
|
+
() =>
|
|
121
|
+
usePersistedQuery({
|
|
122
|
+
queryKey: ['layout'],
|
|
123
|
+
queryFn: async () => ({ order: ['fresh'] }),
|
|
124
|
+
persist: { snapshot: snap, scope: null },
|
|
125
|
+
}),
|
|
126
|
+
{ wrapper: wrapperFor(newClient()) },
|
|
127
|
+
)
|
|
128
|
+
expect(result.current.data).toBeUndefined()
|
|
129
|
+
await waitFor(() => expect(result.current.data).toEqual({ order: ['fresh'] }))
|
|
130
|
+
expect(snap.read('org-a')?.data).toEqual({ order: ['cached'] })
|
|
131
|
+
})
|
|
132
|
+
})
|
package/src/api-context.tsx
CHANGED
|
@@ -90,11 +90,13 @@ export function useApi(): ApiClient {
|
|
|
90
90
|
// remembered, so the refresh after a save reads the saved row. The
|
|
91
91
|
// wrapper is stable while the host client is.
|
|
92
92
|
return useMemo<ApiClient>(() => ({
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
93
|
+
// Each call reaches the host client with the arguments it was given —
|
|
94
|
+
// never a trailing `undefined` it didn't ask for.
|
|
95
|
+
get: (...args: Parameters<ApiClient['get']>) => batchGet(ctx, ...args),
|
|
96
|
+
post: (...args: Parameters<ApiClient['post']>) =>
|
|
97
|
+
isWrite('post', args[0]) ? mutating(ctx.post(...args)) : ctx.post(...args),
|
|
98
|
+
put: (...args: Parameters<ApiClient['put']>) => mutating(ctx.put(...args)),
|
|
99
|
+
delete: (...args: Parameters<ApiClient['delete']>) => mutating(ctx.delete(...args)),
|
|
98
100
|
}), [ctx])
|
|
99
101
|
}
|
|
100
102
|
|
package/src/dynamic-icon.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
// DynamicIcon resolves a lucide glyph by name without pulling the full icon
|
|
2
|
-
// set into the shared chunk.
|
|
3
|
-
|
|
2
|
+
// set into the shared chunk. It draws through the shared glyph registry of
|
|
3
|
+
// @asteby/metacore-ui: a glyph seen before renders in the first frame, and an
|
|
4
|
+
// unseen one holds its box until it loads, so the label next to it stays put.
|
|
4
5
|
import dynamicIconImports from 'lucide-react/dynamicIconImports'
|
|
6
|
+
import { Glyph } from '@asteby/metacore-ui/icons'
|
|
5
7
|
|
|
6
8
|
export interface DynamicIconProps {
|
|
7
9
|
name: string
|
|
@@ -25,24 +27,18 @@ function kebabToPascal(kebab: string): string {
|
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
function loaderFor(pascal: string) {
|
|
28
|
-
const kebab = pascalToKebab(pascal)
|
|
29
|
-
|
|
30
|
+
const kebab = pascalToKebab(pascal)
|
|
31
|
+
// Lucide keys split digits ("building-2"); "building2" is not one.
|
|
32
|
+
return (
|
|
33
|
+
dynamicIconImports[kebab as IconName] ??
|
|
34
|
+
dynamicIconImports[kebab.replace(/([a-z])(\d)/g, '$1-$2') as IconName]
|
|
35
|
+
)
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export function DynamicIcon({ name, className }: DynamicIconProps) {
|
|
33
39
|
const resolved = resolveLucideIconName(name)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const loader = loaderFor(resolved)
|
|
37
|
-
if (!loader) return null
|
|
38
|
-
return lazy(loader as () => Promise<{ default: ComponentType<{ className?: string }> }>)
|
|
39
|
-
}, [resolved])
|
|
40
|
-
if (!Icon) return null
|
|
41
|
-
return (
|
|
42
|
-
<Suspense fallback={null}>
|
|
43
|
-
<Icon className={className} />
|
|
44
|
-
</Suspense>
|
|
45
|
-
)
|
|
40
|
+
if (!resolved) return null
|
|
41
|
+
return <Glyph name={resolved} className={className} />
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
// resolveLucideIconName — canonical PascalCase lucide name for a value that is
|