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.
This commit is contained in:
Maxime Quandalle 2015-09-08 20:19:42 +02:00
parent c04341f1ea
commit 45b662a1dd
26 changed files with 395 additions and 377 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

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

View file

@ -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'); const currentBoard = Boards.findOne(Session.get('currentBoard'));
Boards.update(boardId, { $set: { archived: true }}); 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(); const newTitle = tpl.$('.js-board-name').val().trim();
if (title) { if (newTitle) {
Boards.update(this._id, { this.rename(newTitle);
$set: {
title,
},
});
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'); const currentBoard = Boards.findOne(Session.get('currentBoard'));
Boards.update(currentBoardId, { const newColor = this.currentData().toString();
$set: { currentBoard.setColor(newColor);
color: this.currentData().toString(),
},
});
evt.preventDefault(); evt.preventDefault();
}, },
}]; }];
@ -168,11 +161,9 @@ BlazeComponent.extendComponent({
}, },
selectBoardVisibility() { selectBoardVisibility() {
Boards.update(Session.get('currentBoard'), { const currentBoard = Boards.findOne(Session.get('currentBoard'));
$set: { const visibility = this.currentData();
permission: this.currentData(), currentBoard.setVisibility(visibility);
},
});
Popup.close(); Popup.close();
}, },

View file

@ -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();
}, },
}); });

View file

@ -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, { this.archive();
$set: {
archived: true,
},
});
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, { card.move(newListId);
$set: {
listId: newListId,
},
});
Popup.close(); Popup.close();
}, },
}); });

View file

@ -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; card.toggleLabel(labelId);
if (Cards.find({ _id: cardId, labelIds: labelId}).count() === 0)
operation = '$addToSet';
else
operation = '$pull';
Cards.update(cardId, {
[operation]: {
labelIds: 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;
board.addLabel(name, color);
Boards.update(boardId, {
$push: {
labels: {
name,
color,
_id: Random.id(6),
},
},
});
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'); const board = Boards.findOne(Session.get('currentBoard'));
Boards.update(boardId, { board.removeLabel(this._id);
$pull: {
labels: {
_id: 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;
board.editLabel(this._id, name, color);
Boards.update(boardId, {
$set: {
[getLabel.key('name')]: name,
[getLabel.key('color')]: color,
},
});
Popup.back(); Popup.back();
}, },
}); });

View file

@ -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.find(MultiSelection.getMongoSelector()).forEach((card, i) => {
Cards.update(c._id, { card.move(listId, sortIndex.base + i * sortIndex.increment);
$set: {
listId,
sort: 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; const card = Blaze.getData(cardDomElement);
Cards.update(cardId, { card.move(listId, sortIndex.base);
$set: {
listId,
sort: 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 });
}, },
}); });
}); });

View file

@ -5,14 +5,10 @@ BlazeComponent.extendComponent({
editTitle(evt) { editTitle(evt) {
evt.preventDefault(); evt.preventDefault();
const form = this.componentChildren('inlinedForm')[0]; const newTitle = this.componentChildren('inlinedForm')[0].getValue();
const newTitle = form.getValue(); const list = this.currentData();
if ($.trim(newTitle)) { if ($.trim(newTitle)) {
Lists.update(this.currentData()._id, { list.rename(newTitle);
$set: {
title: 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( const cardIds = this.allCards().map((card) => card._id);
{listId: this._id},
{fields: { _id: 1 }}
).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) => { this.allCards().forEach((card) => {
Cards.update(card._id, { card.archive();
$set: {
archived: true,
},
});
}); });
Popup.close(); Popup.close();
}), }),
'click .js-close-list'(evt) { 'click .js-close-list'(evt) {
evt.preventDefault(); evt.preventDefault();
Lists.update(this._id, { this.archive();
$set: {
archived: true,
},
});
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) => { fromList.allCards().forEach((card) => {
Cards.update(card._id, { card.move(toList);
$set: {
listId: toList,
},
});
}); });
Popup.close(); Popup.close();
}, },

View file

@ -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); const memberId = this.userId;
currentBoard.removeMember(memberId);
Boards.update(currentBoard._id, {
$set: {
[`members.${memberIndex}.isActive`]: false,
},
});
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'); currentBoard.addMember(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,
},
});
}
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');
currentBoard.setMemberPermission(memberId, isAdmin);
Boards.update(currentBoard._id, {
$set: {
[`members.${memberIndex}.isAdmin`]: isAdmin,
},
});
Popup.back(1); Popup.back(1);
}, },
}); });

