Kaynağa Gözat

Add Feature: allow user to sort Lists in Board by his own preference, boardadmin can star list

Sam X. Chen 5 yıl önce
ebeveyn
işleme
bc2a20f04e

+ 18 - 0
client/components/boards/boardHeader.jade

@@ -77,6 +77,10 @@ template(name="boardHeaderBar")
             i.fa.fa-archive
             i.fa.fa-archive
             span {{_ 'archives'}}
             span {{_ 'archives'}}
 
 
+      if showSort
+       a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
+        i.fa(class="{{directionClass}}")
+        span {{_ 'sort'}}{{_ listSortShortDesc}}
       a.board-header-btn.js-open-filter-view(
       a.board-header-btn.js-open-filter-view(
           title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
           title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
           class="{{#if Filter.isActive}}emphasis{{/if}}")
           class="{{#if Filter.isActive}}emphasis{{/if}}")
@@ -194,6 +198,20 @@ template(name="createBoard")
       | /
       | /
       a.js-board-template {{_ 'template'}}
       a.js-board-template {{_ 'template'}}
 
 
+template(name="listsortPopup")
+  h2
+   | {{_ 'list-sort-by'}}
+  hr
+  ul.pop-over-list
+    each value in allowedSortValues
+     li
+      a.js-sort-by(name="{{value.name}}")
+        if $eq sortby value.name
+           i(class="fa {{Direction}}") 
+        | {{_ value.label }}{{_ value.shortLabel}}
+        if $eq sortby value.name
+           i(class="fa fa-check")
+
 template(name="boardChangeTitlePopup")
 template(name="boardChangeTitlePopup")
   form
   form
     label
     label

+ 101 - 1
client/components/boards/boardHeader.js

