Browse Source

Merge GitHub PR #401

I also completed the release notes related to the import feature.

Closes #401
Maxime Quandalle 9 years ago
parent
commit
f565aed259

+ 3 - 2
.meteor/versions

@@ -12,6 +12,7 @@ babel-runtime@0.1.4
 base64@1.0.4
 binary-heap@1.0.4
 blaze@2.1.3
+blaze-html-templates@1.0.1
 blaze-tools@1.0.4
 boilerplate-generator@1.0.4
 caching-compiler@1.0.0
@@ -63,7 +64,7 @@ idmontie:migrations@1.0.1
 jquery@1.11.4
 kadira:blaze-layout@2.2.0
 kadira:dochead@1.3.2
-kadira:flow-router@2.8.0
+kadira:flow-router@2.9.0
 kenton:accounts-sandstorm@0.1.8
 launch-screen@1.0.4
 livedata@1.0.15
@@ -124,7 +125,7 @@ seriousm:emoji-continued@1.4.0
 service-configuration@1.0.5
 session@1.1.1
 sha@1.0.4
-softwarerero:accounts-t9n@1.1.4
+softwarerero:accounts-t9n@1.1.6
 spacebars@1.0.7
 spacebars-compiler@1.0.7
 srp@1.0.4

+ 2 - 1
History.md

@@ -2,7 +2,8 @@
 
 This release features:
 
-* Card import from Trello
+* Trello boards and cards importation, including card history, assigned members,
+  labels, comments, and attachments;
 * Autocompletion in the minicard editor. Start with <kbd>@</kbd> to start the
   a board member autocompletion, or <kbd>#</kbd> for a label;
 * Accelerate the initial page rendering by sending the data on the intial HTTP

+ 2 - 1
client/components/activities/activities.js

