ソースを参照

Add checklist feature

lksime 8 年 前
コミット
59731af139

+ 3 - 1
.eslintrc.json

@@ -117,6 +117,8 @@
     "Notifications": true,
     "Notifications": true,
     "allowIsBoardAdmin": true,
     "allowIsBoardAdmin": true,
     "allowIsBoardMember": true,
     "allowIsBoardMember": true,
-    "Emoji": true
+    "allowIsBoardMemberByCard": true,
+    "Emoji": true,
+    "Checklists": true
   }
   }
 }
 }

+ 11 - 0
client/components/activities/activities.jade

@@ -26,6 +26,12 @@ template(name="boardActivities")
             +viewer
             +viewer
               = comment.text
               = comment.text
 
 
+        if($eq activityType 'addChecklist')
+          | {{{_ 'activity-checklist-added' cardLink}}}.
+          .activity-checklist(href="{{ card.absoluteUrl }}")
+            +viewer
+              = checklist.title
+
         if($eq activityType 'archivedCard')
         if($eq activityType 'archivedCard')
           | {{{_ 'activity-archived' cardLink}}}.
           | {{{_ 'activity-archived' cardLink}}}.
 
 
@@ -103,6 +109,11 @@ template(name="cardActivities")
           | {{{_ 'activity-attached' attachmentLink cardLabel}}}.
           | {{{_ 'activity-attached' attachmentLink cardLabel}}}.
           if attachment.isImage
           if attachment.isImage
             img.attachment-image-preview(src=attachment.url)
             img.attachment-image-preview(src=attachment.url)
+        if($eq activityType 'addChecklist')
+          | {{{_ 'activity-checklist-added' cardLabel}}}.
+          .activity-checklist
+            +viewer
+              = checklist.title
 
 
         if($eq activityType 'addComment')
         if($eq activityType 'addComment')
           +inlinedForm(classNames='js-edit-comment')
           +inlinedForm(classNames='js-edit-comment')

+ 8 - 0
client/components/activities/activities.styl

@@ -26,6 +26,14 @@
         margin-top: 5px
         margin-top: 5px
         padding: 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
       .activity-meta
         font-size: 0.8em
         font-size: 0.8em
         color: darken(white, 40%)
         color: darken(white, 40%)

+ 4 - 0
client/components/cards/cardDetails.jade

@@ -72,6 +72,10 @@ template(name="cardDetails")
       h3.card-details-item-title {{_ 'description'}}
       h3.card-details-item-title {{_ 'description'}}
       +viewer
       +viewer
         = description
         = description
+
+    hr
+    +checklists(cardId = _id)
+
     if attachments.count
     if attachments.count
       hr
       hr
       h2
       h2

+ 61 - 0
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}}

+ 74 - 0
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');

+ 68 - 0
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

+ 6 - 1
client/components/cards/minicard.jade

@@ -14,7 +14,7 @@ template(name="minicard")
     .badges
     .badges
       if comments.count
       if comments.count
         .badge(title="{{_ 'card-comments-title' 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
           span.badge-text= comments.count
       if description
       if description
         .badge.badge-state-image-only(title=description)
         .badge.badge-state-image-only(title=description)
@@ -29,3 +29,8 @@ template(name="minicard")
       if dueAt
       if dueAt
         .badge
         .badge
           +minicardDueDate
           +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}}
+

+ 16 - 0
client/components/cards/minicard.styl

@@ -99,10 +99,26 @@
       .badge-text
       .badge-text
         vertical-align: middle
         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
       .badge-text
         font-size: 0.9em
         font-size: 0.9em
         padding-left: 2px
         padding-left: 2px
         line-height: 14px
         line-height: 14px
+      .check-list-text
+        padding-left: 0px
+        line-height: 12px
+
 
 
   .minicard-members
   .minicard-members
     float: right
     float: right

+ 4 - 0
i18n/en.i18n.json

@@ -36,10 +36,13 @@
     "activity-removed": "removed %s from %s",
     "activity-removed": "removed %s from %s",
     "activity-sent": "sent %s to %s",
     "activity-sent": "sent %s to %s",
     "activity-unjoined": "unjoined %s",
     "activity-unjoined": "unjoined %s",
