@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.7.1",
3
+ "version": "23.8.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
+ })
@@ -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
- className="flex min-w-[280px] max-w-[420px] flex-1 shrink-0 flex-col rounded-xl border border-dashed bg-muted/20"
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 className="flex min-w-0 items-center gap-2">
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"