Browse Source

Merge branch 'edge' into meteor-1.8

Lauri Ojansivu 6 năm trước cách đây
mục cha
commit
82a728df71

+ 7 - 0
CHANGELOG.md

@@ -1,3 +1,10 @@
+# v2.29 2019-02-27 Wekan release
+
+This release adds the following new features:
+
+- Swimlane/List/Board/Card templates. In Progress, please test and [add comment if you find not listed bugs](https://github.com/wekan/wekan/issues/2165).
+  Thanks to GitHub user andresmanelli.
+
 # v2.28 2019-02-27 Wekan release
 
 This release adds the following new Sandstorm features and fixes:

+ 1 - 1
Stackerfile.yml

@@ -1,5 +1,5 @@
 appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
-appVersion: "v2.28.0"
+appVersion: "v2.29.0"
 files:
   userUploads:
     - README.md

+ 0 - 6
client/components/boards/boardArchive.js

@@ -1,9 +1,3 @@
-Template.boardListHeaderBar.events({
-  'click .js-open-archived-board'() {
-    Modal.open('archivedBoards');
-  },
-});
-
 BlazeComponent.extendComponent({
   onCreated() {
     this.subscribe('archivedBoards');

+ 7 - 4
client/components/boards/boardBody.jade

@@ -20,12 +20,15 @@ template(name="boardBody")
       class="{{#if draggingActive.get}}is-dragging-active{{/if}}")
       if showOverlay.get
         .board-overlay
-      if isViewSwimlanes
+      if currentBoard.isTemplatesBoard
         each currentBoard.swimlanes
           +swimlane(this)
-      if isViewLists
-        +listsGroup
-      if isViewCalendar
+      else if isViewSwimlanes
+        each currentBoard.swimlanes
+          +swimlane(this)
+      else if isViewLists
+        +listsGroup(currentBoard)
+      else if isViewCalendar
         +calendarView
 
 template(name="calendarView")

+ 11 - 6
client/components/boards/boardHeader.jade

@@ -94,10 +94,11 @@ template(name="boardHeaderBar")
         i.fa.fa-search
         span {{_ 'search'}}
 
-      a.board-header-btn.js-toggle-board-view(
-        title="{{_ 'board-view'}}")
-        i.fa.fa-th-large
-        span {{_ currentUser.profile.boardView}}
+      unless currentBoard.isTemplatesBoard
+        a.board-header-btn.js-toggle-board-view(
+          title="{{_ 'board-view'}}")
+          i.fa.fa-th-large
+          span {{_ currentUser.profile.boardView}}
 
       if canModifyBoard
         a.board-header-btn.js-multiselection-activate(
@@ -130,7 +131,8 @@ template(name="boardMenuPopup")
       hr
       ul.pop-over-list
         li: a(href="{{exportUrl}}", download="{{exportFilename}}") {{_ 'export-board'}}
-        li: a.js-archive-board {{_ 'archive-board'}}
+        unless currentBoard.isTemplatesBoard
+          li: a.js-archive-board {{_ 'archive-board'}}
         li: a.js-outgoing-webhooks {{_ 'outgoing-webhooks'}}
       hr
       ul.pop-over-list
@@ -275,7 +277,10 @@ template(name="createBoard")
     input.primary.wide(type="submit" value="{{_ 'create'}}")
     span.quiet
       | {{_ 'or'}}
-      a.js-import-board {{_ 'import-board'}}
+      a.js-import-board {{_ 'import'}}
+    span.quiet
+      | /
+      a.js-board-template {{_ 'template'}}
 
 template(name="chooseBoardSource")
   ul.pop-over-list

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

@@ -304,6 +304,7 @@ const CreateBoard = BlazeComponent.extendComponent({
       'click .js-import': Popup.open('boardImportBoard'),
       submit: this.onSubmit,
       'click .js-import-board': Popup.open('chooseBoardSource'),
+      'click .js-board-template': Popup.open('searchElement'),
     }];
   },
 }).register('createBoardPopup');

+ 3 - 0
client/components/boards/boardsList.jade

@@ -36,3 +36,6 @@ template(name="boardListHeaderBar")
     a.board-header-btn.js-open-archived-board
       i.fa.fa-archive
       span {{_ 'archives'}}
+    a.board-header-btn(href="{{pathFor 'board' id=templatesBoardId slug=templatesBoardSlug}}")
+      i.fa.fa-clone
+      span {{_ 'templates'}}

+ 16 - 0
client/components/boards/boardsList.js

@@ -1,5 +1,20 @@
 const subManager = new SubsManager();
 
+Template.boardListHeaderBar.events({
+  'click .js-open-archived-board'() {
+    Modal.open('archivedBoards');
+  },
+});
+
+Template.boardListHeaderBar.helpers({
+  templatesBoardId() {
+    return Meteor.user().getTemplatesBoardId();
+  },
+  templatesBoardSlug() {
+    return Meteor.user().getTemplatesBoardSlug();
+  },
+});
+
 BlazeComponent.extendComponent({
   onCreated() {
     Meteor.subscribe('setting');
@@ -9,6 +24,7 @@ BlazeComponent.extendComponent({
     return Boards.find({
       archived: false,
       'members.userId': Meteor.userId(),
+      type: 'board',
     }, {
       sort: ['title'],
     });

+ 8 - 0
client/components/boards/miniboard.jade

@@ -0,0 +1,8 @@
+template(name="miniboard")
+  .minicard(
+    class="minicard-{{colorClass}}")
+    .minicard-title
+      .handle
+        .fa.fa-arrows
+      +viewer
+        = title

+ 5 - 53
client/components/cards/cardDetails.js

@@ -456,26 +456,9 @@ BlazeComponent.extendComponent({
   },
 }).register('boardsAndLists');
 
-
-function cloneCheckList(_id, checklist) {
-  'use strict';
-  const checklistId = checklist._id;
-  checklist.cardId = _id;
-  checklist._id = null;
-  const newChecklistId = Checklists.insert(checklist);
-  ChecklistItems.find({checklistId}).forEach(function(item) {
-    item._id = null;
-    item.checklistId = newChecklistId;
-    item.cardId = _id;
-    ChecklistItems.insert(item);
-  });
-}
-
 Template.copyCardPopup.events({
   'click .js-done'() {
     const card = Cards.findOne(Session.get('currentCard'));
-    const oldId = card._id;
-    card._id = null;
     const lSelect = $('.js-select-lists')[0];
     card.listId = lSelect.options[lSelect.selectedIndex].value;
     const slSelect = $('.js-select-swimlanes')[0];
@@ -490,38 +473,13 @@ Template.copyCardPopup.events({
     if (title) {
       card.title = title;
       card.coverId = '';
-      const _id = Cards.insert(card);
+      const _id = card.copy();
       // In case the filter is active we need to add the newly inserted card in
       // the list of exceptions -- cards that are not filtered. Otherwise the
       // card will disappear instantly.
       // See https://github.com/wekan/wekan/issues/80
       Filter.addException(_id);
 
-      // copy checklists
-      let cursor = Checklists.find({cardId: oldId});
-      cursor.forEach(function() {
-        cloneCheckList(_id, arguments[0]);
-      });
-
-      // copy subtasks
-      cursor = Cards.find({parentId: oldId});
-      cursor.forEach(function() {
-        'use strict';
-        const subtask = arguments[0];
-        subtask.parentId = _id;
-        subtask._id = null;
-        /* const newSubtaskId = */ Cards.insert(subtask);
-      });
-
-      // copy card comments
-      cursor = CardComments.find({cardId: oldId});
-      cursor.forEach(function () {
-        'use strict';
-        const comment = arguments[0];
-        comment.cardId = _id;
-        comment._id = null;
-        CardComments.insert(comment);
-      });
       Popup.close();
     }
   },
@@ -558,9 +516,8 @@ Template.copyChecklistToManyCardsPopup.events({
         Filter.addException(_id);
 
         // copy checklists
-        let cursor = Checklists.find({cardId: oldId});
-        cursor.forEach(function() {
-          cloneCheckList(_id, arguments[0]);
+        Checklists.find({cardId: oldId}).forEach((ch) => {
+          ch.copy(_id);
         });
 
         // copy subtasks
@@ -574,13 +531,8 @@ Template.copyChecklistToManyCardsPopup.events({
         });
 
         // copy card comments
-        cursor = CardComments.find({cardId: oldId});
-        cursor.forEach(function () {
-          'use strict';
-          const comment = arguments[0];
-          comment.cardId = _id;
-          comment._id = null;
-          CardComments.insert(comment);
+        CardComments.find({cardId: oldId}).forEach((cmt) => {
+          cmt.copy(_id);
         });
       }
       Popup.close();

+ 42 - 22
client/components/lists/listBody.jade

@@ -44,13 +44,19 @@ template(name="addCardForm")
 
   .add-controls.clearfix
     button.primary.confirm(type="submit") {{_ 'add'}}
-    span.quiet
-      | {{_ 'or'}}
-      a.js-link {{_ 'link'}}
-    span.quiet
-      |  
-      | /
-      a.js-search {{_ 'search'}}
+    unless currentBoard.isTemplatesBoard
+      unless currentBoard.isTemplateBoard
+        span.quiet
+          | {{_ 'or'}}
+          a.js-link {{_ 'link'}}
+        span.quiet
+          |  
+          | /
+          a.js-search {{_ 'search'}}
+        span.quiet
+          |  
+          | /
+          a.js-card-template {{_ 'template'}}
 
 template(name="autocompleteLabelLine")
   .minicard-label(class="card-label-{{colorName}}" title=labelName)
@@ -60,11 +66,9 @@ template(name="linkCardPopup")
   label {{_ 'boards'}}:
   .link-board-wrapper
     select.js-select-boards
+      option(value="")
       each boards
-        if $eq _id currentBoard._id
-          option(value="{{_id}}" selected) {{_ 'current'}}
-        else
-          option(value="{{_id}}") {{title}}
+        option(value="{{_id}}") {{title}}
     input.primary.confirm.js-link-board(type="button" value="{{_ 'link'}}")
 
   label {{_ 'swimlanes'}}:
@@ -85,19 +89,35 @@ template(name="linkCardPopup")
   .edit-controls.clearfix
     input.primary.confirm.js-done(type="button" value="{{_ 'link'}}")
 
-template(name="searchCardPopup")
-  label {{_ 'boards'}}:
-  .link-board-wrapper
-    select.js-select-boards
-      each boards
-        if $eq _id currentBoard._id
-          option(value="{{_id}}" selected) {{_ 'current'}}
-        else
+template(name="searchElementPopup")
+  unless isTemplateSearch
+    label {{_ 'boards'}}:
+    .link-board-wrapper
+      select.js-select-boards
+        option(value="")
+        each boards
           option(value="{{_id}}") {{title}}
   form.js-search-term-form
     input(type="text" name="searchTerm" placeholder="{{_ 'search-example'}}" autofocus)
   .list-body.js-perfect-scrollbar.search-card-results
     .minicards.clearfix.js-minicards
-      each results
-        a.minicard-wrapper.js-minicard
-          +minicard(this)
+      if isBoardTemplateSearch
+        each results
+          a.minicard-wrapper.js-minicard
+            +miniboard(this)
+      if isListTemplateSearch
+        each results
+          a.minicard-wrapper.js-minicard
+            +minilist(this)
+      if isSwimlaneTemplateSearch
+        each results
+          a.minicard-wrapper.js-minicard
+            +miniswimlane(this)
+      if isCardTemplateSearch
+        each results
+          a.minicard-wrapper.js-minicard
+            +minicard(this)
+      unless isTemplateSearch
+        each results
+          a.minicard-wrapper.js-minicard
+            +minicard(this)

+ 138 - 63
client/components/lists/listBody.js

@@ -67,25 +67,47 @@ BlazeComponent.extendComponent({
     const labelIds = formComponent.labels.get();
     const customFields = formComponent.customFields.get();
 
-    const boardId = this.data().board();
+    const board = this.data().board();
+    let linkedId = '';
     let swimlaneId = '';
     const boardView = Meteor.user().profile.boardView;
-    if (boardView === 'board-view-swimlanes')
-      swimlaneId = this.parentComponent().parentComponent().data()._id;
-    else if ((boardView === 'board-view-lists') || (boardView === 'board-view-cal'))
-      swimlaneId = boardId.getDefaultSwimline()._id;
-
+    let cardType = 'cardType-card';
     if (title) {
+      if (board.isTemplatesBoard()) {
+        swimlaneId = this.parentComponent().parentComponent().data()._id; // Always swimlanes view
+        const swimlane = Swimlanes.findOne(swimlaneId);
+        // If this is the card templates swimlane, insert a card template
+        if (swimlane.isCardTemplatesSwimlane())
+          cardType = 'template-card';
+        // If this is the board templates swimlane, insert a board template and a linked card
+        else if (swimlane.isBoardTemplatesSwimlane()) {
+          linkedId = Boards.insert({
+            title,
+            permission: 'private',
+            type: 'template-board',
+          });
+          Swimlanes.insert({
+            title: TAPi18n.__('default'),
+            boardId: linkedId,
+          });
+          cardType = 'cardType-linkedBoard';
+        }
+      } else if (boardView === 'board-view-swimlanes')
+        swimlaneId = this.parentComponent().parentComponent().data()._id;
+      else if ((boardView === 'board-view-lists') || (boardView === 'board-view-cal'))
+        swimlaneId = board.getDefaultSwimline()._id;
+
       const _id = Cards.insert({
         title,
         members,
         labelIds,
         customFields,
         listId: this.data()._id,
-        boardId: boardId._id,
+        boardId: board._id,
         sort: sortIndex,
         swimlaneId,
-        type: 'cardType-card',
+        type: cardType,
+        linkedId,
       });
 
       // if the displayed card count is less than the total cards in the list,
@@ -127,9 +149,9 @@ BlazeComponent.extendComponent({
       const methodName = evt.shiftKey ? 'toggleRange' : 'toggle';
       MultiSelection[methodName](this.currentData()._id);
 
-    // If the card is already selected, we want to de-select it.
-    // XXX We should probably modify the minicard href attribute instead of
-    // overwriting the event in case the card is already selected.
+      // If the card is already selected, we want to de-select it.
+      // XXX We should probably modify the minicard href attribute instead of
+      // overwriting the event in case the card is already selected.
     } else if (Session.equals('currentCard', this.currentData()._id)) {
       evt.stopImmediatePropagation();
       evt.preventDefault();
@@ -149,7 +171,8 @@ BlazeComponent.extendComponent({
 
   idOrNull(swimlaneId) {
     const currentUser = Meteor.user();
-    if (currentUser.profile.boardView === 'board-view-swimlanes')
+    if (currentUser.profile.boardView === 'board-view-swimlanes' ||
+        this.data().board().isTemplatesBoard())
       return swimlaneId;
     return undefined;
   },
@@ -269,8 +292,8 @@ BlazeComponent.extendComponent({
       // work.
       $form.find('button[type=submit]').click();
 
-    // Pressing Tab should open the form of the next column, and Maj+Tab go
-    // in the reverse order
+      // Pressing Tab should open the form of the next column, and Maj+Tab go
+      // in the reverse order
     } else if (evt.keyCode === 9) {
       evt.preventDefault();
       const isReverse = evt.shiftKey;
@@ -292,7 +315,8 @@ BlazeComponent.extendComponent({
     return [{
       keydown: this.pressKey,
       'click .js-link': Popup.open('linkCard'),
-      'click .js-search': Popup.open('searchCard'),
+      'click .js-search': Popup.open('searchElement'),
+      'click .js-card-template': Popup.open('searchElement'),
     }];
   },
 
@@ -330,7 +354,7 @@ BlazeComponent.extendComponent({
           const currentBoard = Boards.findOne(Session.get('currentBoard'));
           callback($.map(currentBoard.labels, (label) => {
             if (label.name.indexOf(term) > -1 ||
-                label.color.indexOf(term) > -1) {
+              label.color.indexOf(term) > -1) {
               return label;
             }
             return null;
@@ -367,17 +391,7 @@ BlazeComponent.extendComponent({
 
 BlazeComponent.extendComponent({
   onCreated() {
-    // Prefetch first non-current board id
-    const boardId = Boards.findOne({
-      archived: false,
-      'members.userId': Meteor.userId(),
-      _id: {$ne: Session.get('currentBoard')},
-    }, {
-      sort: ['title'],
-    })._id;
-    // Subscribe to this board
-    subManager.subscribe('board', boardId);
-    this.selectedBoardId = new ReactiveVar(boardId);
+    this.selectedBoardId = new ReactiveVar('');
     this.selectedSwimlaneId = new ReactiveVar('');
     this.selectedListId = new ReactiveVar('');
 
@@ -403,6 +417,7 @@ BlazeComponent.extendComponent({
       archived: false,
       'members.userId': Meteor.userId(),
       _id: {$ne: Session.get('currentBoard')},
+      type: 'board',
     }, {
       sort: ['title'],
     });
@@ -410,7 +425,7 @@ BlazeComponent.extendComponent({
   },
 
   swimlanes() {
-    if (!this.selectedBoardId) {
+    if (!this.selectedBoardId.get()) {
       return [];
     }
     const swimlanes = Swimlanes.find({boardId: this.selectedBoardId.get()});
@@ -420,7 +435,7 @@ BlazeComponent.extendComponent({
   },
 
   lists() {
-    if (!this.selectedBoardId) {
+    if (!this.selectedBoardId.get()) {
       return [];
     }
     const lists = Lists.find({boardId: this.selectedBoardId.get()});
@@ -441,6 +456,7 @@ BlazeComponent.extendComponent({
       archived: false,
       linkedId: {$nin: ownCardsIds},
       _id: {$nin: ownCardsIds},
+      type: {$nin: ['template-card']},
     });
   },
 
@@ -508,12 +524,25 @@ BlazeComponent.extendComponent({
   },
 
   onCreated() {
-    // Prefetch first non-current board id
-    let board = Boards.findOne({
-      archived: false,
-      'members.userId': Meteor.userId(),
-      _id: {$ne: Session.get('currentBoard')},
-    });
+    this.isCardTemplateSearch = $(Popup._getTopStack().openerElement).hasClass('js-card-template');
+    this.isListTemplateSearch = $(Popup._getTopStack().openerElement).hasClass('js-list-template');
+    this.isSwimlaneTemplateSearch = $(Popup._getTopStack().openerElement).hasClass('js-open-add-swimlane-menu');
+    this.isBoardTemplateSearch = $(Popup._getTopStack().openerElement).hasClass('js-add-board');
+    this.isTemplateSearch = this.isCardTemplateSearch ||
+      this.isListTemplateSearch ||
+      this.isSwimlaneTemplateSearch ||
+      this.isBoardTemplateSearch;
+    let board = {};
+    if (this.isTemplateSearch) {
+      board = Boards.findOne(Meteor.user().profile.templatesBoardId);
+    } else {
+      // Prefetch first non-current board id
+      board = Boards.findOne({
+        archived: false,
+        'members.userId': Meteor.userId(),
+        _id: {$nin: [Session.get('currentBoard'), Meteor.user().profile.templatesBoardId]},
+      });
+    }
     if (!board) {
       Popup.close();
       return;
@@ -523,20 +552,21 @@ BlazeComponent.extendComponent({
     subManager.subscribe('board', boardId);
     this.selectedBoardId = new ReactiveVar(boardId);
 
-    this.boardId = Session.get('currentBoard');
-    // In order to get current board info
-    subManager.subscribe('board', this.boardId);
-    board = Boards.findOne(this.boardId);
-    // List where to insert card
-    const list = $(Popup._getTopStack().openerElement).closest('.js-list');
-    this.listId = Blaze.getData(list[0])._id;
-    // Swimlane where to insert card
-    const swimlane = $(Popup._getTopStack().openerElement).closest('.js-swimlane');
-    this.swimlaneId = '';
-    if (board.view === 'board-view-swimlanes')
-      this.swimlaneId = Blaze.getData(swimlane[0])._id;
-    else
-      this.swimlaneId = Swimlanes.findOne({boardId: this.boardId})._id;
+    if (!this.isBoardTemplateSearch) {
+      this.boardId = Session.get('currentBoard');
+      // In order to get current board info
+      subManager.subscribe('board', this.boardId);
+      this.swimlaneId = '';
+      // Swimlane where to insert card
+      const swimlane = $(Popup._getTopStack().openerElement).parents('.js-swimlane');
+      if (Meteor.user().profile.boardView === 'board-view-swimlanes')
+        this.swimlaneId = Blaze.getData(swimlane[0])._id;
+      else
+        this.swimlaneId = Swimlanes.findOne({boardId: this.boardId})._id;
+      // List where to insert card
+      const list = $(Popup._getTopStack().openerElement).closest('.js-list');
+      this.listId = Blaze.getData(list[0])._id;
+    }
     this.term = new ReactiveVar('');
   },
 
@@ -545,6 +575,7 @@ BlazeComponent.extendComponent({
       archived: false,
       'members.userId': Meteor.userId(),
       _id: {$ne: Session.get('currentBoard')},
+      type: 'board',
     }, {
       sort: ['title'],
     });
@@ -556,7 +587,21 @@ BlazeComponent.extendComponent({
       return [];
     }
     const board = Boards.findOne(this.selectedBoardId.get());
-    return board.searchCards(this.term.get(), false);
+    if (!this.isTemplateSearch || this.isCardTemplateSearch) {
+      return board.searchCards(this.term.get(), false);
+    } else if (this.isListTemplateSearch) {
+      return board.searchLists(this.term.get());
+    } else if (this.isSwimlaneTemplateSearch) {
+      return board.searchSwimlanes(this.term.get());
+    } else if (this.isBoardTemplateSearch) {
+      const boards = board.searchBoards(this.term.get());
+      boards.forEach((board) => {
+        subManager.subscribe('board', board.linkedId);
+      });
+      return boards;
+    } else {
+      return [];
+    }
   },
 
   events() {
@@ -570,20 +615,50 @@ BlazeComponent.extendComponent({
         this.term.set(evt.target.searchTerm.value);
       },
       'click .js-minicard'(evt) {
-        // LINK CARD
-        const card = Blaze.getData(evt.currentTarget);
-        const _id = Cards.insert({
-          title: card.title, //dummy
-          listId: this.listId,
-          swimlaneId: this.swimlaneId,
-          boardId: this.boardId,
-          sort: Lists.findOne(this.listId).cards().count(),
-          type: 'cardType-linkedCard',
-          linkedId: card.linkedId || card._id,
-        });
-        Filter.addException(_id);
+        // 0. Common
+        const element = Blaze.getData(evt.currentTarget);
+        let _id = '';
+        if (!this.isTemplateSearch || this.isCardTemplateSearch) {
+          // Card insertion
+          // 1. Common
+          element.boardId = this.boardId;
+          element.listId = this.listId;
+          element.swimlaneId = this.swimlaneId;
+          element.sort = Lists.findOne(this.listId).cards().count();
+          // 1.A From template
+          if (this.isTemplateSearch) {
+            element.type = 'cardType-card';
+            element.linkedId = '';
+            _id = element.copy();
+            // 1.B Linked card
+          } else {
+            delete element._id;
+            element.type = 'cardType-linkedCard';
+            element.linkedId = element.linkedId || element._id;
+            _id = Cards.insert(element);
+          }
+          Filter.addException(_id);
+          // List insertion
+        } else if (this.isListTemplateSearch) {
+          element.boardId = this.boardId;
+          element.sort = Swimlanes.findOne(this.swimlaneId).lists().count();
+          element.type = 'list';
+          _id = element.copy(this.swimlaneId);
+        } else if (this.isSwimlaneTemplateSearch) {
+          element.boardId = this.boardId;
+          element.sort = Boards.findOne(this.boardId).swimlanes().count();
+          element.type = 'swimlalne';
+          _id = element.copy();
+        } else if (this.isBoardTemplateSearch) {
+          board = Boards.findOne(element.linkedId);
+          board.sort = Boards.find({archived: false}).count();
+          board.type = 'board';
+          delete board.slug;
+          delete board.members;
+          _id = board.copy();
+        }
         Popup.close();
       },
     }];
   },
-}).register('searchCardPopup');
+}).register('searchElementPopup');

+ 8 - 0
client/components/lists/minilist.jade

@@ -0,0 +1,8 @@
+template(name="minilist")
+  .minicard(
+    class="minicard-{{colorClass}}")
+    .minicard-title
+      .handle
+        .fa.fa-arrows
+      +viewer
+        = title

+ 3 - 1
client/components/main/editor.js

@@ -36,7 +36,10 @@ import sanitizeXss from 'xss';
 const at = HTML.CharRef({html: '@', str: '@'});
 Blaze.Template.registerHelper('mentions', new Template('mentions', function() {
   const view = this;
+  let content = Blaze.toHTML(view.templateContentBlock);
   const currentBoard = Boards.findOne(Session.get('currentBoard'));
+  if (!currentBoard)
+    return HTML.Raw(sanitizeXss(content));
   const knowedUsers = currentBoard.members.map((member) => {
     const u = Users.findOne(member.userId);
     if(u){
@@ -45,7 +48,6 @@ Blaze.Template.registerHelper('mentions', new Template('mentions', function() {
     return member;
   });
   const mentionRegex = /\B@([\w.]*)/gi;
-  let content = Blaze.toHTML(view.templateContentBlock);
 
   let currentMention;
   while ((currentMention = mentionRegex.exec(content)) !== null) {

+ 8 - 0
client/components/swimlanes/miniswimlane.jade

@@ -0,0 +1,8 @@
+template(name="miniswimlane")
+  .minicard(
+    class="minicard-{{colorClass}}")
+    .minicard-title
+      .handle
+        .fa.fa-arrows
+      +viewer
+        = title

+ 24 - 12
client/components/swimlanes/swimlaneHeader.jade

@@ -1,15 +1,21 @@
 template(name="swimlaneHeader")
   .swimlane-header-wrap.js-swimlane-header(class='{{#if colorClass}}swimlane-{{colorClass}}{{/if}}')
-    +inlinedForm
-      +editSwimlaneTitleForm
+    if this.isTemplateContainer
+        +swimlaneFixedHeader(this)
     else
-      .swimlane-header(
-        class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
-        = title
-      .swimlane-header-menu
-        unless currentUser.isCommentOnly
-          a.fa.fa-plus.js-open-add-swimlane-menu.swimlane-header-plus-icon
-          a.fa.fa-navicon.js-open-swimlane-menu
+      +inlinedForm
+        +editSwimlaneTitleForm
+      else
+        +swimlaneFixedHeader(this)
+
+template(name="swimlaneFixedHeader")
+  .swimlane-header(
+    class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}")
+    = title
+  .swimlane-header-menu
+    unless currentUser.isCommentOnly
+      a.fa.fa-plus.js-open-add-swimlane-menu.swimlane-header-plus-icon
+      a.fa.fa-navicon.js-open-swimlane-menu
 
 template(name="editSwimlaneTitleForm")
   .list-composer
@@ -22,9 +28,10 @@ template(name="swimlaneActionPopup")
   unless currentUser.isCommentOnly
     ul.pop-over-list
        li: a.js-set-swimlane-color {{_ 'select-color'}}
-    hr
-    ul.pop-over-list
-      li: a.js-close-swimlane {{_ 'archive-swimlane'}}
+    unless this.isTemplateContainer
+      hr
+      ul.pop-over-list
+        li: a.js-close-swimlane {{_ 'archive-swimlane'}}
 
 template(name="swimlaneAddPopup")
   unless currentUser.isCommentOnly
@@ -33,6 +40,11 @@ template(name="swimlaneAddPopup")
         autocomplete="off" autofocus)
       .edit-controls.clearfix
         button.primary.confirm(type="submit") {{_ 'add'}}
+        unless currentBoard.isTemplatesBoard
+          unless currentBoard.isTemplateBoard
+            span.quiet
+              | {{_ 'or'}}
+              a.js-swimlane-template {{_ 'template'}}
 
 template(name="setSwimlaneColorPopup")
   form.edit-label

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

@@ -47,12 +47,14 @@ BlazeComponent.extendComponent({
         const titleInput = this.find('.swimlane-name-input');
         const title = titleInput.value.trim();
         const sortValue = calculateIndexData(this.currentSwimlane, nextSwimlane, 1);
+        const swimlaneType = (currentBoard.isTemplatesBoard())?'template-swimlane':'swimlane';
 
         if (title) {
           Swimlanes.insert({
             title,
             boardId: Session.get('currentBoard'),
             sort: sortValue.base,
+            type: swimlaneType,
           });
 
           titleInput.value = '';
@@ -63,6 +65,7 @@ BlazeComponent.extendComponent({
         // with a minimum of interactions
         Popup.close();
       },
+      'click .js-swimlane-template': Popup.open('searchElement'),
     }];
   },
 }).register('swimlaneAddPopup');

+ 11 - 7
client/components/swimlanes/swimlanes.jade

@@ -3,15 +3,15 @@ template(name="swimlane")
     +swimlaneHeader
   .swimlane.js-lists.js-swimlane
     if isMiniScreen
-      if currentList
+      if currentListIsInThisSwimlane _id
         +list(currentList)
-      else
-        each currentBoard.lists
+      unless currentList
+        each lists
           +miniList(this)
         if currentUser.isBoardMember
           +addListForm
     else
-      each currentBoard.lists
+      each lists
         +list(this)
         if currentCardIsInThisList _id ../_id
           +cardDetails(currentCard)
@@ -24,12 +24,12 @@ template(name="listsGroup")
       if currentList
         +list(currentList)
       else
-        each currentBoard.lists
+        each lists
           +miniList(this)
         if currentUser.isBoardMember
           +addListForm
     else
-      each currentBoard.lists
+      each lists
         +list(this)
         if currentCardIsInThisList _id null
           +cardDetails(currentCard)
@@ -44,7 +44,11 @@ template(name="addListForm")
           autocomplete="off" autofocus)
         .edit-controls.clearfix
           button.primary.confirm(type="submit") {{_ 'save'}}
-          a.fa.fa-times-thin.js-close-inlined-form
+          unless currentBoard.isTemplatesBoard
+            unless currentBoard.isTemplateBoard
+              span.quiet
+                | {{_ 'or'}}
+                a.js-list-template {{_ 'template'}}
       else
         a.open-list-composer.js-open-inlined-form
           i.fa.fa-plus

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

@@ -1,5 +1,10 @@
 const { calculateIndex, enableClickOnTouch } = Utils;
 
+function currentListIsInThisSwimlane(swimlaneId) {
+  const currentList = Lists.findOne(Session.get('currentList'));
+  return currentList && (currentList.swimlaneId === swimlaneId || currentList.swimlaneId === '');
+}
+
 function currentCardIsInThisList(listId, swimlaneId) {
   const currentCard = Cards.findOne(Session.get('currentCard'));
   const currentUser = Meteor.user();
@@ -114,6 +119,10 @@ BlazeComponent.extendComponent({
     return currentCardIsInThisList(listId, swimlaneId);
   },
 
+  currentListIsInThisSwimlane(swimlaneId) {
+    return currentListIsInThisSwimlane(swimlaneId);
+  },
+
   events() {
     return [{
       // Click-and-drag action
@@ -153,6 +162,12 @@ BlazeComponent.extendComponent({
 }).register('swimlane');
 
 BlazeComponent.extendComponent({
+  onCreated() {
+    this.currentBoard = Boards.findOne(Session.get('currentBoard'));
+    this.isListTemplatesSwimlane = this.currentBoard.isTemplatesBoard() && this.currentData().isListTemplatesSwimlane();
+    this.currentSwimlane = this.currentData();
+  },
+
   // Proxy
   open() {
     this.childComponents('inlinedForm')[0].open();
@@ -169,12 +184,15 @@ BlazeComponent.extendComponent({
             title,
             boardId: Session.get('currentBoard'),
             sort: $('.list').length,
+            type: (this.isListTemplatesSwimlane)?'template-list':'list',
+            swimlaneId: (this.currentBoard.isTemplatesBoard())?this.currentSwimlane._id:'',
           });
 
           titleInput.value = '';
           titleInput.focus();
         }
       },
+      'click .js-list-template': Popup.open('searchElement'),
     }];
   },
 }).register('addListForm');

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

@@ -21,6 +21,9 @@ template(name="memberMenuPopup")
         li: a.js-change-language {{_ 'changeLanguagePopup-title'}}
     if currentUser.isAdmin
       li: a.js-go-setting(href="{{pathFor 'setting'}}") {{_ 'admin-panel'}}
+  hr
+  ul.pop-over-list
+    li: a(href="{{pathFor 'board' id=templatesBoardId slug=templatesBoardSlug}}") {{_ 'templates'}}
   unless isSandstorm
     hr
     ul.pop-over-list

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

@@ -3,6 +3,15 @@ Template.headerUserBar.events({
   'click .js-change-avatar': Popup.open('changeAvatar'),
 });
 
+Template.memberMenuPopup.helpers({
+  templatesBoardId() {
+    return Meteor.user().getTemplatesBoardId();
+  },
+  templatesBoardSlug() {
+    return Meteor.user().getTemplatesBoardSlug();
+  },
+});
+
 Template.memberMenuPopup.events({
   'click .js-edit-profile': Popup.open('editProfile'),
   'click .js-change-settings': Popup.open('changeSettings'),

+ 6 - 0
i18n/en.i18n.json

@@ -92,6 +92,8 @@
     "restore-board": "Restore Board",
     "no-archived-boards": "No Boards in Archive.",
     "archives": "Archive",
+    "template": "Template",
+    "templates": "Templates",
     "assign-member": "Assign member",
     "attached": "attached",
     "attachment": "Attachment",
@@ -143,6 +145,7 @@
     "cardLabelsPopup-title": "Labels",
     "cardMembersPopup-title": "Members",
     "cardMorePopup-title": "More",
+    "cardTemplatePopup-title": "Create template",
     "cards": "Cards",
     "cards-count": "Cards",
     "casSignIn" : "Sign In with CAS",
@@ -453,6 +456,9 @@
     "welcome-swimlane": "Milestone 1",
     "welcome-list1": "Basics",
     "welcome-list2": "Advanced",
+    "card-templates-swimlane": "Card Templates",
+    "list-templates-swimlane": "List Templates",
+    "board-templates-swimlane": "Board Templates",
     "what-to-do": "What do you want to do?",
     "wipLimitErrorPopup-title": "Invalid WIP Limit",
     "wipLimitErrorPopup-dialog-pt1": "The number of tasks in this list is higher than the WIP limit you've defined.",

+ 6 - 6
i18n/ru.i18n.json

@@ -92,8 +92,8 @@
     "restore-board": "Востановить доску",
     "no-archived-boards": "Нет досок в архиве.",
     "archives": "Архив",
-    "template": "Template",
-    "templates": "Templates",
+    "template": "Шаблон",
+    "templates": "Шаблоны",
     "assign-member": "Назначить участника",
     "attached": "прикреплено",
     "attachment": "Вложение",
@@ -145,7 +145,7 @@
     "cardLabelsPopup-title": "Метки",
     "cardMembersPopup-title": "Участники",
     "cardMorePopup-title": "Поделиться",
-    "cardTemplatePopup-title": "Create template",
+    "cardTemplatePopup-title": "Создать шаблон",
     "cards": "Карточки",
     "cards-count": "Карточки",
     "casSignIn": "Войти через CAS",
@@ -456,9 +456,9 @@
     "welcome-swimlane": "Этап 1",
     "welcome-list1": "Основы",
     "welcome-list2": "Расширенно",
-    "card-templates-swimlane": "Card Templates",
-    "list-templates-swimlane": "List Templates",
-    "board-templates-swimlane": "Board Templates",
+    "card-templates-swimlane": "Шаблоны карточек",
+    "list-templates-swimlane": "Шаблоны списков",
+    "board-templates-swimlane": "Шаблоны досок",
     "what-to-do": "Что вы хотите сделать?",
     "wipLimitErrorPopup-title": "Некорректный лимит на кол-во задач",
     "wipLimitErrorPopup-dialog-pt1": "Количество задач в этом списке превышает установленный вами лимит",

+ 107 - 3
models/boards.js

@@ -304,10 +304,32 @@ Boards.attachSchema(new SimpleSchema({
     defaultValue: false,
     optional: true,
   },
+  type: {
+    /**
+     * The type of board
+     */
+    type: String,
+    defaultValue: 'board',
+  },
 }));
 
 
 Boards.helpers({
+  copy() {
+    const oldId = this._id;
+    delete this._id;
+    const _id = Boards.insert(this);
+
+    // Copy all swimlanes in board
+    Swimlanes.find({
+      boardId: oldId,
+      archived: false,
+    }).forEach((swimlane) => {
+      swimlane.type = 'swimlane';
+      swimlane.boardId = _id;
+      swimlane.copy(oldId);
+    });
+  },
   /**
    * Is supplied user authorized to view this board?
    */
@@ -456,6 +478,75 @@ Boards.helpers({
     return _id;
   },
 
+  searchBoards(term) {
+    check(term, Match.OneOf(String, null, undefined));
+
+    const query = { boardId: this._id };
+    query.type = 'cardType-linkedBoard';
+    query.archived = false;
+
+    const projection = { limit: 10, sort: { createdAt: -1 } };
+
+    if (term) {
+      const regex = new RegExp(term, 'i');
+
+      query.$or = [
+        { title: regex },
+        { description: regex },
+      ];
+    }
+
+    return Cards.find(query, projection);
+  },
+
+  searchSwimlanes(term) {
+    check(term, Match.OneOf(String, null, undefined));
+
+    const query = { boardId: this._id };
+    if (this.isTemplatesBoard()) {
+      query.type = 'template-swimlane';
+      query.archived = false;
+    } else {
+      query.type = {$nin: ['template-swimlane']};
+    }
+    const projection = { limit: 10, sort: { createdAt: -1 } };
+
+    if (term) {
+      const regex = new RegExp(term, 'i');
+
+      query.$or = [
+        { title: regex },
+        { description: regex },
+      ];
+    }
+
+    return Swimlanes.find(query, projection);
+  },
+
+  searchLists(term) {
+    check(term, Match.OneOf(String, null, undefined));
+
+    const query = { boardId: this._id };
+    if (this.isTemplatesBoard()) {
+      query.type = 'template-list';
+      query.archived = false;
+    } else {
+      query.type = {$nin: ['template-list']};
+    }
+    const projection = { limit: 10, sort: { createdAt: -1 } };
+
+    if (term) {
+      const regex = new RegExp(term, 'i');
+
+      query.$or = [
+        { title: regex },
+        { description: regex },
+      ];
+    }
+
+    return Lists.find(query, projection);
+  },
+
   searchCards(term, excludeLinked) {
     check(term, Match.OneOf(String, null, undefined));
 
@@ -463,6 +554,12 @@ Boards.helpers({
     if (excludeLinked) {
       query.linkedId = null;
     }
+    if (this.isTemplatesBoard()) {
+      query.type = 'template-card';
+      query.archived = false;
+    } else {
+      query.type = {$nin: ['template-card']};
+    }
     const projection = { limit: 10, sort: { createdAt: -1 } };
 
     if (term) {
@@ -559,6 +656,13 @@ Boards.helpers({
     });
   },
 
+  isTemplateBoard() {
+    return this.type === 'template-board';
+  },
+
+  isTemplatesBoard() {
+    return this.type === 'template-container';
+  },
 });
 
 
@@ -907,7 +1011,7 @@ if (Meteor.isServer) {
    * @param {string} userId the ID of the user to retrieve the data
    * @return_type [{_id: string,
                     title: string}]
-   */
+                    */
   JsonRoutes.add('GET', '/api/users/:userId/boards', function (req, res) {
     try {
       Authentication.checkLoggedIn(req.userId);
@@ -944,7 +1048,7 @@ if (Meteor.isServer) {
    *
    * @return_type [{_id: string,
                     title: string}]
-   */
+                    */
   JsonRoutes.add('GET', '/api/boards', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);
@@ -1015,7 +1119,7 @@ if (Meteor.isServer) {
    *
    * @return_type {_id: string,
                    defaultSwimlaneId: string}
-   */
+                   */
   JsonRoutes.add('POST', '/api/boards', function (req, res) {
     try {
       Authentication.checkUserId(req.userId);

+ 6 - 0
models/cardComments.js

@@ -67,6 +67,12 @@ CardComments.allow({
 });
 
 CardComments.helpers({
+  copy(newCardId) {
+    this.cardId = newCardId;
+    delete this._id;
+    CardComments.insert(this);
+  },
+
   user() {
     return Users.findOne(this.userId);
   },

+ 31 - 2
models/cards.js

@@ -246,7 +246,7 @@ Cards.attachSchema(new SimpleSchema({
      * type of the card
      */
     type: String,
-    defaultValue: '',
+    defaultValue: 'cardType-card',
   },
   linkedId: {
     /**
@@ -272,6 +272,31 @@ Cards.allow({
 });
 
 Cards.helpers({
+  copy() {
+    const oldId = this._id;
+    delete this._id;
+    const _id = Cards.insert(this);
+
+    // copy checklists
+    Checklists.find({cardId: oldId}).forEach((ch) => {
+      ch.copy(_id);
+    });
+
+    // copy subtasks
+    Cards.find({parentId: oldId}).forEach((subtask) => {
+      subtask.parentId = _id;
+      subtask._id = null;
+      Cards.insert(subtask);
+    });
+
+    // copy card comments
+    CardComments.find({cardId: oldId}).forEach((cmt) => {
+      cmt.copy(_id);
+    });
+
+    return _id;
+  },
+
   list() {
     return Lists.findOne(this.listId);
   },
@@ -930,6 +955,10 @@ Cards.helpers({
       return this.assignedBy;
     }
   },
+
+  isTemplateCard() {
+    return this.type === 'template-card';
+  },
 });
 
 Cards.mutations({
@@ -1230,7 +1259,7 @@ Cards.mutations({
 
 function cardMove(userId, doc, fieldNames, oldListId, oldSwimlaneId) {
   if ((_.contains(fieldNames, 'listId') && doc.listId !== oldListId) ||
-      (_.contains(fieldNames, 'swimlaneId') && doc.swimlaneId !== oldSwimlaneId)){
+    (_.contains(fieldNames, 'swimlaneId') && doc.swimlaneId !== oldSwimlaneId)){
     Activities.insert({
       userId,
       oldListId,

+ 13 - 0
models/checklists.js

@@ -48,6 +48,19 @@ Checklists.attachSchema(new SimpleSchema({
 }));
 
 Checklists.helpers({
+  copy(newCardId) {
+    const oldChecklistId = this._id;
+    this._id = null;
+    this.cardId = newCardId;
+    const newChecklistId = Checklists.insert(this);
+    ChecklistItems.find({checklistId: oldChecklistId}).forEach((item) => {
+      item._id = null;
+      item.checklistId = newChecklistId;
+      item.cardId = newCardId;
+      ChecklistItems.insert(item);
+    });
+  },
+
   itemCount() {
     return ChecklistItems.find({ checklistId: this._id }).count();
   },

+ 59 - 0
models/lists.js

@@ -27,6 +27,13 @@ Lists.attachSchema(new SimpleSchema({
      */
     type: String,
   },
+  swimlaneId: {
+    /**
+     * the swimlane associated to this list. Used for templates
+     */
+    type: String,
+    defaultValue: '',
+  },
   createdAt: {
     /**
      * creation date
@@ -107,6 +114,13 @@ Lists.attachSchema(new SimpleSchema({
       'saddlebrown', 'paleturquoise', 'mistyrose', 'indigo',
     ],
   },
+  type: {
+    /**
+     * The type of list
+     */
+    type: String,
+    defaultValue: 'list',
+  },
 }));
 
 Lists.allow({
@@ -123,6 +137,37 @@ Lists.allow({
 });
 
 Lists.helpers({
+  copy(swimlaneId) {
+    const oldId = this._id;
+    const oldSwimlaneId = this.swimlaneId || null;
+    let _id = null;
+    existingListWithSameName = Lists.findOne({
+      boardId: this.boardId,
+      title: this.title,
+      archived: false,
+    });
+    if (existingListWithSameName) {
+      _id = existingListWithSameName._id;
+    } else {
+      delete this._id;
+      delete this.swimlaneId;
+      _id = Lists.insert(this);
+    }
+
+    // Copy all cards in list
+    Cards.find({
+      swimlaneId: oldSwimlaneId,
+      listId: oldId,
+      archived: false,
+    }).forEach((card) => {
+      card.type = 'cardType-card';
+      card.listId = _id;
+      card.boardId = this.boardId;
+      card.swimlaneId = swimlaneId;
+      card.copy();
+    });
+  },
+
   cards(swimlaneId) {
     const selector = {
       listId: this._id,
@@ -169,6 +214,10 @@ Lists.helpers({
       return this.color;
     return '';
   },
+
+  isTemplateList() {
+    return this.type === 'template-list';
+  },
 });
 
 Lists.mutations({
@@ -177,10 +226,20 @@ Lists.mutations({
   },
 
   archive() {
+    if (this.isTemplateList()) {
+      this.cards().forEach((card) => {
+        return card.archive();
+      });
+    }
     return { $set: { archived: true } };
   },
 
   restore() {
+    if (this.isTemplateList()) {
+      this.allCards().forEach((card) => {
+        return card.restore();
+      });
+    }
     return { $set: { archived: false } };
   },
 

+ 74 - 0
models/swimlanes.js

@@ -78,6 +78,13 @@ Swimlanes.attachSchema(new SimpleSchema({
       }
     },
   },
+  type: {
+    /**
+     * The type of swimlane
+     */
+    type: String,
+    defaultValue: 'swimlane',
+  },
 }));
 
 Swimlanes.allow({
@@ -94,6 +101,28 @@ Swimlanes.allow({
 });
 
 Swimlanes.helpers({
+  copy(oldBoardId) {
+    const oldId = this._id;
+    delete this._id;
+    const _id = Swimlanes.insert(this);
+
+    const query = {
+      swimlaneId: {$in: [oldId, '']},
+      archived: false,
+    };
+    if (oldBoardId) {
+      query.boardId = oldBoardId;
+    }
+
+    // Copy all lists in swimlane
+    Lists.find(query).forEach((list) => {
+      list.type = 'list';
+      list.swimlaneId = oldId;
+      list.boardId = this.boardId;
+      list.copy(_id);
+    });
+  },
+
   cards() {
     return Cards.find(Filter.mongoSelector({
       swimlaneId: this._id,
@@ -101,6 +130,18 @@ Swimlanes.helpers({
     }), { sort: ['sort'] });
   },
 
+  lists() {
+    return Lists.find(Filter.mongoSelector({
+      boardId: this.boardId,
+      swimlaneId: {$in: [this._id, '']},
+      archived: false,
+    }), { sort: ['sort'] });
+  },
+
+  allLists() {
+    return Lists.find({ swimlaneId: this._id });
+  },
+
   allCards() {
     return Cards.find({ swimlaneId: this._id });
   },
@@ -114,6 +155,29 @@ Swimlanes.helpers({
       return this.color;
     return '';
   },
+
+  isTemplateSwimlane() {
+    return this.type === 'template-swimlane';
+  },
+
+  isTemplateContainer() {
+    return this.type === 'template-container';
+  },
+
+  isListTemplatesSwimlane() {
+    const user = Users.findOne(Meteor.userId());
+    return user.profile.listTemplatesSwimlaneId === this._id;
+  },
+
+  isCardTemplatesSwimlane() {
+    const user = Users.findOne(Meteor.userId());
+    return user.profile.cardTemplatesSwimlaneId === this._id;
+  },
+
+  isBoardTemplatesSwimlane() {
+    const user = Users.findOne(Meteor.userId());
+    return user.profile.boardTemplatesSwimlaneId === this._id;
+  },
 });
 
 Swimlanes.mutations({
@@ -122,10 +186,20 @@ Swimlanes.mutations({
   },
 
   archive() {
+    if (this.isTemplateSwimlane()) {
+      this.lists().forEach((list) => {
+        return list.archive();
+      });
+    }
     return { $set: { archived: true } };
   },
 
   restore() {
+    if (this.isTemplateSwimlane()) {
+      this.allLists().forEach((list) => {
+        return list.restore();
+      });
+    }
     return { $set: { archived: false } };
   },
 

+ 84 - 2
models/users.js

@@ -159,6 +159,34 @@ Users.attachSchema(new SimpleSchema({
       'board-view-cal',
     ],
   },
+  'profile.templatesBoardId': {
+    /**
+     * Reference to the templates board
+     */
+    type: String,
+    defaultValue: '',
+  },
+  'profile.cardTemplatesSwimlaneId': {
+    /**
+     * Reference to the card templates swimlane Id
+     */
+    type: String,
+    defaultValue: '',
+  },
+  'profile.listTemplatesSwimlaneId': {
+    /**
+     * Reference to the list templates swimlane Id
+     */
+    type: String,
+    defaultValue: '',
+  },
+  'profile.boardTemplatesSwimlaneId': {
+    /**
+     * Reference to the board templates swimlane Id
+     */
+    type: String,
+    defaultValue: '',
+  },
   services: {
     /**
      * services field of the user
@@ -328,6 +356,14 @@ Users.helpers({
     const profile = this.profile || {};
     return profile.language || 'en';
   },
+
+  getTemplatesBoardId() {
+    return this.profile.templatesBoardId;
+  },
+
+  getTemplatesBoardSlug() {
+    return Boards.findOne(this.profile.templatesBoardId).slug;
+  },
 });
 
 Users.mutations({
@@ -675,7 +711,6 @@ if (Meteor.isServer) {
   CollectionHooks.getUserId = () => {
     return fakeUserId.get() || getUserId();
   };
-  /*
   if (!isSandstorm) {
     Users.after.insert((userId, doc) => {
       const fakeUser = {
@@ -685,6 +720,7 @@ if (Meteor.isServer) {
       };
 
       fakeUserId.withValue(doc._id, () => {
+      /*
         // Insert the Welcome Board
         Boards.insert({
           title: TAPi18n.__('welcome-board'),
@@ -701,10 +737,56 @@ if (Meteor.isServer) {
             Lists.insert({title: TAPi18n.__(title), boardId, sort: titleIndex}, fakeUser);
           });
         });
+        */
+
+        Boards.insert({
+          title: TAPi18n.__('templates'),
+          permission: 'private',
+          type: 'template-container',
+        }, fakeUser, (err, boardId) => {
+
+          // Insert the reference to our templates board
+          Users.update(fakeUserId.get(), {$set: {'profile.templatesBoardId': boardId}});
+
+          // Insert the card templates swimlane
+          Swimlanes.insert({
+            title: TAPi18n.__('card-templates-swimlane'),
+            boardId,
+            sort: 1,
+            type: 'template-container',
+          }, fakeUser, (err, swimlaneId) => {
+
+            // Insert the reference to out card templates swimlane
+            Users.update(fakeUserId.get(), {$set: {'profile.cardTemplatesSwimlaneId': swimlaneId}});
+          });
+
+          // Insert the list templates swimlane
+          Swimlanes.insert({
+            title: TAPi18n.__('list-templates-swimlane'),
+            boardId,
+            sort: 2,
+            type: 'template-container',
+          }, fakeUser, (err, swimlaneId) => {
+
+            // Insert the reference to out list templates swimlane
+            Users.update(fakeUserId.get(), {$set: {'profile.listTemplatesSwimlaneId': swimlaneId}});
+          });
+
+          // Insert the board templates swimlane
+          Swimlanes.insert({
+            title: TAPi18n.__('board-templates-swimlane'),
+            boardId,
+            sort: 3,
+            type: 'template-container',
+          }, fakeUser, (err, swimlaneId) => {
+
+            // Insert the reference to out board templates swimlane
+            Users.update(fakeUserId.get(), {$set: {'profile.boardTemplatesSwimlaneId': swimlaneId}});
+          });
+        });
       });
     });
   }
-  */
 
   Users.after.insert((userId, doc) => {
 

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "wekan",
-  "version": "v2.28.0",
+  "version": "v2.29.0",
   "description": "Open-Source kanban",
   "private": true,
   "scripts": {

+ 2 - 2
sandstorm-pkgdef.capnp

@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
     appTitle = (defaultText = "Wekan"),
     # The name of the app as it is displayed to the user.
 
-    appVersion = 230,
+    appVersion = 231,
     # Increment this for every release.
 
-    appMarketingVersion = (defaultText = "2.28.0~2019-02-27"),
+    appMarketingVersion = (defaultText = "2.29.0~2019-02-27"),
     # Human-readable presentation of the app version.
 
     minUpgradableAppVersion = 0,

+ 95 - 0
server/migrations.js

@@ -422,3 +422,98 @@ Migrations.add('add-defaultAuthenticationMethod', () => {
     },
   }, noValidateMulti);
 });
+
+Migrations.add('add-templates', () => {
+  Boards.update({
+    type: {
+      $exists: false,
+    },
+  }, {
+    $set: {
+      type: 'board',
+    },
+  }, noValidateMulti);
+  Swimlanes.update({
+    type: {
+      $exists: false,
+    },
+  }, {
+    $set: {
+      type: 'swimlane',
+    },
+  }, noValidateMulti);
+  Lists.update({
+    type: {
+      $exists: false,
+    },
+    swimlaneId: {
+      $exists: false,
+    },
+  }, {
+    $set: {
+      type: 'list',
+      swimlaneId: '',
+    },
+  }, noValidateMulti);
+  Users.find({
+    'profile.templatesBoardId': {
+      $exists: false,
+    },
+  }).forEach((user) => {
+    // Create board and swimlanes
+    Boards.insert({
+      title: TAPi18n.__('templates'),
+      permission: 'private',
+      type: 'template-container',
+      members: [
+        {
+          userId: user._id,
+          isAdmin: true,
+          isActive: true,
+          isNoComments: false,
+          isCommentOnly: false,
+        },
+      ],
+    }, (err, boardId) => {
+
+      // Insert the reference to our templates board
+      Users.update(user._id, {$set: {'profile.templatesBoardId': boardId}});
+
+      // Insert the card templates swimlane
+      Swimlanes.insert({
+        title: TAPi18n.__('card-templates-swimlane'),
+        boardId,
+        sort: 1,
+        type: 'template-container',
+      }, (err, swimlaneId) => {
+
+        // Insert the reference to out card templates swimlane
+        Users.update(user._id, {$set: {'profile.cardTemplatesSwimlaneId': swimlaneId}});
+      });
+
+      // Insert the list templates swimlane
+      Swimlanes.insert({
+        title: TAPi18n.__('list-templates-swimlane'),
+        boardId,
+        sort: 2,
+        type: 'template-container',
+      }, (err, swimlaneId) => {
+
+        // Insert the reference to out list templates swimlane
+        Users.update(user._id, {$set: {'profile.listTemplatesSwimlaneId': swimlaneId}});
+      });
+
+      // Insert the board templates swimlane
+      Swimlanes.insert({
+        title: TAPi18n.__('board-templates-swimlane'),
+        boardId,
+        sort: 3,
+        type: 'template-container',
+      }, (err, swimlaneId) => {
+
+        // Insert the reference to out board templates swimlane
+        Users.update(user._id, {$set: {'profile.boardTemplatesSwimlaneId': swimlaneId}});
+      });
+    });
+  });
+});

+ 1 - 0
server/publications/boards.js

@@ -32,6 +32,7 @@ Meteor.publish('boards', function() {
       color: 1,
       members: 1,
       permission: 1,
+      type: 1,
     },
   });
 });