diff --git a/client/components/swimlanes/swimlanes.js b/client/components/swimlanes/swimlanes.js index 32a778b3c..1ac583a05 100644 --- a/client/components/swimlanes/swimlanes.js +++ b/client/components/swimlanes/swimlanes.js @@ -63,7 +63,7 @@ function initSortable(boardComponent, $listsDom) { }; $listsDom.sortable({ - connectWith: '.board-canvas', + connectWith: '.js-swimlane, .js-lists', tolerance: 'pointer', helper: 'clone', items: '.js-list:not(.js-list-composer)', @@ -82,10 +82,31 @@ function initSortable(boardComponent, $listsDom) { const nextListDom = ui.item.next('.js-list').get(0); const sortIndex = calculateIndex(prevListDom, nextListDom, 1); - $listsDom.sortable('cancel'); const listDomElement = ui.item.get(0); const list = Blaze.getData(listDomElement); + // Detect if the list was dropped in a different swimlane + const targetSwimlaneDom = ui.item.closest('.js-swimlane'); + let targetSwimlaneId = null; + + if (targetSwimlaneDom.length > 0) { + // List was dropped in a swimlane + targetSwimlaneId = targetSwimlaneDom.attr('id').replace('swimlane-', ''); + } else { + // List was dropped in lists view (not swimlanes view) + // In this case, assign to the default swimlane + const currentBoard = ReactiveCache.getBoard(Session.get('currentBoard')); + if (currentBoard) { + const defaultSwimlane = currentBoard.getDefaultSwimline(); + if (defaultSwimlane) { + targetSwimlaneId = defaultSwimlane._id; + } + } + } + + // Get the original swimlane ID of the list + const originalSwimlaneId = list.swimlaneId; + /* Reverted incomplete change list width, removed from below Lists.update: @@ -95,10 +116,44 @@ function initSortable(boardComponent, $listsDom) { height: list._id.height(), */ + // Prepare update object + const updateData = { + sort: sortIndex.base, + }; + + // Check if the list was dropped in a different swimlane + const isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId; + + // If the list was dropped in a different swimlane, update the swimlaneId + if (isDifferentSwimlane) { + updateData.swimlaneId = targetSwimlaneId; + if (process.env.DEBUG === 'true') { + console.log(`Moving list "${list.title}" from swimlane ${originalSwimlaneId} to swimlane ${targetSwimlaneId}`); + } + + // Move all cards in the list to the new swimlane + const cardsInList = ReactiveCache.getCards({ + listId: list._id, + archived: false + }); + + cardsInList.forEach(card => { + card.move(list.boardId, targetSwimlaneId, list._id); + }); + + if (process.env.DEBUG === 'true') { + console.log(`Moved ${cardsInList.length} cards to swimlane ${targetSwimlaneId}`); + } + + // Don't cancel the sortable when moving to a different swimlane + // The DOM move should be allowed to complete + } else { + // If staying in the same swimlane, cancel the sortable to prevent DOM manipulation issues + $listsDom.sortable('cancel'); + } + Lists.update(list._id, { - $set: { - sort: sortIndex.base, - }, + $set: updateData, }); boardComponent.setIsDragging(false); @@ -109,10 +164,12 @@ function initSortable(boardComponent, $listsDom) { if (Utils.isTouchScreenOrShowDesktopDragHandles()) { $listsDom.sortable({ handle: '.js-list-handle', + connectWith: '.js-swimlane, .js-lists', }); } else { $listsDom.sortable({ handle: '.js-list-header', + connectWith: '.js-swimlane, .js-lists', }); } @@ -281,7 +338,7 @@ BlazeComponent.extendComponent({ boardId: Session.get('currentBoard'), sort: sortIndex, type: this.isListTemplatesSwimlane ? 'template-list' : 'list', - swimlaneId: this.currentBoard.isTemplatesBoard() ? this.currentSwimlane._id : '', + swimlaneId: this.currentSwimlane._id, // Always set swimlaneId for per-swimlane list titles }); titleInput.value = ''; diff --git a/models/boards.js b/models/boards.js index 835b73c95..3329b7747 100644 --- a/models/boards.js +++ b/models/boards.js @@ -777,13 +777,22 @@ Boards.helpers({ { boardId: this._id, archived: false, + // Get lists for all swimlanes in this board + swimlaneId: { $in: this.swimlanes().map(s => s._id) }, }, { sort: sortKey }, ); }, draggableLists() { - return ReactiveCache.getLists({ boardId: this._id }, { sort: { sort: 1 } }); + return ReactiveCache.getLists( + { + boardId: this._id, + // Get lists for all swimlanes in this board + swimlaneId: { $in: this.swimlanes().map(s => s._id) } + }, + { sort: { sort: 1 } } + ); }, /** returns the last list @@ -1171,6 +1180,7 @@ Boards.helpers({ this.subtasksDefaultListId = Lists.insert({ title: TAPi18n.__('queue'), boardId: this._id, + swimlaneId: this.getDefaultSwimline()._id, // Set default swimlane for subtasks list }); this.setSubtasksDefaultListId(this.subtasksDefaultListId); } @@ -1189,6 +1199,7 @@ Boards.helpers({ this.dateSettingsDefaultListId = Lists.insert({ title: TAPi18n.__('queue'), boardId: this._id, + swimlaneId: this.getDefaultSwimline()._id, // Set default swimlane for date settings list }); this.setDateSettingsDefaultListId(this.dateSettingsDefaultListId); } diff --git a/models/lists.js b/models/lists.js index 10504ab58..74800aefd 100644 --- a/models/lists.js +++ b/models/lists.js @@ -50,10 +50,10 @@ Lists.attachSchema( }, swimlaneId: { /** - * the swimlane associated to this list. Used for templates + * the swimlane associated to this list. Required for per-swimlane list titles */ type: String, - defaultValue: '', + // Remove defaultValue to make it required }, createdAt: { /** @@ -196,7 +196,7 @@ Lists.helpers({ _id = existingListWithSameName._id; } else { delete this._id; - delete this.swimlaneId; + this.swimlaneId = swimlaneId; // Set the target swimlane for the copied list _id = Lists.insert(this); } @@ -231,6 +231,7 @@ Lists.helpers({ type: this.type, archived: false, wipLimit: this.wipLimit, + swimlaneId: swimlaneId, // Set the target swimlane for the moved list }); } @@ -585,6 +586,7 @@ if (Meteor.isServer) { title: req.body.title, boardId: paramBoardId, sort: board.lists().length, + swimlaneId: req.body.swimlaneId || board.getDefaultSwimline()._id, // Use provided swimlaneId or default }); JsonRoutes.sendResult(res, { code: 200, diff --git a/models/swimlanes.js b/models/swimlanes.js index 6a834370d..2636b274a 100644 --- a/models/swimlanes.js +++ b/models/swimlanes.js @@ -173,6 +173,7 @@ Swimlanes.helpers({ type: list.type, archived: false, wipLimit: list.wipLimit, + swimlaneId: toSwimlaneId, // Set the target swimlane for the copied list }); } @@ -213,7 +214,7 @@ Swimlanes.helpers({ return ReactiveCache.getLists( { boardId: this.boardId, - swimlaneId: { $in: [this._id, ''] }, + swimlaneId: this._id, // Only get lists that belong to this specific swimlane archived: false, }, { sort: { modifiedAt: -1 } }, @@ -223,7 +224,7 @@ Swimlanes.helpers({ return ReactiveCache.getLists( { boardId: this.boardId, - swimlaneId: { $in: [this._id, ''] }, + swimlaneId: this._id, // Only get lists that belong to this specific swimlane //archived: false, }, { sort: ['sort'] }, diff --git a/server/migrations.js b/server/migrations.js index 2930d6fa7..a9cadc42d 100644 --- a/server/migrations.js +++ b/server/migrations.js @@ -1489,3 +1489,64 @@ Migrations.add('remove-user-profile-hideCheckedItems', () => { noValidateMulti, ); }); + +Migrations.add('migrate-lists-to-per-swimlane', () => { + if (process.env.DEBUG === 'true') { + console.log('Starting migration: migrate-lists-to-per-swimlane'); + } + + try { + // Get all boards + const boards = Boards.find({}).fetch(); + + boards.forEach(board => { + if (process.env.DEBUG === 'true') { + console.log(`Processing board: ${board.title} (${board._id})`); + } + + // Get the default swimlane for this board + const defaultSwimlane = board.getDefaultSwimline(); + if (!defaultSwimlane) { + if (process.env.DEBUG === 'true') { + console.log(`No default swimlane found for board ${board._id}, skipping`); + } + return; + } + + // Get all lists for this board that don't have a swimlaneId or have empty swimlaneId + const listsWithoutSwimlane = Lists.find({ + boardId: board._id, + $or: [ + { swimlaneId: { $exists: false } }, + { swimlaneId: '' }, + { swimlaneId: null } + ] + }).fetch(); + + if (process.env.DEBUG === 'true') { + console.log(`Found ${listsWithoutSwimlane.length} lists without swimlaneId in board ${board._id}`); + } + + // Update each list to belong to the default swimlane + listsWithoutSwimlane.forEach(list => { + if (process.env.DEBUG === 'true') { + console.log(`Updating list "${list.title}" to belong to swimlane "${defaultSwimlane.title}"`); + } + + Lists.direct.update(list._id, { + $set: { + swimlaneId: defaultSwimlane._id + } + }, noValidate); + }); + }); + + if (process.env.DEBUG === 'true') { + console.log('Migration migrate-lists-to-per-swimlane completed successfully'); + } + + } catch (error) { + console.error('Error during migration migrate-lists-to-per-swimlane:', error); + throw error; + } +});