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)
+}
@@ -58,7 +75,7 @@ defineEmits<{
:selected-card-id="selectedCardId"
@card-drag-start="$emit('cardDragStart', $event)"
@card-drag-end="$emit('cardDragEnd')"
- @card-editor-saving-change="$emit('cardEditorSavingChange', $event)"
+ @card-editor-saving-change="handleCardEditorSavingChange(column.id, $event)"
/>