@@ -86,7 +86,8 @@ BlazeComponent.extendComponent({
 
   attachmentLink() {
     const attachment = this.currentData().attachment();
-    return attachment && Blaze.toHTML(HTML.A({
+    // trying to display url before file is stored generates js errors
+    return attachment && attachment.url({ download: true }) && Blaze.toHTML(HTML.A({
       href: FlowRouter.path(attachment.url({ download: true })),
       target: '_blank',
     }, attachment.name()));

+ 47 - 0
client/components/import/import.jade

@@ -4,4 +4,51 @@ template(name="importPopup")
   form
     p: label(for='import-textarea') {{_ getLabel}}
     textarea#import-textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus)
+      | {{jsonText}}
+    if membersMapping
+      div
+        a.show-mapping
+          | {{_ 'import-show-user-mapping'}}
     input.primary.wide(type="submit" value="{{_ 'import'}}")
+
+template(name="mapMembersPopup")
+  .map-members
+    p {{_ 'import-members-map'}}
+    .mapping-list
+      each members
+        .mapping
+          a.source
+            div.full-name
+              = fullName
+            div.username
+              | ({{username}})
+          .wekan
+            if wekan
+              +userAvatar(userId=wekan._id)
+            else
+              a.member.add-member.js-add-members
+                i.fa.fa-plus
+    form
+      input.primary.wide(type="submit" value="{{_ 'done'}}")
+
+      template(name="addMemberPopup")
+
+template(name="mapMembersAddPopup")
+  .select-member
+    p
+      | {{_ 'import-user-select'}}
+    .js-map-member
+      +esInput(index="users")
+    ul.pop-over-list
+      +esEach(index="users")
+        li.item.js-member-item
+          a.name.js-select-import(title="{{profile.name}} ({{username}})" data-id="{{_id}}")
+            +userAvatar(userId=_id esSearch=true)
+            span.full-name
+              = profile.name
+              | (<span class="username">{{username}}</span>)
+      +ifEsIsSearching(index='users')
+        +spinner
+      +ifEsHasNoResults(index="users")
+        .manage-member-section
+          p.quiet {{_ 'no-results'}}

+ 208 - 27
client/components/import/import.js

@@ -11,48 +11,122 @@ const ImportPopup = BlazeComponent.extendComponent({
     return 'importPopup';
   },
 
-  events() {
-    return [{
-      'submit': (evt) => {
-        evt.preventDefault();
-        const dataJson = $(evt.currentTarget).find('.js-import-json').val();
-        let dataObject;
-        try {
-          dataObject = JSON.parse(dataJson);
-        } catch (e) {
-          this.setError('error-json-malformed');
-          return;
-        }
-        Meteor.call(this.getMethodName(), dataObject, this.getAdditionalData(),
-          (error, response) => {
-            if (error) {
-              this.setError(error.error);
-            } else {
-              Filter.addException(response);
-              this.onFinish(response);
-            }
-          }
-        );
-      },
-    }];
+  jsonText() {
+    return Session.get('import.text');
+  },
+
+  membersMapping() {
+    return Session.get('import.membersToMap');
   },
 
   onCreated() {
     this.error = new ReactiveVar('');
+    this.dataToImport = '';
+  },
+
+  onFinish() {
+    Popup.close();
+  },
+
+  onShowMapping(evt) {
+    this._storeText(evt);
+    Popup.open('mapMembers')(evt);
+  },
+
+  onSubmit(evt){
+    evt.preventDefault();
+    const dataJson = this._storeText(evt);
+    let dataObject;
+    try {
+      dataObject = JSON.parse(dataJson);
+      this.setError('');
+    } catch (e) {
+      this.setError('error-json-malformed');
+      return;
+    }
+    if(this._hasAllNeededData(dataObject)) {
+      this._import(dataObject);
+    } else {
+      this._prepareAdditionalData(dataObject);
+      Popup.open(this._screenAdditionalData())(evt);
+
+    }
+  },
+
+  events() {
+    return [{
+      submit: this.onSubmit,
+      'click .show-mapping': this.onShowMapping,
+    }];
   },
 
   setError(error) {
     this.error.set(error);
   },
 
-  onFinish() {
-    Popup.close();
+  _import(dataObject) {
+    const additionalData = this.getAdditionalData();
+    const membersMapping = this.membersMapping();
+    if (membersMapping) {
+      const mappingById = {};
+      membersMapping.forEach((member) => {
+        if (member.wekan) {
+          mappingById[member.id] = member.wekan._id;
+        }
+      });
+      additionalData.membersMapping = mappingById;
+    }
+    Session.set('import.membersToMap', null);
+    Session.set('import.text', null);
+    Meteor.call(this.getMethodName(), dataObject, additionalData,
+      (error, response) => {
+        if (error) {
+          this.setError(error.error);
+        } else {
+          // ensure will display what we just imported
+          Filter.addException(response);
+          this.onFinish(response);
+        }
+      }
+    );
+  },
+
+  _hasAllNeededData(dataObject) {
+    // import has no members or they are already mapped
+    return dataObject.members.length === 0 || this.membersMapping();
+  },
+
+  _prepareAdditionalData(dataObject) {
+    // we will work on the list itself (an ordered array of objects)
+    // when a mapping is done, we add a 'wekan' field to the object representing the imported member
+    const membersToMap = dataObject.members;
+    // auto-map based on username
+    membersToMap.forEach((importedMember) => {
+      const wekanUser = Users.findOne({username: importedMember.username});
+      if(wekanUser) {
+        importedMember.wekan = wekanUser;
+      }
+    });
+    // store members data and mapping in Session
+    // (we go deep and 2-way, so storing in data context is not a viable option)
+    Session.set('import.membersToMap', membersToMap);
+    return membersToMap;
+  },
+
+  _screenAdditionalData() {
+    return 'mapMembers';
+  },
+
+  _storeText() {
+    const dataJson = this.$('.js-import-json').val();
+    Session.set('import.text', dataJson);
+    return dataJson;
   },
 });
 
 ImportPopup.extendComponent({
   getAdditionalData() {
-    const listId = this.data()._id;
+    const listId = this.currentData()._id;
     const selector = `#js-list-${this.currentData()._id} .js-minicard:first`;
     const firstCardDom = $(selector).get(0);
     const sortIndex = Utils.calculateIndex(null, firstCardDom).base;
@@ -88,3 +162,110 @@ ImportPopup.extendComponent({
   },
 }).register('boardImportBoardPopup');
 
+const ImportMapMembers = BlazeComponent.extendComponent({
+  members() {
+    return Session.get('import.membersToMap');
+  },
+  _refreshMembers(listOfMembers) {
+    Session.set('import.membersToMap', listOfMembers);
+  },
+  /**
+   * Will look into the list of members to import for the specified memberId,
+   * then set its property to the supplied value.
+   * If unset is true, it will remove the property from the rest of the list as well.
+   *
+   * use:
+   * - memberId = null to use selected member
+   * - value = null to unset a property
+   * - unset = true to ensure property is only set on 1 member at a time
+   */
+  _setPropertyForMember(property, value, memberId, unset = false) {
+    const listOfMembers = this.members();
+    let finder = null;
+    if(memberId) {
+      finder = (member) => member.id === memberId;
+    } else {
+      finder = (member) => member.selected;
+    }
+    listOfMembers.forEach((member) => {
+      if(finder(member)) {
+        if(value !== null) {
+          member[property] = value;
+        } else {
+          delete member[property];
+        }
+        if(!unset) {
+          // we shortcut if we don't care about unsetting the others
+          return false;
+        }
+      } else if(unset) {
+        delete member[property];
+      }
+      return true;
+    });
+    // Session.get gives us a copy, we have to set it back so it sticks
+    this._refreshMembers(listOfMembers);
+  },
+  setSelectedMember(memberId) {
+    return this._setPropertyForMember('selected', true, memberId, true);
+  },
+  /**
+   * returns the member with specified id,
+   * or the selected member if memberId is not specified
+   */
+  getMember(memberId = null) {
+    const allMembers = Session.get('import.membersToMap');
+    let finder = null;
+    if(memberId) {
+      finder = (user) => user.id === memberId;
+    } else {
+      finder = (user) => user.selected;
+    }
+    return allMembers.find(finder);
+  },
+  mapSelectedMember(wekan) {
+    return this._setPropertyForMember('wekan', wekan, null);
+  },
+  unmapMember(memberId){
+    return this._setPropertyForMember('wekan', null, memberId);
+  },
+});
+
+ImportMapMembers.extendComponent({
+  onMapMember(evt) {
+    const memberToMap = this.currentData();
+    if(memberToMap.wekan) {
+      // todo xxx ask for confirmation?
+      this.unmapMember(memberToMap.id);
+    } else {
+      this.setSelectedMember(memberToMap.id);
+      Popup.open('mapMembersAdd')(evt);
+    }
+  },
+  onSubmit(evt) {
+    evt.preventDefault();
+    Popup.back();
+  },
+  events() {
+    return [{
+      'submit': this.onSubmit,
+      'click .mapping': this.onMapMember,
+    }];
+  },
+}).register('mapMembersPopup');
+
+ImportMapMembers.extendComponent({
+  onSelectUser(){
+    this.mapSelectedMember(this.currentData());
+    Popup.back();
+  },
+  events() {
+    return [{
+      'click .js-select-import': this.onSelectUser,
+    }];
+  },
+  onRendered() {
+    // todo XXX why do I not get the focus??
+    this.find('.js-map-member input').focus();
+  },
+}).register('mapMembersAddPopup');

+ 17 - 0
client/components/import/import.styl

@@ -0,0 +1,17 @@
+.map-members
+  .mapping:first-of-type
+    border-top: solid 1px #999
+  .mapping
+    padding: 10px 0
+    border-bottom: solid 1px #999
+    .source
+      display: inline-block
+      width: 80%
+    .wekan
+      display: inline-block
+      width: 35px
+      .member
+        float: none
+
+a.show-mapping
+  text-decoration underline

+ 6 - 0
i18n/en.i18n.json

@@ -115,6 +115,7 @@
     "disambiguateMultiLabelPopup-title": "Disambiguate Label Action",
     "disambiguateMultiMemberPopup-title": "Disambiguate Member Action",
     "discard": "Discard",
+    "done": "Done",
     "download": "Download",
     "edit": "Edit",
     "edit-avatar": "Change Avatar",
@@ -142,6 +143,9 @@
     "import-card": "Import a Trello card",
     "import-card-trello-instruction": "Go to a Trello card, select 'Share and more...' then 'Export JSON' and copy the resulting text",
     "import-json-placeholder": "Paste your valid JSON data here",
+    "import-members-map": "Your imported board has some members. Please map the members you want to import to Wekan users",
+    "import-show-user-mapping": "Review members mapping",
+    "import-user-select": "Pick the Wekan user you want to use as this member",
     "info": "Infos",
     "initials": "Initials",
     "joined": "joined",
@@ -165,6 +169,8 @@
     "lists": "Lists",
     "log-out": "Log Out",
     "loginPopup-title": "Log In",
+    "mapMembersPopup-title": "Map members",
+    "mapMembersAddPopup-title": "Select Wekan member",
     "memberMenuPopup-title": "Member Settings",
     "members": "Members",
     "menu": "Menu",

+ 4 - 1
models/cards.js

@@ -108,7 +108,10 @@ Cards.helpers({
   },
 
   cover() {
-    return Attachments.findOne(this.coverId);
+    const cover = Attachments.findOne(this.coverId);
+    // if we return a cover before it is fully stored, we will get errors when we try to display it
+    // todo XXX we could return a default "upload pending" image in the meantime?
+    return cover && cover.url() && cover;
   },
 
   absoluteUrl() {

+ 161 - 66
models/import.js

@@ -4,7 +4,7 @@ const DateString = Match.Where(function (dateAsString) {
 });
 
 class TrelloCreator {
-  constructor() {
+  constructor(data) {
     // The object creation dates, indexed by Trello id (so we only parse actions
     // once!)
     this.createdAt = {
@@ -18,6 +18,11 @@ class TrelloCreator {
     this.lists = {};
     // The comments, indexed by Trello card id (to map when importing cards)
     this.comments = {};
+    // the members, indexed by Trello member id => Wekan user ID
+    this.members = data.membersMapping ? data.membersMapping : {};
+
+    // maps a trelloCardId to an array of trelloAttachments
+    this.attachments = {};
   }
 
   checkActions(trelloActions) {
@@ -90,6 +95,24 @@ class TrelloCreator {
       stars: 0,
       title: trelloBoard.name,
     };
+    // now add other members
+    if(trelloBoard.memberships) {
+      trelloBoard.memberships.forEach((trelloMembership) => {
+        const trelloId = trelloMembership.idMember;
+        // do we have a mapping?
+        if(this.members[trelloId]) {
+          const wekanId = this.members[trelloId];
+          // do we already have it in our list?
+          if(!boardToCreate.members.find((wekanMember) => wekanMember.userId === wekanId)) {
+            boardToCreate.members.push({
+              userId: wekanId,
+              isAdmin: false,
+              isActive: true,
+            });
+          }
+        }
+      });
+    }
     trelloBoard.labels.forEach((label) => {
       const labelToCreate = {
         _id: Random.id(6),
@@ -121,56 +144,14 @@ class TrelloCreator {
     return boardId;
   }
 
-  // Create labels if they do not exist and load this.labels.
-  createLabels(trelloLabels, board) {
-    trelloLabels.forEach((label) => {
-      const color = label.color;
-      const name = label.name;
-      const existingLabel = board.getLabel(name, color);
-      if (existingLabel) {
-        this.labels[label.id] = existingLabel._id;
-      } else {
-        const idLabelCreated = board.pushLabel(name, color);
-        this.labels[label.id] = idLabelCreated;
-      }
-    });
-  }
-
-  createLists(trelloLists, boardId) {
-    trelloLists.forEach((list) => {
-      const listToCreate = {
-        archived: list.closed,
-        boardId,
-        // We are being defensing here by providing a default date (now) if the
-        // creation date wasn't found on the action log. This happen on old
-        // Trello boards (eg from 2013) that didn't log the 'createList' action
-        // we require.
-        createdAt: new Date(this.createdAt.lists[list.id] || Date.now()),
-        title: list.name,
-        userId: Meteor.userId(),
-      };
-      const listId = Lists.direct.insert(listToCreate);
-      const now = new Date();
-      Lists.direct.update(listId, {$set: {'updatedAt': now}});
-      this.lists[list.id] = listId;
-      // log activity
-      Activities.direct.insert({
-        activityType: 'importList',
-        boardId,
-        createdAt: now,
-        listId,
-        source: {
-          id: list.id,
-          system: 'Trello',
-        },
-        // We attribute the import to current user, not the one from the
-        // original object
-        userId: Meteor.userId(),
-      });
-    });
-  }
-
-  createCardsAndComments(trelloCards, boardId) {
+  /**
+   * Create the Wekan cards corresponding to the supplied Trello cards,
+   * as well as all linked data: activities, comments, and attachments
+   * @param trelloCards
+   * @param boardId
+   * @returns {Array}
+   */
+  createCards(trelloCards, boardId) {
     const result = [];
     trelloCards.forEach((card) => {
       const cardToCreate = {
@@ -191,6 +172,25 @@ class TrelloCreator {
           return this.labels[trelloId];
         });
       }
+      // add members {
+      if(card.idMembers) {
+        const wekanMembers = [];
+        // we can't just map, as some members may not have been mapped
+        card.idMembers.forEach((trelloId) => {
+          if(this.members[trelloId]) {
+            const wekanId = this.members[trelloId];
+            // we may map multiple Trello members to the same wekan user
+            // in which case we risk adding the same user multiple times
+            if(!wekanMembers.find((wId) => wId === wekanId)){
+              wekanMembers.push(wekanId);
+            }
+          }
+          return true;
+        });
+        if(wekanMembers.length>0) {
+          cardToCreate.members = wekanMembers;
+        }
+      }
       // insert card
       const cardId = Cards.direct.insert(cardToCreate);
       // log activity
@@ -234,12 +234,90 @@ class TrelloCreator {
           });
         });
       }
-      // XXX add attachments
+      const attachments = this.attachments[card.id];
+      const trelloCoverId = card.idAttachmentCover;
+      if (attachments) {
+        attachments.forEach((att) => {
+          const file = new FS.File();
+          // Simulating file.attachData on the client generates multiple errors
+          // - HEAD returns null, which causes exception down the line
+          // - the template then tries to display the url to the attachment which causes other errors
+          // so we make it server only, and let UI catch up once it is done, forget about latency comp.
+          if(Meteor.isServer) {
+            file.attachData(att.url, function (error) {
+              file.boardId = boardId;
+              file.cardId = cardId;
+              if (error) {
+                throw(error);
+              } else {
+                const wekanAtt = Attachments.insert(file, () => {
+                  // we do nothing
+                });
+                //
+                if(trelloCoverId === att.id) {
+                  Cards.direct.update(cardId, { $set: {coverId: wekanAtt._id}});
+                }
+              }
+            });
+          }
+          // todo XXX set cover - if need be
+        });
+      }
       result.push(cardId);
     });
     return result;
   }
 
+  // Create labels if they do not exist and load this.labels.
+  createLabels(trelloLabels, board) {
+    trelloLabels.forEach((label) => {
+      const color = label.color;
+      const name = label.name;
+      const existingLabel = board.getLabel(name, color);
+      if (existingLabel) {
+        this.labels[label.id] = existingLabel._id;
+      } else {
+        const idLabelCreated = board.pushLabel(name, color);
+        this.labels[label.id] = idLabelCreated;
+      }
+    });
+  }
+
+  createLists(trelloLists, boardId) {
+    trelloLists.forEach((list) => {
+      const listToCreate = {
+        archived: list.closed,
+        boardId,
+        // We are being defensing here by providing a default date (now) if the
+        // creation date wasn't found on the action log. This happen on old
+        // Trello boards (eg from 2013) that didn't log the 'createList' action
+        // we require.
+        createdAt: new Date(this.createdAt.lists[list.id] || Date.now()),
+        title: list.name,
+        userId: Meteor.userId(),
+      };
+      const listId = Lists.direct.insert(listToCreate);
+      const now = new Date();
+      Lists.direct.update(listId, {$set: {'updatedAt': now}});
+      this.lists[list.id] = listId;
+      // log activity
+      Activities.direct.insert({
+        activityType: 'importList',
+        boardId,
+        createdAt: now,
+        listId,
+        source: {
+          id: list.id,
+          system: 'Trello',
+        },
+        // We attribute the import to current user, not the one from the
+        // original object
+        userId: Meteor.userId(),
+      });
+    });
+  }
+
+
   getColor(trelloColorCode) {
     // trello color name => wekan color
     const mapColors = {
@@ -269,6 +347,29 @@ class TrelloCreator {
   parseActions(trelloActions) {
     trelloActions.forEach((action) => {
       switch (action.type) {
+      case 'addAttachmentToCard':
+        // We have to be cautious, because the attachment could have been removed later.
+        // In that case Trello still reports its addition, but removes its 'url' field.
+        // So we test for that
+        const trelloAttachment = action.data.attachment;
+        if(trelloAttachment.url) {
+          // we cannot actually create the Wekan attachment, because we don't yet
+          // have the cards to attach it to, so we store it in the instance variable.
+          const trelloCardId = action.data.card.id;
+          if(!this.attachments[trelloCardId]) {
+            this.attachments[trelloCardId] = [];
+          }
+          this.attachments[trelloCardId].push(trelloAttachment);
+        }
+        break;
+      case 'commentCard':
+        const id = action.data.card.id;
+        if (this.comments[id]) {
+          this.comments[id].push(action);
+        } else {
+          this.comments[id] = [action];
+        }
+        break;
       case 'createBoard':
         this.createdAt.board = action.date;
         break;
@@ -280,14 +381,6 @@ class TrelloCreator {
         const listId = action.data.list.id;
         this.createdAt.lists[listId] = action.date;
         break;
-      case 'commentCard':
-        const id = action.data.card.id;
-        if (this.comments[id]) {
-          this.comments[id].push(action);
-        } else {
-          this.comments[id] = [action];
-        }
-        break;
       default:
         // do nothing
         break;
@@ -298,12 +391,13 @@ class TrelloCreator {
 
 Meteor.methods({
   importTrelloBoard(trelloBoard, data) {
-    const trelloCreator = new TrelloCreator();
+    const trelloCreator = new TrelloCreator(data);
 
     // 1. check all parameters are ok from a syntax point of view
     try {
-      // we don't use additional data - this should be an empty object
-      check(data, {});
+      check(data, {
+        membersMapping: Match.Optional(Object),
+      });
       trelloCreator.checkActions(trelloBoard.actions);
       trelloCreator.checkBoard(trelloBoard);
       trelloCreator.checkLabels(trelloBoard.labels);
@@ -320,19 +414,20 @@ Meteor.methods({
     trelloCreator.parseActions(trelloBoard.actions);
     const boardId = trelloCreator.createBoardAndLabels(trelloBoard);
     trelloCreator.createLists(trelloBoard.lists, boardId);
-    trelloCreator.createCardsAndComments(trelloBoard.cards, boardId);
+    trelloCreator.createCards(trelloBoard.cards, boardId);
     // XXX add members
     return boardId;
   },
 
   importTrelloCard(trelloCard, data) {
-    const trelloCreator = new TrelloCreator();
+    const trelloCreator = new TrelloCreator(data);
 
     // 1. check parameters are ok from a syntax point of view
     try {
       check(data, {
         listId: String,
         sortIndex: Number,
+        membersMapping: Match.Optional(Object),
       });
       trelloCreator.checkCards([trelloCard]);
       trelloCreator.checkLabels(trelloCard.labels);
@@ -358,7 +453,7 @@ Meteor.methods({
     trelloCreator.parseActions(trelloCard.actions);
     const board = list.board();
     trelloCreator.createLabels(trelloCard.labels, board);
-    const cardIds = trelloCreator.createCardsAndComments([trelloCard], board._id);
+    const cardIds = trelloCreator.createCards([trelloCard], board._id);
     return cardIds[0];
   },
 });

+ 1 - 1
models/users.js

@@ -2,7 +2,7 @@ Users = Meteor.users; // eslint-disable-line meteor/collections
 
 // Search a user in the complete server database by its name or username. This
 // is used for instance to add a new user to a board.
-const searchInFields = ['username', 'profile.name'];
+const searchInFields = ['username', 'profile.fullname'];
 Users.initEasySearch(searchInFields, {
   use: 'mongo-db',
   returnFields: [...searchInFields, 'profile.avatarUrl'],