2
0
Эх сурвалжийг харах

Manually merged fixes from seve12.

Thanks to seve12 !

Related https://github.com/wekan/wekan/pull/5967
Lauri Ojansivu 1 өдөр өмнө
parent
commit
ecfb0f0fdf

+ 6 - 0
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.
 

+ 1 - 1
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);
           }

+ 3 - 0
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}}
 

+ 13 - 14
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());

+ 18 - 0
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');
     }

+ 2 - 0
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'}}

+ 15 - 1
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 [
       {

+ 1 - 0
client/components/rules/ruleDetails.js

@@ -5,6 +5,7 @@ BlazeComponent.extendComponent({
     this.subscribe('allRules');
     this.subscribe('allTriggers');
     this.subscribe('allActions');
+    this.subscribe('boards');
   },
 
   trigger() {

+ 3 - 1
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

+ 3 - 1
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

+ 22 - 0
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) {

+ 50 - 1
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;
+      }
     });
   }
 

+ 7 - 1
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
 		;;

+ 313 - 0
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');
+}