+    "activity-checklist-added": "added checklist to %s",
     "add": "Add",
     "add": "Add",
     "add-attachment": "Add an attachment",
     "add-attachment": "Add an attachment",
     "add-board": "Add a new board",
     "add-board": "Add a new board",
     "add-card": "Add a card",
     "add-card": "Add a card",
+    "add-checklist": "Add a checklist",
+    "add-checklist-item": "Add an item to checklist",
     "add-cover": "Add Cover",
     "add-cover": "Add Cover",
     "add-label": "Add the label",
     "add-label": "Add the label",
     "add-list": "Add a list",
     "add-list": "Add a list",
@@ -115,6 +118,7 @@
     "changePasswordPopup-title": "Change Password",
     "changePasswordPopup-title": "Change Password",
     "changePermissionsPopup-title": "Change Permissions",
     "changePermissionsPopup-title": "Change Permissions",
     "changeSettingsPopup-title": "Change Settings",
     "changeSettingsPopup-title": "Change Settings",
+    "checklists": "Checklists",
     "click-to-star": "Click to star this board.",
     "click-to-star": "Click to star this board.",
     "click-to-unstar": "Click to unstar this board.",
     "click-to-unstar": "Click to unstar this board.",
     "clipboard" : "Clipboard or drag & drop",
     "clipboard" : "Clipboard or drag & drop",

+ 7 - 0
models/activities.js

@@ -35,6 +35,9 @@ Activities.helpers({
   attachment() {
   attachment() {
     return Attachments.findOne(this.attachmentId);
     return Attachments.findOne(this.attachmentId);
   },
   },
+  checklist() {
+    return Checklists.findOne(this.checklistId);
+  },
 });
 });
 
 
 Activities.before.insert((userId, doc) => {
 Activities.before.insert((userId, doc) => {
@@ -102,6 +105,10 @@ if (Meteor.isServer) {
       const attachment = activity.attachment();
       const attachment = activity.attachment();
       params.attachment = attachment._id;
       params.attachment = attachment._id;
     }
     }
+    if (activity.checklistId) {
+      const checklist = activity.checklist();
+      params.checklist = checklist.title;
+    }
     if (board) {
     if (board) {
       const watchingUsers = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId');
       const watchingUsers = _.pluck(_.where(board.watchers, {level: 'watching'}), 'userId');
       const trackingUsers = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId');
       const trackingUsers = _.pluck(_.where(board.watchers, {level: 'tracking'}), 'userId');

+ 30 - 0
models/cards.js

@@ -141,6 +141,36 @@ Cards.helpers({
     return cover && cover.url() && cover;
     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() {
   absoluteUrl() {
     const board = this.board();
     const board = this.board();
     return FlowRouter.url('card', {
     return FlowRouter.url('card', {

+ 164 - 0
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);
+    }
+  });
+}

+ 5 - 0
server/lib/utils.js

@@ -5,3 +5,8 @@ allowIsBoardAdmin = function(userId, board) {
 allowIsBoardMember = function(userId, board) {
 allowIsBoardMember = function(userId, board) {
   return board && board.hasMember(userId);
   return board && board.hasMember(userId);
 };
 };
+
+allowIsBoardMemberByCard = function(userId, card) {
+  const board = card.board();
+  return board && board.hasMember(userId);
+};

+ 1 - 0
server/publications/boards.js

@@ -98,6 +98,7 @@ Meteor.publishRelations('board', function(boardId) {
     this.cursor(Cards.find({ boardId }), function(cardId) {
     this.cursor(Cards.find({ boardId }), function(cardId) {
       this.cursor(CardComments.find({ cardId }));
       this.cursor(CardComments.find({ cardId }));
       this.cursor(Attachments.find({ cardId }));
       this.cursor(Attachments.find({ cardId }));
+      this.cursor(Checklists.find({ cardId }));
     });
     });
 
 
     if (board.members) {
     if (board.members) {