diff options
-rw-r--r-- | .eslintrc.json | 4 | ||||
-rw-r--r-- | client/components/activities/activities.jade | 11 | ||||
-rw-r--r-- | client/components/activities/activities.styl | 8 | ||||
-rw-r--r-- | client/components/cards/cardDetails.jade | 4 | ||||
-rw-r--r-- | client/components/cards/checklists.jade | 61 | ||||
-rw-r--r-- | client/components/cards/checklists.js | 74 | ||||
-rw-r--r-- | client/components/cards/checklists.styl | 68 | ||||
-rw-r--r-- | client/components/cards/minicard.jade | 7 | ||||
-rw-r--r-- | client/components/cards/minicard.styl | 16 | ||||
-rw-r--r-- | i18n/en.i18n.json | 4 | ||||
-rw-r--r-- | models/activities.js | 7 | ||||
-rw-r--r-- | models/cards.js | 30 | ||||
-rw-r--r-- | models/checklists.js | 164 | ||||
-rw-r--r-- | server/lib/utils.js | 5 | ||||
-rw-r--r-- | server/publications/boards.js | 1 |
15 files changed, 462 insertions, 2 deletions
diff --git a/.eslintrc.json b/.eslintrc.json index 87c2e2cf..4808d873 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -117,6 +117,8 @@ "Notifications": true, "allowIsBoardAdmin": true, "allowIsBoardMember": true, - "Emoji": true + "allowIsBoardMemberByCard": true, + "Emoji": true, + "Checklists": true } } diff --git a/client/components/activities/activities.jade b/client/components/activities/activities.jade index 9ff73864..9bbcd055 100644 --- a/client/components/activities/activities.jade +++ b/client/components/activities/activities.jade @@ -26,6 +26,12 @@ template(name="boardActivities") +viewer = comment.text + if($eq activityType 'addChecklist') + | {{{_ 'activity-checklist-added' cardLink}}}. + .activity-checklist(href="{{ card.absoluteUrl }}") + +viewer + = checklist.title + if($eq activityType 'archivedCard') | {{{_ 'activity-archived' cardLink}}}. @@ -103,6 +109,11 @@ template(name="cardActivities") | {{{_ 'activity-attached' attachmentLink cardLabel}}}. if attachment.isImage img.attachment-image-preview(src=attachment.url) + if($eq activityType 'addChecklist') + | {{{_ 'activity-checklist-added' cardLabel}}}. + .activity-checklist + +viewer + = checklist.title if($eq activityType 'addComment') +inlinedForm(classNames='js-edit-comment') diff --git a/client/components/activities/activities.styl b/client/components/activities/activities.styl index 1f0494c7..2285fc0a 100644 --- a/client/components/activities/activities.styl +++ b/client/components/activities/activities.styl @@ -26,6 +26,14 @@ margin-top: 5px padding: 5px + .activity-checklist + display: block + border-radius: 3px + background: white + text-decoration: none + box-shadow: 0 1px 2px rgba(0,0,0,.2) + margin-top: 5px + padding: 5px .activity-meta font-size: 0.8em color: darken(white, 40%) diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade index f4212d83..cf113951 100644 --- a/client/components/cards/cardDetails.jade +++ b/client/components/cards/cardDetails.jade @@ -72,6 +72,10 @@ template(name="cardDetails") h3.card-details-item-title {{_ 'description'}} +viewer = description + + hr + +checklists(cardId = _id) + if attachments.count hr h2 diff --git a/client/components/cards/checklists.jade b/client/components/cards/checklists.jade new file mode 100644 index 00000000..396cb107 --- /dev/null +++ b/client/components/cards/checklists.jade @@ -0,0 +1,61 @@ +template(name="checklists") + h2 {{_ 'checklists'}} + .card-checklist-items + each checklist in currentCard.checklists + +checklistDetail(checklist = checklist) + +inlinedForm(classNames="js-add-checklist" cardId = cardId) + +addChecklistItemForm + else + a.js-open-inlined-form + i.fa.fa-plus + | {{_ 'add-checklist'}}... + +template(name="checklistDetail") + +inlinedForm(classNames="js-edit-checklist-title") + +editChecklistItemForm(checklist = checklist) + else + .checklist-title + .checkbox.fa.fa-check-square-o + a.js-delete-checklist {{_ "delete"}}... + span.checklist-stat(class="{{#if checklist.isFinished}}is-finished{{/if}}") {{checklist.finishedCount}}/{{checklist.itemCount}} + h2.title.js-open-inlined-form.is-editable {{checklist.title}} + +checklistItems(checklist = checklist) + +template(name="addChecklistItemForm") + textarea.js-add-checklist-item(rows='1' autofocus) + .edit-controls.clearfix + button.primary.confirm.js-submit-add-checklist-item-form(type="submit") {{_ 'save'}} + a.fa.fa-times-thin.js-close-inlined-form + +template(name="editChecklistItemForm") + textarea.js-edit-checklist-item(rows='1' autofocus) + if $eq type 'item' + = item.title + else + = checklist.title + .edit-controls.clearfix + button.primary.confirm.js-submit-edit-checklist-item-form(type="submit") {{_ 'save'}} + a.fa.fa-times-thin.js-close-inlined-form + span(title=createdAt) {{ moment createdAt }} + if currentUser.isBoardMember + a.js-delete-checklist-item {{_ "delete"}}... + +template(name="checklistItems") + .checklist-items + each item in checklist.items + +inlinedForm(classNames="js-edit-checklist-item") + +editChecklistItemForm(type = 'item' item = item checklist = checklist) + else + +itemDetail(item = item checklist = checklist) + if currentUser.isBoardMember + +inlinedForm(classNames="js-add-checklist-item" checklist = checklist) + +addChecklistItemForm + else + a.add-checklist-item.js-open-inlined-form + i.fa.fa-plus + | {{_ 'add-checklist-item'}}... + +template(name='itemDetail') + .item + .check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}") + .item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}") {{item.title}} diff --git a/client/components/cards/checklists.js b/client/components/cards/checklists.js new file mode 100644 index 00000000..b8113a54 --- /dev/null +++ b/client/components/cards/checklists.js @@ -0,0 +1,74 @@ +BlazeComponent.extendComponent({ + addChecklist(event) { + event.preventDefault(); + const textarea = this.find('textarea.js-add-checklist-item'); + const title = textarea.value.trim(); + const cardId = this.currentData().cardId; + Checklists.insert({ + cardId, + title, + }); + }, + + addChecklistItem(event) { + event.preventDefault(); + const textarea = this.find('textarea.js-add-checklist-item'); + const title = textarea.value.trim(); + const checklist = this.currentData().checklist; + checklist.addItem(title); + }, + + editChecklist(event) { + event.preventDefault(); + const textarea = this.find('textarea.js-edit-checklist-item'); + const title = textarea.value.trim(); + const checklist = this.currentData().checklist; + checklist.setTitle(title); + }, + + editChecklistItem(event) { + event.preventDefault(); + + const textarea = this.find('textarea.js-edit-checklist-item'); + const title = textarea.value.trim(); + const itemId = this.currentData().item._id; + const checklist = this.currentData().checklist; + checklist.editItem(itemId, title); + }, + + deleteItem() { + const checklist = this.currentData().checklist; + const item = this.currentData().item; + if (checklist && item && item._id) { + checklist.removeItem(item._id); + } + }, + + deleteChecklist() { + const checklist = this.currentData().checklist; + if (checklist && checklist._id) { + Checklists.remove(checklist._id); + } + }, + + pressKey(event) { + //If user press enter key inside a form, submit it, so user doesn't have to leave keyboard to submit a form. + if (event.keyCode === 13) { + event.preventDefault(); + const $form = $(event.currentTarget).closest('form'); + $form.find('button[type=submit]').click(); + } + }, + + events() { + return [{ + 'submit .js-add-checklist': this.addChecklist, + 'submit .js-edit-checklist-title': this.editChecklist, + 'submit .js-add-checklist-item': this.addChecklistItem, + 'submit .js-edit-checklist-item': this.editChecklistItem, + 'click .js-delete-checklist-item': this.deleteItem, + 'click .js-delete-checklist': this.deleteChecklist, + keydown: this.pressKey, + }]; + }, +}).register('checklists'); diff --git a/client/components/cards/checklists.styl b/client/components/cards/checklists.styl new file mode 100644 index 00000000..885d7528 --- /dev/null +++ b/client/components/cards/checklists.styl @@ -0,0 +1,68 @@ +.js-add-checklist + color: #8c8c8c + +textarea.js-add-checklist-item, textarea.js-edit-checklist-item + overflow: hidden + word-wrap: break-word + resize: none + height: 34px + +.delete-text + color: #8c8c8c + text-decoration: underline + word-wrap: break-word + float: right + padding-top: 6px + &:hover + color: inherit + +.checklist-title + .checkbox + float: left + width: 30px + height 30px + font-size: 18px + line-height: 30px + + .title + font-size: 18px + line-height: 30px + + .checklist-stat + margin: 0 0.5em + float: right + padding-top: 6px + &.is-finished + color: #3cb500 + + .js-delete-checklist + @extends .delete-text + +.checklist-items + margin: 0 0 0.5em 1.33em + + .item + line-height: 25px + font-size: 1.1em + margin-top: 3px + display: flex + + .check-box + margin-top: 5px + &.is-checked + border-bottom: 2px solid #3cb500 + border-right: 2px solid #3cb500 + + .item-title + padding-left: 10px; + &.is-checked + color: #8c8c8c + font-style: italic + + .js-delete-checklist-item + @extends .delete-text + padding: 12px 0 0 0 + + .add-checklist-item + padding-top: 0.5em + display: inline-block diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade index edc7d2d3..8b46ee74 100644 --- a/client/components/cards/minicard.jade +++ b/client/components/cards/minicard.jade @@ -14,7 +14,7 @@ template(name="minicard") .badges if comments.count .badge(title="{{_ 'card-comments-title' comments.count }}") - span.badge-icon.fa.fa-comment-o + span.badge-icon.fa.fa-comment-o.badge-comment span.badge-text= comments.count if description .badge.badge-state-image-only(title=description) @@ -29,3 +29,8 @@ template(name="minicard") if dueAt .badge +minicardDueDate + if checklists.count + .badge(class="{{#if checklistFinished}}is-finished{{/if}}") + span.badge-icon.fa.fa-check-square-o + span.badge-text.check-list-text {{checklistFinishedCount}}/{{checklistItemCount}} + diff --git a/client/components/cards/minicard.styl b/client/components/cards/minicard.styl index a61f6067..12a89785 100644 --- a/client/components/cards/minicard.styl +++ b/client/components/cards/minicard.styl @@ -99,10 +99,26 @@ .badge-text vertical-align: middle + &.is-finished + background: #3cb500 + padding: 0px 3px + border-radius: 3px + color: white + + .badge-icon, + .badge-text + vertical-align: middle//didn't figure why use top, it'd be easier to fill bg if it's middle. This was introduced in commit "91cfcf7b12b5e7c137c2e765b2c378dde6b82966" & "* Improve the design of the minicards badges" was mentioned. + &.badge-comment + margin-bottom: 0.1rem + .badge-text font-size: 0.9em padding-left: 2px line-height: 14px + .check-list-text + padding-left: 0px + line-height: 12px + .minicard-members float: right diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 91c8e0af..1d803ee3 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -36,10 +36,13 @@ "activity-removed": "removed %s from %s", "activity-sent": "sent %s to %s", "activity-unjoined": "unjoined %s", + "activity-checklist-added": "added checklist to %s", "add": "Add", "add-attachment": "Add an attachment", "add-board": "Add a new board", "add-card": "Add a card", + "add-checklist": "Add a checklist", + "add-checklist-item": "Add an item to checklist", "add-cover": "Add Cover", "add-label": "Add the label", "add-list": "Add a list", @@ -115,6 +118,7 @@ "changePasswordPopup-title": "Change Password", "changePermissionsPopup-title": "Change Permissions", "changeSettingsPopup-title": "Change Settings", + "checklists": "Checklists", "click-to-star": "Click to star this board.", "click-to-unstar": "Click to unstar this board.", "clipboard" : "Clipboard or drag & drop", diff --git a/models/activities.js b/models/activities.js index aa2ea3ec..7d262ec6 100644 --- a/models/activities.js +++ b/models/activities.js @@ -35,6 +35,9 @@ Activities.helpers({ attachment() { return Attachments.findOne(this.attachmentId); }, + checklist() { + return Checklists.findOne(this.checklistId); + }, }); Activities.before.insert((userId, doc) => { @@ -102,6 +105,10 @@ if (Meteor.isServer) { const attachment = activity.attachment(); params.attachment = attachment._id; } + if (activity.checklistId) { + const checklist = activity.checklist(); + params.checklist = checklist.title; + } if (board) { const watchingUsers = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId'); const trackingUsers = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId'); diff --git a/models/cards.js b/models/cards.js index 9e7d58c8..f6bd0b06 100644 --- a/models/cards.js +++ b/models/cards.js @@ -141,6 +141,36 @@ Cards.helpers({ return cover && cover.url() && cover; }, + checklists() { + return Checklists.find({ cardId: this._id }, { sort: { createdAt: 1 }}); + }, + + checklistItemCount() { + const checklists = this.checklists().fetch(); + return checklists.map((checklist) => { + return checklist.itemCount(); + }).reduce((prev, next) => { + return prev + next; + }, 0); + }, + + checklistFinishedCount() { + const checklists = this.checklists().fetch(); + return checklists.map((checklist) => { + return checklist.finishedCount(); + }).reduce((prev, next) => { + return prev + next; + }, 0); + }, + + checklistFinished() { + return this.hasChecklist() && this.checklistItemCount() === this.checklistFinishedCount(); + }, + + hasChecklist() { + return this.checklistItemCount() !== 0; + }, + absoluteUrl() { const board = this.board(); return FlowRouter.url('card', { diff --git a/models/checklists.js b/models/checklists.js new file mode 100644 index 00000000..35be4dcc --- /dev/null +++ b/models/checklists.js @@ -0,0 +1,164 @@ +Checklists = new Mongo.Collection('checklists'); + +Checklists.attachSchema(new SimpleSchema({ + cardId: { + type: String, + }, + title: { + type: String, + }, + items: { + type: [Object], + defaultValue: [], + }, + 'items.$._id': { + type: String, + }, + 'items.$.title': { + type: String, + }, + 'items.$.isFinished': { + type: Boolean, + defaultValue: false, + }, + finishedAt: { + type: Date, + optional: true, + }, + createdAt: { + type: Date, + denyUpdate: false, + }, +})); + +Checklists.helpers({ + itemCount () { + return this.items.length; + }, + finishedCount () { + return this.items.filter((item) => { + return item.isFinished; + }).length; + }, + isFinished () { + return 0 !== this.itemCount() && this.itemCount() === this.finishedCount(); + }, + getItem (_id) { + return _.findWhere(this.items, { _id }); + }, + itemIndex(itemId) { + return _.pluck(this.items, '_id').indexOf(itemId); + }, +}); + +Checklists.allow({ + insert(userId, doc) { + return allowIsBoardMemberByCard(userId, Cards.findOne(doc.cardId)); + }, + update(userId, doc) { + return allowIsBoardMemberByCard(userId, Cards.findOne(doc.cardId)); + }, + remove(userId, doc) { + return allowIsBoardMemberByCard(userId, Cards.findOne(doc.cardId)); + }, + fetch: ['userId', 'cardId'], +}); + +Checklists.before.insert((userId, doc) => { + doc.createdAt = new Date(); + if (!doc.userId) { + doc.userId = userId; + } +}); + +Checklists.mutations({ + //for checklist itself + setTitle(title){ + return { $set: { title }}; + }, + //for items in checklist + addItem(title) { + const itemCount = this.itemCount(); + const _id = `${this._id}${itemCount}`; + return { $addToSet: {items: {_id, title, isFinished: false}} }; + }, + removeItem(itemId) { + return {$pull: {items: {_id : itemId}}}; + }, + editItem(itemId, title) { + if (this.getItem(itemId)) { + const itemIndex = this.itemIndex(itemId); + return { + $set: { + [`items.${itemIndex}.title`]: title, + }, + }; + } + return {}; + }, + finishItem(itemId) { + if (this.getItem(itemId)) { + const itemIndex = this.itemIndex(itemId); + return { + $set: { + [`items.${itemIndex}.isFinished`]: true, + }, + }; + } + return {}; + }, + resumeItem(itemId) { + if (this.getItem(itemId)) { + const itemIndex = this.itemIndex(itemId); + return { + $set: { + [`items.${itemIndex}.isFinished`]: false, + }, + }; + } + return {}; + }, + toggleItem(itemId) { + const item = this.getItem(itemId); + if (item) { + const itemIndex = this.itemIndex(itemId); + return { + $set: { + [`items.${itemIndex}.isFinished`]: !item.isFinished, + }, + }; + } + return {}; + }, +}); + +if (Meteor.isServer) { + Checklists.after.insert((userId, doc) => { + Activities.insert({ + userId, + activityType: 'addChecklist', + cardId: doc.cardId, + boardId: Cards.findOne(doc.cardId).boardId, + checklistId: doc._id, + }); + }); + + //TODO: so there will be no activity for adding item into checklist, maybe will be implemented in the future. + // Checklists.after.update((userId, doc) => { + // console.log('update:', doc) + // Activities.insert({ + // userId, + // activityType: 'addChecklist', + // boardId: doc.boardId, + // cardId: doc.cardId, + // checklistId: doc._id, + // }); + // }); + + Checklists.before.remove((userId, doc) => { + const activity = Activities.findOne({ checklistId: doc._id }); + if (activity) { + Activities.remove(activity._id); + } + }); +} diff --git a/server/lib/utils.js b/server/lib/utils.js index b59671fb..bc3807bb 100644 --- a/server/lib/utils.js +++ b/server/lib/utils.js @@ -5,3 +5,8 @@ allowIsBoardAdmin = function(userId, board) { allowIsBoardMember = function(userId, board) { return board && board.hasMember(userId); }; + +allowIsBoardMemberByCard = function(userId, card) { + const board = card.board(); + return board && board.hasMember(userId); +}; diff --git a/server/publications/boards.js b/server/publications/boards.js index 89681978..133082dd 100644 --- a/server/publications/boards.js +++ b/server/publications/boards.js @@ -98,6 +98,7 @@ Meteor.publishRelations('board', function(boardId) { this.cursor(Cards.find({ boardId }), function(cardId) { this.cursor(CardComments.find({ cardId })); this.cursor(Attachments.find({ cardId })); + this.cursor(Checklists.find({ cardId })); }); if (board.members) { |