From 734784a956eb36ddf0fb121f944cd7de4051fc82 Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:48:38 +0100 Subject: [PATCH 1/2] test(board): reproduce cross-lane save-state clobber --- .../BoardCanvasSaveOwnership.spec.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts diff --git a/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts new file mode 100644 index 000000000..70023ec66 --- /dev/null +++ b/frontend/taskdeck-web/src/tests/components/BoardCanvasSaveOwnership.spec.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { mount } from '@vue/test-utils' +import BoardCanvas from '../../components/board/BoardCanvas.vue' +import type { Column } from '../../types/board' + +const columns: Column[] = [ + { + id: 'column-a', + boardId: 'board-1', + name: 'Todo', + position: 0, + wipLimit: null, + cardCount: 0, + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', + }, + { + id: 'column-b', + boardId: 'board-1', + name: 'Done', + position: 1, + wipLimit: null, + cardCount: 0, + createdAt: '2026-09-20T10:00:00Z', + updatedAt: '2026-09-20T10:00:00Z', + }, +] + +function mountCanvas() { + return mount(BoardCanvas, { + props: { + sortedColumns: columns, + cardsByColumn: new Map(), + labels: [], + boardId: 'board-1', + hasColumns: true, + draggedColumn: null, + dragOverColumnId: null, + draggedCard: null, + selectedCardId: null, + }, + global: { + stubs: { + ColumnLane: { + name: 'ColumnLane', + props: ['column'], + emits: ['card-editor-saving-change'], + template: ` +
+ + +
+ `, + }, + }, + }, + }) +} + +describe('BoardCanvas assignment-save ownership', () => { + it('stays globally saving until the final lane owner settles', async () => { + const wrapper = mountCanvas() + + await wrapper.get('[data-testid="start-column-a"]').trigger('click') + expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]]) + + await wrapper.get('[data-testid="start-column-b"]').trigger('click') + expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]]) + + await wrapper.get('[data-testid="settle-column-a"]').trigger('click') + expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]]) + + await wrapper.get('[data-testid="settle-column-b"]').trigger('click') + expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false]]) + }) + + it('treats duplicate lane events as idempotent aggregate state', async () => { + const wrapper = mountCanvas() + + await wrapper.get('[data-testid="start-column-a"]').trigger('click') + await wrapper.get('[data-testid="start-column-a"]').trigger('click') + await wrapper.get('[data-testid="settle-column-b"]').trigger('click') + expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true]]) + + await wrapper.get('[data-testid="settle-column-a"]').trigger('click') + await wrapper.get('[data-testid="settle-column-a"]').trigger('click') + expect(wrapper.emitted('cardEditorSavingChange')).toEqual([[true], [false]]) + }) +}) From 76e590bbef559a9f9aab8675815bb0c2fab3313c Mon Sep 17 00:00:00 2001 From: Cristian Tcaci <59696583+Chris0Jeky@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:49:04 +0100 Subject: [PATCH 2/2] fix(board): aggregate assignment-save lane owners --- .../src/components/board/BoardCanvas.vue | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/frontend/taskdeck-web/src/components/board/BoardCanvas.vue b/frontend/taskdeck-web/src/components/board/BoardCanvas.vue index 0fb0b7d92..5a26d7db2 100644 --- a/frontend/taskdeck-web/src/components/board/BoardCanvas.vue +++ b/frontend/taskdeck-web/src/components/board/BoardCanvas.vue @@ -14,7 +14,7 @@ defineProps<{ selectedCardId: string | null }>() -defineEmits<{ +const emit = defineEmits<{ columnDragStart: [column: Column, event: DragEvent] columnDragEnd: [] columnDragOver: [column: Column, event: DragEvent] @@ -24,6 +24,23 @@ defineEmits<{ cardDragEnd: [] cardEditorSavingChange: [saving: boolean] }>() + +// One CardModal lives inside each ColumnLane. The route boundary needs the +// aggregate, not whichever lane happened to emit last: a clean modal closing +// in column B must not clear column A's unanswered assignment PUT. +const savingColumnIds = new Set() +let aggregateCardEditorSaving = false + +function handleCardEditorSavingChange(columnId: string, saving: boolean) { + if (saving) savingColumnIds.add(columnId) + else savingColumnIds.delete(columnId) + + const nextAggregate = savingColumnIds.size > 0 + if (nextAggregate === aggregateCardEditorSaving) return + + aggregateCardEditorSaving = nextAggregate + emit('cardEditorSavingChange', nextAggregate) +}