Jelajahi Sumber

Add Features: allowing lists to be sorted by modifiedAt when not in draggable mode.
Bug Fix #2093: the broken should be prior to file attachment feature introduced, and tested export board is working.

Thanks to whowillcare !

( xet7 merged this pull request manually from https://github.com/wekan/wekan/pull/2756 )

Closes #2093

Lauri Ojansivu 5 tahun lalu
induk
melakukan
7d6d3af54a

+ 1 - 1
client/components/boards/boardBody.js

@@ -89,7 +89,7 @@ BlazeComponent.extendComponent({
         helper.append(list.clone());
         return helper;
       },
-      handle: '.js-swimlane-header',
+      handle: '.js-swimlane-header-handle',
       items: '.swimlane:not(.placeholder)',
       placeholder: 'swimlane placeholder',
       distance: 7,

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

@@ -77,6 +77,10 @@ template(name="boardHeaderBar")
             i.fa.fa-archive
             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(
           title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
           class="{{#if Filter.isActive}}emphasis{{/if}}")
@@ -194,6 +198,20 @@ template(name="createBoard")
       | /
       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")
   form
     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({
   'click .js-rename-board': Popup.open('boardChangeTitle'),
   'click .js-custom-fields'() {
@@ -80,7 +82,25 @@ BlazeComponent.extendComponent({
     const currentBoard = Boards.findOne(Session.get('currentBoard'));
     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() {
     return [
       {
@@ -118,6 +138,16 @@ BlazeComponent.extendComponent({
         'click .js-open-filter-view'() {
           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) {
           event.stopPropagation();
           Sidebar.setView();
@@ -277,3 +307,73 @@ BlazeComponent.extendComponent({
     ];
   },
 }).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');

+ 9 - 2
client/components/cards/minicard.jade

@@ -3,6 +3,13 @@ template(name="minicard")
     class="{{#if isLinkedCard}}linked-card{{/if}}"
     class="{{#if isLinkedBoard}}linked-board{{/if}}"
     class="minicard-{{colorClass}}")
+    if isMiniScreen
+      .handle
+        .fa.fa-arrows
+    unless isMiniScreen
+      if showDesktopDragHandles
+        .handle
+          .fa.fa-arrows
     if cover
       .minicard-cover(style="background-image: url('{{cover.url}}');")
     if labels
@@ -15,8 +22,6 @@ template(name="minicard")
           if hiddenMinicardLabelText
             .minicard-label(class="card-label-{{color}}" title="{{name}}")
     .minicard-title
-      .handle
-        .fa.fa-arrows
       if $eq 'prefix-with-full-path' currentBoard.presentParentTask
         .parent-prefix
           | {{ parentString ' > ' }}
@@ -53,6 +58,8 @@ template(name="minicard")
       if getDue
         .date
           +minicardDueDate
+      if getEnd
+         +minicardEndDate
       if getSpentTime
         .date
           +cardSpentTime

+ 3 - 0
client/components/cards/minicard.js

@@ -26,6 +26,9 @@ BlazeComponent.extendComponent({
 }).register('minicard');
 
 Template.minicard.helpers({
+  showDesktopDragHandles() {
+    return Meteor.user().hasShowDesktopDragHandles();
+  },
   hiddenMinicardLabelText() {
     return Meteor.user().hasHiddenMinicardLabelText();
   },

+ 2 - 3
client/components/cards/minicard.styl

@@ -105,8 +105,7 @@
     right: 5px;
     top: 5px;
     display:none;
-    // @media only screen and (max-width: 1199px) {
-    @media only screen and (max-width: 800px) {
+    @media only screen {
       display:block;
     }
     .fa-arrows
@@ -128,7 +127,7 @@
   .badges
     float: left
     margin-top: 7px
-    color: darken(white, 80%)
+    color: darken(white, 50%)
 
     &:empty
       display: none

+ 13 - 1
client/components/lists/list.js

@@ -31,7 +31,13 @@ BlazeComponent.extendComponent({
     const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
     const $cards = this.$('.js-minicards');
 
-    if (Utils.isMiniScreen()) {
+    if (Utils.isMiniScreen) {
+      $('.js-minicards').sortable({
+        handle: '.handle',
+      });
+    }
+
+    if (!Utils.isMiniScreen && showDesktopDragHandles) {
       $('.js-minicards').sortable({
         handle: '.handle',
       });
@@ -155,6 +161,12 @@ BlazeComponent.extendComponent({
   },
 }).register('list');
 
+Template.list.helpers({
+  showDesktopDragHandles() {
+    return Meteor.user().hasShowDesktopDragHandles();
+  },
+});
+
 Template.miniList.events({
   'click .js-select-list'() {
     const listId = this._id;

+ 21 - 5
client/components/lists/list.styl

@@ -84,17 +84,16 @@
     padding-left: 10px
     color: #a6a6a6
 
-
   .list-header-menu
     position: absolute
     padding: 27px 19px
     margin-top: 1px
     top: -7px
-    right: -7px
+    right: 3px
 
   .list-header-plus-icon
     color: #a6a6a6
-    margin-right: 10px
+    margin-right: 15px
 
   .highlight
     color: #ce1414
@@ -165,7 +164,16 @@
 
 @media screen and (max-width: 800px)
   .list-header-menu
-    margin-right: 30px
+    position: absolute
+    padding: 27px 19px
+    margin-top: 1px
+    top: -7px
+    margin-right: 50px
+    right: -3px
+
+  .list-header
+    .list-header-name
+      margin-left: 1.4rem
 
   .mini-list
     flex: 0 0 60px
@@ -221,9 +229,17 @@
       padding: 7px
       top: 50%
       transform: translateY(-50%)
-      right: 17px
+      margin-right: 27px
       font-size: 20px
 
+    .list-header-menu-handle
+      position: absolute
+      padding: 7px
+      top: 50%
+      transform: translateY(-50%)
+      right: 10px
+      font-size: 24px
+
 .link-board-wrapper
   display: flex
   align-items: baseline

+ 7 - 0
client/components/lists/listHeader.jade

@@ -9,6 +9,7 @@ template(name="listHeader")
         if currentList
           a.list-header-left-icon.fa.fa-angle-left.js-unselect-list
       h2.list-header-name(
+        title="{{ moment modifiedAt 'LLL' }}"
         class="{{#if currentUser.isBoardMember}}{{#unless currentUser.isCommentOnly}}js-open-inlined-form is-editable{{/unless}}{{/if}}")
         +viewer
           = title
@@ -29,16 +30,22 @@ template(name="listHeader")
               if canSeeAddCard
                 a.js-add-card.fa.fa-plus.list-header-plus-icon
               a.fa.fa-navicon.js-open-list-menu
+          a.list-header-menu-handle.handle.fa.fa-arrows.js-list-handle
         else
           a.list-header-menu-icon.fa.fa-angle-right.js-select-list
+          a.list-header-menu-handle.handle.fa.fa-arrows.js-list-handle
       else if currentUser.isBoardMember
         if isWatching
           i.list-header-watch-icon.fa.fa-eye
         div.list-header-menu
           unless currentUser.isCommentOnly
+            if isBoardAdmin
+              a.fa.js-list-star.list-header-plus-icon(class="fa-star{{#unless starred}}-o{{/unless}}")
             if canSeeAddCard
               a.js-add-card.fa.fa-plus.list-header-plus-icon
             a.fa.fa-navicon.js-open-list-menu
+          if showDesktopDragHandles
+            a.list-header-menu-handle.handle.fa.fa-arrows.js-list-handle
 
 template(name="editListTitleForm")
   .list-composer

+ 24 - 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) {
     event.preventDefault();
     const newTitle = this.childComponents('inlinedForm')[0]
@@ -61,6 +75,10 @@ BlazeComponent.extendComponent({
   events() {
     return [
       {
+        'click .js-list-star'(event) {
+          event.preventDefault();
+          this.starred(!this.starred());
+        },
         'click .js-open-list-menu': Popup.open('listAction'),
         'click .js-add-card'(event) {
           const listDom = $(event.target).parents(
@@ -80,6 +98,12 @@ BlazeComponent.extendComponent({
   },
 }).register('listHeader');
 
+Template.listHeader.helpers({
+  showDesktopDragHandles() {
+    return Meteor.user().hasShowDesktopDragHandles();
+  },
+});
+
 Template.listActionPopup.helpers({
   isWipLimitEnabled() {
     return Template.currentData().getWipLimit('enabled');

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

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

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

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

+ 4 - 0
client/components/sidebar/sidebarSearches.jade

@@ -2,6 +2,10 @@ template(name="searchSidebar")
   form.js-search-term-form
     input(type="text" name="searchTerm" placeholder="{{_ 'search-example'}}" autofocus dir="auto")
   .list-body.js-perfect-scrollbar
+    .minilists.clearfix.js-minilists
+      each (lists)
+        a.minilist-wrapper.js-minilist(href=absoluteUrl)
+          +minilist(this)
     .minicards.clearfix.js-minicards
       each (results)
         a.minicard-wrapper.js-minicard(href=absoluteUrl)

+ 5 - 0
client/components/sidebar/sidebarSearches.js

@@ -8,6 +8,11 @@ BlazeComponent.extendComponent({
     return currentBoard.searchCards(this.term.get());
   },
 
+  lists() {
+    const currentBoard = Boards.findOne(Session.get('currentBoard'));
+    return currentBoard.searchLists(this.term.get());
+  },
+
   events() {
     return [
       {

+ 2 - 0
client/components/swimlanes/swimlaneHeader.jade

@@ -16,6 +16,8 @@ template(name="swimlaneFixedHeader")
     unless currentUser.isCommentOnly
       a.fa.fa-plus.js-open-add-swimlane-menu.swimlane-header-plus-icon
       a.fa.fa-navicon.js-open-swimlane-menu
+      if showDesktopDragHandles
+        a.swimlane-header-menu-handle.handle.fa.fa-arrows.js-swimlane-header-handle
 
 template(name="editSwimlaneTitleForm")
   .list-composer

+ 6 - 0
client/components/swimlanes/swimlaneHeader.js

@@ -28,6 +28,12 @@ BlazeComponent.extendComponent({
   },
 }).register('swimlaneHeader');
 
+Template.swimlaneHeader.helpers({
+  showDesktopDragHandles() {
+    return Meteor.user().hasShowDesktopDragHandles();
+  },
+});
+
 Template.swimlaneActionPopup.events({
   'click .js-set-swimlane-color': Popup.open('setSwimlaneColor'),
   'click .js-close-swimlane'(event) {

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

@@ -12,13 +12,13 @@ template(name="swimlane")
           unless currentUser.isCommentOnly
             +addListForm
     else
+      if currentUser.isBoardMember
+        unless currentUser.isCommentOnly
+          +addListForm
       each lists
         +list(this)
         if currentCardIsInThisList _id ../_id
           +cardDetails(currentCard)
-      if currentUser.isBoardMember
-        unless currentUser.isCommentOnly
-          +addListForm
 
 template(name="listsGroup")
   .swimlane.list-group.js-lists
@@ -26,23 +26,23 @@ template(name="listsGroup")
       if currentList
         +list(currentList)
       else
-        each lists
-          +miniList(this)
         if currentUser.isBoardMember
           unless currentUser.isCommentOnly
             +addListForm
+        each lists
+          +miniList(this)
     else
+      if currentUser.isBoardMember
+        unless currentUser.isCommentOnly
+          +addListForm
       each lists
         if visible this
           +list(this)
         if currentCardIsInThisList _id null
           +cardDetails(currentCard)
-      if currentUser.isBoardMember
-        unless currentUser.isCommentOnly
-          +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
       +inlinedForm(autoclose=false)
         input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}"

+ 27 - 8
client/components/swimlanes/swimlanes.js

@@ -53,10 +53,21 @@ function initSortable(boardComponent, $listsDom) {
     },
   };
 
+  if (Utils.isMiniScreen) {
+    $listsDom.sortable({
+      handle: '.js-list-handle',
+    });
+  }
+
+  if (!Utils.isMiniScreen && showDesktopDragHandles) {
+    $listsDom.sortable({
+      handle: '.js-list-header',
+    });
+  }
+
   $listsDom.sortable({
     tolerance: 'pointer',
     helper: 'clone',
-    handle: '.js-list-header',
     items: '.js-list:not(.js-list-composer)',
     placeholder: 'list placeholder',
     distance: 7,
@@ -151,13 +162,13 @@ BlazeComponent.extendComponent({
           // define a list of elements in which we disable the dragging because
           // the user will legitimately expect to be able to select some text with
           // his mouse.
-          const noDragInside = [
-            'a',
-            'input',
-            'textarea',
-            'p',
-            '.js-list-header',
-          ];
+
+          const noDragInside = ['a', 'input', 'textarea', 'p'].concat(
+            Util.isMiniScreen || (!Util.isMiniScreen && showDesktopDragHandles)
+              ? ['.js-list-handle', '.js-swimlane-header-handle']
+              : ['.js-list-header'],
+          );
+
           if (
             $(evt.target).closest(noDragInside.join(',')).length === 0 &&
             this.$('.swimlane').prop('clientHeight') > evt.offsetY
@@ -233,6 +244,9 @@ BlazeComponent.extendComponent({
 }).register('addListForm');
 
 Template.swimlane.helpers({
+  showDesktopDragHandles() {
+    return Meteor.user().hasShowDesktopDragHandles();
+  },
   canSeeAddList() {
     return (
       Meteor.user() &&
@@ -253,6 +267,11 @@ BlazeComponent.extendComponent({
         return false;
       }
     }
+    if (Filter.lists._isActive()) {
+      if (!list.title.match(Filter.lists.getRegexSelector())) {
+        return false;
+      }
+    }
     if (Filter.hideEmpty.isSelected()) {
       const swimlaneId = this.parentComponent()
         .parentComponent()

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

@@ -50,6 +50,14 @@
       margin-left: 5px
       margin-right: 10px
 
+    .swimlane-header-menu-handle
+      position: absolute
+      padding: 7px
+      top: 50%
+      transform: translateY(-50%)
+      left: 300px
+      font-size: 18px
+
 .list-group
   height: 100%
 

+ 5 - 0
client/components/users/userHeader.jade

@@ -78,6 +78,11 @@ template(name="changeSettingsPopup")
         | {{_ 'hide-system-messages'}}
         if hiddenSystemMessages
           i.fa.fa-check
+    li
+      a.js-toggle-desktop-drag-handles
+        | {{_ 'show-desktop-drag-handles'}}
+        if showDesktopDragHandles
+          i.fa.fa-check
     li
       label.bold
         | {{_ 'show-cards-minimum-count'}}

+ 6 - 0
client/components/users/userHeader.js

@@ -161,6 +161,9 @@ Template.changeLanguagePopup.events({
 });
 
 Template.changeSettingsPopup.helpers({
+  showDesktopDragHandles() {
+    return Meteor.user().hasShowDesktopDragHandles();
+  },
   hiddenSystemMessages() {
     return Meteor.user().hasHiddenSystemMessages();
   },
@@ -170,6 +173,9 @@ Template.changeSettingsPopup.helpers({
 });
 
 Template.changeSettingsPopup.events({
+  'click .js-toggle-desktop-drag-handles'() {
+    Meteor.call('toggleDesktopDragHandles');
+  },
   'click .js-toggle-system-messages'() {
     Meteor.call('toggleSystemMessages');
   },

+ 10 - 0
client/lib/filter.js

@@ -439,6 +439,14 @@ class AdvancedFilter {
     const commands = this._filterToCommands();
     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.
@@ -455,6 +463,7 @@ Filter = {
   hideEmpty: new SetFilter(),
   customFields: new SetFilter('_id'),
   advanced: new AdvancedFilter(),
+  lists: new AdvancedFilter(), // we need the ability to filter list by name as well
 
   _fields: ['labelIds', 'members', 'archive', 'hideEmpty', 'customFields'],
 
@@ -533,6 +542,7 @@ Filter = {
       const filter = this[fieldName];
       filter.reset();
     });
+    this.lists.reset();
     this.advanced.reset();
     this.resetExceptions();
   },

+ 12 - 2
i18n/en.i18n.json

@@ -300,8 +300,18 @@
   "error-username-taken": "This username is already taken",
   "error-email-taken": "Email has already been taken",
   "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-cards": "Filter Cards",
+  "filter-cards": "Filter Cards or Lists",
+  "list-filter-label": "Filter List by Title",
   "filter-clear": "Clear filter",
   "filter-no-label": "No label",
   "filter-no-member": "No member",
@@ -426,7 +436,7 @@
   "save": "Save",
   "search": "Search",
   "rules": "Rules",
-  "search-cards": "Search from card titles and descriptions on this board",
+  "search-cards": "Search from card/list titles and descriptions on this board",
   "search-example": "Text to search for?",
   "select-color": "Select Color",
   "set-wip-limit-value": "Set a limit for the maximum number of tasks in this list",

+ 17 - 0
models/boards.js

@@ -409,6 +409,23 @@ Boards.helpers({
   },
 
   lists() {
+    const enabled = Meteor.user().hasSortBy();
+    return enabled ? this.newestLists() : this.draggableLists();
+  },
+
+  newestLists() {
+    // 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(
+      {
+        boardId: this._id,
+        archived: false,
+      },
+      { sort: sortKey },
+    );
+  },
+  draggableLists() {
     return Lists.find({ boardId: this._id }, { sort: { sort: 1 } });
   },
 

+ 20 - 8
models/cards.js

@@ -1695,6 +1695,25 @@ if (Meteor.isServer) {
       const oldvalue = doc[action] || '';
       const activityType = `a-${action}`;
       const card = Cards.findOne(doc._id);
+      const list = card.list();
+      if (list && action === 'endAt') {
+        // change list modifiedAt
+        const modifiedAt = new Date(
+          new Date(value).getTime() - 365 * 24 * 3600 * 1e3,
+        ); // set it as 1 year before
+        const boardId = list.boardId;
+        Lists.direct.update(
+          {
+            _id: list._id,
+          },
+          {
+            $set: {
+              modifiedAt,
+              boardId,
+            },
+          },
+        );
+      }
       const username = Users.findOne(userId).username;
       const activity = {
         userId,
@@ -1852,15 +1871,8 @@ if (Meteor.isServer) {
     const check = Users.findOne({
       _id: req.body.authorId,
     });
+    const members = req.body.members || [req.body.authorId];
     if (typeof check !== 'undefined') {
-      let members = req.body.members || [];
-      if (_.isString(members)) {
-        if (members === '') {
-          members = [];
-        } else {
-          members = [members];
-        }
-      }
       const id = Cards.direct.insert({
         title: req.body.title,
         boardId: paramBoardId,

+ 15 - 0
models/export.js

@@ -50,12 +50,18 @@ if (Meteor.isServer) {
   });
 }
 
+// exporter maybe is broken since Gridfs introduced, add fs and path
+
 export class Exporter {
   constructor(boardId) {
     this._boardId = boardId;
   }
 
   build() {
+    const fs = Npm.require('fs');
+    const os = Npm.require('os');
+    const path = Npm.require('path');
+
     const byBoard = { boardId: this._boardId };
     const byBoardNoLinked = {
       boardId: this._boardId,
@@ -134,6 +140,11 @@ export class Exporter {
     const getBase64Data = function(doc, callback) {
       let buffer = new Buffer(0);
       // callback has the form function (err, res) {}
+      const tmpFile = path.join(
+        os.tmpdir(),
+        `tmpexport${process.pid}${Math.random()}`,
+      );
+      const tmpWriteable = fs.createWriteStream(tmpFile);
       const readStream = doc.createReadStream();
       readStream.on('data', function(chunk) {
         buffer = Buffer.concat([buffer, chunk]);
@@ -143,8 +154,12 @@ export class Exporter {
       });
       readStream.on('end', function() {
         // done
+        fs.unlink(tmpFile, () => {
+          //ignored
+        });
         callback(null, buffer.toString('base64'));
       });
+      readStream.pipe(tmpWriteable);
     };
     const getBase64DataSync = Meteor.wrapAsync(getBase64Data);
     result.attachments = Attachments.find(byBoard)

+ 25 - 1
models/lists.js

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

+ 15 - 0
models/swimlanes.js

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

+ 82 - 0
models/users.js

@@ -4,6 +4,16 @@ const isSandstorm =
   Meteor.settings && Meteor.settings.public && Meteor.settings.public.sandstorm;
 Users = Meteor.users;
 
+const allowedSortValues = [
+  '-modifiedAt',
+  'modifiedAt',
+  '-title',
+  'title',
+  '-sort',
+  'sort',
+];
+const defaultSortBy = allowedSortValues[0];
+
 /**
  * A User in wekan
  */
@@ -109,6 +119,13 @@ Users.attachSchema(
       type: String,
       optional: true,
     },
+    'profile.showDesktopDragHandles': {
+      /**
+       * does the user want to hide system messages?
+       */
+      type: Boolean,
+      optional: true,
+    },
     'profile.hiddenSystemMessages': {
       /**
        * does the user want to hide system messages?
@@ -184,6 +201,15 @@ Users.attachSchema(
         'board-view-cal',
       ],
     },
+    'profile.listSortBy': {
+      /**
+       * default sort list for user
+       */
+      type: String,
+      optional: true,
+      defaultValue: defaultSortBy,
+      allowedValues: allowedSortValues,
+    },
     'profile.templatesBoardId': {
       /**
        * Reference to the templates board
@@ -358,6 +384,31 @@ Users.helpers({
     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) {
     const { tags = [] } = this.profile || {};
     return _.contains(tags, tag);
@@ -368,6 +419,11 @@ Users.helpers({
     return _.contains(notifications, activityId);
   },
 
+  hasShowDesktopDragHandles() {
+    const profile = this.profile || {};
+    return profile.showDesktopDragHandles || false;
+  },
+
   hasHiddenSystemMessages() {
     const profile = this.profile || {};
     return profile.hiddenSystemMessages || false;
@@ -473,6 +529,21 @@ Users.mutations({
     else this.addTag(tag);
   },
 
+  setListSortBy(value) {
+    return {
+      $set: {
+        'profile.listSortBy': value,
+      },
+    };
+  },
+  toggleDesktopHandles(value = false) {
+    return {
+      $set: {
+        'profile.showDesktopDragHandles': !value,
+      },
+    };
+  },
+
   toggleSystem(value = false) {
     return {
       $set: {
@@ -549,6 +620,14 @@ Meteor.methods({
       Users.update(userId, { $set: { username } });
     }
   },
+  setListSortBy(value) {
+    check(value, String);
+    Meteor.user().setListSortBy(value);
+  },
+  toggleDesktopDragHandles() {
+    const user = Meteor.user();
+    user.toggleDesktopHandles(user.hasShowDesktopDragHandles());
+  },
   toggleSystemMessages() {
     const user = Meteor.user();
     user.toggleSystem(user.hasHiddenSystemMessages());
@@ -776,6 +855,9 @@ if (Meteor.isServer) {
 if (Meteor.isServer) {
   // Let mongoDB ensure username unicity
   Meteor.startup(() => {
+    allowedSortValues.forEach(value => {
+      Lists._collection._ensureIndex(value);
+    });
     Users._collection._ensureIndex({ modifiedAt: -1 });
     Users._collection._ensureIndex(
       {