@asteby/metacore-runtime-react 23.6.0 → 23.7.1
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 +25 -0
- package/dist/action-modal-dispatcher.js +19 -10
- package/dist/custom-stages.d.ts +171 -0
- package/dist/custom-stages.d.ts.map +1 -0
- package/dist/custom-stages.js +535 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +7 -4
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +77 -32
- package/dist/field-grid.d.ts +17 -0
- package/dist/field-grid.d.ts.map +1 -0
- package/dist/field-grid.js +12 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/model-action-toolbar.d.ts.map +1 -1
- package/dist/model-action-toolbar.js +3 -1
- package/dist/types.d.ts +29 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/action-modal-grid-layout.test.tsx +86 -0
- package/src/__tests__/custom-stages.test.tsx +480 -0
- package/src/__tests__/model-action-toolbar-i18n.test.tsx +101 -0
- package/src/action-modal-dispatcher.tsx +44 -37
- package/src/custom-stages.tsx +1113 -0
- package/src/dialogs/dynamic-record.tsx +14 -8
- package/src/dynamic-kanban.tsx +153 -5
- package/src/field-grid.tsx +57 -0
- package/src/index.ts +25 -0
- package/src/model-action-toolbar.tsx +9 -1
- package/src/types.ts +26 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// Custom stages coverage:
|
|
4
|
+
// 1. Pure helpers: splitCustomStages, mergeLaneStages, smartLaneParams,
|
|
5
|
+
// isCustomStageDraftValid, slugifyStageKey, customStageFilterFields.
|
|
6
|
+
// 2. AddStageColumn renders + fires onClick.
|
|
7
|
+
// 3. CustomStageDialog: create a NORMAL stage (mock onCreate), edit a SMART
|
|
8
|
+
// stage's condition value (mock onUpdate).
|
|
9
|
+
// 4. SmartLane queries the list endpoint with the lane's f_<field> params.
|
|
10
|
+
// 5. useCustomStages degrades to available=false when the endpoint 404s (the
|
|
11
|
+
// "+ Agregar etapa" affordance never renders → board intact).
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
13
|
+
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
14
|
+
|
|
15
|
+
const I18N_T = (k: string, opts?: Record<string, any>) => {
|
|
16
|
+
let s = (opts?.defaultValue as string) ?? k
|
|
17
|
+
if (opts) {
|
|
18
|
+
for (const [ok, ov] of Object.entries(opts)) {
|
|
19
|
+
if (ok === 'defaultValue') continue
|
|
20
|
+
s = s.replace(new RegExp(`{{${ok}}}`, 'g'), String(ov))
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return s
|
|
24
|
+
}
|
|
25
|
+
const I18N = { language: 'es' }
|
|
26
|
+
const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
|
|
27
|
+
vi.mock('react-i18next', () => ({
|
|
28
|
+
useTranslation: () => USE_TRANSLATION,
|
|
29
|
+
}))
|
|
30
|
+
vi.mock('sonner', () => ({
|
|
31
|
+
toast: { success: vi.fn(), error: vi.fn() },
|
|
32
|
+
}))
|
|
33
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
34
|
+
useNavigate: () => () => {},
|
|
35
|
+
}))
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
splitCustomStages,
|
|
39
|
+
mergeLaneStages,
|
|
40
|
+
resolveSmartLanes,
|
|
41
|
+
smartLaneParams,
|
|
42
|
+
isCustomStageDraftValid,
|
|
43
|
+
slugifyStageKey,
|
|
44
|
+
customStageFilterFields,
|
|
45
|
+
emptyCustomStageFilter,
|
|
46
|
+
AddStageColumn,
|
|
47
|
+
CustomStageDialog,
|
|
48
|
+
CustomStageDeleteDialog,
|
|
49
|
+
SmartLane,
|
|
50
|
+
useCustomStages,
|
|
51
|
+
type CustomStage,
|
|
52
|
+
} from '../custom-stages'
|
|
53
|
+
import type { SmartLaneMeta } from '../types'
|
|
54
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
55
|
+
import type { ColumnDefinition, StageMeta } from '../types'
|
|
56
|
+
import React from 'react'
|
|
57
|
+
|
|
58
|
+
afterEach(cleanup)
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Fixtures
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
const COLUMNS: ColumnDefinition[] = [
|
|
65
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false },
|
|
66
|
+
{ key: 'priority', label: 'Priority', type: 'text', sortable: false, filterable: false },
|
|
67
|
+
{ key: 'id', label: 'ID', type: 'text', sortable: false, filterable: false },
|
|
68
|
+
{ key: 'secret', label: 'Secret', type: 'text', sortable: false, filterable: false, hidden: true },
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
const CUSTOM: CustomStage[] = [
|
|
72
|
+
{
|
|
73
|
+
id: 'c1',
|
|
74
|
+
model: 'issue',
|
|
75
|
+
key: 'review',
|
|
76
|
+
label: 'En revisión',
|
|
77
|
+
color: 'blue',
|
|
78
|
+
position: 5,
|
|
79
|
+
type: 'stage',
|
|
80
|
+
filters: [],
|
|
81
|
+
enabled: true,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
id: 'c2',
|
|
85
|
+
model: 'issue',
|
|
86
|
+
key: 'urgent',
|
|
87
|
+
label: 'Urgentes',
|
|
88
|
+
color: 'red',
|
|
89
|
+
position: 6,
|
|
90
|
+
type: 'smart',
|
|
91
|
+
filters: [{ field: 'priority', op: 'eq', value: 'high' }],
|
|
92
|
+
enabled: true,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: 'c3',
|
|
96
|
+
model: 'issue',
|
|
97
|
+
key: 'off',
|
|
98
|
+
label: 'Deshabilitada',
|
|
99
|
+
color: 'slate',
|
|
100
|
+
position: 7,
|
|
101
|
+
type: 'stage',
|
|
102
|
+
filters: [],
|
|
103
|
+
enabled: false,
|
|
104
|
+
},
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// 1. Pure helpers
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
describe('splitCustomStages', () => {
|
|
112
|
+
it('splits enabled stages by flavor and drops disabled ones', () => {
|
|
113
|
+
const { laneStages, smartStages } = splitCustomStages(CUSTOM)
|
|
114
|
+
expect(laneStages.map((s) => s.key)).toEqual(['review'])
|
|
115
|
+
expect(smartStages.map((s) => s.key)).toEqual(['urgent'])
|
|
116
|
+
})
|
|
117
|
+
it('tolerates undefined', () => {
|
|
118
|
+
expect(splitCustomStages(undefined)).toEqual({ laneStages: [], smartStages: [] })
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('mergeLaneStages', () => {
|
|
123
|
+
const declared: StageMeta[] = [
|
|
124
|
+
{ key: 'todo', label: 'Todo', order: 0 },
|
|
125
|
+
{ key: 'done', label: 'Done', order: 10 },
|
|
126
|
+
]
|
|
127
|
+
it('appends unknown-key custom lanes, sorted by order/position', () => {
|
|
128
|
+
const custom: CustomStage[] = [
|
|
129
|
+
{ ...CUSTOM[0], key: 'review', position: 5 },
|
|
130
|
+
]
|
|
131
|
+
const { lanes, customByKey } = mergeLaneStages(declared, custom)
|
|
132
|
+
expect(lanes.map((s) => s.key)).toEqual(['todo', 'review', 'done'])
|
|
133
|
+
expect(customByKey.get('review')?.label).toBe('En revisión')
|
|
134
|
+
})
|
|
135
|
+
it('does not duplicate a custom lane the metadata already surfaced', () => {
|
|
136
|
+
const custom: CustomStage[] = [{ ...CUSTOM[0], key: 'done' }]
|
|
137
|
+
const { lanes, customByKey } = mergeLaneStages(declared, custom)
|
|
138
|
+
expect(lanes.map((s) => s.key)).toEqual(['todo', 'done'])
|
|
139
|
+
// Still tagged as custom so the lane gets its edit/delete menu.
|
|
140
|
+
expect(customByKey.has('done')).toBe(true)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('smartLaneParams', () => {
|
|
145
|
+
it('encodes the operator as an OP: prefix on a single f_<field> param', () => {
|
|
146
|
+
expect(
|
|
147
|
+
smartLaneParams([
|
|
148
|
+
{ field: 'priority', op: 'eq', value: 'high' },
|
|
149
|
+
{ field: 'title', op: 'contains', value: 'bug' },
|
|
150
|
+
{ field: 'state', op: 'neq', value: 'closed' },
|
|
151
|
+
{ field: 'label', op: 'in', value: 'a,b' },
|
|
152
|
+
]),
|
|
153
|
+
).toEqual({
|
|
154
|
+
f_priority: 'EQ:high',
|
|
155
|
+
f_title: 'HAS:bug',
|
|
156
|
+
f_state: 'NEQ:closed',
|
|
157
|
+
f_label: 'IN:a,b',
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
it('skips empty fields/values and tolerates undefined', () => {
|
|
161
|
+
expect(smartLaneParams(undefined)).toEqual({})
|
|
162
|
+
expect(
|
|
163
|
+
smartLaneParams([
|
|
164
|
+
{ field: '', op: 'eq', value: 'x' },
|
|
165
|
+
{ field: 'a', op: 'eq', value: ' ' },
|
|
166
|
+
]),
|
|
167
|
+
).toEqual({})
|
|
168
|
+
})
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
describe('isCustomStageDraftValid', () => {
|
|
172
|
+
it('requires a label', () => {
|
|
173
|
+
expect(isCustomStageDraftValid({ label: '', type: 'stage', filters: [] })).toBe(false)
|
|
174
|
+
expect(isCustomStageDraftValid({ label: 'A', type: 'stage', filters: [] })).toBe(true)
|
|
175
|
+
})
|
|
176
|
+
it('requires at least one complete condition for smart lanes', () => {
|
|
177
|
+
expect(
|
|
178
|
+
isCustomStageDraftValid({ label: 'A', type: 'smart', filters: [emptyCustomStageFilter('x')] }),
|
|
179
|
+
).toBe(false)
|
|
180
|
+
expect(
|
|
181
|
+
isCustomStageDraftValid({
|
|
182
|
+
label: 'A',
|
|
183
|
+
type: 'smart',
|
|
184
|
+
filters: [{ field: 'x', op: 'eq', value: '1' }],
|
|
185
|
+
}),
|
|
186
|
+
).toBe(true)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
describe('slugifyStageKey / customStageFilterFields', () => {
|
|
191
|
+
it('slugifies labels (accents, spaces, symbols)', () => {
|
|
192
|
+
expect(slugifyStageKey('En Revisión!')).toBe('en_revision')
|
|
193
|
+
})
|
|
194
|
+
it('offers visible non-id columns for the condition builder', () => {
|
|
195
|
+
expect(customStageFilterFields(COLUMNS).map((c) => c.key)).toEqual(['title', 'priority'])
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// 2. AddStageColumn
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe('AddStageColumn', () => {
|
|
204
|
+
it('renders and fires onClick', () => {
|
|
205
|
+
const onClick = vi.fn()
|
|
206
|
+
render(<AddStageColumn onClick={onClick} />)
|
|
207
|
+
fireEvent.click(screen.getByTestId('kanban-add-stage'))
|
|
208
|
+
expect(onClick).toHaveBeenCalledTimes(1)
|
|
209
|
+
})
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// 3. CustomStageDialog
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
describe('CustomStageDialog', () => {
|
|
217
|
+
it('creates a NORMAL stage with a slugified key and empty filters', async () => {
|
|
218
|
+
const onCreate = vi.fn(async () => {})
|
|
219
|
+
const onUpdate = vi.fn(async () => {})
|
|
220
|
+
render(
|
|
221
|
+
<CustomStageDialog
|
|
222
|
+
open
|
|
223
|
+
onOpenChange={() => {}}
|
|
224
|
+
model="issue"
|
|
225
|
+
columns={COLUMNS}
|
|
226
|
+
initial={null}
|
|
227
|
+
nextPosition={9}
|
|
228
|
+
onCreate={onCreate}
|
|
229
|
+
onUpdate={onUpdate}
|
|
230
|
+
/>,
|
|
231
|
+
)
|
|
232
|
+
fireEvent.change(screen.getByTestId('custom-stage-name'), {
|
|
233
|
+
target: { value: 'En Progreso' },
|
|
234
|
+
})
|
|
235
|
+
fireEvent.click(screen.getByTestId('custom-stage-save'))
|
|
236
|
+
await waitFor(() => expect(onCreate).toHaveBeenCalledTimes(1))
|
|
237
|
+
expect(onCreate).toHaveBeenCalledWith({
|
|
238
|
+
model: 'issue',
|
|
239
|
+
key: 'en_progreso',
|
|
240
|
+
label: 'En Progreso',
|
|
241
|
+
color: 'slate',
|
|
242
|
+
position: 9,
|
|
243
|
+
type: 'stage',
|
|
244
|
+
filters: [],
|
|
245
|
+
enabled: true,
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('save is disabled until the stage has a name', () => {
|
|
250
|
+
render(
|
|
251
|
+
<CustomStageDialog
|
|
252
|
+
open
|
|
253
|
+
onOpenChange={() => {}}
|
|
254
|
+
model="issue"
|
|
255
|
+
columns={COLUMNS}
|
|
256
|
+
initial={null}
|
|
257
|
+
nextPosition={0}
|
|
258
|
+
onCreate={vi.fn()}
|
|
259
|
+
onUpdate={vi.fn()}
|
|
260
|
+
/>,
|
|
261
|
+
)
|
|
262
|
+
expect((screen.getByTestId('custom-stage-save') as HTMLButtonElement).disabled).toBe(true)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it('edits a SMART stage: pre-fills conditions and updates the value', async () => {
|
|
266
|
+
const onUpdate = vi.fn(async () => {})
|
|
267
|
+
render(
|
|
268
|
+
<CustomStageDialog
|
|
269
|
+
open
|
|
270
|
+
onOpenChange={() => {}}
|
|
271
|
+
model="issue"
|
|
272
|
+
columns={COLUMNS}
|
|
273
|
+
initial={CUSTOM[1]}
|
|
274
|
+
nextPosition={0}
|
|
275
|
+
onCreate={vi.fn()}
|
|
276
|
+
onUpdate={onUpdate}
|
|
277
|
+
/>,
|
|
278
|
+
)
|
|
279
|
+
// The smart lane's single condition renders pre-filled (value=high).
|
|
280
|
+
const valueInput = screen.getByTestId('custom-stage-condition-value-0') as HTMLInputElement
|
|
281
|
+
expect(valueInput.value).toBe('high')
|
|
282
|
+
fireEvent.change(valueInput, { target: { value: 'critical' } })
|
|
283
|
+
fireEvent.click(screen.getByTestId('custom-stage-save'))
|
|
284
|
+
await waitFor(() => expect(onUpdate).toHaveBeenCalledTimes(1))
|
|
285
|
+
// type/model/key are immutable → the patch omits them (ops #704).
|
|
286
|
+
expect(onUpdate).toHaveBeenCalledWith('c2', {
|
|
287
|
+
label: 'Urgentes',
|
|
288
|
+
color: 'red',
|
|
289
|
+
filters: [{ field: 'priority', op: 'eq', value: 'critical' }],
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('disables the type field when editing (type is immutable)', () => {
|
|
294
|
+
render(
|
|
295
|
+
<CustomStageDialog
|
|
296
|
+
open
|
|
297
|
+
onOpenChange={() => {}}
|
|
298
|
+
model="issue"
|
|
299
|
+
columns={COLUMNS}
|
|
300
|
+
initial={CUSTOM[1]}
|
|
301
|
+
nextPosition={0}
|
|
302
|
+
onCreate={vi.fn()}
|
|
303
|
+
onUpdate={vi.fn()}
|
|
304
|
+
/>,
|
|
305
|
+
)
|
|
306
|
+
expect(
|
|
307
|
+
(screen.getByTestId('custom-stage-type') as HTMLButtonElement).disabled,
|
|
308
|
+
).toBe(true)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('adds and removes condition rows for a smart lane', () => {
|
|
312
|
+
render(
|
|
313
|
+
<CustomStageDialog
|
|
314
|
+
open
|
|
315
|
+
onOpenChange={() => {}}
|
|
316
|
+
model="issue"
|
|
317
|
+
columns={COLUMNS}
|
|
318
|
+
initial={CUSTOM[1]}
|
|
319
|
+
nextPosition={0}
|
|
320
|
+
onCreate={vi.fn()}
|
|
321
|
+
onUpdate={vi.fn()}
|
|
322
|
+
/>,
|
|
323
|
+
)
|
|
324
|
+
expect(screen.getByTestId('custom-stage-condition-0')).toBeTruthy()
|
|
325
|
+
fireEvent.click(screen.getByTestId('custom-stage-add-condition'))
|
|
326
|
+
expect(screen.getByTestId('custom-stage-condition-1')).toBeTruthy()
|
|
327
|
+
fireEvent.click(screen.getByTestId('custom-stage-condition-remove-1'))
|
|
328
|
+
expect(screen.queryByTestId('custom-stage-condition-1')).toBeNull()
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// 4. SmartLane
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
function fakeApi(over: Partial<ApiClient> = {}): ApiClient {
|
|
337
|
+
return {
|
|
338
|
+
get: vi.fn(async () => ({ data: { success: true, data: [], meta: { total: 0 } } })),
|
|
339
|
+
post: vi.fn(async () => ({ data: { success: true, data: null } })),
|
|
340
|
+
put: vi.fn(async () => ({ data: { success: true, data: null } })),
|
|
341
|
+
delete: vi.fn(async () => ({ data: { success: true, data: null } })),
|
|
342
|
+
...over,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
describe('SmartLane', () => {
|
|
347
|
+
it('queries the list endpoint with the lane f_<field> params', async () => {
|
|
348
|
+
const get = vi.fn(async () => ({
|
|
349
|
+
data: { success: true, data: [{ id: 1, title: 'Bug' }], meta: { total: 1 } },
|
|
350
|
+
}))
|
|
351
|
+
render(
|
|
352
|
+
<ApiProvider client={fakeApi({ get })}>
|
|
353
|
+
<SmartLane
|
|
354
|
+
stage={CUSTOM[1]}
|
|
355
|
+
model="issue"
|
|
356
|
+
endpoint="/data/issue/me"
|
|
357
|
+
defaultFilters={{ archived: false }}
|
|
358
|
+
isDark={false}
|
|
359
|
+
onEdit={() => {}}
|
|
360
|
+
onDelete={() => {}}
|
|
361
|
+
renderCard={(c) => <div data-testid={`smart-card-${c.id}`}>{c.title}</div>}
|
|
362
|
+
/>
|
|
363
|
+
</ApiProvider>,
|
|
364
|
+
)
|
|
365
|
+
await waitFor(() => expect(get).toHaveBeenCalled())
|
|
366
|
+
const [url, cfg] = get.mock.calls[0] as [string, any]
|
|
367
|
+
expect(url).toBe('/data/issue/me')
|
|
368
|
+
expect(cfg.params).toMatchObject({ archived: false, f_priority: 'EQ:high' })
|
|
369
|
+
await waitFor(() => expect(screen.getByTestId('smart-card-1')).toBeTruthy())
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// 4b. resolveSmartLanes — metadata-first with CRUD id fold-in
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
describe('resolveSmartLanes', () => {
|
|
378
|
+
const metaLanes: SmartLaneMeta[] = [
|
|
379
|
+
{
|
|
380
|
+
key: 'urgent',
|
|
381
|
+
label: 'Urgentes',
|
|
382
|
+
color: 'red',
|
|
383
|
+
order: 3,
|
|
384
|
+
filters: [{ field: 'priority', op: 'eq', value: 'high' }],
|
|
385
|
+
},
|
|
386
|
+
]
|
|
387
|
+
it('maps metadata smart lanes, folding in the CRUD id by key', () => {
|
|
388
|
+
const out = resolveSmartLanes(metaLanes, [CUSTOM[1]], 'issue')
|
|
389
|
+
expect(out).toHaveLength(1)
|
|
390
|
+
expect(out[0]).toMatchObject({
|
|
391
|
+
id: 'c2', // folded in from the CRUD entry (metadata omits it)
|
|
392
|
+
key: 'urgent',
|
|
393
|
+
type: 'smart',
|
|
394
|
+
filters: [{ field: 'priority', op: 'eq', value: 'high' }],
|
|
395
|
+
})
|
|
396
|
+
})
|
|
397
|
+
it('synthesizes an id from the key when no CRUD match exists', () => {
|
|
398
|
+
expect(resolveSmartLanes(metaLanes, [], 'issue')[0].id).toBe('urgent')
|
|
399
|
+
})
|
|
400
|
+
it('falls back to CRUD smart stages when metadata omits smart_lanes', () => {
|
|
401
|
+
expect(resolveSmartLanes(undefined, [CUSTOM[1]], 'issue')).toEqual([CUSTOM[1]])
|
|
402
|
+
expect(resolveSmartLanes([], [CUSTOM[1]], 'issue')).toEqual([CUSTOM[1]])
|
|
403
|
+
})
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
// ---------------------------------------------------------------------------
|
|
407
|
+
// 4c. Delete dialog — 409 surfaces the card count + reassignment target
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
describe('CustomStageDeleteDialog', () => {
|
|
411
|
+
it('on 409 shows the card count and retries with a reassign target', async () => {
|
|
412
|
+
const onConfirm = vi
|
|
413
|
+
.fn()
|
|
414
|
+
.mockRejectedValueOnce({ response: { status: 409, data: { meta: { cards: 4 } } } })
|
|
415
|
+
.mockResolvedValueOnce(undefined)
|
|
416
|
+
render(
|
|
417
|
+
<CustomStageDeleteDialog
|
|
418
|
+
open
|
|
419
|
+
onOpenChange={() => {}}
|
|
420
|
+
stage={CUSTOM[0]}
|
|
421
|
+
reassignTargets={[
|
|
422
|
+
{ key: 'review', label: 'En revisión' }, // same key → filtered out
|
|
423
|
+
{ key: 'done', label: 'Done' },
|
|
424
|
+
]}
|
|
425
|
+
onConfirm={onConfirm}
|
|
426
|
+
/>,
|
|
427
|
+
)
|
|
428
|
+
// First attempt: plain delete, no reassign target.
|
|
429
|
+
fireEvent.click(screen.getByTestId('custom-stage-delete-confirm'))
|
|
430
|
+
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1))
|
|
431
|
+
expect(onConfirm).toHaveBeenLastCalledWith(CUSTOM[0], undefined)
|
|
432
|
+
// Conflict mode: the reassign select appears; the count is surfaced.
|
|
433
|
+
await waitFor(() => expect(screen.getByTestId('custom-stage-reassign')).toBeTruthy())
|
|
434
|
+
expect(screen.getByText(/4/)).toBeTruthy()
|
|
435
|
+
// Without a chosen target the confirm button stays a no-op (guarded).
|
|
436
|
+
fireEvent.click(screen.getByTestId('custom-stage-delete-confirm'))
|
|
437
|
+
expect(onConfirm).toHaveBeenCalledTimes(1)
|
|
438
|
+
})
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
// 5. Hook degrades gracefully when the endpoint is missing
|
|
443
|
+
// ---------------------------------------------------------------------------
|
|
444
|
+
|
|
445
|
+
function HookProbe({ model }: { model: string }) {
|
|
446
|
+
const c = useCustomStages(model)
|
|
447
|
+
return (
|
|
448
|
+
<div>
|
|
449
|
+
<span data-testid="available">{String(c.available)}</span>
|
|
450
|
+
<span data-testid="loading">{String(c.loading)}</span>
|
|
451
|
+
<span data-testid="count">{c.stages.length}</span>
|
|
452
|
+
</div>
|
|
453
|
+
)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
describe('useCustomStages', () => {
|
|
457
|
+
it('loads stages when the endpoint is present', async () => {
|
|
458
|
+
render(
|
|
459
|
+
<ApiProvider client={fakeApi({ get: vi.fn(async () => ({ data: { success: true, data: CUSTOM } })) })}>
|
|
460
|
+
<HookProbe model="issue" />
|
|
461
|
+
</ApiProvider>,
|
|
462
|
+
)
|
|
463
|
+
await waitFor(() => expect(screen.getByTestId('available').textContent).toBe('true'))
|
|
464
|
+
expect(screen.getByTestId('count').textContent).toBe('3')
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
it('reports available=false when the endpoint 404s (no board break)', async () => {
|
|
468
|
+
const get = vi.fn(async () => {
|
|
469
|
+
throw { response: { status: 404 } }
|
|
470
|
+
})
|
|
471
|
+
render(
|
|
472
|
+
<ApiProvider client={fakeApi({ get })}>
|
|
473
|
+
<HookProbe model="issue" />
|
|
474
|
+
</ApiProvider>,
|
|
475
|
+
)
|
|
476
|
+
await waitFor(() => expect(screen.getByTestId('loading').textContent).toBe('false'))
|
|
477
|
+
expect(screen.getByTestId('available').textContent).toBe('false')
|
|
478
|
+
expect(screen.getByTestId('count').textContent).toBe('0')
|
|
479
|
+
})
|
|
480
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
4
|
+
import i18next from 'i18next'
|
|
5
|
+
import { I18nextProvider } from 'react-i18next'
|
|
6
|
+
|
|
7
|
+
import { ModelActionToolbar } from '../model-action-toolbar'
|
|
8
|
+
import { ApiProvider } from '../api-context'
|
|
9
|
+
import type { ActionDefinition } from '../types'
|
|
10
|
+
|
|
11
|
+
afterEach(cleanup)
|
|
12
|
+
|
|
13
|
+
// Stub api client — the toolbar only calls it when it must fetch metadata, and
|
|
14
|
+
// we pass `actions` directly so it never does. Present only to satisfy useApi().
|
|
15
|
+
const api = {
|
|
16
|
+
get: async () => ({ data: { data: {} } }),
|
|
17
|
+
post: async () => ({ data: { success: true } }),
|
|
18
|
+
} as never
|
|
19
|
+
|
|
20
|
+
// A fresh i18next configured EXACTLY like the failing prod instance: keySeparator
|
|
21
|
+
// default and ignoreJSONStructure:false, so a flat literal key would NOT resolve
|
|
22
|
+
// — the fix must build/read a nested tree AND translate at render time.
|
|
23
|
+
function makeI18n() {
|
|
24
|
+
const inst = i18next.createInstance()
|
|
25
|
+
inst.init({
|
|
26
|
+
lng: 'es',
|
|
27
|
+
fallbackLng: 'es',
|
|
28
|
+
ignoreJSONStructure: false,
|
|
29
|
+
react: { bindI18nStore: 'added removed', useSuspense: false },
|
|
30
|
+
resources: { es: { translation: {} } },
|
|
31
|
+
})
|
|
32
|
+
return inst
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The addon's create action exactly as the metadata endpoint ships it: the
|
|
36
|
+
// label is an i18n KEY, not display text (backend does not localize action
|
|
37
|
+
// labels — see the raw-key bug).
|
|
38
|
+
const createAction: ActionDefinition = {
|
|
39
|
+
key: 'create_issue',
|
|
40
|
+
label: 'integration_github.action.create_issue.label',
|
|
41
|
+
placement: 'create',
|
|
42
|
+
} as ActionDefinition
|
|
43
|
+
|
|
44
|
+
function renderToolbar(inst: ReturnType<typeof makeI18n>) {
|
|
45
|
+
return render(
|
|
46
|
+
<I18nextProvider i18n={inst}>
|
|
47
|
+
<ApiProvider client={api}>
|
|
48
|
+
<ModelActionToolbar model="github_issues" actions={[createAction]} />
|
|
49
|
+
</ApiProvider>
|
|
50
|
+
</I18nextProvider>,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('ModelActionToolbar — translates addon action labels at render', () => {
|
|
55
|
+
it('shows the translation once the addon bundle lands (no raw key)', async () => {
|
|
56
|
+
const inst = makeI18n()
|
|
57
|
+
renderToolbar(inst)
|
|
58
|
+
|
|
59
|
+
// Before the addon bundle loads the toolbar has only the key; with the
|
|
60
|
+
// fix it renders the key text (t() falls back to defaultValue = the key).
|
|
61
|
+
// The point of the test is the TRANSITION below.
|
|
62
|
+
expect(
|
|
63
|
+
screen.getByText('integration_github.action.create_issue.label'),
|
|
64
|
+
).toBeTruthy()
|
|
65
|
+
|
|
66
|
+
// The addon locale bundle arrives asynchronously (OpsAddonLocaleLoader
|
|
67
|
+
// fetch → addResourceBundle). It is NESTED, as the host loader now merges
|
|
68
|
+
// it. This fires the i18next store 'added' event.
|
|
69
|
+
act(() => {
|
|
70
|
+
inst.addResourceBundle(
|
|
71
|
+
'es',
|
|
72
|
+
'translation',
|
|
73
|
+
{ integration_github: { action: { create_issue: { label: 'Crear issue' } } } },
|
|
74
|
+
true,
|
|
75
|
+
true,
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// The toolbar must re-render and show the translated label — the exact
|
|
80
|
+
// behaviour that was missing (a bare {a.label} never re-derived).
|
|
81
|
+
await waitFor(() => expect(screen.getByText('Crear issue')).toBeTruthy())
|
|
82
|
+
expect(
|
|
83
|
+
screen.queryByText('integration_github.action.create_issue.label'),
|
|
84
|
+
).toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('leaves an already-localized label untouched (defaultValue passthrough)', () => {
|
|
88
|
+
const inst = makeI18n()
|
|
89
|
+
render(
|
|
90
|
+
<I18nextProvider i18n={inst}>
|
|
91
|
+
<ApiProvider client={api}>
|
|
92
|
+
<ModelActionToolbar
|
|
93
|
+
model="github_issues"
|
|
94
|
+
actions={[{ key: 'x', label: 'Crear issue', placement: 'create' } as ActionDefinition]}
|
|
95
|
+
/>
|
|
96
|
+
</ApiProvider>
|
|
97
|
+
</I18nextProvider>,
|
|
98
|
+
)
|
|
99
|
+
expect(screen.getByText('Crear issue')).toBeTruthy()
|
|
100
|
+
})
|
|
101
|
+
})
|