From d68ad47de66651763e22d6d0a7d2fc5a13a19c9f Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Thu, 5 Feb 2026 02:28:00 +0200 Subject: [PATCH 1/3] Await async setDone before closing popup in copy/move dialogs The click handler called setDone() without await then immediately called Popup.back(2), destroying the popup template while the async operation was still running. This caused unhandled promise rejections and made errors invisible to the user. --- client/lib/dialogWithBoardSwimlaneList.js | 8 ++++++-- client/lib/dialogWithBoardSwimlaneListCard.js | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/client/lib/dialogWithBoardSwimlaneList.js b/client/lib/dialogWithBoardSwimlaneList.js index 0471efd88..888601a56 100644 --- a/client/lib/dialogWithBoardSwimlaneList.js +++ b/client/lib/dialogWithBoardSwimlaneList.js @@ -186,7 +186,7 @@ export class DialogWithBoardSwimlaneList extends BlazeComponent { events() { return [ { - 'click .js-done'() { + async 'click .js-done'() { const boardSelect = this.$('.js-select-boards')[0]; const boardId = boardSelect.options[boardSelect.selectedIndex].value; @@ -201,7 +201,11 @@ export class DialogWithBoardSwimlaneList extends BlazeComponent { 'swimlaneId' : swimlaneId, 'listId' : listId, } - this.setDone(boardId, swimlaneId, listId, options); + try { + await this.setDone(boardId, swimlaneId, listId, options); + } catch (e) { + console.error('Error in list dialog operation:', e); + } Popup.back(2); }, 'change .js-select-boards'(event) { diff --git a/client/lib/dialogWithBoardSwimlaneListCard.js b/client/lib/dialogWithBoardSwimlaneListCard.js index 77ceb968b..10421c3c1 100644 --- a/client/lib/dialogWithBoardSwimlaneListCard.js +++ b/client/lib/dialogWithBoardSwimlaneListCard.js @@ -80,7 +80,7 @@ export class DialogWithBoardSwimlaneListCard extends DialogWithBoardSwimlaneList events() { return [ { - 'click .js-done'() { + async 'click .js-done'() { const boardSelect = this.$('.js-select-boards')[0]; const boardId = boardSelect.options[boardSelect.selectedIndex].value; @@ -99,7 +99,11 @@ export class DialogWithBoardSwimlaneListCard extends DialogWithBoardSwimlaneList 'listId' : listId, 'cardId': cardId, } - this.setDone(cardId, options); + try { + await this.setDone(cardId, options); + } catch (e) { + console.error('Error in card dialog operation:', e); + } Popup.back(2); }, 'change .js-select-boards'(event) { From e8b9a3a163a21e03008724302e08bc3060e8e285 Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Thu, 5 Feb 2026 02:28:15 +0200 Subject: [PATCH 2/3] Fix Card.copy() mutating source card and normalize customFields Card.copy() mutated `this` directly (boardId, labelIds, customFields, etc.), corrupting the cached source card object and causing intermittent failures on repeated copy operations. Now works on a shallow copy. Also normalizes customFields to [] when it's not an array (e.g. legacy {} data in the database), preventing "Custom fields must be an array" schema validation errors on both copy and move operations. --- models/cards.js | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/models/cards.js b/models/cards.js index 87be370d8..450fb0e35 100644 --- a/models/cards.js +++ b/models/cards.js @@ -611,6 +611,15 @@ Cards.helpers({ const oldId = this._id; const oldCard = ReactiveCache.getCard(oldId); + // Work on a shallow copy to avoid mutating the source card in ReactiveCache + const cardData = { ...this }; + delete cardData._id; + + // Normalize customFields to ensure it's always an array + if (!Array.isArray(cardData.customFields)) { + cardData.customFields = []; + } + // we must only copy the labels and custom fields if the target board // differs from the source board if (this.boardId !== boardId) { @@ -633,19 +642,16 @@ Cards.helpers({ }), '_id', ); - // now set the new label ids - delete this.labelIds; - this.labelIds = newCardLabels; + cardData.labelIds = newCardLabels; - this.customFields = await this.mapCustomFieldsToBoard(newBoard._id); + cardData.customFields = await this.mapCustomFieldsToBoard(newBoard._id); } - delete this._id; - this.boardId = boardId; - this.cardNumber = ReactiveCache.getBoard(boardId).getNextCardNumber(); - this.swimlaneId = swimlaneId; - this.listId = listId; - const _id = Cards.insert(this); + cardData.boardId = boardId; + cardData.cardNumber = ReactiveCache.getBoard(boardId).getNextCardNumber(); + cardData.swimlaneId = swimlaneId; + cardData.listId = listId; + const _id = Cards.insert(cardData); // Copy attachments oldCard.attachments() @@ -669,8 +675,6 @@ Cards.helpers({ ReactiveCache.getCardComments({ cardId: oldId }).forEach(cmt => { cmt.copy(_id); }); - // restore the id, otherwise new copies will fail - this._id = oldId; return _id; }, @@ -2102,6 +2106,11 @@ Cards.helpers({ }); mutatedFields.customFields = await this.mapCustomFieldsToBoard(newBoard._id); + + // Ensure customFields is always an array (guards against legacy {} data) + if (!Array.isArray(mutatedFields.customFields)) { + mutatedFields.customFields = []; + } } await Cards.updateAsync(this._id, { $set: mutatedFields }); From 30458c617e8eb9e3bf2c23643b85a9db0cb6d173 Mon Sep 17 00:00:00 2001 From: Harry Adel Date: Thu, 5 Feb 2026 02:39:39 +0200 Subject: [PATCH 3/3] Guard against null newCard after cross-board copy After a cross-board copy, ReactiveCache.getCard(newCardId) can return null if the publication update hasn't reached the client yet. The card is already created with a valid sort position server-side, so the client-side repositioning is safely skippable. --- client/components/cards/cardDetails.js | 63 ++++++++++++++------------ 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index f508dab3b..5f69d4bd8 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -1012,6 +1012,9 @@ Template.editCardAssignerForm.events({ return ret; } async setDone(cardId, options) { + // Capture DOM values immediately before any async operations + const position = this.$('input[name="position"]:checked').val(); + ReactiveCache.getCurrentUser().setMoveAndCopyDialogOption(this.currentBoardId, options); const card = this.data(); let sortIndex = 0; @@ -1019,7 +1022,6 @@ Template.editCardAssignerForm.events({ if (cardId) { const targetCard = ReactiveCache.getCard(cardId); if (targetCard) { - const position = this.$('input[name="position"]:checked').val(); if (position === 'above') { sortIndex = targetCard.sort - 0.5; } else { @@ -1042,37 +1044,40 @@ Template.editCardAssignerForm.events({ return ret; } async setDone(cardId, options) { - ReactiveCache.getCurrentUser().setMoveAndCopyDialogOption(this.currentBoardId, options); - const card = this.data(); - - // const textarea = $('#copy-card-title'); + // Capture DOM values immediately before any async operations const textarea = this.$('#copy-card-title'); const title = textarea.val().trim(); + const position = this.$('input[name="position"]:checked').val(); + + ReactiveCache.getCurrentUser().setMoveAndCopyDialogOption(this.currentBoardId, options); + const card = this.data(); if (title) { const newCardId = await Meteor.callAsync('copyCard', card._id, options.boardId, options.swimlaneId, options.listId, true, {title: title}); - // Position the copied card + // Position the copied card (newCard may be null for cross-board copies + // if the client hasn't received the publication update yet) if (newCardId) { const newCard = ReactiveCache.getCard(newCardId); - let sortIndex = 0; + if (newCard) { + let sortIndex = 0; - if (cardId) { - const targetCard = ReactiveCache.getCard(cardId); - if (targetCard) { - const position = this.$('input[name="position"]:checked').val(); - if (position === 'above') { - sortIndex = targetCard.sort - 0.5; - } else { - sortIndex = targetCard.sort + 0.5; + if (cardId) { + const targetCard = ReactiveCache.getCard(cardId); + if (targetCard) { + if (position === 'above') { + sortIndex = targetCard.sort - 0.5; + } else { + sortIndex = targetCard.sort + 0.5; + } } + } else { + // If no card selected, copy to end + sortIndex = newCard.getMaxSort(options.listId, options.swimlaneId) + 1; } - } else { - // If no card selected, copy to end - sortIndex = newCard.getMaxSort(options.listId, options.swimlaneId) + 1; - } - await newCard.move(options.boardId, options.swimlaneId, options.listId, sortIndex); + await newCard.move(options.boardId, options.swimlaneId, options.listId, sortIndex); + } } // In case the filter is active we need to add the newly inserted card in @@ -1091,11 +1096,13 @@ Template.editCardAssignerForm.events({ return ret; } async setDone(cardId, options) { - ReactiveCache.getCurrentUser().setMoveAndCopyDialogOption(this.currentBoardId, options); - const card = this.data(); - + // Capture DOM values immediately before any async operations const textarea = this.$('#copy-card-title'); const title = textarea.val().trim(); + const position = this.$('input[name="position"]:checked').val(); + + ReactiveCache.getCurrentUser().setMoveAndCopyDialogOption(this.currentBoardId, options); + const card = this.data(); if (title) { const _id = Cards.insert({ @@ -1111,7 +1118,6 @@ Template.editCardAssignerForm.events({ if (cardId) { const targetCard = ReactiveCache.getCard(cardId); if (targetCard) { - const position = this.$('input[name="position"]:checked').val(); if (position === 'above') { sortIndex = targetCard.sort - 0.5; } else { @@ -1136,11 +1142,13 @@ Template.editCardAssignerForm.events({ return ret; } async setDone(cardId, options) { - ReactiveCache.getCurrentUser().setMoveAndCopyDialogOption(this.currentBoardId, options); - const card = this.data(); - + // Capture DOM values immediately before any async operations const textarea = this.$('#copy-card-title'); const title = textarea.val().trim(); + const position = this.$('input[name="position"]:checked').val(); + + ReactiveCache.getCurrentUser().setMoveAndCopyDialogOption(this.currentBoardId, options); + const card = this.data(); if (title) { const titleList = JSON.parse(title); @@ -1155,7 +1163,6 @@ Template.editCardAssignerForm.events({ if (cardId) { const targetCard = ReactiveCache.getCard(cardId); if (targetCard) { - const position = this.$('input[name="position"]:checked').val(); if (position === 'above') { sortIndex = targetCard.sort - 0.5; } else {