Преглед изворни кода

Centralize all mutations at the model level

This commit uses a new package that I need to document. It tries to
solve the long-standing debate in the Meteor community about
allow/deny rules versus methods (RPC).

This approach gives us both the centralized security rules of
allow/deny and the white-list of allowed mutations similarly to Meteor
methods. The idea to have static mutation descriptions is also
inspired by Facebook's Relay/GraphQL.

This will allow the development of a REST API using the high-level
methods instead of the MongoDB queries to do the mapping between the
HTTP requests and our collections.
Maxime Quandalle пре 9 година
родитељ
комит
45b662a1dd

+ 1 - 0
.eslintrc

@@ -117,6 +117,7 @@ globals:
   presences: true
   presences: true
   Ps: true
   Ps: true
   ReactiveTabs: false
   ReactiveTabs: false
+  Restivus: false
   SimpleSchema: false
   SimpleSchema: false
   SubsManager: false
   SubsManager: false
   T9n: false
   T9n: false

+ 2 - 1
.meteor/packages

@@ -18,7 +18,6 @@ mquandalle:stylus
 es5-shim
 es5-shim
 
 
 # Collections
 # Collections
-mongo
 aldeed:collection2
 aldeed:collection2
 cfs:gridfs
 cfs:gridfs
 cfs:standard-packages
 cfs:standard-packages
@@ -26,6 +25,8 @@ dburles:collection-helpers
 idmontie:migrations
 idmontie:migrations
 matb33:collection-hooks
 matb33:collection-hooks
 matteodem:easy-search
 matteodem:easy-search
+mongo
+mquandalle:collection-mutations
 reywood:publish-composite
 reywood:publish-composite
 
 
 # Account system
 # Account system

+ 1 - 0
.meteor/versions

@@ -86,6 +86,7 @@ mongo-id@1.0.1-rc.0
 mongo-livedata@1.0.9-rc.0
 mongo-livedata@1.0.9-rc.0
 mousetrap:mousetrap@1.4.6_1
 mousetrap:mousetrap@1.4.6_1
 mquandalle:autofocus@1.0.0
 mquandalle:autofocus@1.0.0
+mquandalle:collection-mutations@0.1.0
 mquandalle:jade@0.4.3_1
 mquandalle:jade@0.4.3_1
 mquandalle:jade-compiler@0.4.3
 mquandalle:jade-compiler@0.4.3
 mquandalle:jquery-textcomplete@0.3.9_1
 mquandalle:jquery-textcomplete@0.3.9_1

+ 3 - 7
client/components/boards/boardArchive.js

