Skip to content

Commit 0df7bd4

Browse files
Merge pull request #135 from laststance/feat/move-to-another-board
feat: add Move to Another Board for repo cards
2 parents 36f73ae + a8b8f88 commit 0df7bd4

File tree

13 files changed

+959
-1
lines changed

13 files changed

+959
-1
lines changed

e2e/helpers/db-query.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,13 @@ export const STATUS_IDS = {
168168
productionRelease: '00000000-0000-0000-0000-000000000205',
169169
} as const
170170

171+
/** Work Projects board status list UUIDs */
172+
export const WORK_PROJECT_STATUS_IDS = {
173+
backlog: '00000000-0000-0000-0000-000000000211',
174+
active: '00000000-0000-0000-0000-000000000212',
175+
complete: '00000000-0000-0000-0000-000000000213',
176+
} as const
177+
171178
/** Repo card UUIDs */
172179
export const CARD_IDS = {
173180
card1: '00000000-0000-0000-0000-000000000301',
@@ -796,3 +803,48 @@ export async function resetUserSettings(): Promise<void> {
796803
throw new Error(`resetUserSettings: ${error.message}`)
797804
}
798805
}
806+
807+
/**
808+
* Reset a moved repo card back to its original board (testBoard).
809+
* Call this in afterEach for move-to-another-board tests to ensure clean state.
810+
*
811+
* @param cardId - The card ID to restore
812+
* @param originalStatusId - The original status list ID (defaults to Planning)
813+
* @param originalOrder - The original order position (defaults to 0).
814+
* NOTE: The default order=0 may not match seeded data for some cards
815+
* (e.g. card5 is seeded at order=2). Pass the correct originalOrder when known.
816+
*
817+
* @example
818+
* test.afterEach(async () => {
819+
* await resetRepoCardToOriginalBoard(CARD_IDS.card5, STATUS_IDS.planning, 2)
820+
* })
821+
*/
822+
export async function resetRepoCardToOriginalBoard(
823+
cardId: string,
824+
originalStatusId: string = STATUS_IDS.planning,
825+
originalOrder: number = 0,
826+
): Promise<void> {
827+
const supabase = createLocalSupabaseClient()
828+
829+
const { data, error } = await supabase
830+
.from('repocard')
831+
.update({
832+
board_id: BOARD_IDS.testBoard,
833+
status_id: originalStatusId,
834+
order: originalOrder,
835+
})
836+
.eq('id', cardId)
837+
.select('id')
838+
839+
if (error) {
840+
throw new Error(
841+
`resetRepoCardToOriginalBoard: failed for id=${cardId}: ${error.message}`,
842+
)
843+
}
844+
if (!data || data.length === 0) {
845+
throw new Error(
846+
`resetRepoCardToOriginalBoard: UPDATE matched 0 rows for id=${cardId}. ` +
847+
`URL=${process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://127.0.0.1:54321'}`,
848+
)
849+
}
850+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* Move to Another Board E2E Tests
3+
*
4+
* Tests for moving repository cards between boards while preserving projectinfo.
5+
* Requires authentication (uses storageState from auth.setup.ts).
6+
*
7+
* Feature (Issue #133):
8+
* - OverflowMenu displays "Move to Another Board" option
9+
* - Dialog opens with board/column selects (current board excluded)
10+
* - Card moves atomically via RPC, preserving projectinfo FK
11+
* - Card disappears from source board after move
12+
*/
13+
14+
import { test, expect } from '../fixtures/coverage'
15+
import {
16+
querySingle,
17+
CARD_IDS,
18+
BOARD_IDS,
19+
resetRepoCards,
20+
} from '../helpers/db-query'
21+
22+
test.describe('Move to Another Board Feature', () => {
23+
test.use({ storageState: 'e2e/.auth/user.json' })
24+
25+
const BOARD_URL = `/board/${BOARD_IDS.testBoard}`
26+
27+
test.afterEach(async () => {
28+
// Restore all cards to original board for test isolation
29+
await resetRepoCards()
30+
})
31+
32+
test('should display Move to Another Board option in overflow menu', async ({
33+
page,
34+
}) => {
35+
await page.goto(BOARD_URL)
36+
await page.waitForLoadState('networkidle')
37+
38+
// Open overflow menu on first card
39+
const overflowMenuTrigger = page
40+
.locator('[data-testid^="overflow-menu-trigger-"]')
41+
.first()
42+
await expect(overflowMenuTrigger).toBeVisible({ timeout: 10000 })
43+
await overflowMenuTrigger.click()
44+
45+
// Wait for dropdown menu
46+
const dropdownMenu = page.getByTestId('overflow-menu')
47+
await expect(dropdownMenu).toBeVisible({ timeout: 5000 })
48+
49+
// Verify "Move to Another Board" menu item visible
50+
const moveMenuItem = page
51+
.locator('[data-testid^="move-to-another-board-"]')
52+
.first()
53+
await expect(moveMenuItem).toBeVisible()
54+
55+
// Verify positioned after "Move to Maintenance"
56+
const maintenanceMenuItem = page
57+
.locator('[data-testid^="move-to-maintenance-"]')
58+
.first()
59+
await expect(maintenanceMenuItem).toBeVisible()
60+
})
61+
62+
test('should open dialog with board list (current board excluded)', async ({
63+
page,
64+
}) => {
65+
await page.goto(BOARD_URL)
66+
await page.waitForLoadState('networkidle')
67+
68+
// Open overflow menu and click Move to Another Board
69+
const overflowMenuTrigger = page
70+
.locator('[data-testid^="overflow-menu-trigger-"]')
71+
.first()
72+
await expect(overflowMenuTrigger).toBeVisible({ timeout: 10000 })
73+
await overflowMenuTrigger.click()
74+
75+
const moveMenuItem = page
76+
.locator('[data-testid^="move-to-another-board-"]')
77+
.first()
78+
await moveMenuItem.click()
79+
80+
// Dialog should appear
81+
const dialog = page.getByRole('dialog')
82+
await expect(dialog).toBeVisible({ timeout: 5000 })
83+
84+
// Verify title (use heading role to avoid accessibleTitle duplicate)
85+
await expect(
86+
dialog.getByRole('heading', { name: 'Move to Another Board' }).first(),
87+
).toBeVisible()
88+
89+
// Open board select
90+
await dialog.locator('#board-select').click()
91+
92+
// "Work Projects" should be visible (another board)
93+
await expect(
94+
page.getByRole('option', { name: 'Work Projects' }),
95+
).toBeVisible()
96+
})
97+
98+
test('should close dialog on Cancel', async ({ page }) => {
99+
await page.goto(BOARD_URL)
100+
await page.waitForLoadState('networkidle')
101+
102+
// Open dialog
103+
const overflowMenuTrigger = page
104+
.locator('[data-testid^="overflow-menu-trigger-"]')
105+
.first()
106+
await expect(overflowMenuTrigger).toBeVisible({ timeout: 10000 })
107+
await overflowMenuTrigger.click()
108+
109+
const moveMenuItem = page
110+
.locator('[data-testid^="move-to-another-board-"]')
111+
.first()
112+
await moveMenuItem.click()
113+
114+
const dialog = page.getByRole('dialog')
115+
await expect(dialog).toBeVisible({ timeout: 5000 })
116+
117+
// Click Cancel
118+
await dialog.getByRole('button', { name: /cancel/i }).click()
119+
120+
// Dialog should close
121+
await expect(dialog).not.toBeVisible({ timeout: 5000 })
122+
})
123+
124+
test('should move card to another board and remove from source', async ({
125+
page,
126+
}) => {
127+
await resetRepoCards()
128+
const cardId = CARD_IDS.card5 // laststance/use-app-state
129+
130+
// Verify card exists on testBoard before move
131+
const cardBefore = await querySingle('repocard', { id: cardId })
132+
expect(cardBefore).not.toBeNull()
133+
expect((cardBefore as { board_id: string }).board_id).toBe(
134+
BOARD_IDS.testBoard,
135+
)
136+
137+
await page.goto(BOARD_URL)
138+
await page.waitForLoadState('networkidle')
139+
140+
// Open overflow menu on card5
141+
const overflowMenuTrigger = page.locator(
142+
`[data-testid="overflow-menu-trigger-${cardId}"]`,
143+
)
144+
await expect(overflowMenuTrigger).toBeVisible({ timeout: 10000 })
145+
await overflowMenuTrigger.click()
146+
147+
// Click "Move to Another Board"
148+
const moveMenuItem = page.locator(
149+
`[data-testid="move-to-another-board-${cardId}"]`,
150+
)
151+
await moveMenuItem.click()
152+
153+
// Dialog appears
154+
const dialog = page.getByRole('dialog')
155+
await expect(dialog).toBeVisible({ timeout: 5000 })
156+
157+
// Select "Work Projects" board
158+
await dialog.locator('#board-select').click()
159+
await page.getByRole('option', { name: 'Work Projects' }).click()
160+
161+
// Click Move
162+
await dialog.getByRole('button', { name: /^move$/i }).click()
163+
164+
// Dialog closes, card disappears from source board
165+
await expect(dialog).not.toBeVisible({ timeout: 5000 })
166+
await expect(
167+
page.locator(`[data-testid="repo-card-${cardId}"]`),
168+
).not.toBeVisible({ timeout: 5000 })
169+
170+
// DB verification: card now on workProjects board
171+
await expect(async () => {
172+
const cardAfter = await querySingle('repocard', { id: cardId })
173+
expect(cardAfter).not.toBeNull()
174+
expect((cardAfter as { board_id: string }).board_id).toBe(
175+
BOARD_IDS.workProjects,
176+
)
177+
}).toPass({ timeout: 10000 })
178+
})
179+
180+
test('should preserve projectinfo after move', async ({ page }) => {
181+
await resetRepoCards()
182+
const cardId = CARD_IDS.card1 // testuser/test-repo (has projectinfo with notes+links)
183+
184+
// Get projectinfo before move
185+
const projInfoBefore = await querySingle('projectinfo', {
186+
repo_card_id: cardId,
187+
})
188+
expect(projInfoBefore).not.toBeNull()
189+
expect((projInfoBefore as { note: string }).note).toBe(
190+
'Important project notes here',
191+
)
192+
193+
await page.goto(BOARD_URL)
194+
await page.waitForLoadState('networkidle')
195+
196+
// Open overflow menu on card1
197+
const overflowMenuTrigger = page.locator(
198+
`[data-testid="overflow-menu-trigger-${cardId}"]`,
199+
)
200+
await expect(overflowMenuTrigger).toBeVisible({ timeout: 10000 })
201+
await overflowMenuTrigger.click()
202+
203+
// Click "Move to Another Board"
204+
const moveMenuItem = page.locator(
205+
`[data-testid="move-to-another-board-${cardId}"]`,
206+
)
207+
await moveMenuItem.click()
208+
209+
// Select "Work Projects" board
210+
const dialog = page.getByRole('dialog')
211+
await expect(dialog).toBeVisible({ timeout: 5000 })
212+
await dialog.locator('#board-select').click()
213+
await page.getByRole('option', { name: 'Work Projects' }).click()
214+
215+
// Click Move
216+
await dialog.getByRole('button', { name: /^move$/i }).click()
217+
await expect(dialog).not.toBeVisible({ timeout: 5000 })
218+
219+
// Verify projectinfo FK unchanged, data intact
220+
await expect(async () => {
221+
const projInfoAfter = await querySingle('projectinfo', {
222+
repo_card_id: cardId,
223+
})
224+
expect(projInfoAfter).not.toBeNull()
225+
expect((projInfoAfter as { note: string }).note).toBe(
226+
'Important project notes here',
227+
)
228+
expect((projInfoAfter as { id: string }).id).toBe(
229+
(projInfoBefore as { id: string }).id,
230+
)
231+
}).toPass({ timeout: 10000 })
232+
})
233+
})

src/app/board/[id]/BoardPageClient.tsx

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,13 @@
1818
import * as Sentry from '@sentry/nextjs'
1919
import { Link, Plus, Settings } from 'lucide-react'
2020
import { useRouter } from 'next/navigation'
21-
import { useCallback, memo, useEffect, useLayoutEffect } from 'react'
21+
import { useState, useCallback, memo, useEffect, useLayoutEffect } from 'react'
2222
import { toast } from 'sonner'
2323

2424
import { AddRepositoryCombobox } from '@/components/Board/AddRepositoryCombobox'
2525
import { KanbanBoard } from '@/components/Board/KanbanBoard'
2626
import { BoardSettingsDialog } from '@/components/Boards/BoardSettingsDialog'
27+
import { MoveToAnotherBoardDialog } from '@/components/Modals/MoveToAnotherBoardDialog'
2728
import { NoteModal } from '@/components/Modals/NoteModal'
2829
import { StatusListDialog } from '@/components/Modals/StatusListDialog'
2930
import {
@@ -57,6 +58,7 @@ import {
5758
setRepoCards,
5859
setActiveBoard,
5960
addRepoCards,
61+
removeRepoCard,
6062
selectStatusLists,
6163
selectRepoCards,
6264
} from '@/lib/redux/slices/boardSlice'
@@ -160,6 +162,24 @@ export const BoardPageClient = memo(function BoardPageClient({
160162
[executeCardAction],
161163
)
162164

165+
// ========================================
166+
// Move to Another Board Handler
167+
// ========================================
168+
169+
const [moveDialogCardId, setMoveDialogCardId] = useState<string | null>(null)
170+
171+
const handleMoveToAnotherBoard = useCallback((cardId: string) => {
172+
setMoveDialogCardId(cardId)
173+
}, [])
174+
175+
const handleMoveSuccess = useCallback(
176+
(cardId: string) => {
177+
dispatch(removeRepoCard(cardId))
178+
setMoveDialogCardId(null)
179+
},
180+
[dispatch],
181+
)
182+
163183
// ========================================
164184
// Board Delete Handler
165185
// ========================================
@@ -319,6 +339,7 @@ export const BoardPageClient = memo(function BoardPageClient({
319339
initialComments={initialData.comments}
320340
cardDisplaySettings={boardSettings.cardDisplaySettings}
321341
onMoveToMaintenance={handleMoveToMaintenance}
342+
onMoveToAnotherBoard={handleMoveToAnotherBoard}
322343
onNote={noteModal.open}
323344
onRemove={handleRemoveFromBoard}
324345
onEditStatus={statusListDialog.openEdit}
@@ -368,6 +389,20 @@ export const BoardPageClient = memo(function BoardPageClient({
368389
onDeleteSuccess={handleDeleteSuccess}
369390
/>
370391

392+
{/* Move to Another Board Dialog */}
393+
{moveDialogCardId && (
394+
<MoveToAnotherBoardDialog
395+
isOpen={!!moveDialogCardId}
396+
onClose={() => setMoveDialogCardId(null)}
397+
cardId={moveDialogCardId}
398+
repoName={
399+
repoCards.find((c) => c.id === moveDialogCardId)?.title ?? ''
400+
}
401+
currentBoardId={boardId}
402+
onMoved={handleMoveSuccess}
403+
/>
404+
)}
405+
371406
{/* StatusList Delete Confirmation Dialog */}
372407
<AlertDialog
373408
open={statusListDialog.isDeleteConfirmOpen}

src/components/Board/KanbanBoard.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ interface KanbanBoardProps {
9898
/** Card display settings from board.settings JSON */
9999
cardDisplaySettings?: CardDisplaySettings
100100
onMoveToMaintenance?: (cardId: string) => void
101+
/** Callback when card is moved to another board */
102+
onMoveToAnotherBoard?: (cardId: string) => void
101103
/** Callback when Note button is clicked (opens unified NoteModal with notes + links) */
102104
onNote?: (cardId: string) => void
103105
/** Callback when repository is removed from board */
@@ -160,6 +162,7 @@ export const KanbanBoard = memo<KanbanBoardProps>(
160162
initialComments,
161163
cardDisplaySettings,
162164
onMoveToMaintenance,
165+
onMoveToAnotherBoard,
163166
onNote,
164167
onRemove,
165168
onEditStatus,
@@ -756,6 +759,7 @@ export const KanbanBoard = memo<KanbanBoardProps>(
756759
comments={comments}
757760
cardDisplaySettings={cardDisplaySettings}
758761
onMaintenance={onMoveToMaintenance}
762+
onMoveToBoard={onMoveToAnotherBoard}
759763
onNote={onNote}
760764
onRemove={onRemove}
761765
onCommentChange={handleCommentChange}

0 commit comments

Comments
 (0)