View file

@ -29,8 +29,8 @@ BlazeComponent.extendComponent({
events() { events() {
return [{ return [{
'click .js-restore-card'() { 'click .js-restore-card'() {
const cardId = this.currentData()._id; const card = this.currentData();
Cards.update(cardId, {$set: {archived: false}}); 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; const list = this.currentData();
Lists.update(listId, {$set: {archived: false}}); list.restore();
}, },
}]; }];
}, },

View file

@ -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)) if (_.every(mappedSelection)) {
operation = '$pull'; mutateSelectedCards('addLabel', labelId);
else if (_.every(mappedSelection, (bool) => !bool)) } else if (_.every(mappedSelection, (bool) => !bool)) {
operation = '$addToSet'; mutateSelectedCards('removeLabel', labelId);
else { } 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)) {
if (_.every(mappedSelection)) mutateSelectedCards('assignMember', memberId);
operation = '$pull'; } else if (_.every(mappedSelection, (bool) => !bool)) {
else if (_.every(mappedSelection, (bool) => !bool)) mutateSelectedCards('unassignMember', memberId);
operation = '$addToSet'; } else {
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();
}, },
}); });

View file

@ -82,11 +82,7 @@ BlazeComponent.extendComponent({
}, },
setAvatar(avatarUrl) { setAvatar(avatarUrl) {
Meteor.users.update(Meteor.userId(), { Meteor.user().setAvatarUrl(avatarUrl);
$set: {
'profile.avatarUrl': 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; card.toggleMember(memberId);
if (Cards.find({ _id: cardId, members: memberId}).count() === 0)
operation = '$addToSet';
else
operation = '$pull';
Cards.update(cardId, {
[operation]: {
members: 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'),

View file

@ -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 = '';

View file

@ -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;

View file

@ -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
models/cardComments.js Normal file
View file

@ -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);
}
});
}

View file

@ -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({ Cards.allow({
boardId: { insert(userId, doc) {
type: String, return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
}, },
cardId: { update(userId, doc) {
type: String, return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
}, },
// XXX Rename in `content`? `text` is a bit vague... remove(userId, doc) {
text: { return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
type: String,
}, },
// XXX We probably don't need this information here, since we already have it fetch: ['boardId'],
// in the associated comment creation activity });
createdAt: {
type: Date,
denyUpdate: false,
},
// XXX Should probably be called `authorId`
userId: {
type: String,
},
}));
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'],
});
}
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({ Cards.mutations({
user() { archive() {
return Users.findOne(this.userId); 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);
}
});
} }

View file

@ -27,20 +27,18 @@ Lists.attachSchema(new SimpleSchema({
}, },
})); }));
if (Meteor.isServer) { Lists.allow({
Lists.allow({ insert(userId, doc) {
insert(userId, doc) { return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); },
}, update(userId, doc) {
update(userId, doc) { return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); },
}, remove(userId, doc) {
remove(userId, doc) { return allowIsBoardMember(userId, Boards.findOne(doc.boardId));
return allowIsBoardMember(userId, Boards.findOne(doc.boardId)); },
}, fetch: ['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) => {

View file

@ -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 }};
}, },
}); });

View file

@ -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, { Users.findOne(userId).setAvatarUrl(avatarUrl);
$set: {
'profile.avatarUrl': avatarUrl,
},
});
} }
function updateUserPermissions(userId, permissions) { function updateUserPermissions(userId, permissions) {