From ecfb0f0fdf03efa0ad8d61e6b2c7107fae898b8b Mon Sep 17 00:00:00 2001 From: Lauri Ojansivu Date: Mon, 22 Dec 2025 23:18:01 +0200 Subject: [PATCH] Manually merged fixes from seve12. Thanks to seve12 ! Related https://github.com/wekan/wekan/pull/5967 --- CHANGELOG.md | 6 + client/components/cards/checklists.js | 2 +- client/components/lists/listBody.jade | 3 + client/components/lists/listBody.js | 27 +- client/components/main/dueCards.js | 18 + .../rules/actions/boardActions.jade | 2 + .../components/rules/actions/boardActions.js | 16 +- client/components/rules/ruleDetails.js | 1 + client/components/rules/rulesActions.jade | 4 +- client/components/rules/rulesTriggers.jade | 4 +- models/cards.js | 22 ++ models/wekanCreator.js | 51 ++- rebuild-wekan.sh | 8 +- tests/wekanCreator.import.test.js | 313 ++++++++++++++++++ 14 files changed, 457 insertions(+), 20 deletions(-) create mode 100644 tests/wekanCreator.import.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d582a28..0a5582b87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,12 @@ and fixes the following bugs: Thanks to brlin-tw. - [Updated Mac docs for Applite](https://github.com/wekan/wekan/commit/400eb81206f346a973d871a8aaa55d4ac5d48753). Thanks to xet7. +- [Fix checklist delete action (issue #6020), link-card popup defaults, and stabilize due-cards ordering](https://github.com/wekan/wekan/pull/5967). + Thanks to seve12. +- [Improve rules UI board dropdowns/loading, rule header titles, and ensure card move updates attachment metadata](https://github.com/wekan/wekan/pull/5967). + Thanks to seve12. +- [Improve imports: normalize id → _id, add default swimlane fallback, and add regression test](https://github.com/wekan/wekan/pull/5967). + Thanks to seve12. Thanks to above GitHub users for their contributions and translators for their translations. diff --git a/client/components/cards/checklists.js b/client/components/cards/checklists.js index 6762eab02..40faa6262 100644 --- a/client/components/cards/checklists.js +++ b/client/components/cards/checklists.js @@ -305,7 +305,7 @@ BlazeComponent.extendComponent({ { 'click .js-delete-checklist': Popup.afterConfirm('checklistDelete', function () { Popup.back(2); - const checklist = this.checklist; + const checklist = this.data().checklist; if (checklist && checklist._id) { Checklists.remove(checklist._id); } diff --git a/client/components/lists/listBody.jade b/client/components/lists/listBody.jade index e08684a4f..7128148b8 100644 --- a/client/components/lists/listBody.jade +++ b/client/components/lists/listBody.jade @@ -85,16 +85,19 @@ template(name="linkCardPopup") label {{_ 'swimlanes'}}: select.js-select-swimlanes + option(value="") {{_ 'custom-field-dropdown-none'}} each swimlanes option(value="{{_id}}") {{isTitleDefault title}} label {{_ 'lists'}}: select.js-select-lists + option(value="") {{_ 'custom-field-dropdown-none'}} each lists option(value="{{_id}}") {{isTitleDefault title}} label {{_ 'cards'}}: select.js-select-cards + option(value="") {{_ 'custom-field-dropdown-none'}} each cards option(value="{{getRealId}}") {{getTitle}} diff --git a/client/components/lists/listBody.js b/client/components/lists/listBody.js index 5ac0b8dd7..eeda23cb2 100644 --- a/client/components/lists/listBody.js +++ b/client/components/lists/listBody.js @@ -542,8 +542,6 @@ BlazeComponent.extendComponent({ { sort: { sort: 1 }, }); - if (swimlanes.length) - this.selectedSwimlaneId.set(swimlanes[0]._id); return swimlanes; }, @@ -558,7 +556,6 @@ BlazeComponent.extendComponent({ { sort: { sort: 1 }, }); - if (lists.length) this.selectedListId.set(lists[0]._id); return lists; }, @@ -567,19 +564,17 @@ BlazeComponent.extendComponent({ return []; } const ownCardsIds = this.board.cards().map(card => card.getRealId()); - const ret = ReactiveCache.getCards( - { - boardId: this.selectedBoardId.get(), - swimlaneId: this.selectedSwimlaneId.get(), - listId: this.selectedListId.get(), + const selector = { archived: false, linkedId: { $nin: ownCardsIds }, _id: { $nin: ownCardsIds }, type: { $nin: ['template-card'] }, - }, - { - sort: { sort: 1 }, - }); + }; + if (this.selectedBoardId.get()) selector.boardId = this.selectedBoardId.get(); + if (this.selectedSwimlaneId.get()) selector.swimlaneId = this.selectedSwimlaneId.get(); + if (this.selectedListId.get()) selector.listId = this.selectedListId.get(); + + const ret = ReactiveCache.getCards(selector, { sort: { sort: 1 } }); return ret; }, @@ -600,8 +595,12 @@ BlazeComponent.extendComponent({ return [ { 'change .js-select-boards'(evt) { - subManager.subscribe('board', $(evt.currentTarget).val(), false); - this.selectedBoardId.set($(evt.currentTarget).val()); + const val = $(evt.currentTarget).val(); + subManager.subscribe('board', val, false); + // Clear selections to allow linking only board or re-choose swimlane/list + this.selectedSwimlaneId.set(''); + this.selectedListId.set(''); + this.selectedBoardId.set(val); }, 'change .js-select-swimlanes'(evt) { this.selectedSwimlaneId.set($(evt.currentTarget).val()); diff --git a/client/components/main/dueCards.js b/client/components/main/dueCards.js index f17bc9a74..bdde0e1df 100644 --- a/client/components/main/dueCards.js +++ b/client/components/main/dueCards.js @@ -232,6 +232,24 @@ class DueCardsComponent extends BlazeComponent { }); } + // Normalize dueAt to timestamps for stable client-side ordering + const future = new Date('2100-12-31').getTime(); + const toTime = v => { + if (v === null || v === undefined || v === '') return future; + if (v instanceof Date) return v.getTime(); + const t = new Date(v); + if (!isNaN(t.getTime())) return t.getTime(); + return future; + }; + + filteredCards.sort((a, b) => { + const x = toTime(a.dueAt); + const y = toTime(b.dueAt); + if (x > y) return 1; + if (x < y) return -1; + return 0; + }); + if (process.env.DEBUG === 'true') { console.log('dueCards client: filtered to', filteredCards.length, 'cards'); } diff --git a/client/components/rules/actions/boardActions.jade b/client/components/rules/actions/boardActions.jade index 6f63635fa..85ff97ebe 100644 --- a/client/components/rules/actions/boardActions.jade +++ b/client/components/rules/actions/boardActions.jade @@ -24,6 +24,7 @@ template(name="boardActions") | {{_'r-the-board'}} div.trigger-dropdown select(id="board-id") + option(value="" disabled selected if=not boards.length) {{loadingBoardsLabel}} each boards if $eq _id currentBoard._id option(value="{{_id}}" selected) {{_ 'current'}} @@ -85,6 +86,7 @@ template(name="boardActions") | {{_'r-the-board'}} div.trigger-dropdown select(id="board-id-link") + option(value="" disabled selected if=not boards.length) {{loadingBoardsLabel}} each boards if $eq _id currentBoard._id option(value="{{_id}}" selected) {{_ 'current'}} diff --git a/client/components/rules/actions/boardActions.js b/client/components/rules/actions/boardActions.js index b69e9f476..0d3a9b2b6 100644 --- a/client/components/rules/actions/boardActions.js +++ b/client/components/rules/actions/boardActions.js @@ -1,7 +1,11 @@ import { ReactiveCache } from '/imports/reactiveCache'; +import { TAPi18n } from '/imports/i18n'; BlazeComponent.extendComponent({ - onCreated() {}, + onCreated() { + // Ensure boards are available for action dropdowns + this.subscribe('boards'); + }, boards() { const ret = ReactiveCache.getBoards( @@ -19,6 +23,16 @@ BlazeComponent.extendComponent({ return ret; }, + loadingBoardsLabel() { + try { + const txt = TAPi18n.__('loading-boards'); + if (txt && !txt.startsWith("key '")) return txt; + } catch (e) { + // ignore translation lookup errors + } + return 'Loading boards...'; + }, + events() { return [ { diff --git a/client/components/rules/ruleDetails.js b/client/components/rules/ruleDetails.js index 235f17179..e300c77b6 100644 --- a/client/components/rules/ruleDetails.js +++ b/client/components/rules/ruleDetails.js @@ -5,6 +5,7 @@ BlazeComponent.extendComponent({ this.subscribe('allRules'); this.subscribe('allTriggers'); this.subscribe('allActions'); + this.subscribe('boards'); }, trigger() { diff --git a/client/components/rules/rulesActions.jade b/client/components/rules/rulesActions.jade index bcc5d7ff0..7f11d9e9c 100644 --- a/client/components/rules/rulesActions.jade +++ b/client/components/rules/rulesActions.jade @@ -1,7 +1,9 @@ template(name="rulesActions") h2 | ✨ - | {{_ 'r-rule' }} "#{data.ruleName.get}" - {{_ 'r-add-action'}} + | {{_ 'r-rule' }} " + = ruleName.get() + | " - {{_ 'r-add-action'}} .triggers-content .triggers-body .triggers-side-menu diff --git a/client/components/rules/rulesTriggers.jade b/client/components/rules/rulesTriggers.jade index e34c1dfb5..dd3d431c1 100644 --- a/client/components/rules/rulesTriggers.jade +++ b/client/components/rules/rulesTriggers.jade @@ -1,7 +1,9 @@ template(name="rulesTriggers") h2 | ✨ - | {{_ 'r-rule' }} "#{data.ruleName.get}" - {{_ 'r-add-trigger'}} + | {{_ 'r-rule' }} " + = ruleName.get() + | " - {{_ 'r-add-trigger'}} .triggers-content .triggers-body .triggers-side-menu diff --git a/models/cards.js b/models/cards.js index a0eaaa8ca..800d7c59e 100644 --- a/models/cards.js +++ b/models/cards.js @@ -2107,6 +2107,28 @@ Cards.mutations({ Cards.update(this._id, { $set: mutatedFields, }); + + // Ensure attachments follow the card to its new board/list/swimlane + if (Meteor.isServer) { + const updateMeta = {}; + if (mutatedFields.boardId !== undefined) updateMeta['meta.boardId'] = mutatedFields.boardId; + if (mutatedFields.listId !== undefined) updateMeta['meta.listId'] = mutatedFields.listId; + if (mutatedFields.swimlaneId !== undefined) updateMeta['meta.swimlaneId'] = mutatedFields.swimlaneId; + + if (Object.keys(updateMeta).length > 0) { + try { + Attachments.collection.update( + { 'meta.cardId': this._id }, + { $set: updateMeta }, + { multi: true }, + ); + } catch (err) { + // Do not block the move if attachment update fails + // eslint-disable-next-line no-console + console.error('Failed to update attachments metadata after moving card', this._id, err); + } + } + } }, addLabel(labelId) { diff --git a/models/wekanCreator.js b/models/wekanCreator.js index 4d7f4425d..2bf142d50 100644 --- a/models/wekanCreator.js +++ b/models/wekanCreator.js @@ -76,6 +76,33 @@ export class WekanCreator { // maps a wekanCardId to an array of wekanAttachments this.attachments = {}; + + // default swimlane id created during import if necessary + this._defaultSwimlaneId = null; + + // Normalize possible exported id fields: some exports may use `id` instead of `_id`. + // Ensure every item we rely on has an `_id` so mappings work consistently. + const normalizeIds = arr => { + if (!arr) return; + arr.forEach(item => { + if (item && item.id && !item._id) { + item._id = item.id; + } + }); + }; + + normalizeIds(data.lists); + normalizeIds(data.cards); + normalizeIds(data.swimlanes); + normalizeIds(data.checklists); + normalizeIds(data.checklistItems); + normalizeIds(data.triggers); + normalizeIds(data.actions); + normalizeIds(data.labels); + normalizeIds(data.customFields); + normalizeIds(data.comments); + normalizeIds(data.activities); + normalizeIds(data.rules); } /** @@ -348,7 +375,7 @@ export class WekanCreator { dateLastActivity: this._now(), description: card.description, listId: this.lists[card.listId], - swimlaneId: this.swimlanes[card.swimlaneId], + swimlaneId: this.swimlanes[card.swimlaneId] || this._defaultSwimlaneId, sort: card.sort, title: card.title, // we attribute the card to its creator if available @@ -588,6 +615,25 @@ export class WekanCreator { } createSwimlanes(wekanSwimlanes, boardId) { + // If no swimlanes provided, create a default so cards still render + if (!wekanSwimlanes || wekanSwimlanes.length === 0) { + const swimlaneToCreate = { + archived: false, + boardId, + createdAt: this._now(), + title: 'Default', + sort: 0, + }; + const created = Swimlanes.direct.insert(swimlaneToCreate); + Swimlanes.direct.update(created, { + $set: { + updatedAt: this._now(), + }, + }); + this._defaultSwimlaneId = created; + return; + } + wekanSwimlanes.forEach((swimlane, swimlaneIndex) => { const swimlaneToCreate = { archived: swimlane.archived, @@ -611,6 +657,9 @@ export class WekanCreator { }, }); this.swimlanes[swimlane._id] = swimlaneId; + if (!this._defaultSwimlaneId) { + this._defaultSwimlaneId = swimlaneId; + } }); } diff --git a/rebuild-wekan.sh b/rebuild-wekan.sh index 45067cfa0..6d3475e04 100755 --- a/rebuild-wekan.sh +++ b/rebuild-wekan.sh @@ -12,7 +12,7 @@ function pause(){ echo PS3='Please enter your choice: ' -options=("Install Wekan dependencies" "Build Wekan" "Run Meteor for dev on http://localhost:4000" "Run Meteor for dev on http://localhost:4000 with trace warnings, and warnings using old Meteor API that will not exist in Meteor 3.0" "Run Meteor for dev on http://localhost:4000 with bundle visualizer" "Run Meteor for dev on http://CURRENT-IP-ADDRESS:4000" "Run Meteor for dev on http://CURRENT-IP-ADDRESS:4000 with MONGO_URL=mongodb://127.0.0.1:27019/wekan" "Run Meteor for dev on http://CUSTOM-IP-ADDRESS:PORT" "Save Meteor dependency chain to ../meteor-deps.txt" "Quit") +options=("Install Wekan dependencies" "Build Wekan" "Run Meteor for dev on http://localhost:4000" "Run Meteor for dev on http://localhost:4000 with trace warnings, and warnings using old Meteor API that will not exist in Meteor 3.0" "Run Meteor for dev on http://localhost:4000 with bundle visualizer" "Run Meteor for dev on http://CURRENT-IP-ADDRESS:4000" "Run Meteor for dev on http://CURRENT-IP-ADDRESS:4000 with MONGO_URL=mongodb://127.0.0.1:27019/wekan" "Run Meteor for dev on http://CUSTOM-IP-ADDRESS:PORT" "Run tests" "Save Meteor dependency chain to ../meteor-deps.txt" "Quit") select opt in "${options[@]}" do @@ -240,6 +240,12 @@ do break ;; + "Run tests") + echo "Running tests (import regression)." + node tests/wekanCreator.import.test.js + break + ;; + "Quit") break ;; diff --git a/tests/wekanCreator.import.test.js b/tests/wekanCreator.import.test.js new file mode 100644 index 000000000..55dc99539 --- /dev/null +++ b/tests/wekanCreator.import.test.js @@ -0,0 +1,313 @@ +/** + * Test: WekanCreator import with swimlane preservation + * + * Simulates exporting a board with swimlanes and importing it back, + * verifying that: + * 1. Swimlanes are correctly mapped from old IDs to new IDs + * 2. Cards reference the correct swimlane IDs after import + * 3. A default swimlane is created when no swimlanes exist + * 4. ID normalization (id → _id) works for all exported items + */ + +// Mock data: exported board with swimlanes and cards +const mockExportedBoard = { + _format: 'wekan-board-1.0.0', + _id: 'board1', + title: 'Test Board', + archived: false, + color: 'bgnone', + permission: 'private', + createdAt: new Date().toISOString(), + modifiedAt: new Date().toISOString(), + members: [ + { + userId: 'user1', + wekanId: 'user1', + isActive: true, + isAdmin: true, + }, + ], + labels: [], + swimlanes: [ + { + _id: 'swimlane1', + title: 'Swimlane 1', + archived: false, + sort: 0, + }, + { + _id: 'swimlane2', + title: 'Swimlane 2', + archived: false, + sort: 1, + }, + ], + lists: [ + { + _id: 'list1', + title: 'To Do', + archived: false, + sort: 0, + }, + { + _id: 'list2', + title: 'Done', + archived: false, + sort: 1, + }, + ], + cards: [ + { + _id: 'card1', + title: 'Card in swimlane 1', + archived: false, + swimlaneId: 'swimlane1', + listId: 'list1', + sort: 0, + description: 'Test card', + dateLastActivity: new Date().toISOString(), + labelIds: [], + }, + { + _id: 'card2', + title: 'Card in swimlane 2', + archived: false, + swimlaneId: 'swimlane2', + listId: 'list2', + sort: 0, + description: 'Another test card', + dateLastActivity: new Date().toISOString(), + labelIds: [], + }, + ], + comments: [], + activities: [ + { + activityType: 'createBoard', + createdAt: new Date().toISOString(), + userId: 'user1', + }, + ], + checklists: [], + checklistItems: [], + subtaskItems: [], + customFields: [], + rules: [], + triggers: [], + actions: [], + users: [ + { + _id: 'user1', + username: 'admin', + profile: { + fullname: 'Admin User', + }, + }, + ], +}; + +// Export format variation: using 'id' instead of '_id' +const mockExportedBoardWithIdField = { + ...mockExportedBoard, + swimlanes: [ + { + id: 'swimlane1', + title: 'Swimlane 1 (id variant)', + archived: false, + sort: 0, + }, + ], + lists: [ + { + id: 'list1', + title: 'To Do (id variant)', + archived: false, + sort: 0, + }, + ], + cards: [ + { + id: 'card1', + title: 'Card (id variant)', + archived: false, + swimlaneId: 'swimlane1', + listId: 'list1', + sort: 0, + description: 'Test card with id field', + dateLastActivity: new Date().toISOString(), + labelIds: [], + }, + ], +}; + +// Test: Verify id → _id normalization +function testIdNormalization() { + console.log('\n=== Test: ID Normalization (id → _id) ==='); + + // Simulate the normalization logic from WekanCreator constructor + const normalizeIds = arr => { + if (!arr) return; + arr.forEach(item => { + if (item && item.id && !item._id) { + item._id = item.id; + } + }); + }; + + const testData = { + lists: mockExportedBoardWithIdField.lists, + cards: mockExportedBoardWithIdField.cards, + swimlanes: mockExportedBoardWithIdField.swimlanes, + }; + + normalizeIds(testData.lists); + normalizeIds(testData.cards); + normalizeIds(testData.swimlanes); + + // Check results + if (testData.swimlanes[0]._id === 'swimlane1') { + console.log('✓ Swimlane: id → _id normalization created _id'); + } else { + console.log('✗ Swimlane: id → _id normalization FAILED'); + } + + if (testData.lists[0]._id === 'list1') { + console.log('✓ List: id → _id normalization created _id'); + } else { + console.log('✗ List: id → _id normalization FAILED'); + } + + if (testData.cards[0]._id === 'card1') { + console.log('✓ Card: id → _id normalization created _id'); + } else { + console.log('✗ Card: id → _id normalization FAILED'); + } +} + +// Test: Verify swimlane mapping during import +function testSwimlaneMapping() { + console.log('\n=== Test: Swimlane Mapping (export → import) ==='); + + // Simulate WekanCreator swimlane mapping + const swimlanes = {}; + const swimlaneIndexMap = {}; // Track old → new ID mappings + + // Simulate createSwimlanes: build mapping of old ID → new ID + mockExportedBoard.swimlanes.forEach((swimlane, index) => { + const oldId = swimlane._id; + const newId = `new_swimlane_${index}`; // Simulated new ID + swimlanes[oldId] = newId; + swimlaneIndexMap[oldId] = newId; + }); + + console.log(`✓ Created mapping for ${Object.keys(swimlanes).length} swimlanes:`); + Object.entries(swimlanes).forEach(([oldId, newId]) => { + console.log(` ${oldId} → ${newId}`); + }); + + // Simulate createCards: cards reference swimlanes using mapping + const cardSwimlaneCheck = mockExportedBoard.cards.every(card => { + const oldSwimlaneId = card.swimlaneId; + const newSwimlaneId = swimlanes[oldSwimlaneId]; + return newSwimlaneId !== undefined; + }); + + if (cardSwimlaneCheck) { + console.log('✓ All cards can be mapped to swimlanes'); + } else { + console.log('✗ Some cards have missing swimlane mappings'); + } +} + +// Test: Verify default swimlane creation when none exist +function testDefaultSwimlaneCreation() { + console.log('\n=== Test: Default Swimlane Creation ==='); + + const boardWithoutSwimlanes = { + ...mockExportedBoard, + swimlanes: [], + }; + + // Simulate the default swimlane logic from WekanCreator + let swimlanes = {}; + let defaultSwimlaneId = null; + + // If no swimlanes were provided, create a default + if (!swimlanes || Object.keys(swimlanes).length === 0) { + defaultSwimlaneId = 'new_default_swimlane'; + console.log('✓ Default swimlane created:', defaultSwimlaneId); + } + + // Verify cards without swimlane references use the default + const cardsWithoutSwimlane = boardWithoutSwimlanes.cards.filter(c => !c.swimlaneId); + if (cardsWithoutSwimlane.length > 0 && defaultSwimlaneId) { + console.log(`✓ ${cardsWithoutSwimlane.length} cards will use default swimlane`); + } else if (cardsWithoutSwimlane.length === 0) { + console.log('✓ No cards lacking swimlane (test data all have swimlaneId)'); + } +} + +// Test: Verify swimlane + card integrity after full import cycle +function testFullImportCycle() { + console.log('\n=== Test: Full Import Cycle ==='); + + // Step 1: Normalize IDs + const normalizeIds = arr => { + if (!arr) return; + arr.forEach(item => { + if (item && item.id && !item._id) { + item._id = item.id; + } + }); + }; + + const data = JSON.parse(JSON.stringify(mockExportedBoard)); // Deep copy + normalizeIds(data.swimlanes); + normalizeIds(data.lists); + normalizeIds(data.cards); + + // Step 2: Map swimlanes + const swimlaneMap = {}; + data.swimlanes.forEach((s, idx) => { + swimlaneMap[s._id] = `imported_swimlane_${idx}`; + }); + + // Step 3: Verify cards get mapped swimlane IDs + let unmappedCards = 0; + data.cards.forEach(card => { + if (card.swimlaneId && !swimlaneMap[card.swimlaneId]) { + unmappedCards++; + } + }); + + if (unmappedCards === 0) { + console.log('✓ All cards have valid swimlane references'); + } else { + console.log(`✗ ${unmappedCards} cards have unmapped swimlane references`); + } + + if (data.swimlanes.length > 0) { + console.log(`✓ ${data.swimlanes.length} swimlanes preserved in import`); + } + + if (data.cards.length > 0) { + console.log(`✓ ${data.cards.length} cards preserved in import`); + } +} + +// Run all tests +if (typeof describe === 'undefined') { + // Running in Node.js or standalone (not Mocha) + console.log('===================================='); + console.log('WekanCreator Import Tests'); + console.log('===================================='); + + testIdNormalization(); + testSwimlaneMapping(); + testDefaultSwimlaneCreation(); + testFullImportCycle(); + + console.log('\n===================================='); + console.log('Tests completed'); + console.log('====================================\n'); +}