@@ -1,3 +1,5 @@
+const DOWNCLS = 'fa-sort-down';
+const UPCLS = 'fa-sort-up';
 Template.boardMenuPopup.events({
 Template.boardMenuPopup.events({
   'click .js-rename-board': Popup.open('boardChangeTitle'),
   'click .js-rename-board': Popup.open('boardChangeTitle'),
   'click .js-custom-fields'() {
   'click .js-custom-fields'() {
@@ -80,7 +82,25 @@ BlazeComponent.extendComponent({
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
     return currentBoard && currentBoard.stars >= 2;
     return currentBoard && currentBoard.stars >= 2;
   },
   },
-
+  showSort() {
+    return Meteor.user().hasSortBy();
+  },
+  directionClass() {
+    return this.currentDirection() === -1 ? DOWNCLS : UPCLS;
+  },
+  changeDirection() {
+    const direction = 0 - this.currentDirection() === -1 ? '-' : '';
+    Meteor.call('setListSortBy', direction + this.currentListSortBy());
+  },
+  currentDirection() {
+    return Meteor.user().getListSortByDirection();
+  },
+  currentListSortBy() {
+    return Meteor.user().getListSortBy();
+  },
+  listSortShortDesc() {
+    return `list-label-short-${this.currentListSortBy()}`;
+  },
   events() {
   events() {
     return [
     return [
       {
       {
@@ -118,6 +138,16 @@ BlazeComponent.extendComponent({
         'click .js-open-filter-view'() {
         'click .js-open-filter-view'() {
           Sidebar.setView('filter');
           Sidebar.setView('filter');
         },
         },
+        'click .js-open-sort-view'(evt) {
+          const target = evt.target;
+          if (target.tagName === 'I') {
+            // click on the text, popup choices
+            this.changeDirection();
+          } else {
+            // change the sort order
+            Popup.open('listsort')(evt);
+          }
+        },
         'click .js-filter-reset'(event) {
         'click .js-filter-reset'(event) {
           event.stopPropagation();
           event.stopPropagation();
           Sidebar.setView();
           Sidebar.setView();
@@ -277,3 +307,73 @@ BlazeComponent.extendComponent({
     ];
     ];
   },
   },
 }).register('boardChangeWatchPopup');
 }).register('boardChangeWatchPopup');
+
+BlazeComponent.extendComponent({
+  onCreated() {
+    //this.sortBy = new ReactiveVar();
+    ////this.sortDirection = new ReactiveVar();
+    //this.setSortBy();
+    this.downClass = DOWNCLS;
+    this.upClass = UPCLS;
+  },
+  allowedSortValues() {
+    const types = [];
+    const pushed = {};
+    Meteor.user()
+      .getListSortTypes()
+      .forEach(type => {
+        const key = type.replace(/^-/, '');
+        if (pushed[key] === undefined) {
+          types.push({
+            name: key,
+            label: `list-label-${key}`,
+            shortLabel: `list-label-short-${key}`,
+          });
+          pushed[key] = 1;
+        }
+      });
+    return types;
+  },
+  Direction() {
+    return Meteor.user().getListSortByDirection() === -1
+      ? this.downClass
+      : this.upClass;
+  },
+  sortby() {
+    return Meteor.user().getListSortBy();
+  },
+
+  setSortBy(type = null) {
+    const user = Meteor.user();
+    if (type === null) {
+      type = user._getListSortBy();
+    } else {
+      let value = '';
+      if (type.map) {
+        // is an array
+        value = (type[1] === -1 ? '-' : '') + type[0];
+      }
+      Meteor.call('setListSortBy', value);
+    }
+    //this.sortBy.set(type[0]);
+    //this.sortDirection.set(type[1]);
+  },
+
+  events() {
+    return [
+      {
+        'click .js-sort-by'(evt) {
+          evt.preventDefault();
+          const target = evt.target;
+          const sortby = target.getAttribute('name');
+          const down = !!target.querySelector(`.${this.upClass}`);
+          const direction = down ? -1 : 1;
+          this.setSortBy([sortby, direction]);
+          if (Utils.isMiniScreen) {
+            Popup.close();
+          }
+        },
+      },
+    ];
+  },
+}).register('listsortPopup');

+ 3 - 1
client/components/lists/listHeader.jade

@@ -9,7 +9,7 @@ template(name="listHeader")
         if currentList
         if currentList
           a.list-header-left-icon.fa.fa-angle-left.js-unselect-list
           a.list-header-left-icon.fa.fa-angle-left.js-unselect-list
       h2.list-header-name(
       h2.list-header-name(
-        title="{{ moment updatedAt 'LLL' }}"
+        title="{{ moment modifiedAt 'LLL' }}"
         class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}js-open-inlined-form is-editable{{/unless}}{{/if}}")
         class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}js-open-inlined-form is-editable{{/unless}}{{/if}}")
         +viewer
         +viewer
           = title
           = title
@@ -39,6 +39,8 @@ template(name="listHeader")
           i.list-header-watch-icon.fa.fa-eye
           i.list-header-watch-icon.fa.fa-eye
         div.list-header-menu
         div.list-header-menu
           unless currentUser.isCommentOnly
           unless currentUser.isCommentOnly
+            if isBoardAdmin
+              a.fa.js-list-star.list-header-plus-icon(class="fa-star{{#unless starred}}-o{{/unless}}")
             if canSeeAddCard
             if canSeeAddCard
               a.js-add-card.fa.fa-plus.list-header-plus-icon
               a.js-add-card.fa.fa-plus.list-header-plus-icon
             a.fa.fa-navicon.js-open-list-menu
             a.fa.fa-navicon.js-open-list-menu

+ 18 - 0
client/components/lists/listHeader.js

@@ -13,6 +13,20 @@ BlazeComponent.extendComponent({
     );
     );
   },
   },
 
 
+  isBoardAdmin() {
+    return Meteor.user().isBoardAdmin();
+  },
+  starred(check = undefined) {
+    const list = Template.currentData();
+    const status = list.isStarred();
+    if (check === undefined) {
+      // just check
+      return status;
+    } else {
+      list.star(!status);
+      return !status;
+    }
+  },
   editTitle(event) {
   editTitle(event) {
     event.preventDefault();
     event.preventDefault();
     const newTitle = this.childComponents('inlinedForm')[0]
     const newTitle = this.childComponents('inlinedForm')[0]
@@ -61,6 +75,10 @@ BlazeComponent.extendComponent({
   events() {
   events() {
     return [
     return [
       {
       {
+        'click .js-list-star'(event) {
+          event.preventDefault();
+          this.starred(!this.starred());
+        },
         'click .js-open-list-menu': Popup.open('listAction'),
         'click .js-open-list-menu': Popup.open('listAction'),
         'click .js-add-card'(event) {
         'click .js-add-card'(event) {
           const listDom = $(event.target).parents(
           const listDom = $(event.target).parents(

+ 3 - 0
client/components/sidebar/sidebarFilters.jade

@@ -4,6 +4,9 @@
   and #each x in y constructors to fix this.
   and #each x in y constructors to fix this.
 
 
 template(name="filterSidebar")
 template(name="filterSidebar")
+  ul.sidebar-list
+    span {{_ 'list-filter-label'}}          
+    input.js-list-filter(type="text")
   ul.sidebar-list
   ul.sidebar-list
     li(class="{{#if Filter.labelIds.isSelected undefined}}active{{/if}}")
     li(class="{{#if Filter.labelIds.isSelected undefined}}active{{/if}}")
           a.name.js-toggle-label-filter
           a.name.js-toggle-label-filter

+ 4 - 0
client/components/sidebar/sidebarFilters.js

@@ -4,6 +4,10 @@ BlazeComponent.extendComponent({
   events() {
   events() {
     return [
     return [
       {
       {
+        'change .js-list-filter'(evt) {
+          evt.preventDefault();
+          Filter.lists.set(this.find('.js-list-filter').value.trim());
+        },
         'click .js-toggle-label-filter'(evt) {
         'click .js-toggle-label-filter'(evt) {
           evt.preventDefault();
           evt.preventDefault();
           Filter.labelIds.toggle(this.currentData()._id);
           Filter.labelIds.toggle(this.currentData()._id);

+ 1 - 1
client/components/swimlanes/swimlanes.jade

@@ -42,7 +42,7 @@ template(name="listsGroup")
           +cardDetails(currentCard)
           +cardDetails(currentCard)
 
 
 template(name="addListForm")
 template(name="addListForm")
-  .list.list-composer.js-list-composer
+  .list.list-composer.js-list-composer(class="{{#if isMiniScreen}}mini-list{{/if}}")
     .list-header-add
     .list-header-add
       +inlinedForm(autoclose=false)
       +inlinedForm(autoclose=false)
         input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}"
         input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}"

+ 5 - 0
client/components/swimlanes/swimlanes.js

@@ -267,6 +267,11 @@ BlazeComponent.extendComponent({
         return false;
         return false;
       }
       }
     }
     }
+    if (Filter.lists._isActive()) {
+      if (!list.title.match(Filter.lists.getRegexSelector())) {
+        return false;
+      }
+    }
     if (Filter.hideEmpty.isSelected()) {
     if (Filter.hideEmpty.isSelected()) {
       const swimlaneId = this.parentComponent()
       const swimlaneId = this.parentComponent()
         .parentComponent()
         .parentComponent()

+ 18 - 2
client/lib/filter.js

@@ -439,6 +439,14 @@ class AdvancedFilter {
     const commands = this._filterToCommands();
     const commands = this._filterToCommands();
     return this._arrayToSelector(commands);
     return this._arrayToSelector(commands);
   }
   }
+  getRegexSelector() {
+    // generate a regex for filter list
+    this._dep.depend();
+    return new RegExp(
+      `^.*${this._filter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`,
+      'i',
+    );
+  }
 }
 }
 
 
 // The global Filter object.
 // The global Filter object.
@@ -455,8 +463,16 @@ Filter = {
   hideEmpty: new SetFilter(),
   hideEmpty: new SetFilter(),
   customFields: new SetFilter('_id'),
   customFields: new SetFilter('_id'),
   advanced: new AdvancedFilter(),
   advanced: new AdvancedFilter(),
-
-  _fields: ['labelIds', 'members', 'archive', 'hideEmpty', 'customFields'],
+  lists: new AdvancedFilter(), // we need the ability to filter list by name as well
+
+  _fields: [
+    'labelIds',
+    'members',
+    'archive',
+    'hideEmpty',
+    'customFields',
+    'lists',
+  ],
 
 
   // We don't filter cards that have been added after the last filter change. To
   // We don't filter cards that have been added after the last filter change. To
   // implement this we keep the id of these cards in this `_exceptions` fields
   // implement this we keep the id of these cards in this `_exceptions` fields

+ 11 - 1
i18n/en.i18n.json

@@ -300,8 +300,18 @@
   "error-username-taken": "This username is already taken",
   "error-username-taken": "This username is already taken",
   "error-email-taken": "Email has already been taken",
   "error-email-taken": "Email has already been taken",
   "export-board": "Export board",
   "export-board": "Export board",
+  "sort": "Sort",
+  "sort-desc": "Click to Sort List",
+  "list-sort-by": "Sort the List By:",
+  "list-label-modifiedAt": "Last Access Time",
+  "list-label-title": "Name of the List",
+  "list-label-sort": "Your Manual Order",
+  "list-label-short-modifiedAt": "(L)",
+  "list-label-short-title": "(N)",
+  "list-label-short-sort": "(M)",
   "filter": "Filter",
   "filter": "Filter",
-  "filter-cards": "Filter Cards",
+  "filter-cards": "Filter Cards or Lists",
+  "list-filter-label": "Filter List by Title",
   "filter-clear": "Clear filter",
   "filter-clear": "Clear filter",
   "filter-no-label": "No label",
   "filter-no-label": "No label",
   "filter-no-member": "No member",
   "filter-no-member": "No member",

+ 5 - 3
models/boards.js

@@ -409,18 +409,20 @@ Boards.helpers({
   },
   },
 
 
   lists() {
   lists() {
-    const enabled = Meteor.user().hasShowDesktopDragHandles();
-    return enabled ? this.draggableLists() : this.newestLists();
+    const enabled = Meteor.user().hasSortBy();
+    return enabled ? this.newestLists() : this.draggableLists();
   },
   },
 
 
   newestLists() {
   newestLists() {
     // sorted lists from newest to the oldest, by its creation date or its cards' last modification date
     // sorted lists from newest to the oldest, by its creation date or its cards' last modification date
+    const value = Meteor.user()._getListSortBy();
+    const sortKey = { starred: -1, [value[0]]: value[1] }; // [["starred",-1],value];
     return Lists.find(
     return Lists.find(
       {
       {
         boardId: this._id,
         boardId: this._id,
         archived: false,
         archived: false,
       },
       },
-      { sort: { updatedAt: -1 } },
+      { sort: sortKey },
     );
     );
   },
   },
   draggableLists() {
   draggableLists() {

+ 4 - 2
models/cards.js

@@ -1696,9 +1696,11 @@ if (Meteor.isServer) {
       const activityType = `a-${action}`;
       const activityType = `a-${action}`;
       const card = Cards.findOne(doc._id);
       const card = Cards.findOne(doc._id);
       const list = card.list();
       const list = card.list();
-      if (list) {
+      if (list && action === 'endAt') {
         // change list modifiedAt
         // change list modifiedAt
-        const modifiedAt = new Date();
+        const modifiedAt = new Date(
+          new Date(value).getTime() - 365 * 24 * 3600 * 1e3,
+        ); // set it as 1 year before
         const boardId = list.boardId;
         const boardId = list.boardId;
         Lists.direct.update(
         Lists.direct.update(
           {
           {

+ 21 - 1
models/lists.js

@@ -11,6 +11,15 @@ Lists.attachSchema(
        */
        */
       type: String,
       type: String,
     },
     },
+    starred: {
+      /**
+       * if a list is stared
+       * then we put it on the top
+       */
+      type: Boolean,
+      optional: true,
+      defaultValue: false,
+    },
     archived: {
     archived: {
       /**
       /**
        * is the list archived
        * is the list archived
@@ -81,10 +90,14 @@ Lists.attachSchema(
       denyUpdate: false,
       denyUpdate: false,
       // eslint-disable-next-line consistent-return
       // eslint-disable-next-line consistent-return
       autoValue() {
       autoValue() {
-        if (this.isInsert || this.isUpsert || this.isUpdate) {
+        // this is redundant with updatedAt
+        /*if (this.isInsert || this.isUpsert || this.isUpdate) {
           return new Date();
           return new Date();
         } else {
         } else {
           this.unset();
           this.unset();
+        }*/
+        if (!this.isSet) {
+          return new Date();
         }
         }
       },
       },
     },
     },
@@ -252,6 +265,10 @@ Lists.helpers({
     return this.type === 'template-list';
     return this.type === 'template-list';
   },
   },
 
 
+  isStarred() {
+    return this.starred === true;
+  },
+
   remove() {
   remove() {
     Lists.remove({ _id: this._id });
     Lists.remove({ _id: this._id });
   },
   },
@@ -261,6 +278,9 @@ Lists.mutations({
   rename(title) {
   rename(title) {
     return { $set: { title } };
     return { $set: { title } };
   },
   },
+  star(enable = true) {
+    return { $set: { starred: !!enable } };
+  },
 
 
   archive() {
   archive() {
     if (this.isTemplateList()) {
     if (this.isTemplateList()) {

+ 3 - 3
models/swimlanes.js

@@ -174,8 +174,8 @@ Swimlanes.helpers({
   },
   },
 
 
   lists() {
   lists() {
-    const enabled = Meteor.user().hasShowDesktopDragHandles();
-    return enabled ? this.draggableLists() : this.newestLists();
+    const enabled = Meteor.user().hasSortBy();
+    return enabled ? this.newestLists() : this.draggableLists();
   },
   },
   newestLists() {
   newestLists() {
     // sorted lists from newest to the oldest, by its creation date or its cards' last modification date
     // sorted lists from newest to the oldest, by its creation date or its cards' last modification date
@@ -185,7 +185,7 @@ Swimlanes.helpers({
         swimlaneId: { $in: [this._id, ''] },
         swimlaneId: { $in: [this._id, ''] },
         archived: false,
         archived: false,
       },
       },
-      { sort: { updatedAt: -1 } },
+      { sort: { modifiedAt: -1 } },
     );
     );
   },
   },
   draggableLists() {
   draggableLists() {

+ 58 - 0
models/users.js

@@ -4,6 +4,16 @@ const isSandstorm =
   Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm;
   Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm;
 Users = Meteor.users;
 Users = Meteor.users;
 
 
+const allowedSortValues = [
+  '-modifiedAt',
+  'modifiedAt',
+  '-title',
+  'title',
+  '-sort',
+  'sort',
+];
+const defaultSortBy = allowedSortValues[0];
+
 /**
 /**
  * A User in wekan
  * A User in wekan
  */
  */
@@ -191,6 +201,15 @@ Users.attachSchema(
         'board-view-cal',
         'board-view-cal',
       ],
       ],
     },
     },
+    'profile.listSortBy': {
+      /**
+       * default sort list for user
+       */
+      type: String,
+      optional: true,
+      defaultValue: defaultSortBy,
+      allowedValues: allowedSortValues,
+    },
     'profile.templatesBoardId': {
     'profile.templatesBoardId': {
       /**
       /**
        * Reference to the templates board
        * Reference to the templates board
@@ -365,6 +384,31 @@ Users.helpers({
     return _.contains(invitedBoards, boardId);
     return _.contains(invitedBoards, boardId);
   },
   },
 
 
+  _getListSortBy() {
+    const profile = this.profile || {};
+    const sortBy = profile.listSortBy || defaultSortBy;
+    const keyPattern = /^(-{0,1})(.*$)/;
+    const ret = [];
+    if (keyPattern.exec(sortBy)) {
+      ret[0] = RegExp.$2;
+      ret[1] = RegExp.$1 ? -1 : 1;
+    }
+    return ret;
+  },
+  hasSortBy() {
+    // if use doesn't have dragHandle, then we can let user to choose sort list by different order
+    return !this.hasShowDesktopDragHandles();
+  },
+  getListSortBy() {
+    return this._getListSortBy()[0];
+  },
+  getListSortTypes() {
+    return allowedSortValues;
+  },
+  getListSortByDirection() {
+    return this._getListSortBy()[1];
+  },
+
   hasTag(tag) {
   hasTag(tag) {
     const { tags = [] } = this.profile || {};
     const { tags = [] } = this.profile || {};
     return _.contains(tags, tag);
     return _.contains(tags, tag);
@@ -485,6 +529,13 @@ Users.mutations({
     else this.addTag(tag);
     else this.addTag(tag);
   },
   },
 
 
+  setListSortBy(value) {
+    return {
+      $set: {
+        'profile.listSortBy': value,
+      },
+    };
+  },
   toggleDesktopHandles(value = false) {
   toggleDesktopHandles(value = false) {
     return {
     return {
       $set: {
       $set: {
@@ -569,6 +620,10 @@ Meteor.methods({
       Users.update(userId, { $set: { username } });
       Users.update(userId, { $set: { username } });
     }
     }
   },
   },
+  setListSortBy(value) {
+    check(value, String);
+    Meteor.user().setListSortBy(value);
+  },
   toggleDesktopDragHandles() {
   toggleDesktopDragHandles() {
     const user = Meteor.user();
     const user = Meteor.user();
     user.toggleDesktopHandles(user.hasShowDesktopDragHandles());
     user.toggleDesktopHandles(user.hasShowDesktopDragHandles());
@@ -800,6 +855,9 @@ if (Meteor.isServer) {
 if (Meteor.isServer) {
 if (Meteor.isServer) {
   // Let mongoDB ensure username unicity
   // Let mongoDB ensure username unicity
   Meteor.startup(() => {
   Meteor.startup(() => {
+    allowedSortValues.forEach(value => {
+      Lists._collection._ensureIndex(value);
+    });
     Users._collection._ensureIndex({ modifiedAt: -1 });
     Users._collection._ensureIndex({ modifiedAt: -1 });
     Users._collection._ensureIndex(
     Users._collection._ensureIndex(
       {
       {