adds card comment reactions feature

This commit is contained in:
Kai Lehmann 2021-08-13 20:47:55 +02:00
parent d8e8512d66
commit 2977120129
8 changed files with 239 additions and 10 deletions

View file

@ -122,6 +122,7 @@
"Activities": true,
"Attachments": true,
"Boards": true,
"CardCommentReactions": true,
"CardComments": true,
"DatePicker": true,
"Cards": true,

View file

@ -21,6 +21,22 @@ template(name="editOrDeleteComment")
= ' - '
a.js-delete-comment {{_ "delete"}}
template(name="commentReactions")
.reactions
each reaction in reactions
span.reaction(class="{{#if isSelected reaction.userIds}}selected{{/if}}" data-codepoint="#{reaction.reactionCodepoint}" title="{{userNames reaction.userIds}}")
span.reaction-codepoint !{reaction.reactionCodepoint}
span.reaction-count #{reaction.userIds.length}
if (currentUser.isBoardMember)
a.open-comment-reaction-popup(title="{{_ 'addReactionPopup-title'}}")
i.fa.fa-smile-o
i.fa.fa-plus
template(name="addReactionPopup")
.reactions-popup
each codepoint in codepoints
span.add-comment-reaction(data-codepoint="#{codepoint}") !{codepoint}
template(name="activity")
.activity
+userAvatar(userId=activity.user._id)
@ -124,6 +140,7 @@ template(name="activity")
.activity-comment
+viewer
= activity.comment.text
+commentReactions(reactions=activity.comment.reactions commentId=activity.comment._id)
span(title=activity.createdAt).activity-meta {{ moment activity.createdAt }}
if($eq currentUser._id activity.comment.userId)
+editOrDeleteComment
@ -150,20 +167,20 @@ template(name="activity")
if($eq activity.activityType 'a-startAt')
| {{{_ 'activity-startDate' (sanitize startDate) cardLink}}}.
if($eq activity.activityType 'a-dueAt')
| {{{_ 'activity-dueDate' (sanitize dueDate) cardLink}}}.
if($eq activity.activityType 'a-endAt')
| {{{_ 'activity-endDate' (sanitize endDate) cardLink}}}.
if($eq mode 'board')
if($eq activity.activityType 'a-receivedAt')
| {{{_ 'activity-receivedDate' (sanitize receivedDate) cardLink}}}.
if($eq activity.activityType 'a-startAt')
| {{{_ 'activity-startDate' (sanitize startDate) cardLink}}}.
if($eq activity.activityType 'a-dueAt')
| {{{_ 'activity-dueDate' (sanitize dueDate) cardLink}}}.

View file

@ -240,6 +240,59 @@ Template.activity.helpers({
},
});
Template.commentReactions.events({
'click .reaction'(event) {
if (Meteor.user().isBoardMember()) {
const codepoint = event.currentTarget.dataset['codepoint'];
const commentId = Template.instance().data.commentId;
const cardComment = CardComments.findOne({_id: commentId});
cardComment.toggleReaction(codepoint);
}
},
'click .open-comment-reaction-popup': Popup.open('addReaction'),
})
Template.addReactionPopup.events({
'click .add-comment-reaction'(event) {
if (Meteor.user().isBoardMember()) {
const codepoint = event.currentTarget.dataset['codepoint'];
const commentId = Template.instance().data.commentId;
const cardComment = CardComments.findOne({_id: commentId});
cardComment.toggleReaction(codepoint);
}
Popup.close();
},
})
Template.addReactionPopup.helpers({
codepoints() {
return [
'👍',
'👎',
'👀',
'✅',
'❌',
'🙏',
'👏',
'🎉',
'🚀',
'😊',
'🤔',
'😔'];
}
})
Template.commentReactions.helpers({
isSelected(userIds) {
return userIds.includes(Meteor.user()._id);
},
userNames(userIds) {
return Users.find({_id: {$in: userIds}})
.map(user => user.profile.fullname)
.join(', ');
}
})
function createCardLink(card) {
if (!card) return '';
return (

View file

@ -5,6 +5,20 @@
display: flex
justify-content:space-between
.reactions-popup
.add-comment-reaction
display: inline-block
cursor: pointer
border-radius: 5px
font-size: 22px
text-align: center
line-height: 30px
width: 40px
&:hover {
background-color: #b0c4de
}
.activities
clear: both
@ -18,7 +32,7 @@
height: @width
.activity-member
font-weight: 700
font-weight: 700
.activity-desc
word-wrap: break-word
@ -39,6 +53,45 @@
margin-top: 5px
padding: 5px
.reactions
display: flex
margin-top: 5px
gap: 5px
.open-comment-reaction-popup
display: flex
align-items: center
text-decoration: none
height: 24px;
i.fa.fa-smile-o
font-size: 17px
font-weight: 500
margin-left: 2px
i.fa.fa-plus
font-size: 8px;
margin-top: -7px;
margin-left: 1px;
.reaction
cursor: pointer
border: 1px solid grey
border-radius: 15px
display: flex
padding: 2px 5px
&.selected {
background-color: #b0c4de
}
&:hover {
background-color: #b0c4de
}
.reaction-count
font-size: 12px
.activity-checklist
display: block
border-radius: 3px

View file

@ -0,0 +1,59 @@
const commentReactionSchema = new SimpleSchema({
reactionCodepoint: { type: String, optional: false },
userIds: { type: [String], defaultValue: [] }
});
CardCommentReactions = new Mongo.Collection('card_comment_reactions');
/**
* All reactions of a card comment
*/
CardCommentReactions.attachSchema(
new SimpleSchema({
boardId: {
/**
* the board ID
*/
type: String,
optional: false
},
cardId: {
/**
* the card ID
*/
type: String,
optional: false
},
cardCommentId: {
/**
* the card comment ID
*/
type: String,
optional: false
},
reactions: {
type: [commentReactionSchema],
defaultValue: []
}
}),
);
CardCommentReactions.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'],
});
if (Meteor.isServer) {
Meteor.startup(() => {
CardCommentReactions._collection._ensureIndex({ cardCommentId: 1 }, { unique: true });
});
}

