@asteby/metacore-runtime-react 23.7.1 → 23.8.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 +6 -0
- package/dist/custom-stages.d.ts +13 -1
- package/dist/custom-stages.d.ts.map +1 -1
- package/dist/custom-stages.js +3 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +259 -43
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/stage-layout.d.ts +23 -0
- package/dist/stage-layout.d.ts.map +1 -0
- package/dist/stage-layout.js +70 -0
- package/package.json +1 -1
- package/src/__tests__/dynamic-kanban-lane-reorder.test.tsx +228 -0
- package/src/__tests__/stage-layout.test.tsx +210 -0
- package/src/custom-stages.tsx +30 -3
- package/src/dynamic-kanban.tsx +474 -144
- package/src/index.ts +5 -0
- package/src/stage-layout.ts +93 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Stage layout — per-org persistence of the DynamicKanban lane order. Lets a
|
|
2
|
+
// user drag the board's columns (declared stages, custom stages and smart
|
|
3
|
+
// lanes alike) into any order; the chosen order is saved server-side and
|
|
4
|
+
// re-applied on load (the ops backend stamps it onto `metadata.stages[].order`
|
|
5
|
+
// / `smart_lanes[].order`, so the board already paints ordered — this hook only
|
|
6
|
+
// owns the drag + the optimistic PUT).
|
|
7
|
+
//
|
|
8
|
+
// Non-intrusive by design: mirrors `useStageAutomations`. If the host wires no
|
|
9
|
+
// `/stage-layout` endpoint, the GET 404s, `available` stays false, and the lane
|
|
10
|
+
// drag simply never turns on — the kanban keeps working untouched.
|
|
11
|
+
//
|
|
12
|
+
// Contract (matches the ops backend; envelope is {success, data} → read .data):
|
|
13
|
+
// GET /stage-layout?model=<m> → { model, stage_order: string[] } | null
|
|
14
|
+
// PUT /stage-layout { model, stage_order: string[] } (full order)
|
|
15
|
+
// DELETE /stage-layout?model=<m> → reset to the declared order
|
|
16
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
17
|
+
import { useApi } from './api-context';
|
|
18
|
+
function unwrap(res) {
|
|
19
|
+
const body = res?.data;
|
|
20
|
+
if (body && typeof body === 'object' && 'data' in body)
|
|
21
|
+
return body.data;
|
|
22
|
+
return body;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Loads a model's saved lane order (only to learn availability + whether a
|
|
26
|
+
* custom order exists) and exposes save/reset. A missing endpoint degrades to
|
|
27
|
+
* `available: false` so the board's lane drag stays off; a real save failure
|
|
28
|
+
* re-throws so the caller can revert its optimistic reorder.
|
|
29
|
+
*/
|
|
30
|
+
export function useStageLayout(model) {
|
|
31
|
+
const api = useApi();
|
|
32
|
+
const [available, setAvailable] = useState(false);
|
|
33
|
+
const [hasCustomLayout, setHasCustomLayout] = useState(false);
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
let cancelled = false;
|
|
36
|
+
api
|
|
37
|
+
.get(`/stage-layout?model=${encodeURIComponent(model)}`)
|
|
38
|
+
.then((res) => {
|
|
39
|
+
if (cancelled)
|
|
40
|
+
return;
|
|
41
|
+
setAvailable(true);
|
|
42
|
+
const data = unwrap(res);
|
|
43
|
+
const order = data?.stage_order;
|
|
44
|
+
setHasCustomLayout(Array.isArray(order) && order.length > 0);
|
|
45
|
+
})
|
|
46
|
+
.catch(() => {
|
|
47
|
+
// Endpoint absent or errored — leave lane drag off.
|
|
48
|
+
if (!cancelled)
|
|
49
|
+
setAvailable(false);
|
|
50
|
+
});
|
|
51
|
+
return () => {
|
|
52
|
+
cancelled = true;
|
|
53
|
+
};
|
|
54
|
+
}, [api, model]);
|
|
55
|
+
const save = useCallback(async (order) => {
|
|
56
|
+
const res = (await api.put('/stage-layout', {
|
|
57
|
+
model,
|
|
58
|
+
stage_order: order,
|
|
59
|
+
}));
|
|
60
|
+
if (res?.data && res.data.success === false) {
|
|
61
|
+
throw new Error(res.data.message || 'stage_layout_save_failed');
|
|
62
|
+
}
|
|
63
|
+
setHasCustomLayout(true);
|
|
64
|
+
}, [api, model]);
|
|
65
|
+
const reset = useCallback(async () => {
|
|
66
|
+
await api.delete(`/stage-layout?model=${encodeURIComponent(model)}`);
|
|
67
|
+
setHasCustomLayout(false);
|
|
68
|
+
}, [api, model]);
|
|
69
|
+
return { available, hasCustomLayout, save, reset };
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// DynamicKanban lane reordering (drag a column header to reorder lanes).
|
|
4
|
+
// happy-dom can't faithfully simulate a dnd-kit pointer drag, so we mock
|
|
5
|
+
// @dnd-kit/core + @dnd-kit/sortable down to a passthrough that CAPTURES the
|
|
6
|
+
// board's `onDragEnd`, then drive it with a synthetic lane / card drop. This
|
|
7
|
+
// exercises the real handler: optimistic reorder + `PUT /stage-layout` with the
|
|
8
|
+
// full new key order, revert on failure, and that a card drop still moves the
|
|
9
|
+
// card (not the lanes).
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
|
|
12
|
+
|
|
13
|
+
const I18N_T = (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k
|
|
14
|
+
const I18N = { language: 'es' }
|
|
15
|
+
const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
|
|
16
|
+
vi.mock('react-i18next', () => ({
|
|
17
|
+
useTranslation: () => USE_TRANSLATION,
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
21
|
+
useNavigate: () => () => {},
|
|
22
|
+
}))
|
|
23
|
+
|
|
24
|
+
// Capture the board's drag callbacks so the test can fire a synthetic drop.
|
|
25
|
+
let captured: {
|
|
26
|
+
onDragEnd?: (e: any) => void | Promise<void>
|
|
27
|
+
onDragStart?: (e: any) => void
|
|
28
|
+
} = {}
|
|
29
|
+
vi.mock('@dnd-kit/core', () => ({
|
|
30
|
+
DndContext: ({ children, onDragEnd, onDragStart }: any) => {
|
|
31
|
+
captured.onDragEnd = onDragEnd
|
|
32
|
+
captured.onDragStart = onDragStart
|
|
33
|
+
return children
|
|
34
|
+
},
|
|
35
|
+
DragOverlay: ({ children }: any) => children ?? null,
|
|
36
|
+
PointerSensor: class {},
|
|
37
|
+
useSensor: () => ({}),
|
|
38
|
+
useSensors: () => [],
|
|
39
|
+
useDraggable: () => ({
|
|
40
|
+
attributes: {},
|
|
41
|
+
listeners: {},
|
|
42
|
+
setNodeRef: () => {},
|
|
43
|
+
isDragging: false,
|
|
44
|
+
}),
|
|
45
|
+
useDroppable: () => ({ setNodeRef: () => {}, isOver: false }),
|
|
46
|
+
}))
|
|
47
|
+
vi.mock('@dnd-kit/sortable', async (orig) => {
|
|
48
|
+
const actual = (await orig()) as Record<string, unknown>
|
|
49
|
+
return {
|
|
50
|
+
...actual,
|
|
51
|
+
SortableContext: ({ children }: any) => children,
|
|
52
|
+
useSortable: () => ({
|
|
53
|
+
setNodeRef: () => {},
|
|
54
|
+
setActivatorNodeRef: () => {},
|
|
55
|
+
attributes: {},
|
|
56
|
+
listeners: {},
|
|
57
|
+
transform: null,
|
|
58
|
+
transition: undefined,
|
|
59
|
+
isDragging: false,
|
|
60
|
+
isOver: false,
|
|
61
|
+
}),
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
import { DynamicKanban } from '../dynamic-kanban'
|
|
66
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
67
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
68
|
+
import type { TableMetadata } from '../types'
|
|
69
|
+
|
|
70
|
+
const STAGES = [
|
|
71
|
+
{ key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
|
|
72
|
+
{ key: 'in_progress', label: 'In Progress', color: 'blue', order: 1 },
|
|
73
|
+
{ key: 'review', label: 'Review', color: 'amber', order: 2 },
|
|
74
|
+
{ key: 'done', label: 'Done', color: 'green', order: 3 },
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
function meta(): TableMetadata {
|
|
78
|
+
return {
|
|
79
|
+
title: 'Issues',
|
|
80
|
+
endpoint: '/data/issue',
|
|
81
|
+
view_type: 'kanban',
|
|
82
|
+
group_by: 'stage',
|
|
83
|
+
stages: STAGES,
|
|
84
|
+
transitions: [
|
|
85
|
+
{ from: 'backlog', to: 'in_progress' },
|
|
86
|
+
{ from: 'in_progress', to: 'review' },
|
|
87
|
+
{ from: 'review', to: 'done' },
|
|
88
|
+
],
|
|
89
|
+
columns: [
|
|
90
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
91
|
+
{
|
|
92
|
+
key: 'stage',
|
|
93
|
+
label: 'Stage',
|
|
94
|
+
type: 'status',
|
|
95
|
+
sortable: false,
|
|
96
|
+
filterable: true,
|
|
97
|
+
options: STAGES.map((s) => ({ value: s.key, label: s.label, color: s.color })),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
actions: [],
|
|
101
|
+
perPageOptions: [50],
|
|
102
|
+
defaultPerPage: 50,
|
|
103
|
+
searchPlaceholder: 'Buscar...',
|
|
104
|
+
enableCRUDActions: true,
|
|
105
|
+
hasActions: false,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const CARDS = [
|
|
110
|
+
{ id: 1, title: 'Fix login bug', stage: 'backlog' },
|
|
111
|
+
{ id: 7, title: 'E2E tests', stage: 'review' },
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
function fakeApi(over: Partial<ApiClient> = {}): ApiClient {
|
|
115
|
+
const ok = (data: unknown) => ({ data: { success: true, data } })
|
|
116
|
+
return {
|
|
117
|
+
get: vi.fn(async (url: string) => {
|
|
118
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
119
|
+
if (url.startsWith('/stage-layout')) return ok({ model: 'issue', stage_order: null })
|
|
120
|
+
if (url.startsWith('/custom-stages')) return { data: { success: false } }
|
|
121
|
+
return ok(CARDS)
|
|
122
|
+
}),
|
|
123
|
+
post: vi.fn(async () => ok(null)),
|
|
124
|
+
put: vi.fn(async () => ok(null)),
|
|
125
|
+
delete: vi.fn(async () => ok(null)),
|
|
126
|
+
...over,
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const laneOrder = () =>
|
|
131
|
+
Array.from(document.querySelectorAll('[data-stage]')).map((el) =>
|
|
132
|
+
el.getAttribute('data-stage'),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
beforeEach(() => {
|
|
136
|
+
captured = {}
|
|
137
|
+
})
|
|
138
|
+
afterEach(cleanup)
|
|
139
|
+
|
|
140
|
+
describe('DynamicKanban lane reorder', () => {
|
|
141
|
+
it('an optimistic reorder PUTs the full new key order to /stage-layout', async () => {
|
|
142
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
143
|
+
const put = vi.fn(async () => ({ data: { success: true, data: null } }))
|
|
144
|
+
render(
|
|
145
|
+
<ApiProvider client={fakeApi({ put })}>
|
|
146
|
+
<DynamicKanban model="issue" />
|
|
147
|
+
</ApiProvider>,
|
|
148
|
+
)
|
|
149
|
+
await screen.findByText('Backlog')
|
|
150
|
+
// baseline order
|
|
151
|
+
await waitFor(() =>
|
|
152
|
+
expect(laneOrder()).toEqual(['backlog', 'in_progress', 'review', 'done']),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
// drag "in_progress" onto "backlog" (move index 1 → 0)
|
|
156
|
+
await act(async () => {
|
|
157
|
+
await captured.onDragEnd!({
|
|
158
|
+
active: { id: 'in_progress', data: { current: { type: 'lane' } } },
|
|
159
|
+
over: { id: 'backlog' },
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
expect(put).toHaveBeenCalledWith('/stage-layout', {
|
|
164
|
+
model: 'issue',
|
|
165
|
+
stage_order: ['in_progress', 'backlog', 'review', 'done'],
|
|
166
|
+
})
|
|
167
|
+
// the board reflects the new order optimistically
|
|
168
|
+
expect(laneOrder()).toEqual(['in_progress', 'backlog', 'review', 'done'])
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('reverts the order when the PUT fails', async () => {
|
|
172
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
173
|
+
const put = vi.fn(async (url: string) => {
|
|
174
|
+
if (url === '/stage-layout') throw new Error('boom')
|
|
175
|
+
return { data: { success: true, data: null } }
|
|
176
|
+
})
|
|
177
|
+
render(
|
|
178
|
+
<ApiProvider client={fakeApi({ put })}>
|
|
179
|
+
<DynamicKanban model="issue" />
|
|
180
|
+
</ApiProvider>,
|
|
181
|
+
)
|
|
182
|
+
await screen.findByText('Backlog')
|
|
183
|
+
await waitFor(() =>
|
|
184
|
+
expect(laneOrder()).toEqual(['backlog', 'in_progress', 'review', 'done']),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
await act(async () => {
|
|
188
|
+
await captured.onDragEnd!({
|
|
189
|
+
active: { id: 'done', data: { current: { type: 'lane' } } },
|
|
190
|
+
over: { id: 'backlog' },
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
expect(put).toHaveBeenCalledWith(
|
|
195
|
+
'/stage-layout',
|
|
196
|
+
expect.objectContaining({ stage_order: ['done', 'backlog', 'in_progress', 'review'] }),
|
|
197
|
+
)
|
|
198
|
+
// reverted back to the declared order
|
|
199
|
+
await waitFor(() =>
|
|
200
|
+
expect(laneOrder()).toEqual(['backlog', 'in_progress', 'review', 'done']),
|
|
201
|
+
)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('a card drop still moves the card (PUT to the record), not the lanes', async () => {
|
|
205
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
206
|
+
const put = vi.fn(async () => ({ data: { success: true, data: null } }))
|
|
207
|
+
render(
|
|
208
|
+
<ApiProvider client={fakeApi({ put })}>
|
|
209
|
+
<DynamicKanban model="issue" />
|
|
210
|
+
</ApiProvider>,
|
|
211
|
+
)
|
|
212
|
+
await screen.findByText('E2E tests')
|
|
213
|
+
|
|
214
|
+
// card 7 review -> done (a declared transition)
|
|
215
|
+
await act(async () => {
|
|
216
|
+
await captured.onDragEnd!({
|
|
217
|
+
active: { id: '7', data: { current: { type: 'card' } } },
|
|
218
|
+
over: { id: 'done' },
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
expect(put).toHaveBeenCalledWith('/data/issue/7', { stage: 'done' })
|
|
223
|
+
// no /stage-layout PUT for a card move
|
|
224
|
+
expect(put).not.toHaveBeenCalledWith('/stage-layout', expect.anything())
|
|
225
|
+
// lane order unchanged
|
|
226
|
+
expect(laneOrder()).toEqual(['backlog', 'in_progress', 'review', 'done'])
|
|
227
|
+
})
|
|
228
|
+
})
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
//
|
|
3
|
+
// useStageLayout (per-org kanban lane-order persistence) + its non-intrusive
|
|
4
|
+
// gating in DynamicKanban: the reset affordance only shows when a custom order
|
|
5
|
+
// exists, and a missing `/stage-layout` endpoint leaves the board fully intact.
|
|
6
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
7
|
+
import {
|
|
8
|
+
cleanup,
|
|
9
|
+
fireEvent,
|
|
10
|
+
render,
|
|
11
|
+
renderHook,
|
|
12
|
+
screen,
|
|
13
|
+
waitFor,
|
|
14
|
+
} from '@testing-library/react'
|
|
15
|
+
import React from 'react'
|
|
16
|
+
|
|
17
|
+
const I18N_T = (_k: string, opts?: { defaultValue?: string }) => opts?.defaultValue ?? _k
|
|
18
|
+
const I18N = { language: 'es' }
|
|
19
|
+
const USE_TRANSLATION = { t: I18N_T, i18n: I18N }
|
|
20
|
+
vi.mock('react-i18next', () => ({
|
|
21
|
+
useTranslation: () => USE_TRANSLATION,
|
|
22
|
+
}))
|
|
23
|
+
vi.mock('@tanstack/react-router', () => ({
|
|
24
|
+
useNavigate: () => () => {},
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
import { useStageLayout } from '../stage-layout'
|
|
28
|
+
import { DynamicKanban } from '../dynamic-kanban'
|
|
29
|
+
import { ApiProvider, type ApiClient } from '../api-context'
|
|
30
|
+
import { useMetadataCache } from '../metadata-cache'
|
|
31
|
+
import type { TableMetadata } from '../types'
|
|
32
|
+
|
|
33
|
+
const STAGES = [
|
|
34
|
+
{ key: 'backlog', label: 'Backlog', color: 'slate', order: 0 },
|
|
35
|
+
{ key: 'in_progress', label: 'In Progress', color: 'blue', order: 1 },
|
|
36
|
+
{ key: 'done', label: 'Done', color: 'green', order: 2 },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
function meta(): TableMetadata {
|
|
40
|
+
return {
|
|
41
|
+
title: 'Issues',
|
|
42
|
+
endpoint: '/data/issue',
|
|
43
|
+
view_type: 'kanban',
|
|
44
|
+
group_by: 'stage',
|
|
45
|
+
stages: STAGES,
|
|
46
|
+
columns: [
|
|
47
|
+
{ key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
|
|
48
|
+
{
|
|
49
|
+
key: 'stage',
|
|
50
|
+
label: 'Stage',
|
|
51
|
+
type: 'status',
|
|
52
|
+
sortable: false,
|
|
53
|
+
filterable: true,
|
|
54
|
+
options: STAGES.map((s) => ({ value: s.key, label: s.label, color: s.color })),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
actions: [],
|
|
58
|
+
perPageOptions: [50],
|
|
59
|
+
defaultPerPage: 50,
|
|
60
|
+
searchPlaceholder: 'Buscar...',
|
|
61
|
+
enableCRUDActions: true,
|
|
62
|
+
hasActions: false,
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const CARDS = [{ id: 1, title: 'Fix login bug', stage: 'backlog' }]
|
|
67
|
+
|
|
68
|
+
function fakeApi(over: Partial<ApiClient> = {}, stageLayoutData: any = { model: 'issue', stage_order: null }): ApiClient {
|
|
69
|
+
const ok = (data: unknown) => ({ data: { success: true, data } })
|
|
70
|
+
return {
|
|
71
|
+
get: vi.fn(async (url: string) => {
|
|
72
|
+
if (url.startsWith('/metadata/table/')) return ok(meta())
|
|
73
|
+
if (url.startsWith('/stage-layout')) return ok(stageLayoutData)
|
|
74
|
+
if (url.startsWith('/custom-stages')) return { data: { success: false } }
|
|
75
|
+
return ok(CARDS)
|
|
76
|
+
}),
|
|
77
|
+
post: vi.fn(async () => ok(null)),
|
|
78
|
+
put: vi.fn(async () => ok(null)),
|
|
79
|
+
delete: vi.fn(async () => ok(null)),
|
|
80
|
+
...over,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const wrapper =
|
|
85
|
+
(client: ApiClient) =>
|
|
86
|
+
({ children }: { children: React.ReactNode }) =>
|
|
87
|
+
<ApiProvider client={client}>{children}</ApiProvider>
|
|
88
|
+
|
|
89
|
+
afterEach(cleanup)
|
|
90
|
+
|
|
91
|
+
describe('useStageLayout', () => {
|
|
92
|
+
it('reports available + hasCustomLayout from the GET', async () => {
|
|
93
|
+
const api = fakeApi({}, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
|
|
94
|
+
const { result } = renderHook(() => useStageLayout('issue'), {
|
|
95
|
+
wrapper: wrapper(api),
|
|
96
|
+
})
|
|
97
|
+
await waitFor(() => expect(result.current.available).toBe(true))
|
|
98
|
+
expect(result.current.hasCustomLayout).toBe(true)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('is unavailable when the endpoint 404s (drag stays off)', async () => {
|
|
102
|
+
const api = fakeApi({
|
|
103
|
+
get: vi.fn(async (url: string) => {
|
|
104
|
+
if (url.startsWith('/stage-layout')) throw new Error('404')
|
|
105
|
+
return { data: { success: true, data: meta() } }
|
|
106
|
+
}),
|
|
107
|
+
})
|
|
108
|
+
const { result } = renderHook(() => useStageLayout('issue'), {
|
|
109
|
+
wrapper: wrapper(api),
|
|
110
|
+
})
|
|
111
|
+
// give the effect a tick
|
|
112
|
+
await waitFor(() => expect(api.get).toHaveBeenCalled())
|
|
113
|
+
await new Promise((r) => setTimeout(r, 0))
|
|
114
|
+
expect(result.current.available).toBe(false)
|
|
115
|
+
expect(result.current.hasCustomLayout).toBe(false)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('save PUTs the full order and reset DELETEs', async () => {
|
|
119
|
+
const put = vi.fn(async () => ({ data: { success: true, data: null } }))
|
|
120
|
+
const del = vi.fn(async () => ({ data: { success: true, data: null } }))
|
|
121
|
+
const api = fakeApi({ put, delete: del })
|
|
122
|
+
const { result } = renderHook(() => useStageLayout('issue'), {
|
|
123
|
+
wrapper: wrapper(api),
|
|
124
|
+
})
|
|
125
|
+
await waitFor(() => expect(result.current.available).toBe(true))
|
|
126
|
+
|
|
127
|
+
await result.current.save(['done', 'backlog', 'in_progress'])
|
|
128
|
+
expect(put).toHaveBeenCalledWith('/stage-layout', {
|
|
129
|
+
model: 'issue',
|
|
130
|
+
stage_order: ['done', 'backlog', 'in_progress'],
|
|
131
|
+
})
|
|
132
|
+
await waitFor(() => expect(result.current.hasCustomLayout).toBe(true))
|
|
133
|
+
|
|
134
|
+
await result.current.reset()
|
|
135
|
+
expect(del).toHaveBeenCalledWith('/stage-layout?model=issue')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('save re-throws on a {success:false} envelope', async () => {
|
|
139
|
+
const put = vi.fn(async () => ({ data: { success: false, message: 'nope' } }))
|
|
140
|
+
const api = fakeApi({ put })
|
|
141
|
+
const { result } = renderHook(() => useStageLayout('issue'), {
|
|
142
|
+
wrapper: wrapper(api),
|
|
143
|
+
})
|
|
144
|
+
await waitFor(() => expect(result.current.available).toBe(true))
|
|
145
|
+
await expect(result.current.save(['a', 'b'])).rejects.toThrow()
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('DynamicKanban stage-layout gating', () => {
|
|
150
|
+
it('shows the reset-order affordance only when a custom order exists', async () => {
|
|
151
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
152
|
+
const api = fakeApi({}, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
|
|
153
|
+
render(
|
|
154
|
+
<ApiProvider client={api}>
|
|
155
|
+
<DynamicKanban model="issue" />
|
|
156
|
+
</ApiProvider>,
|
|
157
|
+
)
|
|
158
|
+
await screen.findByText('Backlog')
|
|
159
|
+
expect(
|
|
160
|
+
await screen.findByTestId('kanban-reset-order'),
|
|
161
|
+
).toBeTruthy()
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('resets the order: DELETE + metadata refetch', async () => {
|
|
165
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
166
|
+
const del = vi.fn(async () => ({ data: { success: true, data: null } }))
|
|
167
|
+
const api = fakeApi({ delete: del }, { model: 'issue', stage_order: ['done', 'backlog', 'in_progress'] })
|
|
168
|
+
render(
|
|
169
|
+
<ApiProvider client={api}>
|
|
170
|
+
<DynamicKanban model="issue" />
|
|
171
|
+
</ApiProvider>,
|
|
172
|
+
)
|
|
173
|
+
const btn = await screen.findByTestId('kanban-reset-order')
|
|
174
|
+
const metaCallsBefore = (api.get as any).mock.calls.filter((c: any[]) =>
|
|
175
|
+
String(c[0]).startsWith('/metadata/table/'),
|
|
176
|
+
).length
|
|
177
|
+
fireEvent.click(btn)
|
|
178
|
+
await waitFor(() => expect(del).toHaveBeenCalledWith('/stage-layout?model=issue'))
|
|
179
|
+
// a fresh metadata GET followed the reset (falls back to declared order)
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
const after = (api.get as any).mock.calls.filter((c: any[]) =>
|
|
182
|
+
String(c[0]).startsWith('/metadata/table/'),
|
|
183
|
+
).length
|
|
184
|
+
expect(after).toBeGreaterThan(metaCallsBefore)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('a missing /stage-layout endpoint leaves the board intact (no reset, lanes + cards render)', async () => {
|
|
189
|
+
useMetadataCache.getState().setMetadata('issue', meta())
|
|
190
|
+
const api = fakeApi({
|
|
191
|
+
get: vi.fn(async (url: string) => {
|
|
192
|
+
if (url.startsWith('/metadata/table/')) return { data: { success: true, data: meta() } }
|
|
193
|
+
if (url.startsWith('/stage-layout')) throw new Error('404')
|
|
194
|
+
if (url.startsWith('/custom-stages')) return { data: { success: false } }
|
|
195
|
+
return { data: { success: true, data: CARDS } }
|
|
196
|
+
}),
|
|
197
|
+
})
|
|
198
|
+
render(
|
|
199
|
+
<ApiProvider client={api}>
|
|
200
|
+
<DynamicKanban model="issue" />
|
|
201
|
+
</ApiProvider>,
|
|
202
|
+
)
|
|
203
|
+
// board still paints every lane + a card
|
|
204
|
+
expect(await screen.findByText('Backlog')).toBeTruthy()
|
|
205
|
+
expect(screen.getByText('Done')).toBeTruthy()
|
|
206
|
+
expect(await screen.findByText('Fix login bug')).toBeTruthy()
|
|
207
|
+
// no reset affordance without a wired endpoint
|
|
208
|
+
expect(screen.queryByTestId('kanban-reset-order')).toBeNull()
|
|
209
|
+
})
|
|
210
|
+
})
|
package/src/custom-stages.tsx
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// All UI text goes through t() with a Spanish defaultValue.
|
|
20
20
|
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
|
21
21
|
import { useTranslation } from 'react-i18next'
|
|
22
|
-
import { Plus, Trash2, Pencil, MoreVertical, Filter, X } from 'lucide-react'
|
|
22
|
+
import { Plus, Trash2, Pencil, MoreVertical, Filter, GripVertical, X } from 'lucide-react'
|
|
23
23
|
import { toast } from 'sonner'
|
|
24
24
|
import {
|
|
25
25
|
Badge,
|
|
@@ -987,6 +987,18 @@ export interface SmartLaneProps {
|
|
|
987
987
|
refreshTrigger?: any
|
|
988
988
|
onEdit: (stage: CustomStage) => void
|
|
989
989
|
onDelete: (stage: CustomStage) => void
|
|
990
|
+
/**
|
|
991
|
+
* Optional drag-and-drop wiring (from the kanban's sortable wrapper) so a
|
|
992
|
+
* smart lane can be reordered by its header like a real stage. Absent → the
|
|
993
|
+
* lane is static.
|
|
994
|
+
*/
|
|
995
|
+
dnd?: {
|
|
996
|
+
setNodeRef: (el: HTMLElement | null) => void
|
|
997
|
+
style?: React.CSSProperties
|
|
998
|
+
isDragging?: boolean
|
|
999
|
+
handleRef?: (el: HTMLElement | null) => void
|
|
1000
|
+
handleProps?: Record<string, any>
|
|
1001
|
+
}
|
|
990
1002
|
}
|
|
991
1003
|
|
|
992
1004
|
/**
|
|
@@ -1006,6 +1018,7 @@ export function SmartLane({
|
|
|
1006
1018
|
refreshTrigger,
|
|
1007
1019
|
onEdit,
|
|
1008
1020
|
onDelete,
|
|
1021
|
+
dnd,
|
|
1009
1022
|
}: SmartLaneProps) {
|
|
1010
1023
|
const { t } = useTranslation()
|
|
1011
1024
|
const api = useApi()
|
|
@@ -1054,12 +1067,26 @@ export function SmartLane({
|
|
|
1054
1067
|
|
|
1055
1068
|
return (
|
|
1056
1069
|
<div
|
|
1057
|
-
|
|
1070
|
+
ref={dnd?.setNodeRef}
|
|
1071
|
+
className="group/lane flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border border-dashed bg-muted/20"
|
|
1072
|
+
style={{ opacity: dnd?.isDragging ? 0.6 : 1, ...dnd?.style }}
|
|
1058
1073
|
data-smart-stage={stage.key}
|
|
1059
1074
|
data-testid={`smart-lane-${stage.key}`}
|
|
1060
1075
|
>
|
|
1061
1076
|
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
|
|
1062
|
-
<div
|
|
1077
|
+
<div
|
|
1078
|
+
ref={dnd ? dnd.handleRef : undefined}
|
|
1079
|
+
{...(dnd ? dnd.handleProps : {})}
|
|
1080
|
+
className={`flex min-w-0 items-center gap-1.5 ${
|
|
1081
|
+
dnd ? 'cursor-grab active:cursor-grabbing' : ''
|
|
1082
|
+
}`}
|
|
1083
|
+
>
|
|
1084
|
+
{dnd && (
|
|
1085
|
+
<GripVertical
|
|
1086
|
+
className="h-3.5 w-3.5 shrink-0 text-muted-foreground/40 opacity-0 transition-opacity group-hover/lane:opacity-70"
|
|
1087
|
+
aria-hidden
|
|
1088
|
+
/>
|
|
1089
|
+
)}
|
|
1063
1090
|
<Badge
|
|
1064
1091
|
variant="outline"
|
|
1065
1092
|
className="border-0 text-xs font-semibold"
|