diff --git a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts index d3f7b558f..2126563d4 100644 --- a/frontend/taskdeck-web/src/store/board/boardCrudStore.ts +++ b/frontend/taskdeck-web/src/store/board/boardCrudStore.ts @@ -20,7 +20,18 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) try { state.loading.value = true state.error.value = null - state.boards.value = await boardsApi.getBoards(search, includeArchived) + const freshBoards = await boardsApi.getBoards(search, includeArchived) + state.boards.value = freshBoards + + // Preserve selection guard: only update activeBoardId if there is no + // current selection or the previously-selected board is no longer in the + // refreshed list (e.g. it was deleted). This prevents polling/subscription + // refreshes from resetting the user's active board to the first item. + const currentId = state.activeBoardId.value + const stillExists = currentId !== null && freshBoards.some((b) => b.id === currentId) + if (!stillExists) { + state.activeBoardId.value = freshBoards[0]?.id ?? null + } } catch (e: unknown) { helpers.handleApiError(e, 'Failed to fetch boards') throw e @@ -117,6 +128,11 @@ export function createBoardCrudActions(state: BoardState, helpers: BoardHelpers) // Remove from boards list state.boards.value = state.boards.value.filter((b) => b.id !== boardId) + // Clear activeBoardId if the deleted board was the active selection + if (state.activeBoardId.value === boardId) { + state.activeBoardId.value = state.boards.value[0]?.id ?? null + } + // Clear current board if it's the one being deleted if (state.currentBoard.value && state.currentBoard.value.id === boardId) { state.currentBoard.value = null diff --git a/frontend/taskdeck-web/src/store/board/boardState.ts b/frontend/taskdeck-web/src/store/board/boardState.ts index 3c77a4829..fe18f4519 100644 --- a/frontend/taskdeck-web/src/store/board/boardState.ts +++ b/frontend/taskdeck-web/src/store/board/boardState.ts @@ -20,6 +20,7 @@ export interface CardFilters { export function createBoardState() { const boards = ref([]) + const activeBoardId = ref(null) const currentBoard = ref(null) const currentBoardCards = ref([]) const currentBoardLabels = ref([]) @@ -38,6 +39,7 @@ export function createBoardState() { return { boards, + activeBoardId, currentBoard, currentBoardCards, currentBoardLabels, diff --git a/frontend/taskdeck-web/src/store/boardStore.ts b/frontend/taskdeck-web/src/store/boardStore.ts index ae21fcdf6..3fac35233 100644 --- a/frontend/taskdeck-web/src/store/boardStore.ts +++ b/frontend/taskdeck-web/src/store/boardStore.ts @@ -38,6 +38,7 @@ export const useBoardStore = defineStore('board', () => { return { // State boards: state.boards, + activeBoardId: state.activeBoardId, currentBoard: state.currentBoard, currentBoardCards: state.currentBoardCards, currentBoardLabels: state.currentBoardLabels, diff --git a/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts b/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts index 739168470..ea4723de1 100644 --- a/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts +++ b/frontend/taskdeck-web/src/tests/store/boardStore.spec.ts @@ -698,4 +698,86 @@ describe('boardStore', () => { expect(store.loading).toBe(false) }) }) + + describe('activeBoardId — preserveSelection guard', () => { + const boardA: Board = { + id: 'board-a', + name: 'Board A', + description: '', + isArchived: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + columns: [], + } + + const boardB: Board = { + id: 'board-b', + name: 'Board B', + description: '', + isArchived: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + columns: [], + } + + it('sets activeBoardId to first board on initial fetchBoards when no prior selection', async () => { + vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA, boardB]) + + await store.fetchBoards() + + expect(store.activeBoardId).toBe('board-a') + }) + + it('preserves activeBoardId across fetchBoards when selected board still exists', async () => { + // First load — sets selection to boardA (first item) + vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA, boardB]) + await store.fetchBoards() + expect(store.activeBoardId).toBe('board-a') + + // User selects boardB + store.activeBoardId = 'board-b' + + // Poll cycle returns boards in a different order — boardB is still present + vi.mocked(boardsApi.getBoards).mockResolvedValue([boardB, boardA]) + await store.fetchBoards() + + // Selection must NOT flip back to boardB (now first) or boardA + expect(store.activeBoardId).toBe('board-b') + }) + + it('falls back to first board when the selected board is removed from the list', async () => { + vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA, boardB]) + await store.fetchBoards() + store.activeBoardId = 'board-b' + + // boardB has been deleted on the server + vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA]) + await store.fetchBoards() + + expect(store.activeBoardId).toBe('board-a') + }) + + it('sets activeBoardId to null when no boards remain after refresh', async () => { + vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA]) + await store.fetchBoards() + store.activeBoardId = 'board-a' + + vi.mocked(boardsApi.getBoards).mockResolvedValue([]) + await store.fetchBoards() + + expect(store.activeBoardId).toBeNull() + }) + + it('clears activeBoardId when the active board is deleted', async () => { + vi.mocked(boardsApi.getBoards).mockResolvedValue([boardA, boardB]) + await store.fetchBoards() + store.activeBoardId = 'board-a' + + vi.mocked(boardsApi.deleteBoard).mockResolvedValue() + await store.deleteBoard('board-a') + + // activeBoardId should fall back to the remaining board + expect(store.activeBoardId).toBe('board-b') + }) + }) })