View file

@ -93,6 +93,43 @@ CardComments.helpers({
user() {
return Users.findOne(this.userId);
},
reactions() {
const cardCommentReactions = CardCommentReactions.findOne({cardCommentId: this._id});
return !!cardCommentReactions ? cardCommentReactions.reactions : [];
},
toggleReaction(reactionCodepoint) {
const cardCommentReactions = CardCommentReactions.findOne({cardCommentId: this._id});
const reactions = !!cardCommentReactions ? cardCommentReactions.reactions : [];
const userId = Meteor.userId();
const reaction = reactions.find(r => r.reactionCodepoint === reactionCodepoint);
if (!reaction) {
reactions.push({ reactionCodepoint, userIds: [userId] });
} else {
const userHasReacted = reaction.userIds.includes(userId);
if (userHasReacted) {
reaction.userIds.splice(reaction.userIds.indexOf(userId), 1);
if (reaction.userIds.length === 0) {
reactions.splice(reactions.indexOf(reaction), 1);
}
} else {
reaction.userIds.push(userId);
}
}
if (!!cardCommentReactions) {
return CardCommentReactions.update({ _id: cardCommentReactions._id }, { $set: { reactions } });
} else {
return CardCommentReactions.insert({
boardId: this.boardId,
cardCommentId: this._id,
cardId: this.cardId,
reactions
});
}
}
});
CardComments.hookOptions.after.update = { fetchPrevious: false };
@ -187,7 +224,7 @@ if (Meteor.isServer) {
* comment: string,
* authorId: string}]
*/
JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', function(
JsonRoutes.add('GET', '/api/boards/:boardId/cards/:cardId/comments', function (
req,
res,
) {
@ -200,7 +237,7 @@ if (Meteor.isServer) {
data: CardComments.find({
boardId: paramBoardId,
cardId: paramCardId,
}).map(function(doc) {
}).map(function (doc) {
return {
_id: doc._id,
comment: doc.text,
@ -228,7 +265,7 @@ if (Meteor.isServer) {
JsonRoutes.add(
'GET',
'/api/boards/:boardId/cards/:cardId/comments/:commentId',
function(req, res) {
function (req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
@ -264,7 +301,7 @@ if (Meteor.isServer) {
JsonRoutes.add(
'POST',
'/api/boards/:boardId/cards/:cardId/comments',
function(req, res) {
function (req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);
@ -310,7 +347,7 @@ if (Meteor.isServer) {
JsonRoutes.add(
'DELETE',
'/api/boards/:boardId/cards/:cardId/comments/:commentId',
function(req, res) {
function (req, res) {
try {
const paramBoardId = req.params.boardId;
Authentication.checkBoardAccess(req.userId, paramBoardId);

View file

@ -129,6 +129,7 @@ Meteor.publishRelations('board', function(boardId, isArchived) {
this.cursor(Lists.find({ boardId, archived: isArchived }));
this.cursor(Swimlanes.find({ boardId, archived: isArchived }));
this.cursor(Integrations.find({ boardId }));
this.cursor(CardCommentReactions.find({ boardId }));
this.cursor(
CustomFields.find(
{ boardIds: { $in: [boardId] } },
@ -161,6 +162,8 @@ Meteor.publishRelations('board', function(boardId, isArchived) {
// Gather queries and send in bulk
const cardComments = this.join(CardComments);
cardComments.selector = _ids => ({ cardId: _ids });
const cardCommentReactions = this.join(CardCommentReactions);
cardCommentReactions.selector = _ids => ({ cardId: _ids });
const attachments = this.join(Attachments);
attachments.selector = _ids => ({ cardId: _ids });
const checklists = this.join(Checklists);
@ -194,12 +197,14 @@ Meteor.publishRelations('board', function(boardId, isArchived) {
checklists.push(cardId);
checklistItems.push(cardId);
parentCards.push(cardId);
cardCommentReactions.push(cardId)
},
);
// Send bulk queries for all found ids
subCards.send();
cardComments.send();
cardCommentReactions.send();
attachments.send();
checklists.send();
checklistItems.send();

View file

@ -5,6 +5,7 @@ import Lists from '../../models/lists';
import Swimlanes from '../../models/swimlanes';
import Cards from '../../models/cards';
import CardComments from '../../models/cardComments';
import CardCommentReactions from '../../models/cardCommentReactions';
import Attachments from '../../models/attachments';
import Checklists from '../../models/checklists';
import ChecklistItems from '../../models/checklistItems';
@ -699,6 +700,8 @@ function findCards(sessionId, query) {
type: 1,
};
const comments = CardComments.find({ cardId: { $in: cards.map(c => c._id) } });
return [
cards,
Boards.find(
@ -714,7 +717,8 @@ function findCards(sessionId, query) {
Users.find({ _id: { $in: users } }, { fields: Users.safeFields }),
Checklists.find({ cardId: { $in: cards.map(c => c._id) } }),
Attachments.find({ cardId: { $in: cards.map(c => c._id) } }),
CardComments.find({ cardId: { $in: cards.map(c => c._id) } }),
comments,
CardCommentReactions.find({cardCommentId: {$in: comments.map(c => c._id) }}),
SessionData.find({ userId, sessionId }),
];
}