From d9b8c9ff57beda5b37bf21eddb2662433c4a51d1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:50:58 +0100 Subject: [PATCH 1/2] fix: preserve activeBoardId across fetchBoards refreshes (#509) Add activeBoardId to boardState and apply a preserveSelection guard in fetchBoards: only update activeBoardId when there is no current selection or the selected board no longer exists in the refreshed list. Also clear activeBoardId correctly on deleteBoard, falling back to the first remaining board. Prevents polling/SignalR-triggered fetchBoards calls from resetting the visible board to the first item in the returned list. --- .../src/store/board/boardCrudStore.ts | 18 +++++++++++++++++- .../taskdeck-web/src/store/board/boardState.ts | 2 ++ frontend/taskdeck-web/src/store/boardStore.ts | 1 + 3 files changed, 20 insertions(+), 1 deletion(-) 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, From a07623025b4f4eb169f5e45e7878ba22d43496d1 Mon Sep 17 00:00:00 2001 From: Chris0Jeky Date: Sun, 29 Mar 2026 14:51:03 +0100 Subject: [PATCH 2/2] test: add activeBoardId preserveSelection guard coverage Five cases: initial load sets selection, repeated fetchBoards preserves user selection, missing board falls back to first, empty list yields null, deleteBoard falls back to remaining board. --- .../src/tests/store/boardStore.spec.ts | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) 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') + }) + }) })