@asteby/metacore-runtime-react 23.8.0 → 23.9.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.
@@ -0,0 +1,293 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Stage overrides (the per-lane ⚙ gear) coverage:
4
+ // 1. useStageOverrides: available flips on GET success/404; save PUTs the
5
+ // {model, stage_key, ...patch} body; reset DELETEs with model + stage_key.
6
+ // 2. cardMatchesStageFilters: the client-side belt-and-suspenders over the
7
+ // unscoped initial board page (eq/neq/contains/in).
8
+ // 3. StageConfigDialog: a DECLARED lane saves via onSaveOverride + resets when
9
+ // overridden; a CUSTOM lane saves via onUpdateCustom + deletes via onDelete.
10
+ import { afterEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'
12
+
13
+ const I18N_T = (k: string, opts?: Record<string, any>) => {
14
+ let s = (opts?.defaultValue as string) ?? k
15
+ if (opts) {
16
+ for (const [ok, ov] of Object.entries(opts)) {
17
+ if (ok === 'defaultValue') continue
18
+ s = s.replace(new RegExp(`{{${ok}}}`, 'g'), String(ov))
19
+ }
20
+ }
21
+ return s
22
+ }
23
+ const USE_TRANSLATION = { t: I18N_T, i18n: { language: 'es' } }
24
+ vi.mock('react-i18next', () => ({ useTranslation: () => USE_TRANSLATION }))
25
+ vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
26
+
27
+ import React from 'react'
28
+ import { cardMatchesStageFilters, StageConfigDialog, type CustomStage, type StageConfigTarget } from '../custom-stages'
29
+ import { useStageOverrides } from '../stage-overrides'
30
+ import { ApiProvider, type ApiClient } from '../api-context'
31
+ import type { ColumnDefinition } from '../types'
32
+
33
+ afterEach(cleanup)
34
+
35
+ const COLUMNS: ColumnDefinition[] = [
36
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false },
37
+ { key: 'priority', label: 'Priority', type: 'text', sortable: false, filterable: false },
38
+ { key: 'id', label: 'ID', type: 'text', sortable: false, filterable: false },
39
+ ]
40
+
41
+ function fakeApi(over: Partial<ApiClient> = {}): ApiClient {
42
+ const ok = (data: unknown) => ({ data: { success: true, data } })
43
+ return {
44
+ get: vi.fn(async () => ok([])),
45
+ post: vi.fn(async () => ok(null)),
46
+ put: vi.fn(async () => ok(null)),
47
+ delete: vi.fn(async () => ok(null)),
48
+ ...over,
49
+ }
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // 1. useStageOverrides
54
+ // ---------------------------------------------------------------------------
55
+
56
+ function OverridesProbe({ model }: { model: string }) {
57
+ const o = useStageOverrides(model)
58
+ return (
59
+ <div>
60
+ <span data-testid="available">{String(o.available)}</span>
61
+ <button data-testid="save" onClick={() => void o.save('backlog', { label: 'Nuevo', color: 'blue', filters: [{ field: 'priority', op: 'eq', value: 'high' }] })} />
62
+ <button data-testid="reset" onClick={() => void o.reset('backlog')} />
63
+ </div>
64
+ )
65
+ }
66
+
67
+ describe('useStageOverrides', () => {
68
+ it('reports available after a successful GET and PUTs/DELETEs the right shape', async () => {
69
+ const api = fakeApi()
70
+ render(
71
+ <ApiProvider client={api}>
72
+ <OverridesProbe model="issue" />
73
+ </ApiProvider>,
74
+ )
75
+ await waitFor(() => expect(screen.getByTestId('available').textContent).toBe('true'))
76
+ expect(api.get).toHaveBeenCalledWith('/stage-overrides?model=issue')
77
+
78
+ fireEvent.click(screen.getByTestId('save'))
79
+ await waitFor(() =>
80
+ expect(api.put).toHaveBeenCalledWith('/stage-overrides', {
81
+ model: 'issue',
82
+ stage_key: 'backlog',
83
+ label: 'Nuevo',
84
+ color: 'blue',
85
+ filters: [{ field: 'priority', op: 'eq', value: 'high' }],
86
+ }),
87
+ )
88
+
89
+ fireEvent.click(screen.getByTestId('reset'))
90
+ await waitFor(() =>
91
+ expect(api.delete).toHaveBeenCalledWith(
92
+ '/stage-overrides?model=issue&stage_key=backlog',
93
+ ),
94
+ )
95
+ })
96
+
97
+ it('degrades to available=false when the endpoint 404s', async () => {
98
+ const api = fakeApi({ get: vi.fn(async () => Promise.reject(new Error('404'))) })
99
+ render(
100
+ <ApiProvider client={api}>
101
+ <OverridesProbe model="issue" />
102
+ </ApiProvider>,
103
+ )
104
+ await waitFor(() => expect(screen.getByTestId('available').textContent).toBe('false'))
105
+ })
106
+ })
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // 2. cardMatchesStageFilters
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('cardMatchesStageFilters', () => {
113
+ it('applies eq / neq / contains (array + string) / in', () => {
114
+ expect(cardMatchesStageFilters({ priority: 'high' }, [{ field: 'priority', op: 'eq', value: 'high' }])).toBe(true)
115
+ expect(cardMatchesStageFilters({ priority: 'low' }, [{ field: 'priority', op: 'eq', value: 'high' }])).toBe(false)
116
+ expect(cardMatchesStageFilters({ priority: 'low' }, [{ field: 'priority', op: 'neq', value: 'high' }])).toBe(true)
117
+ // contains against a jsonb array membership + a string substring
118
+ expect(cardMatchesStageFilters({ tags: ['a', 'b'] }, [{ field: 'tags', op: 'contains', value: 'b' }])).toBe(true)
119
+ expect(cardMatchesStageFilters({ title: 'hello world' }, [{ field: 'title', op: 'contains', value: 'world' }])).toBe(true)
120
+ // in: comma-separated candidates
121
+ expect(cardMatchesStageFilters({ priority: 'mid' }, [{ field: 'priority', op: 'in', value: 'mid,high' }])).toBe(true)
122
+ expect(cardMatchesStageFilters({ priority: 'low' }, [{ field: 'priority', op: 'in', value: 'mid,high' }])).toBe(false)
123
+ })
124
+
125
+ it('AND-combines multiple conditions and passes empty/blank ones', () => {
126
+ expect(
127
+ cardMatchesStageFilters({ priority: 'high', assignee: 'ana' }, [
128
+ { field: 'priority', op: 'eq', value: 'high' },
129
+ { field: 'assignee', op: 'eq', value: 'ana' },
130
+ ]),
131
+ ).toBe(true)
132
+ expect(cardMatchesStageFilters({ priority: 'high' }, [])).toBe(true)
133
+ expect(cardMatchesStageFilters({ priority: 'high' }, [{ field: 'priority', op: 'eq', value: ' ' }])).toBe(true)
134
+ })
135
+ })
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // 3. StageConfigDialog
139
+ // ---------------------------------------------------------------------------
140
+
141
+ const DECLARED_TARGET: StageConfigTarget = {
142
+ kind: 'declared',
143
+ stageKey: 'backlog',
144
+ label: 'Backlog',
145
+ color: 'slate',
146
+ filters: [],
147
+ overridden: false,
148
+ }
149
+
150
+ const CUSTOM_STAGE: CustomStage = {
151
+ id: 42,
152
+ model: 'issue',
153
+ key: 'urgent',
154
+ label: 'Urgente',
155
+ color: 'red',
156
+ position: 5,
157
+ type: 'stage',
158
+ filters: [],
159
+ enabled: true,
160
+ }
161
+
162
+ function renderConfig(target: StageConfigTarget, handlers: Partial<React.ComponentProps<typeof StageConfigDialog>> = {}) {
163
+ const props = {
164
+ open: true,
165
+ onOpenChange: vi.fn(),
166
+ columns: COLUMNS,
167
+ target,
168
+ onSaveOverride: vi.fn(async () => {}),
169
+ onResetOverride: vi.fn(async () => {}),
170
+ onUpdateCustom: vi.fn(async () => {}),
171
+ onDeleteCustom: vi.fn(),
172
+ ...handlers,
173
+ }
174
+ render(<StageConfigDialog {...props} />)
175
+ return props
176
+ }
177
+
178
+ describe('StageConfigDialog', () => {
179
+ it('a DECLARED lane saves label/color/conditions via onSaveOverride', async () => {
180
+ const props = renderConfig(DECLARED_TARGET)
181
+ fireEvent.change(screen.getByTestId('stage-config-name'), { target: { value: 'Pendientes' } })
182
+ fireEvent.click(screen.getByTestId('stage-config-color-blue'))
183
+ // add a condition
184
+ fireEvent.click(screen.getByTestId('stage-config-add-first-condition'))
185
+ fireEvent.change(screen.getByTestId('custom-stage-condition-value-0'), { target: { value: 'high' } })
186
+ fireEvent.click(screen.getByTestId('stage-config-save'))
187
+ await waitFor(() => expect(props.onSaveOverride).toHaveBeenCalled())
188
+ const [stageKey, patch] = (props.onSaveOverride as any).mock.calls[0]
189
+ expect(stageKey).toBe('backlog')
190
+ expect(patch.label).toBe('Pendientes')
191
+ expect(patch.color).toBe('blue')
192
+ expect(patch.filters).toEqual([{ field: 'title', op: 'eq', value: 'high' }])
193
+ })
194
+
195
+ it('pre-populates the WHOLE form from the lane (label, color, existing conditions)', () => {
196
+ renderConfig({
197
+ ...DECLARED_TARGET,
198
+ label: 'En revisión',
199
+ color: 'amber',
200
+ filters: [{ field: 'priority', op: 'eq', value: 'high' }],
201
+ })
202
+ // Name + color pre-filled from the current lane state.
203
+ expect((screen.getByTestId('stage-config-name') as HTMLInputElement).value).toBe('En revisión')
204
+ expect(screen.getByTestId('stage-config-color-amber').getAttribute('aria-pressed')).toBe('true')
205
+ // The existing condition is loaded into the builder, editable.
206
+ expect((screen.getByTestId('custom-stage-condition-value-0') as HTMLInputElement).value).toBe('high')
207
+ })
208
+
209
+ it('renders the current-conditions chips: locked base chip, final chip, removable filter chips', () => {
210
+ renderConfig({
211
+ ...DECLARED_TARGET,
212
+ label: 'Hecho',
213
+ isFinal: true,
214
+ filters: [
215
+ { field: 'state', op: 'eq', value: 'open' },
216
+ { field: 'priority', op: 'neq', value: 'low' },
217
+ ],
218
+ })
219
+ // Base chip is always present and shows the stage scope.
220
+ const base = screen.getByTestId('stage-config-base-chip')
221
+ expect(base.textContent).toContain('Etapa = Hecho')
222
+ // Final chip renders for a terminal stage.
223
+ expect(screen.getByTestId('stage-config-final-chip')).toBeTruthy()
224
+ // One editable chip per extra condition, with legible operators.
225
+ expect(screen.getByTestId('stage-config-filter-chip-0').textContent).toContain('state')
226
+ expect(screen.getByTestId('stage-config-filter-chip-0').textContent).toContain('=')
227
+ expect(screen.getByTestId('stage-config-filter-chip-1').textContent).toContain('≠')
228
+ })
229
+
230
+ it('removing a condition chip drops it from the builder', () => {
231
+ renderConfig({
232
+ ...DECLARED_TARGET,
233
+ filters: [
234
+ { field: 'state', op: 'eq', value: 'open' },
235
+ { field: 'priority', op: 'neq', value: 'low' },
236
+ ],
237
+ })
238
+ expect(screen.getByTestId('stage-config-filter-chip-1')).toBeTruthy()
239
+ fireEvent.click(screen.getByTestId('stage-config-filter-chip-remove-1'))
240
+ expect(screen.queryByTestId('stage-config-filter-chip-1')).toBeNull()
241
+ // The first condition survives.
242
+ expect(screen.getByTestId('stage-config-filter-chip-0')).toBeTruthy()
243
+ })
244
+
245
+ it('shows the "Personalizada" badge and a reset confirm listing the original values', async () => {
246
+ renderConfig({ ...DECLARED_TARGET, overridden: false })
247
+ expect(screen.queryByTestId('stage-config-reset')).toBeNull()
248
+ cleanup()
249
+ const props = renderConfig({
250
+ ...DECLARED_TARGET,
251
+ overridden: true,
252
+ original: {
253
+ label: 'Backlog',
254
+ color: 'slate',
255
+ filters: [{ field: 'state', op: 'eq', value: 'todo' }],
256
+ },
257
+ })
258
+ expect(screen.getByTestId('stage-config-personalizada')).toBeTruthy()
259
+ // First click reveals the confirm panel spelling out what reverts.
260
+ fireEvent.click(screen.getByTestId('stage-config-reset'))
261
+ const panel = screen.getByTestId('stage-config-reset-confirm-panel')
262
+ expect(panel.textContent).toContain('Backlog')
263
+ expect(panel.textContent).toContain('slate')
264
+ expect(panel.textContent).toContain('state = todo')
265
+ expect(props.onResetOverride).not.toHaveBeenCalled()
266
+ // The confirm button actually drops the override.
267
+ fireEvent.click(screen.getByTestId('stage-config-reset-confirm'))
268
+ await waitFor(() => expect(props.onResetOverride).toHaveBeenCalledWith('backlog'))
269
+ })
270
+
271
+ it('a CUSTOM lane saves via onUpdateCustom and deletes via onDeleteCustom', async () => {
272
+ const target: StageConfigTarget = {
273
+ kind: 'custom',
274
+ stageKey: 'urgent',
275
+ id: 42,
276
+ label: 'Urgente',
277
+ color: 'red',
278
+ filters: [],
279
+ customStage: CUSTOM_STAGE,
280
+ }
281
+ const props = renderConfig(target)
282
+ expect(screen.queryByTestId('stage-config-reset')).toBeNull()
283
+ fireEvent.change(screen.getByTestId('stage-config-name'), { target: { value: 'Muy urgente' } })
284
+ fireEvent.click(screen.getByTestId('stage-config-save'))
285
+ await waitFor(() => expect(props.onUpdateCustom).toHaveBeenCalled())
286
+ const [id, patch] = (props.onUpdateCustom as any).mock.calls[0]
287
+ expect(id).toBe(42)
288
+ expect(patch.label).toBe('Muy urgente')
289
+
290
+ fireEvent.click(screen.getByTestId('stage-config-delete'))
291
+ expect(props.onDeleteCustom).toHaveBeenCalledWith(CUSTOM_STAGE)
292
+ })
293
+ })