@@ -22,13 +22,9 @@ BlazeComponent.extendComponent({
   events() {
   events() {
     return [{
     return [{
       'click .js-restore-board'() {
       'click .js-restore-board'() {
-        const boardId = this.currentData()._id;
-        Boards.update(boardId, {
-          $set: {
-            archived: false,
-          },
-        });
-        Utils.goBoardId(boardId);
+        const board = this.currentData();
+        board.restore();
+        Utils.goBoardId(board._id);
       },
       },
     }];
     }];
   },
   },

+ 11 - 20
client/components/boards/boardHeader.js

@@ -7,8 +7,8 @@ Template.boardMenuPopup.events({
   'click .js-change-board-color': Popup.open('boardChangeColor'),
   'click .js-change-board-color': Popup.open('boardChangeColor'),
   'click .js-change-language': Popup.open('changeLanguage'),
   'click .js-change-language': Popup.open('changeLanguage'),
   'click .js-archive-board ': Popup.afterConfirm('archiveBoard', () => {
   'click .js-archive-board ': Popup.afterConfirm('archiveBoard', () => {
-    const boardId = Session.get('currentBoard');
-    Boards.update(boardId, { $set: { archived: true }});
+    const currentBoard = Boards.findOne(Session.get('currentBoard'));
+    currentBoard.archive();
     // XXX We should have some kind of notification on top of the page to
     // XXX We should have some kind of notification on top of the page to
     // confirm that the board was successfully archived.
     // confirm that the board was successfully archived.
     FlowRouter.go('home');
     FlowRouter.go('home');
@@ -17,13 +17,9 @@ Template.boardMenuPopup.events({
 
 
 Template.boardChangeTitlePopup.events({
 Template.boardChangeTitlePopup.events({
   submit(evt, tpl) {
   submit(evt, tpl) {
-    const title = tpl.$('.js-board-name').val().trim();
-    if (title) {
-      Boards.update(this._id, {
-        $set: {
-          title,
-        },
-      });
+    const newTitle = tpl.$('.js-board-name').val().trim();
+    if (newTitle) {
+      this.rename(newTitle);
       Popup.close();
       Popup.close();
     }
     }
     evt.preventDefault();
     evt.preventDefault();
@@ -95,12 +91,9 @@ BlazeComponent.extendComponent({
   events() {
   events() {
     return [{
     return [{
       'click .js-select-background'(evt) {
       'click .js-select-background'(evt) {
-        const currentBoardId = Session.get('currentBoard');
-        Boards.update(currentBoardId, {
-          $set: {
-            color: this.currentData().toString(),
-          },
-        });
+        const currentBoard = Boards.findOne(Session.get('currentBoard'));
+        const newColor = this.currentData().toString();
+        currentBoard.setColor(newColor);
         evt.preventDefault();
         evt.preventDefault();
       },
       },
     }];
     }];
@@ -168,11 +161,9 @@ BlazeComponent.extendComponent({
   },
   },
 
 
   selectBoardVisibility() {
   selectBoardVisibility() {
-    Boards.update(Session.get('currentBoard'), {
-      $set: {
-        permission: this.currentData(),
-      },
-    });
+    const currentBoard = Boards.findOne(Session.get('currentBoard'));
+    const visibility = this.currentData();
+    currentBoard.setVisibility(visibility);
     Popup.close();
     Popup.close();
   },
   },
 
 

+ 2 - 2
client/components/cards/attachments.js

@@ -15,10 +15,10 @@ Template.attachmentsGalery.events({
     // XXX Not implemented!
     // XXX Not implemented!
   },
   },
   'click .js-add-cover'() {
   'click .js-add-cover'() {
-    Cards.update(this.cardId, { $set: { coverId: this._id } });
+    Cards.findOne(this.cardId).setCover(this._id);
   },
   },
   'click .js-remove-cover'() {
   'click .js-remove-cover'() {
-    Cards.update(this.cardId, { $unset: { coverId: '' } });
+    Cards.findOne(this.cardId).unsetCover();
   },
   },
 });
 });
 
 

+ 5 - 20
client/components/cards/cardDetails.js

@@ -55,12 +55,6 @@ BlazeComponent.extendComponent({
     this.componentParent().showOverlay.set(false);
     this.componentParent().showOverlay.set(false);
   },
   },
 
 
-  updateCard(modifier) {
-    Cards.update(this.data()._id, {
-      $set: modifier,
-    });
-  },
-
   events() {
   events() {
     const events = {
     const events = {
       [`${CSSEvents.animationend} .js-card-details`]() {
       [`${CSSEvents.animationend} .js-card-details`]() {
@@ -76,13 +70,13 @@ BlazeComponent.extendComponent({
       'submit .js-card-description'(evt) {
       'submit .js-card-description'(evt) {
         evt.preventDefault();
         evt.preventDefault();
         const description = this.currentComponent().getValue();
         const description = this.currentComponent().getValue();
-        this.updateCard({ description });
+        this.data().setDescription(description);
       },
       },
       'submit .js-card-details-title'(evt) {
       'submit .js-card-details-title'(evt) {
         evt.preventDefault();
         evt.preventDefault();
         const title = this.currentComponent().getValue();
         const title = this.currentComponent().getValue();
         if ($.trim(title)) {
         if ($.trim(title)) {
-          this.updateCard({ title });
+          this.data().setTitle(title);
         }
         }
       },
       },
       'click .js-member': Popup.open('cardMember'),
       'click .js-member': Popup.open('cardMember'),
@@ -135,14 +129,9 @@ Template.cardDetailsActionsPopup.events({
   'click .js-labels': Popup.open('cardLabels'),
   'click .js-labels': Popup.open('cardLabels'),
   'click .js-attachments': Popup.open('cardAttachments'),
   'click .js-attachments': Popup.open('cardAttachments'),
   'click .js-move-card': Popup.open('moveCard'),
   'click .js-move-card': Popup.open('moveCard'),
-  // 'click .js-copy': Popup.open(),
   'click .js-archive'(evt) {
   'click .js-archive'(evt) {
     evt.preventDefault();
     evt.preventDefault();
-    Cards.update(this._id, {
-      $set: {
-        archived: true,
-      },
-    });
+    this.archive();
     Popup.close();
     Popup.close();
   },
   },
   'click .js-more': Popup.open('cardMore'),
   'click .js-more': Popup.open('cardMore'),
@@ -152,13 +141,9 @@ Template.moveCardPopup.events({
   'click .js-select-list'() {
   'click .js-select-list'() {
     // XXX We should *not* get the currentCard from the global state, but
     // XXX We should *not* get the currentCard from the global state, but
     // instead from a “component” state.
     // instead from a “component” state.
-    const cardId = Session.get('currentCard');
+    const card = Cards.findOne(Session.get('currentCard'));
     const newListId = this._id;
     const newListId = this._id;
-    Cards.update(cardId, {
-      $set: {
-        listId: newListId,
-      },
-    });
+    card.move(newListId);
     Popup.close();
     Popup.close();
   },
   },
 });
 });

+ 8 - 43
client/components/cards/labels.js

@@ -45,19 +45,9 @@ Template.createLabelPopup.helpers({
 
 
 Template.cardLabelsPopup.events({
 Template.cardLabelsPopup.events({
   'click .js-select-label'(evt) {
   'click .js-select-label'(evt) {
-    const cardId = Template.parentData(2).data._id;
+    const card = Cards.findOne(Session.get('currentCard'));
     const labelId = this._id;
     const labelId = this._id;
-    let operation;
-    if (Cards.find({ _id: cardId, labelIds: labelId}).count() === 0)
-      operation = '$addToSet';
-    else
-      operation = '$pull';
-
-    Cards.update(cardId, {
-      [operation]: {
-        labelIds: labelId,
-      },
-    });
+    card.toggleLabel(labelId);
     evt.preventDefault();
     evt.preventDefault();
   },
   },
   'click .js-edit-label': Popup.open('editLabel'),
   'click .js-edit-label': Popup.open('editLabel'),
@@ -79,20 +69,10 @@ Template.formLabel.events({
 Template.createLabelPopup.events({
 Template.createLabelPopup.events({
   // Create the new label
   // Create the new label
   'submit .create-label'(evt, tpl) {
   'submit .create-label'(evt, tpl) {
+    const board = Boards.findOne(Session.get('currentBoard'));
     const name = tpl.$('#labelName').val().trim();
     const name = tpl.$('#labelName').val().trim();
-    const boardId = Session.get('currentBoard');
     const color = Blaze.getData(tpl.find('.fa-check')).color;
     const color = Blaze.getData(tpl.find('.fa-check')).color;
-
-    Boards.update(boardId, {
-      $push: {
-        labels: {
-          name,
-          color,
-          _id: Random.id(6),
-        },
-      },
-    });
-
+    board.addLabel(name, color);
     Popup.back();
     Popup.back();
     evt.preventDefault();
     evt.preventDefault();
   },
   },
@@ -100,31 +80,16 @@ Template.createLabelPopup.events({
 
 
 Template.editLabelPopup.events({
 Template.editLabelPopup.events({
   'click .js-delete-label': Popup.afterConfirm('deleteLabel', function() {
   'click .js-delete-label': Popup.afterConfirm('deleteLabel', function() {
-    const boardId = Session.get('currentBoard');
-    Boards.update(boardId, {
-      $pull: {
-        labels: {
-          _id: this._id,
-        },
-      },
-    });
-
+    const board = Boards.findOne(Session.get('currentBoard'));
+    board.removeLabel(this._id);
     Popup.back(2);
     Popup.back(2);
   }),
   }),
   'submit .edit-label'(evt, tpl) {
   'submit .edit-label'(evt, tpl) {
     evt.preventDefault();
     evt.preventDefault();
+    const board = Boards.findOne(Session.get('currentBoard'));
     const name = tpl.$('#labelName').val().trim();
     const name = tpl.$('#labelName').val().trim();
-    const boardId = Session.get('currentBoard');
-    const getLabel = Utils.getLabelIndex(boardId, this._id);
     const color = Blaze.getData(tpl.find('.fa-check')).color;
     const color = Blaze.getData(tpl.find('.fa-check')).color;
-
-    Boards.update(boardId, {
-      $set: {
-        [getLabel.key('name')]: name,
-        [getLabel.key('color')]: color,
-      },
-    });
-
+    board.editLabel(this._id, name, color);
     Popup.back();
     Popup.back();
   },
   },
 });
 });

+ 7 - 18
client/components/lists/list.js

@@ -73,23 +73,13 @@ BlazeComponent.extendComponent({
         $cards.sortable('cancel');
         $cards.sortable('cancel');
 
 
         if (MultiSelection.isActive()) {
         if (MultiSelection.isActive()) {
-          Cards.find(MultiSelection.getMongoSelector()).forEach((c, i) => {
-            Cards.update(c._id, {
-              $set: {
-                listId,
-                sort: sortIndex.base + i * sortIndex.increment,
-              },
-            });
+          Cards.find(MultiSelection.getMongoSelector()).forEach((card, i) => {
+            card.move(listId, sortIndex.base + i * sortIndex.increment);
           });
           });
         } else {
         } else {
           const cardDomElement = ui.item.get(0);
           const cardDomElement = ui.item.get(0);
-          const cardId = Blaze.getData(cardDomElement)._id;
-          Cards.update(cardId, {
-            $set: {
-              listId,
-              sort: sortIndex.base,
-            },
-          });
+          const card = Blaze.getData(cardDomElement);
+          card.move(listId, sortIndex.base);
         }
         }
         boardComponent.setIsDragging(false);
         boardComponent.setIsDragging(false);
       },
       },
@@ -107,16 +97,15 @@ BlazeComponent.extendComponent({
           accept: '.js-member,.js-label',
           accept: '.js-member,.js-label',
           drop(event, ui) {
           drop(event, ui) {
             const cardId = Blaze.getData(this)._id;
             const cardId = Blaze.getData(this)._id;
-            let addToSet;
+            const card = Cards.findOne(cardId);
 
 
             if (ui.draggable.hasClass('js-member')) {
             if (ui.draggable.hasClass('js-member')) {
               const memberId = Blaze.getData(ui.draggable.get(0)).userId;
               const memberId = Blaze.getData(ui.draggable.get(0)).userId;
-              addToSet = { members: memberId };
+              card.assignMember(memberId);
             } else {
             } else {
               const labelId = Blaze.getData(ui.draggable.get(0))._id;
               const labelId = Blaze.getData(ui.draggable.get(0))._id;
-              addToSet = { labelIds: labelId };
+              card.addLabel(labelId);
             }
             }
-            Cards.update(cardId, { $addToSet: addToSet });
           },
           },
         });
         });
       });
       });

+ 10 - 29
client/components/lists/listHeader.js

@@ -5,14 +5,10 @@ BlazeComponent.extendComponent({
 
 
   editTitle(evt) {
   editTitle(evt) {
     evt.preventDefault();
     evt.preventDefault();
-    const form = this.componentChildren('inlinedForm')[0];
-    const newTitle = form.getValue();
+    const newTitle = this.componentChildren('inlinedForm')[0].getValue();
+    const list = this.currentData();
     if ($.trim(newTitle)) {
     if ($.trim(newTitle)) {
-      Lists.update(this.currentData()._id, {
-        $set: {
-          title: newTitle,
-        },
-      });
+      list.rename(newTitle);
     }
     }
   },
   },
 
 
@@ -33,45 +29,30 @@ Template.listActionPopup.events({
   },
   },
   'click .js-list-subscribe'() {},
   'click .js-list-subscribe'() {},
   'click .js-select-cards'() {
   'click .js-select-cards'() {
-    const cardIds = Cards.find(
-      {listId: this._id},
-      {fields: { _id: 1 }}
-    ).map((card) => card._id);
+    const cardIds = this.allCards().map((card) => card._id);
     MultiSelection.add(cardIds);
     MultiSelection.add(cardIds);
     Popup.close();
     Popup.close();
   },
   },
   'click .js-move-cards': Popup.open('listMoveCards'),
   'click .js-move-cards': Popup.open('listMoveCards'),
   'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', () => {
   'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', () => {
-    Cards.find({listId: this._id}).forEach((card) => {
-      Cards.update(card._id, {
-        $set: {
-          archived: true,
-        },
-      });
+    this.allCards().forEach((card) => {
+      card.archive();
     });
     });
     Popup.close();
     Popup.close();
   }),
   }),
   'click .js-close-list'(evt) {
   'click .js-close-list'(evt) {
     evt.preventDefault();
     evt.preventDefault();
-    Lists.update(this._id, {
-      $set: {
-        archived: true,
-      },
-    });
+    this.archive();
     Popup.close();
     Popup.close();
   },
   },
 });
 });
 
 
 Template.listMoveCardsPopup.events({
 Template.listMoveCardsPopup.events({
   'click .js-select-list'() {
   'click .js-select-list'() {
-    const fromList = Template.parentData(2).data._id;
+    const fromList = Template.parentData(2).data;
     const toList = this._id;
     const toList = this._id;
-    Cards.find({ listId: fromList }).forEach((card) => {
-      Cards.update(card._id, {
-        $set: {
-          listId: toList,
-        },
-      });
+    fromList.allCards().forEach((card) => {
+      card.move(toList);
     });
     });
     Popup.close();
     Popup.close();
   },
   },

+ 5 - 42
client/components/sidebar/sidebar.js

@@ -109,14 +109,6 @@ EscapeActions.register('sidebarView',
   () => { return Sidebar && Sidebar.getView() !== defaultView; }
   () => { return Sidebar && Sidebar.getView() !== defaultView; }
 );
 );
 
 
-function getMemberIndex(board, searchId) {
-  for (let i = 0; i < board.members.length; i++) {
-    if (board.members[i].userId === searchId)
-      return i;
-  }
-  throw new Meteor.Error('Member not found');
-}
-
 Template.memberPopup.helpers({
 Template.memberPopup.helpers({
   user() {
   user() {
     return Users.findOne(this.userId);
     return Users.findOne(this.userId);
@@ -135,13 +127,8 @@ Template.memberPopup.events({
   'click .js-change-role': Popup.open('changePermissions'),
   'click .js-change-role': Popup.open('changePermissions'),
   'click .js-remove-member': Popup.afterConfirm('removeMember', function() {
   'click .js-remove-member': Popup.afterConfirm('removeMember', function() {
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
-    const memberIndex = getMemberIndex(currentBoard, this.userId);
-
-    Boards.update(currentBoard._id, {
-      $set: {
-        [`members.${memberIndex}.isActive`]: false,
-      },
-    });
+    const memberId = this.userId;
+    currentBoard.removeMember(memberId);
     Popup.close();
     Popup.close();
   }),
   }),
   'click .js-leave-member'() {
   'click .js-leave-member'() {
@@ -209,26 +196,7 @@ Template.addMemberPopup.events({
   'click .js-select-member'() {
   'click .js-select-member'() {
     const userId = this._id;
     const userId = this._id;
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
-    const currentMembersIds = _.pluck(currentBoard.members, 'userId');
-    if (currentMembersIds.indexOf(userId) === -1) {
-      Boards.update(currentBoard._id, {
-        $push: {
-          members: {
-            userId,
-            isAdmin: false,
-            isActive: true,
-          },
-        },
-      });
-    } else {
-      const memberIndex = getMemberIndex(currentBoard, userId);
-
-      Boards.update(currentBoard._id, {
-        $set: {
-          [`members.${memberIndex}.isActive`]: true,
-        },
-      });
-    }
+    currentBoard.addMember(userId);
     Popup.close();
     Popup.close();
   },
   },
 });
 });
@@ -240,14 +208,9 @@ Template.addMemberPopup.onRendered(function() {
 Template.changePermissionsPopup.events({
 Template.changePermissionsPopup.events({
   'click .js-set-admin, click .js-set-normal'(event) {
   'click .js-set-admin, click .js-set-normal'(event) {
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
-    const memberIndex = getMemberIndex(currentBoard, this.userId);
+    const memberId = this.userId;
     const isAdmin = $(event.currentTarget).hasClass('js-set-admin');
     const isAdmin = $(event.currentTarget).hasClass('js-set-admin');
-
-    Boards.update(currentBoard._id, {
-      $set: {
-        [`members.${memberIndex}.isAdmin`]: isAdmin,
-      },
-    });
+    currentBoard.setMemberPermission(memberId, isAdmin);
     Popup.back(1);
     Popup.back(1);
   },
   },
 });
 });

+ 4 - 4
client/components/sidebar/sidebarArchives.js

@@ -29,8 +29,8 @@ BlazeComponent.extendComponent({
   events() {
   events() {
     return [{
     return [{
       'click .js-restore-card'() {
       'click .js-restore-card'() {
-        const cardId = this.currentData()._id;
-        Cards.update(cardId, {$set: {archived: false}});
+        const card = this.currentData();
+        card.restore();
       },
       },
       'click .js-delete-card': Popup.afterConfirm('cardDelete', function() {
       'click .js-delete-card': Popup.afterConfirm('cardDelete', function() {
         const cardId = this._id;
         const cardId = this._id;
@@ -38,8 +38,8 @@ BlazeComponent.extendComponent({
         Popup.close();
         Popup.close();
       }),
       }),
       'click .js-restore-list'() {
       'click .js-restore-list'() {
-        const listId = this.currentData()._id;
-        Lists.update(listId, {$set: {archived: false}});
+        const list = this.currentData();
+        list.restore();
       },
       },
     }];
     }];
   },
   },

+ 18 - 31
client/components/sidebar/sidebarFilters.js

@@ -30,9 +30,9 @@ BlazeComponent.extendComponent({
   },
   },
 }).register('filterSidebar');
 }).register('filterSidebar');
 
 
-function updateSelectedCards(query) {
+function mutateSelectedCards(mutationName, ...args) {
   Cards.find(MultiSelection.getMongoSelector()).forEach((card) => {
   Cards.find(MultiSelection.getMongoSelector()).forEach((card) => {
-    Cards.update(card._id, query);
+    card[mutationName](...args);
   });
   });
 }
 }
 
 
@@ -67,47 +67,34 @@ BlazeComponent.extendComponent({
       'click .js-toggle-label-multiselection'(evt) {
       'click .js-toggle-label-multiselection'(evt) {
         const labelId = this.currentData()._id;
         const labelId = this.currentData()._id;
         const mappedSelection = this.mapSelection('label', labelId);
         const mappedSelection = this.mapSelection('label', labelId);
-        let operation;
-        if (_.every(mappedSelection))
-          operation = '$pull';
-        else if (_.every(mappedSelection, (bool) => !bool))
-          operation = '$addToSet';
-        else {
+
+        if (_.every(mappedSelection)) {
+          mutateSelectedCards('addLabel', labelId);
+        } else if (_.every(mappedSelection, (bool) => !bool)) {
+          mutateSelectedCards('removeLabel', labelId);
+        } else {
           const popup = Popup.open('disambiguateMultiLabel');
           const popup = Popup.open('disambiguateMultiLabel');
           // XXX We need to have a better integration between the popup and the
           // XXX We need to have a better integration between the popup and the
           // UI components systems.
           // UI components systems.
           return popup.call(this.currentData(), evt);
           return popup.call(this.currentData(), evt);
         }
         }
-
-        updateSelectedCards({
-          [operation]: {
-            labelIds: labelId,
-          },
-        });
       },
       },
       'click .js-toggle-member-multiselection'(evt) {
       'click .js-toggle-member-multiselection'(evt) {
         const memberId = this.currentData()._id;
         const memberId = this.currentData()._id;
         const mappedSelection = this.mapSelection('member', memberId);
         const mappedSelection = this.mapSelection('member', memberId);
-        let operation;
-        if (_.every(mappedSelection))
-          operation = '$pull';
-        else if (_.every(mappedSelection, (bool) => !bool))
-          operation = '$addToSet';
-        else {
+        if (_.every(mappedSelection)) {
+          mutateSelectedCards('assignMember', memberId);
+        } else if (_.every(mappedSelection, (bool) => !bool)) {
+          mutateSelectedCards('unassignMember', memberId);
+        } else {
           const popup = Popup.open('disambiguateMultiMember');
           const popup = Popup.open('disambiguateMultiMember');
           // XXX We need to have a better integration between the popup and the
           // XXX We need to have a better integration between the popup and the
           // UI components systems.
           // UI components systems.
           return popup.call(this.currentData(), evt);
           return popup.call(this.currentData(), evt);
         }
         }
-
-        updateSelectedCards({
-          [operation]: {
-            members: memberId,
-          },
-        });
       },
       },
       'click .js-archive-selection'() {
       'click .js-archive-selection'() {
-        updateSelectedCards({$set: {archived: true}});
+        mutateSelectedCards('archive');
       },
       },
     }];
     }];
   },
   },
@@ -115,22 +102,22 @@ BlazeComponent.extendComponent({
 
 
 Template.disambiguateMultiLabelPopup.events({
 Template.disambiguateMultiLabelPopup.events({
   'click .js-remove-label'() {
   'click .js-remove-label'() {
-    updateSelectedCards({$pull: {labelIds: this._id}});
+    mutateSelectedCards('removeLabel', this._id);
     Popup.close();
     Popup.close();
   },
   },
   'click .js-add-label'() {
   'click .js-add-label'() {
-    updateSelectedCards({$addToSet: {labelIds: this._id}});
+    mutateSelectedCards('addLabel', this._id);
     Popup.close();
     Popup.close();
   },
   },
 });
 });
 
 
 Template.disambiguateMultiMemberPopup.events({
 Template.disambiguateMultiMemberPopup.events({
   'click .js-unassign-member'() {
   'click .js-unassign-member'() {
-    updateSelectedCards({$pull: {members: this._id}});
+    mutateSelectedCards('assignMember', this._id);
     Popup.close();
     Popup.close();
   },
   },
   'click .js-assign-member'() {
   'click .js-assign-member'() {
-    updateSelectedCards({$addToSet: {members: this._id}});
+    mutateSelectedCards('unassignMember', this._id);
     Popup.close();
     Popup.close();
   },
   },
 });
 });

+ 4 - 18
client/components/users/userAvatar.js

@@ -82,11 +82,7 @@ BlazeComponent.extendComponent({
   },
   },
 
 
   setAvatar(avatarUrl) {
   setAvatar(avatarUrl) {
-    Meteor.users.update(Meteor.userId(), {
-      $set: {
-        'profile.avatarUrl': avatarUrl,
-      },
-    });
+    Meteor.user().setAvatarUrl(avatarUrl);
   },
   },
 
 
   setError(error) {
   setError(error) {
@@ -151,19 +147,9 @@ Template.cardMembersPopup.helpers({
 
 
 Template.cardMembersPopup.events({
 Template.cardMembersPopup.events({
   'click .js-select-member'(evt) {
   'click .js-select-member'(evt) {
-    const cardId = Template.parentData(2).data._id;
+    const card = Cards.findOne(Session.get('currentCard'));
     const memberId = this.userId;
     const memberId = this.userId;
-    let operation;
-    if (Cards.find({ _id: cardId, members: memberId}).count() === 0)
-      operation = '$addToSet';
-    else
-      operation = '$pull';
-
-    Cards.update(cardId, {
-      [operation]: {
-        members: memberId,
-      },
-    });
+    card.toggleMember(memberId);
     evt.preventDefault();
     evt.preventDefault();
   },
   },
 });
 });
@@ -176,7 +162,7 @@ Template.cardMemberPopup.helpers({
 
 
 Template.cardMemberPopup.events({
 Template.cardMemberPopup.events({
   'click .js-remove-member'() {
   'click .js-remove-member'() {
-    Cards.update(this.cardId, {$pull: {members: this.userId}});
+    Cards.findOne(this.cardId).unassignMember(this.userId);
     Popup.close();
     Popup.close();
   },
   },
   'click .js-edit-profile': Popup.open('editProfile'),
   'click .js-edit-profile': Popup.open('editProfile'),

+ 1 - 1
client/lib/unsavedEdits.js

@@ -65,7 +65,7 @@ UnsavedEdits = {
 };
 };
 
 
 Blaze.registerHelper('getUnsavedValue', (fieldName, docId, defaultTo) => {
 Blaze.registerHelper('getUnsavedValue', (fieldName, docId, defaultTo) => {
-  // Workaround some blaze feature that ass a list of keywords arguments as the
+  // Workaround some blaze feature that pass a list of keywords arguments as the
   // last parameter (even if the caller didn't specify any).
   // last parameter (even if the caller didn't specify any).
   if (!_.isString(defaultTo)) {
   if (!_.isString(defaultTo)) {
     defaultTo = '';
     defaultTo = '';

+ 0 - 14
client/lib/utils.js

@@ -22,20 +22,6 @@ Utils = {
     return string.charAt(0).toUpperCase() + string.slice(1);
     return string.charAt(0).toUpperCase() + string.slice(1);
   },
   },
 
 
-  getLabelIndex(boardId, labelId) {
-    const board = Boards.findOne(boardId);
-    const labels = {};
-    _.each(board.labels, (a, b) => {
-      labels[a._id] = b;
-    });
-    return {
-      index: labels[labelId],
-      key(key) {
-        return `labels.${labels[labelId]}.${key}`;
-      },
-    };
-  },
-
   // Determine the new sort index
   // Determine the new sort index
   calculateIndex(prevCardDomElement, nextCardDomElement, nCards = 1) {
   calculateIndex(prevCardDomElement, nextCardDomElement, nCards = 1) {
     let base, increment;
     let base, increment;

+ 0 - 0
collections/activities.js → models/activities.js


+ 0 - 0
collections/attachments.js → models/attachments.js


+ 0 - 0
collections/avatars.js → models/avatars.js


+ 119 - 27
collections/boards.js → models/boards.js

@@ -73,6 +73,125 @@ Boards.attachSchema(new SimpleSchema({
   },
   },
 }));
 }));
 
 
+
+Boards.helpers({
+  isPublic() {
+    return this.permission === 'public';
+  },
+
+  lists() {
+    return Lists.find({ boardId: this._id, archived: false },
+                                                          { sort: { sort: 1 }});
+  },
+
+  activities() {
+    return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }});
+  },
+
+  activeMembers() {
+    return _.where(this.members, {isActive: true});
+  },
+
+  labelIndex(labelId) {
+    return _.indexOf(_.pluck(this.labels, '_id'), labelId);
+  },
+
+  memberIndex(memberId) {
+    return _.indexOf(_.pluck(this.members, 'userId'), memberId);
+  },
+
+  absoluteUrl() {
+    return FlowRouter.path('board', { id: this._id, slug: this.slug });
+  },
+
+  colorClass() {
+    return `board-color-${this.color}`;
+  },
+});
+
+Boards.mutations({
+  archive() {
+    return { $set: { archived: true }};
+  },
+
+  restore() {
+    return { $set: { archived: false }};
+  },
+
+  rename(title) {
+    return { $set: { title }};
+  },
+
+  setColor(color) {
+    return { $set: { color }};
+  },
+
+  setVisibility(visibility) {
+    return { $set: { permission: visibility }};
+  },
+
+  addLabel(name, color) {
+    const _id = Random.id(6);
+    return { $push: {labels: { _id, name, color }}};
+  },
+
+  editLabel(labelId, name, color) {
+    const labelIndex = this.labelIndex(labelId);
+    return {
+      $set: {
+        [`labels.${labelIndex}.name`]: name,
+        [`labels.${labelIndex}.color`]: color,
+      },
+    };
+  },
+
+  removeLabel(labelId) {
+    return { $pull: { labels: { _id: labelId }}};
+  },
+
+  addMember(memberId) {
+    const memberIndex = this.memberIndex(memberId);
+    if (memberIndex === -1) {
+      return {
+        $push: {
+          members: {
+            userId: memberId,
+            isAdmin: false,
+            isActive: true,
+          },
+        },
+      };
+    } else {
+      return {
+        $set: {
+          [`members.${memberIndex}.isActive`]: true,
+          [`members.${memberIndex}.isAdmin`]: false,
+        },
+      };
+    }
+  },
+
+  removeMember(memberId) {
+    const memberIndex = this.memberIndex(memberId);
+
+    return {
+      $set: {
+        [`members.${memberIndex}.isActive`]: false,
+      },
+    };
+  },
+
+  setMemberPermission(memberId, isAdmin) {
+    const memberIndex = this.memberIndex(memberId);
+
+    return {
+      $set: {
+        [`members.${memberIndex}.isAdmin`]: isAdmin,
+      },
+    };
+  },
+});
+
 if (Meteor.isServer) {
 if (Meteor.isServer) {
   Boards.allow({
   Boards.allow({
     insert: Meteor.userId,
     insert: Meteor.userId,
@@ -119,33 +238,6 @@ if (Meteor.isServer) {
   });
   });
 }
 }
 
 
-Boards.helpers({
-  isPublic() {
-    return this.permission === 'public';
-  },
-
-  lists() {
-    return Lists.find({ boardId: this._id, archived: false },
-                                                          { sort: { sort: 1 }});
-  },
-
-  activities() {
-    return Activities.find({ boardId: this._id }, { sort: { createdAt: -1 }});
-  },
-
-  activeMembers() {
-    return _.where(this.members, {isActive: true});
-  },
-
-  absoluteUrl() {
-    return FlowRouter.path('board', { id: this._id, slug: this.slug });
-  },
-
-  colorClass() {
-    return `board-color-${this.color}`;
-  },
-});
-
 Boards.before.insert((userId, doc) => {
 Boards.before.insert((userId, doc) => {
   // XXX We need to improve slug management. Only the id should be necessary
   // XXX We need to improve slug management. Only the id should be necessary
   // to identify a board in the code.
   // to identify a board in the code.

+ 69 - 0
models/cardComments.js

@@ -0,0 +1,69 @@
+CardComments = new Mongo.Collection('card_comments');
+
+CardComments.attachSchema(new SimpleSchema({
+  boardId: {
+    type: String,
+  },
+  cardId: {
+    type: String,
+  },
+  // XXX Rename in `content`? `text` is a bit vague...
+  text: {
+    type: String,
+  },
+  // XXX We probably don't need this information here, since we already have it
+  // in the associated comment creation activity
+  createdAt: {
+    type: Date,
+    denyUpdate: false,
+  },
+  // XXX Should probably be called `authorId`
+  userId: {
+    type: String,
+  },
+}));
+
+CardComments.allow({
+  insert(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+  },
+  update(userId, doc) {
+    return userId === doc.userId;
+  },
+  remove(userId, doc) {
+    return userId === doc.userId;
+  },
+  fetch: ['userId', 'boardId'],
+});
+
+CardComments.helpers({
+  user() {
+    return Users.findOne(this.userId);
+  },
+});
+
+CardComments.hookOptions.after.update = { fetchPrevious: false };
+
+CardComments.before.insert((userId, doc) => {
+  doc.createdAt = new Date();
+  doc.userId = userId;
+});
+
+if (Meteor.isServer) {
+  CardComments.after.insert((userId, doc) => {
+    Activities.insert({
+      userId,
+      activityType: 'addComment',
+      boardId: doc.boardId,
+      cardId: doc.cardId,
+      commentId: doc._id,
+    });
+  });
+
+  CardComments.after.remove((userId, doc) => {
+    const activity = Activities.findOne({ commentId: doc._id });
+    if (activity) {
+      Activities.remove(activity._id);
+    }
+  });
+}

+ 85 - 78
collections/cards.js → models/cards.js

@@ -1,5 +1,4 @@
 Cards = new Mongo.Collection('cards');
 Cards = new Mongo.Collection('cards');
-CardComments = new Mongo.Collection('card_comments');
 
 
 // XXX To improve pub/sub performances a card document should include a
 // XXX To improve pub/sub performances a card document should include a
 // de-normalized number of comments so we don't have to publish the whole list
 // de-normalized number of comments so we don't have to publish the whole list
@@ -54,64 +53,28 @@ Cards.attachSchema(new SimpleSchema({
   },
   },
 }));
 }));
 
 
-CardComments.attachSchema(new SimpleSchema({
-  boardId: {
-    type: String,
+Cards.allow({
+  insert(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
   },
   },
-  cardId: {
-    type: String,
-  },
-  // XXX Rename in `content`? `text` is a bit vague...
-  text: {
-    type: String,
+  update(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
   },
   },
-  // XXX We probably don't need this information here, since we already have it
-  // in the associated comment creation activity
-  createdAt: {
-    type: Date,
-    denyUpdate: false,
-  },
-  // XXX Should probably be called `authorId`
-  userId: {
-    type: String,
+  remove(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
   },
   },
-}));
-
-if (Meteor.isServer) {
-  Cards.allow({
-    insert(userId, doc) {
-      return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
-    },
-    update(userId, doc) {
-      return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
-    },
-    remove(userId, doc) {
-      return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
-    },
-    fetch: ['boardId'],
-  });
-
-  CardComments.allow({
-    insert(userId, doc) {
-      return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
-    },
-    update(userId, doc) {
-      return userId === doc.userId;
-    },
-    remove(userId, doc) {
-      return userId === doc.userId;
-    },
-    fetch: ['userId', 'boardId'],
-  });
-}
+  fetch: ['boardId'],
+});
 
 
 Cards.helpers({
 Cards.helpers({
   list() {
   list() {
     return Lists.findOne(this.listId);
     return Lists.findOne(this.listId);
   },
   },
+
   board() {
   board() {
     return Boards.findOne(this.boardId);
     return Boards.findOne(this.boardId);
   },
   },
+
   labels() {
   labels() {
     const boardLabels = this.board().labels;
     const boardLabels = this.board().labels;
     const cardLabels = _.filter(boardLabels, (label) => {
     const cardLabels = _.filter(boardLabels, (label) => {
@@ -119,27 +82,35 @@ Cards.helpers({
     });
     });
     return cardLabels;
     return cardLabels;
   },
   },
+
   hasLabel(labelId) {
   hasLabel(labelId) {
     return _.contains(this.labelIds, labelId);
     return _.contains(this.labelIds, labelId);
   },
   },
+
   user() {
   user() {
     return Users.findOne(this.userId);
     return Users.findOne(this.userId);
   },
   },
+
   isAssigned(memberId) {
   isAssigned(memberId) {
     return _.contains(this.members, memberId);
     return _.contains(this.members, memberId);
   },
   },
+
   activities() {
   activities() {
     return Activities.find({ cardId: this._id }, { sort: { createdAt: -1 }});
     return Activities.find({ cardId: this._id }, { sort: { createdAt: -1 }});
   },
   },
+
   comments() {
   comments() {
     return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }});
     return CardComments.find({ cardId: this._id }, { sort: { createdAt: -1 }});
   },
   },
+
   attachments() {
   attachments() {
     return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }});
     return Attachments.find({ cardId: this._id }, { sort: { uploadedAt: -1 }});
   },
   },
+
   cover() {
   cover() {
     return Attachments.findOne(this.coverId);
     return Attachments.findOne(this.coverId);
   },
   },
+
   absoluteUrl() {
   absoluteUrl() {
     const board = this.board();
     const board = this.board();
     return FlowRouter.path('card', {
     return FlowRouter.path('card', {
@@ -148,33 +119,86 @@ Cards.helpers({
       cardId: this._id,
       cardId: this._id,
     });
     });
   },
   },
+
   rootUrl() {
   rootUrl() {
     return Meteor.absoluteUrl(this.absoluteUrl().replace('/', ''));
     return Meteor.absoluteUrl(this.absoluteUrl().replace('/', ''));
   },
   },
 });
 });
 
 
-CardComments.helpers({
-  user() {
-    return Users.findOne(this.userId);
+Cards.mutations({
+  archive() {
+    return { $set: { archived: true }};
+  },
+
+  restore() {
+    return { $set: { archived: false }};
+  },
+
+  setTitle(title) {
+    return { $set: { title }};
+  },
+
+  setDescription(description) {
+    return { $set: { description }};
+  },
+
+  move(listId, sortIndex) {
+    const mutatedFields = { listId };
+    if (sortIndex) {
+      mutatedFields.sort = sortIndex;
+    }
+    return { $set: mutatedFields };
+  },
+
+  addLabel(labelId) {
+    return { $addToSet: { labelIds: labelId }};
+  },
+
+  removeLabel(labelId) {
+    return { $pull: { labelIds: labelId }};
+  },
+
+  toggleLabel(labelId) {
+    if (this.labelIds && this.labelIds.indexOf(labelId) > -1) {
+      return this.removeLabel(labelId);
+    } else {
+      return this.addLabel(labelId);
+    }
+  },
+
+  assignMember(memberId) {
+    return { $addToSet: { members: memberId }};
+  },
+
+  unassignMember(memberId) {
+    return { $pull: { members: memberId }};
+  },
+
+  toggleMember(memberId) {
+    if (this.members && this.members.indexOf(memberId) > -1) {
+      return this.unassignMember(memberId);
+    } else {
+      return this.assignMember(memberId);
+    }
+  },
+
+  setCover(coverId) {
+    return { $set: { coverId }};
+  },
+
+  unsetCover() {
+    return { $unset: { coverId: '' }};
   },
   },
 });
 });
 
 
-CardComments.hookOptions.after.update = { fetchPrevious: false };
 Cards.before.insert((userId, doc) => {
 Cards.before.insert((userId, doc) => {
   doc.createdAt = new Date();
   doc.createdAt = new Date();
   doc.dateLastActivity = new Date();
   doc.dateLastActivity = new Date();
-
-  // defaults
   doc.archived = false;
   doc.archived = false;
 
 
-  // userId native set.
-  if (!doc.userId)
+  if (!doc.userId) {
     doc.userId = userId;
     doc.userId = userId;
-});
-
-CardComments.before.insert((userId, doc) => {
-  doc.createdAt = new Date();
-  doc.userId = userId;
+  }
 });
 });
 
 
 if (Meteor.isServer) {
 if (Meteor.isServer) {
@@ -264,21 +288,4 @@ if (Meteor.isServer) {
       cardId: doc._id,
       cardId: doc._id,
     });
     });
   });
   });
-
-  CardComments.after.insert((userId, doc) => {
-    Activities.insert({
-      userId,
-      activityType: 'addComment',
-      boardId: doc.boardId,
-      cardId: doc.cardId,
-      commentId: doc._id,
-    });
-  });
-
-  CardComments.after.remove((userId, doc) => {
-    const activity = Activities.findOne({ commentId: doc._id });
-    if (activity) {
-      Activities.remove(activity._id);
-    }
-  });
 }
 }

+ 31 - 15
collections/lists.js → models/lists.js

@@ -27,20 +27,18 @@ Lists.attachSchema(new SimpleSchema({
   },
   },
 }));
 }));
 
 
-if (Meteor.isServer) {
-  Lists.allow({
-    insert(userId, doc) {
-      return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
-    },
-    update(userId, doc) {
-      return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
-    },
-    remove(userId, doc) {
-      return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
-    },
-    fetch: ['boardId'],
-  });
-}
+Lists.allow({
+  insert(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+  },
+  update(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+  },
+  remove(userId, doc) {
+    return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
+  },
+  fetch: ['boardId'],
+});
 
 
 Lists.helpers({
 Lists.helpers({
   cards() {
   cards() {
@@ -49,12 +47,30 @@ Lists.helpers({
       archived: false,
       archived: false,
     }), { sort: ['sort'] });
     }), { sort: ['sort'] });
   },
   },
+
+  allCards() {
+    return Cards.find({ listId: this._id });
+  },
+
   board() {
   board() {
     return Boards.findOne(this.boardId);
     return Boards.findOne(this.boardId);
   },
   },
 });
 });
 
 
-// HOOKS
+Lists.mutations({
+  rename(title) {
+    return { $set: { title }};
+  },
+
+  archive() {
+    return { $set: { archived: true }};
+  },
+
+  restore() {
+    return { $set: { archived: false }};
+  },
+});
+
 Lists.hookOptions.after.update = { fetchPrevious: false };
 Lists.hookOptions.after.update = { fetchPrevious: false };
 
 
 Lists.before.insert((userId, doc) => {
 Lists.before.insert((userId, doc) => {

+ 0 - 0
collections/unsavedEdits.js → models/unsavedEdits.js


+ 8 - 2
collections/users.js → models/users.js

@@ -49,14 +49,20 @@ Users.helpers({
       return this.username[0].toUpperCase();
       return this.username[0].toUpperCase();
     }
     }
   },
   },
+});
 
 
+Users.mutations({
   toggleBoardStar(boardId) {
   toggleBoardStar(boardId) {
     const queryKind = this.hasStarred(boardId) ? '$pull' : '$addToSet';
     const queryKind = this.hasStarred(boardId) ? '$pull' : '$addToSet';
-    Meteor.users.update(this._id, {
+    return {
       [queryKind]: {
       [queryKind]: {
         'profile.starredBoards': boardId,
         'profile.starredBoards': boardId,
       },
       },
-    });
+    };
+  },
+
+  setAvatarUrl(avatarUrl) {
+    return { $set: { 'profile.avatarUrl': avatarUrl }};
   },
   },
 });
 });
 
 

+ 1 - 5
sandstorm.js

@@ -25,11 +25,7 @@ if (isSandstorm && Meteor.isServer) {
   // apparently meteor-core misses an API to handle that cleanly, cf.
   // apparently meteor-core misses an API to handle that cleanly, cf.
   // https://github.com/meteor/meteor/blob/ff783e9a12ffa04af6fd163843a563c9f4bbe8c1/packages/accounts-base/accounts_server.js#L1143
   // https://github.com/meteor/meteor/blob/ff783e9a12ffa04af6fd163843a563c9f4bbe8c1/packages/accounts-base/accounts_server.js#L1143
   function updateUserAvatar(userId, avatarUrl) {
   function updateUserAvatar(userId, avatarUrl) {
-    Users.update(userId, {
-      $set: {
-        'profile.avatarUrl': avatarUrl,
-      },
-    });
+    Users.findOne(userId).setAvatarUrl(avatarUrl);
   }
   }
 
 
   function updateUserPermissions(userId, permissions) {
   function updateUserPermissions(userId, permissions) {