diff options
-rw-r--r-- | client/components/boards/boardBody.jade | 35 | ||||
-rw-r--r-- | client/components/boards/boardBody.js | 158 | ||||
-rw-r--r-- | client/components/boards/boardBody.styl | 10 | ||||
-rw-r--r-- | client/components/boards/boardHeader.jade | 5 | ||||
-rw-r--r-- | client/components/boards/boardHeader.js | 21 | ||||
-rw-r--r-- | client/components/cards/cardDetails.js | 12 | ||||
-rw-r--r-- | client/components/lists/list.js | 7 | ||||
-rw-r--r-- | client/components/lists/list.styl | 1 | ||||
-rw-r--r-- | client/components/lists/listBody.jade | 2 | ||||
-rw-r--r-- | client/components/lists/listBody.js | 16 | ||||
-rw-r--r-- | client/components/swimlanes/swimlanes.jade | 78 | ||||
-rw-r--r-- | client/components/swimlanes/swimlanes.js | 223 | ||||
-rw-r--r-- | client/components/swimlanes/swimlanes.styl | 20 | ||||
-rw-r--r-- | i18n/en.i18n.json | 4 | ||||
-rw-r--r-- | models/boards.js | 12 | ||||
-rw-r--r-- | models/cards.js | 6 | ||||
-rw-r--r-- | models/lists.js | 10 | ||||
-rw-r--r-- | models/swimlanes.js | 219 | ||||
-rw-r--r-- | server/migrations.js | 36 | ||||
-rw-r--r-- | server/publications/boards.js | 1 |
20 files changed, 679 insertions, 197 deletions
diff --git a/client/components/boards/boardBody.jade b/client/components/boards/boardBody.jade index bb3d2906..30e70b31 100644 --- a/client/components/boards/boardBody.jade +++ b/client/components/boards/boardBody.jade @@ -20,33 +20,8 @@ template(name="boardBody") class="{{#if draggingActive.get}}is-dragging-active{{/if}}") if showOverlay.get .board-overlay - .lists.js-lists - if isMiniScreen - if currentList - +list(currentList) - else - each currentBoard.lists - +miniList(this) - if currentUser.isBoardMember - +addListForm - else - each currentBoard.lists - +list(this) - if currentCardIsInThisList - +cardDetails(currentCard) - if currentUser.isBoardMember - +addListForm - -template(name="addListForm") - .list.js-list.list-composer.js-list-composer - .list-header - +inlinedForm(autoclose=false) - input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}" - autocomplete="off" autofocus) - .edit-controls.clearfix - button.primary.confirm(type="submit") {{_ 'save'}} - a.fa.fa-times-thin.js-close-inlined-form - else - a.open-list-composer.js-open-inlined-form - i.fa.fa-plus - | {{_ 'add-list'}} + if isViewSwimlanes + each currentBoard.swimlanes + +swimlane(this) + if isViewLists + +listsGroup diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index b3880c61..a068dd04 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -29,10 +29,6 @@ BlazeComponent.extendComponent({ this.mouseHasEnterCardDetails = false; }, - openNewListForm() { - this.childComponents('addListForm')[0].open(); - }, - // XXX Flow components allow us to avoid creating these two setter methods by // exposing a public API to modify the component state. We need to investigate // best practices here. @@ -47,16 +43,22 @@ BlazeComponent.extendComponent({ }); }, - currentCardIsInThisList() { - const currentCard = Cards.findOne(Session.get('currentCard')); - const listId = this.currentData()._id; - return currentCard && currentCard.listId === listId; - }, - onlyShowCurrentCard() { return Utils.isMiniScreen() && Session.get('currentCard'); }, + isViewSwimlanes() { + const currentBoardId = Session.get('currentBoard'); + const board = Boards.findOne(currentBoardId); + return (board.view === 'board-view-swimlanes'); + }, + + isViewLists() { + const currentBoardId = Session.get('currentBoard'); + const board = Boards.findOne(currentBoardId); + return (board.view === 'board-view-lists'); + }, + events() { return [{ // XXX The board-overlay div should probably be moved to the parent @@ -66,147 +68,11 @@ BlazeComponent.extendComponent({ this.showOverlay.set(false); } }, - - // Click-and-drag action - 'mousedown .board-canvas'(evt) { - // Translating the board canvas using the click-and-drag action can - // conflict with the build-in browser mechanism to select text. We - // 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']; - if ($(evt.target).closest(noDragInside.join(',')).length === 0 && $('.lists').prop('clientHeight') > evt.offsetY) { - this._isDragging = true; - this._lastDragPositionX = evt.clientX; - } - }, 'mouseup'() { if (this._isDragging) { this._isDragging = false; } }, - 'mousemove'(evt) { - if (this._isDragging) { - // Update the canvas position - this.listsDom.scrollLeft -= evt.clientX - this._lastDragPositionX; - this._lastDragPositionX = evt.clientX; - // Disable browser text selection while dragging - evt.stopPropagation(); - evt.preventDefault(); - // Don't close opened card or inlined form at the end of the - // click-and-drag. - EscapeActions.executeUpTo('popup-close'); - EscapeActions.preventNextClick(); - } - }, }]; }, }).register('board'); - -Template.boardBody.onRendered(function() { - const self = BlazeComponent.getComponentForElement(this.firstNode); - - self.listsDom = this.find('.js-lists'); - - if (!Session.get('currentCard')) { - self.scrollLeft(); - } - - // We want to animate the card details window closing. We rely on CSS - // transition for the actual animation. - self.listsDom._uihooks = { - removeElement(node) { - const removeNode = _.once(() => { - node.parentNode.removeChild(node); - }); - if ($(node).hasClass('js-card-details')) { - $(node).css({ - flexBasis: 0, - padding: 0, - }); - $(self.listsDom).one(CSSEvents.transitionend, removeNode); - } else { - removeNode(); - } - }, - }; - - $(self.listsDom).sortable({ - tolerance: 'pointer', - helper: 'clone', - handle: '.js-list-header', - items: '.js-list:not(.js-list-composer)', - placeholder: 'list placeholder', - distance: 7, - start(evt, ui) { - ui.placeholder.height(ui.helper.height()); - Popup.close(); - }, - stop() { - $(self.listsDom).find('.js-list:not(.js-list-composer)').each( - (i, list) => { - const data = Blaze.getData(list); - Lists.update(data._id, { - $set: { - sort: i, - }, - }); - } - ); - }, - }); - - function userIsMember() { - return Meteor.user() && Meteor.user().isBoardMember(); - } - - // Disable drag-dropping while in multi-selection mode, or if the current user - // is not a board member - self.autorun(() => { - const $listDom = $(self.listsDom); - if ($listDom.data('sortable')) { - $(self.listsDom).sortable('option', 'disabled', - MultiSelection.isActive() || !userIsMember()); - } - }); - - // If there is no data in the board (ie, no lists) we autofocus the list - // creation form by clicking on the corresponding element. - const currentBoard = Boards.findOne(Session.get('currentBoard')); - if (userIsMember() && currentBoard.lists().count() === 0) { - self.openNewListForm(); - } -}); - -BlazeComponent.extendComponent({ - // Proxy - open() { - this.childComponents('inlinedForm')[0].open(); - }, - - events() { - return [{ - submit(evt) { - evt.preventDefault(); - const titleInput = this.find('.list-name-input'); - const title = titleInput.value.trim(); - if (title) { - Lists.insert({ - title, - boardId: Session.get('currentBoard'), - sort: $('.list').length, - }); - - titleInput.value = ''; - titleInput.focus(); - } - }, - }]; - }, -}).register('addListForm'); - -Template.boardBody.helpers({ - canSeeAddList() { - return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); - }, -}); diff --git a/client/components/boards/boardBody.styl b/client/components/boards/boardBody.styl index bb9beb19..f5ecc08e 100644 --- a/client/components/boards/boardBody.styl +++ b/client/components/boards/boardBody.styl @@ -20,15 +20,14 @@ position() &.is-sibling-sidebar-open margin-right: 248px - .lists - align-items: flex-start + .swimlane + border-bottom: 1px solid #CCC display: flex flex-direction: row margin: 0 0 10px padding: 0 40px 5px 0 overflow-x: auto overflow-y: hidden - position: cover .board-overlay position: cover @@ -49,12 +48,11 @@ position() .board-canvas - .lists - align-items: flex-start + .swimlane + border-bottom: 1px solid #CCC display: flex flex-direction: column margin: 0 padding: 0 40px 0px 0 overflow-x: hidden overflow-y: auto - position: cover diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade index ffb8eb27..1a65ce27 100644 --- a/client/components/boards/boardHeader.jade +++ b/client/components/boards/boardHeader.jade @@ -87,6 +87,11 @@ template(name="boardHeaderBar") a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}") i.fa.fa-times-thin + a.board-header-btn.js-toggle-board-view( + title="{{_ 'board-view'}}") + i.fa.fa-th-large + span {{_ currentBoard.view}} + if canModifyBoard a.board-header-btn.js-multiselection-activate( title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}" diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js index fe10dab1..67b05446 100644 --- a/client/components/boards/boardHeader.js +++ b/client/components/boards/boardHeader.js @@ -76,6 +76,22 @@ BlazeComponent.extendComponent({ 'click .js-open-archived-board'() { Modal.open('archivedBoards'); }, + 'click .js-toggle-board-view'() { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + if (currentBoard.view === 'board-view-swimlanes') { + Boards.update(currentBoard._id, { + $set: { + view: 'board-view-lists', + }, + }); + } else if (currentBoard.view === 'board-view-lists') { + Boards.update(currentBoard._id, { + $set: { + view: 'board-view-swimlanes', + }, + }); + } + }, 'click .js-open-filter-view'() { Sidebar.setView('filter'); }, @@ -164,6 +180,11 @@ const CreateBoard = BlazeComponent.extendComponent({ permission: visibility, })); + Swimlanes.insert({ + title: 'Default', + boardId: this.boardId.get(), + }); + Utils.goBoardId(this.boardId.get()); }, diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index f4e6e773..94a938f0 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -20,8 +20,8 @@ BlazeComponent.extendComponent({ onCreated() { this.isLoaded = new ReactiveVar(false); - this.parentComponent().showOverlay.set(true); - this.parentComponent().mouseHasEnterCardDetails = false; + this.parentComponent().parentComponent().showOverlay.set(true); + this.parentComponent().parentComponent().mouseHasEnterCardDetails = false; this.calculateNextPeak(); Meteor.subscribe('unsaved-edits'); @@ -42,7 +42,7 @@ BlazeComponent.extendComponent({ scrollParentContainer() { const cardPanelWidth = 510; - const bodyBoardComponent = this.parentComponent(); + const bodyBoardComponent = this.parentComponent().parentComponent(); const $cardContainer = bodyBoardComponent.$('.js-lists'); const $cardView = this.$(this.firstNode()); @@ -69,7 +69,7 @@ BlazeComponent.extendComponent({ }, onDestroyed() { - this.parentComponent().showOverlay.set(false); + this.parentComponent().parentComponent().showOverlay.set(false); }, events() { @@ -104,8 +104,8 @@ BlazeComponent.extendComponent({ 'click .js-add-members': Popup.open('cardMembers'), 'click .js-add-labels': Popup.open('cardLabels'), 'mouseenter .js-card-details' () { - this.parentComponent().showOverlay.set(true); - this.parentComponent().mouseHasEnterCardDetails = true; + this.parentComponent().parentComponent().showOverlay.set(true); + this.parentComponent().parentComponent().mouseHasEnterCardDetails = true; }, 'click #toggleButton'() { Meteor.call('toggleSystemMessages'); diff --git a/client/components/lists/list.js b/client/components/lists/list.js index a65ccc56..e922a3fd 100644 --- a/client/components/lists/list.js +++ b/client/components/lists/list.js @@ -18,7 +18,7 @@ BlazeComponent.extendComponent({ // callback, we basically solve all issues related to reactive updates. A // comment below provides further details. onRendered() { - const boardComponent = this.parentComponent(); + const boardComponent = this.parentComponent().parentComponent(); const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)'; const $cards = this.$('.js-minicards'); $cards.sortable({ @@ -55,6 +55,7 @@ BlazeComponent.extendComponent({ const nCards = MultiSelection.isActive() ? MultiSelection.count() : 1; const sortIndex = calculateIndex(prevCardDom, nextCardDom, nCards); const listId = Blaze.getData(ui.item.parents('.list').get(0))._id; + const swimlaneId = Blaze.getData(ui.item.parents('.swimlane').get(0))._id; // Normally the jquery-ui sortable library moves the dragged DOM element // to its new position, which disrupts Blaze reactive updates mechanism @@ -67,12 +68,12 @@ BlazeComponent.extendComponent({ if (MultiSelection.isActive()) { Cards.find(MultiSelection.getMongoSelector()).forEach((card, i) => { - card.move(listId, sortIndex.base + i * sortIndex.increment); + card.move(swimlaneId, listId, sortIndex.base + i * sortIndex.increment); }); } else { const cardDomElement = ui.item.get(0); const card = Blaze.getData(cardDomElement); - card.move(listId, sortIndex.base); + card.move(swimlaneId, listId, sortIndex.base); } boardComponent.setIsDragging(false); }, diff --git a/client/components/lists/list.styl b/client/components/lists/list.styl index 21d267ca..c3753360 100644 --- a/client/components/lists/list.styl +++ b/client/components/lists/list.styl @@ -9,7 +9,6 @@ // Even if this background color is the same as the body we can't leave it // transparent, because that won't work during a list drag. background: darken(white, 13%) - height: 100% border-left: 1px solid darken(white, 20%) padding: 0 float: left diff --git a/client/components/lists/listBody.jade b/client/components/lists/listBody.jade index 840fd801..32c6b278 100644 --- a/client/components/lists/listBody.jade +++ b/client/components/lists/listBody.jade @@ -4,7 +4,7 @@ template(name="listBody") if cards.count +inlinedForm(autoclose=false position="top") +addCardForm(listId=_id position="top") - each cards + each (cards (idOrNull ../../_id)) a.minicard-wrapper.js-minicard(href=absoluteUrl class="{{#if cardIsSelected}}is-selected{{/if}}" class="{{#if MultiSelection.isSelected _id}}is-checked{{/if}}") diff --git a/client/components/lists/listBody.js b/client/components/lists/listBody.js index fe2f1630..6cc94371 100644 --- a/client/components/lists/listBody.js +++ b/client/components/lists/listBody.js @@ -36,6 +36,14 @@ BlazeComponent.extendComponent({ const members = formComponent.members.get(); const labelIds = formComponent.labels.get(); + const boardId = this.data().board()._id; + const board = Boards.findOne(boardId); + let swimlaneId = ''; + if (board.view === 'board-view-swimlanes') + swimlaneId = this.parentComponent().parentComponent().data()._id; + else + swimlaneId = Swimlanes.findOne({boardId})._id; + if (title) { const _id = Cards.insert({ title, @@ -44,6 +52,7 @@ BlazeComponent.extendComponent({ listId: this.data()._id, boardId: this.data().board()._id, sort: sortIndex, + swimlaneId, }); // 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 @@ -96,6 +105,13 @@ BlazeComponent.extendComponent({ MultiSelection.toggle(this.currentData()._id); }, + idOrNull(swimlaneId) { + const board = Boards.findOne(Session.get('currentBoard')); + if (board.view === 'board-view-swimlanes') + return swimlaneId; + return undefined; + }, + canSeeAddCard() { return !this.reachedWipLimit() && Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); }, diff --git a/client/components/swimlanes/swimlanes.jade b/client/components/swimlanes/swimlanes.jade new file mode 100644 index 00000000..77afa399 --- /dev/null +++ b/client/components/swimlanes/swimlanes.jade @@ -0,0 +1,78 @@ +template(name="swimlane") + .swimlane.js-lists + .swimlane-header-wrap + .swimlane-header + = title + if isMiniScreen + if currentList + +list(currentList) + else + each currentBoard.lists + +miniList(this) + if currentUser.isBoardMember + +addListForm + else + each currentBoard.lists + +list(this) + if currentCardIsInThisList + +cardDetails(currentCard) + if currentUser.isBoardMember + +addListForm + +addListAndSwimlaneForm + +template(name="listsGroup") + .swimlane.js-lists + if isMiniScreen + if currentList + +list(currentList) + else + each currentBoard.lists + +miniList(this) + if currentUser.isBoardMember + +addListForm + else + each currentBoard.lists + +list(this) + if currentCardIsInThisList + +cardDetails(currentCard) + if currentUser.isBoardMember + +addListForm + +template(name="addListAndSwimlaneForm") + .list.js-list.list-composer.js-list-composer + .list-header + +inlinedForm(autoclose=false) + input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}" + autocomplete="off" autofocus) + .edit-controls.clearfix + button.primary.confirm(type="submit") {{_ 'save'}} + a.fa.fa-times-thin.js-close-inlined-form + else + a.open-list-composer.js-open-inlined-form + i.fa.fa-plus + | {{_ 'add-list'}} + .list-header + +inlinedForm(autoclose=false) + input.swimlane-name-input.full-line(type="text" placeholder="{{_ 'add-swimlane'}}" + autocomplete="off" autofocus) + .edit-controls.clearfix + button.primary.confirm(type="submit") {{_ 'save'}} + a.fa.fa-times-thin.js-close-inlined-form + else + a.open-list-composer.js-open-inlined-form + i.fa.fa-plus + | {{_ 'add-swimlane'}} + +template(name="addListForm") + .list.js-list.list-composer.js-list-composer + .list-header + +inlinedForm(autoclose=false) + input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}" + autocomplete="off" autofocus) + .edit-controls.clearfix + button.primary.confirm(type="submit") {{_ 'save'}} + a.fa.fa-times-thin.js-close-inlined-form + else + a.open-list-composer.js-open-inlined-form + i.fa.fa-plus + | {{_ 'add-list'}} diff --git a/client/components/swimlanes/swimlanes.js b/client/components/swimlanes/swimlanes.js new file mode 100644 index 00000000..397f9e9b --- /dev/null +++ b/client/components/swimlanes/swimlanes.js @@ -0,0 +1,223 @@ +BlazeComponent.extendComponent({ + onCreated() { + this.draggingActive = new ReactiveVar(false); + + this._isDragging = false; + this._lastDragPositionX = 0; + }, + + openNewListForm() { + this.childComponents('addListForm')[0].open(); + }, + + id() { + return this._id; + }, + + // XXX Flow components allow us to avoid creating these two setter methods by + // exposing a public API to modify the component state. We need to investigate + // best practices here. + setIsDragging(bool) { + this.draggingActive.set(bool); + }, + + scrollLeft(position = 0) { + const lists = this.$('.js-lists'); + lists && lists.animate({ + scrollLeft: position, + }); + }, + + currentCardIsInThisList() { + const currentCard = Cards.findOne(Session.get('currentCard')); + const listId = this.currentData()._id; + return currentCard && currentCard.listId === listId; //TODO: AND IN THIS SWIMLANE + }, + + events() { + return [{ + // Click-and-drag action + 'mousedown .board-canvas'(evt) { + // Translating the board canvas using the click-and-drag action can + // conflict with the build-in browser mechanism to select text. We + // 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']; + if ($(evt.target).closest(noDragInside.join(',')).length === 0 && this.$('.swimlane').prop('clientHeight') > evt.offsetY) { + this._isDragging = true; + this._lastDragPositionX = evt.clientX; + } + }, + 'mouseup'() { + if (this._isDragging) { + this._isDragging = false; + } + }, + 'mousemove'(evt) { + if (this._isDragging) { + // Update the canvas position + this.listsDom.scrollLeft -= evt.clientX - this._lastDragPositionX; + this._lastDragPositionX = evt.clientX; + // Disable browser text selection while dragging + evt.stopPropagation(); + evt.preventDefault(); + // Don't close opened card or inlined form at the end of the + // click-and-drag. + EscapeActions.executeUpTo('popup-close'); + EscapeActions.preventNextClick(); + } + }, + }]; + }, +}).register('swimlane'); + +Template.swimlane.onRendered(function() { + const self = BlazeComponent.getComponentForElement(this.firstNode); + + self.listsDom = this.find('.js-lists'); + + if (!Session.get('currentCard')) { + self.scrollLeft(); + } + + // We want to animate the card details window closing. We rely on CSS + // transition for the actual animation. + self.listsDom._uihooks = { + removeElement(node) { + const removeNode = _.once(() => { + node.parentNode.removeChild(node); + }); + if ($(node).hasClass('js-card-details')) { + $(node).css({ + flexBasis: 0, + padding: 0, + }); + $(self.listsDom).one(CSSEvents.transitionend, removeNode); + } else { + removeNode(); + } + }, + }; + + $(self.listsDom).sortable({ + tolerance: 'pointer', + helper: 'clone', + handle: '.js-list-header', + items: '.js-list:not(.js-list-composer)', + placeholder: 'list placeholder', + distance: 7, + start(evt, ui) { + ui.placeholder.height(ui.helper.height()); + Popup.close(); + }, + stop() { + $(self.listsDom).find('.js-list:not(.js-list-composer)').each( + (i, list) => { + const data = Blaze.getData(list); + Lists.update(data._id, { + $set: { + sort: i, + }, + }); + } + ); + }, + }); + + function userIsMember() { + return Meteor.user() && Meteor.user().isBoardMember(); + } + + // Disable drag-dropping while in multi-selection mode, or if the current user + // is not a board member + self.autorun(() => { + const $listDom = $(self.listsDom); + if ($listDom.data('sortable')) { + $(self.listsDom).sortable('option', 'disabled', + MultiSelection.isActive() || !userIsMember()); + } + }); + + // If there is no data in the board (ie, no lists) we autofocus the list + // creation form by clicking on the corresponding element. + const currentBoard = Boards.findOne(Session.get('currentBoard')); + if (userIsMember() && currentBoard.lists().count() === 0) { + self.openNewListForm(); + } +}); + +BlazeComponent.extendComponent({ + // Proxy + open() { + this.childComponents('inlinedForm')[0].open(); + }, + + events() { + return [{ + submit(evt) { + evt.preventDefault(); + const titleInput = this.find('.list-name-input'); + const title = titleInput.value.trim(); + if (title) { + Lists.insert({ + title, + boardId: Session.get('currentBoard'), + sort: $('.list').length, + }); + + titleInput.value = ''; + titleInput.focus(); + } + }, + }]; + }, +}).register('addListForm'); + +BlazeComponent.extendComponent({ + // Proxy + open() { + this.childComponents('inlinedForm')[0].open(); + }, + + events() { + return [{ + submit(evt) { + evt.preventDefault(); + let titleInput = this.find('.list-name-input'); + if (titleInput) { + const title = titleInput.value.trim(); + if (title) { + Lists.insert({ + title, + boardId: Session.get('currentBoard'), + sort: $('.list').length, + }); + + titleInput.value = ''; + titleInput.focus(); + } + } else { + titleInput = this.find('.swimlane-name-input'); + const title = titleInput.value.trim(); + if (title) { + Swimlanes.insert({ + title, + boardId: Session.get('currentBoard'), + sort: $('.swimlane').length, + }); + + titleInput.value = ''; + titleInput.focus(); + } + } + }, + }]; + }, +}).register('addListAndSwimlaneForm'); + +Template.swimlane.helpers({ + canSeeAddList() { + return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + }, +}); diff --git a/client/components/swimlanes/swimlanes.styl b/client/components/swimlanes/swimlanes.styl new file mode 100644 index 00000000..48bc495c --- /dev/null +++ b/client/components/swimlanes/swimlanes.styl @@ -0,0 +1,20 @@ +@import 'nib' + +.swimlane-header-wrap + display: flex; + flex-direction: column; + flex: 0 0 50px; + + .swimlane-header + writing-mode: sideways-lr; + font-size: 14px; + line-height: 50px; + margin: 0; + font-weight: bold; + min-height: 9px; + min-width: 30px; + overflow: hidden; + -o-text-overflow: ellipsis; + text-overflow: ellipsis; + word-wrap: break-word; + text-align: center; diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index 5536c9c1..71726a39 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -44,6 +44,7 @@ "add-attachment": "Add Attachment", "add-board": "Add Board", "add-card": "Add Card", + "add-swimlane": "Add Swimlane", "add-checklist": "Add Checklist", "add-checklist-item": "Add an item to checklist", "add-cover": "Add Cover", @@ -94,6 +95,9 @@ "boardChangeWatchPopup-title": "Change Watch", "boardMenuPopup-title": "Board Menu", "boards": "Boards", + "board-view": "Board View", + "board-view-swimlanes": "Swimlanes", + "board-view-lists": "Lists", "bucket-example": "Like “Bucket List” for example", "cancel": "Cancel", "card-archived": "This card is archived.", diff --git a/models/boards.js b/models/boards.js index 594bb7b9..84a715fb 100644 --- a/models/boards.js +++ b/models/boards.js @@ -31,6 +31,14 @@ Boards.attachSchema(new SimpleSchema({ } }, }, + view: { + type: String, + autoValue() { // eslint-disable-line consistent-return + if (this.isInsert) { + return 'board-view-swimlanes'; + } + }, + }, createdAt: { type: Date, autoValue() { // eslint-disable-line consistent-return @@ -187,6 +195,10 @@ Boards.helpers({ return Lists.find({ boardId: this._id, archived: false }, { sort: { sort: 1 } }); }, + swimlanes() { + return Swimlanes.find({ boardId: this._id, archived: false }, { sort: { sort: 1 } }); + }, + hasOvertimeCards(){ const card = Cards.findOne({isOvertime: true, boardId: this._id, archived: false} ); return card !== undefined; diff --git a/models/cards.js b/models/cards.js index 8676dfdc..d175a430 100644 --- a/models/cards.js +++ b/models/cards.js @@ -18,6 +18,9 @@ Cards.attachSchema(new SimpleSchema({ listId: { type: String, }, + swimlaneId: { + type: String, + }, // The system could work without this `boardId` information (we could deduce // the board identifier from the card), but it would make the system more // difficult to manage and less efficient. @@ -216,9 +219,10 @@ Cards.mutations({ return {$set: {description}}; }, - move(listId, sortIndex) { + move(swimlaneId, listId, sortIndex) { const list = Lists.findOne(listId); const mutatedFields = { + swimlaneId, listId, boardId: list.boardId, }; diff --git a/models/lists.js b/models/lists.js index a5f4791b..7ed27361 100644 --- a/models/lists.js +++ b/models/lists.js @@ -75,11 +75,15 @@ Lists.allow({ }); Lists.helpers({ - cards() { - return Cards.find(Filter.mongoSelector({ + cards(swimlaneId) { + const selector = { listId: this._id, archived: false, - }), { sort: ['sort'] }); + }; + if (swimlaneId) + selector.swimlaneId = swimlaneId; + return Cards.find(Filter.mongoSelector(selector, + { sort: ['sort'] })); }, allCards() { diff --git a/models/swimlanes.js b/models/swimlanes.js new file mode 100644 index 00000000..68cd77da --- /dev/null +++ b/models/swimlanes.js @@ -0,0 +1,219 @@ +Swimlanes = new Mongo.Collection('swimlanes'); + +Swimlanes.attachSchema(new SimpleSchema({ + title: { + type: String, + }, + archived: { + type: Boolean, + autoValue() { // eslint-disable-line consistent-return + if (this.isInsert && !this.isSet) { + return false; + } + }, + }, + boardId: { + type: String, + }, + createdAt: { + type: Date, + autoValue() { // eslint-disable-line consistent-return + if (this.isInsert) { + return new Date(); + } else { + this.unset(); + } + }, + }, + sort: { + type: Number, + decimal: true, + // XXX We should probably provide a default + optional: true, + }, + updatedAt: { + type: Date, + optional: true, + autoValue() { // eslint-disable-line consistent-return + if (this.isUpdate) { + return new Date(); + } else { + this.unset(); + } + }, + }, +})); + +Swimlanes.allow({ + insert(userId, doc) { + return allowIsBoardMemberNonComment(userId, Boards.findOne(doc.boardId)); + }, + update(userId, doc) { + return allowIsBoardMemberNonComment(userId, Boards.findOne(doc.boardId)); + }, + remove(userId, doc) { + return allowIsBoardMemberNonComment(userId, Boards.findOne(doc.boardId)); + }, + fetch: ['boardId'], +}); + +Swimlanes.helpers({ + cards() { + return Cards.find(Filter.mongoSelector({ + swimlaneId: this._id, + archived: false, + }), { sort: ['sort'] }); + }, + + allCards() { + return Cards.find({ swimlaneId: this._id }); + }, + + board() { + return Boards.findOne(this.boardId); + }, +}); + +Swimlanes.mutations({ + rename(title) { + return { $set: { title } }; + }, + + archive() { + return { $set: { archived: true } }; + }, + + restore() { + return { $set: { archived: false } }; + }, +}); + +Swimlanes.hookOptions.after.update = { fetchPrevious: false }; + +if (Meteor.isServer) { + Meteor.startup(() => { + Swimlanes._collection._ensureIndex({ boardId: 1 }); + }); + + Swimlanes.after.insert((userId, doc) => { + Activities.insert({ + userId, + type: 'swimlane', + activityType: 'createSwimlane', + boardId: doc.boardId, + swimlaneId: doc._id, + }); + }); + + Swimlanes.before.remove((userId, doc) => { + Activities.insert({ + userId, + type: 'swimlane', + activityType: 'removeSwimlane', + boardId: doc.boardId, + swimlaneId: doc._id, + title: doc.title, + }); + }); + + Swimlanes.after.update((userId, doc) => { + if (doc.archived) { + Activities.insert({ + userId, + type: 'swimlane', + activityType: 'archivedSwimlane', + swimlaneId: doc._id, + boardId: doc.boardId, + }); + } + }); +} + +//SWIMLANE REST API +if (Meteor.isServer) { + JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes', function (req, res, next) { + try { + const paramBoardId = req.params.boardId; + Authentication.checkBoardAccess( req.userId, paramBoardId); + + JsonRoutes.sendResult(res, { + code: 200, + data: Swimlanes.find({ boardId: paramBoardId, archived: false }).map(function (doc) { + return { + _id: doc._id, + title: doc.title, + }; + }), + }); + } + catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } + }); + + JsonRoutes.add('GET', '/api/boards/:boardId/swimlanes/:swimlaneId', function (req, res, next) { + try { + const paramBoardId = req.params.boardId; + const paramSwimlaneId = req.params.swimlaneId; + Authentication.checkBoardAccess( req.userId, paramBoardId); + JsonRoutes.sendResult(res, { + code: 200, + data: Swimlanes.findOne({ _id: paramSwimlaneId, boardId: paramBoardId, archived: false }), + }); + } + catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } + }); + + JsonRoutes.add('POST', '/api/boards/:boardId/swimlanes', function (req, res, next) { + try { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const id = Swimlanes.insert({ + title: req.body.title, + boardId: paramBoardId, + }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: id, + }, + }); + } + catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } + }); + + JsonRoutes.add('DELETE', '/api/boards/:boardId/swimlanes/:swimlaneId', function (req, res, next) { + try { + Authentication.checkUserId( req.userId); + const paramBoardId = req.params.boardId; + const paramSwimlaneId = req.params.swimlaneId; + Swimlanes.remove({ _id: paramSwimlaneId, boardId: paramBoardId }); + JsonRoutes.sendResult(res, { + code: 200, + data: { + _id: paramSwimlaneId, + }, + }); + } + catch (error) { + JsonRoutes.sendResult(res, { + code: 200, + data: error, + }); + } + }); + +} diff --git a/server/migrations.js b/server/migrations.js index f828a14c..f2cb124b 100644 --- a/server/migrations.js +++ b/server/migrations.js @@ -151,3 +151,39 @@ Migrations.add('add-sort-checklists', () => { }); }); }); + +Migrations.add('add-swimlanes', () => { + Boards.find().forEach((board) => { + const swimlane = Swimlanes.findOne({ boardId: board._id }); + let swimlaneId = ''; + if (swimlane) + swimlaneId = swimlane._id; + else + swimlaneId = Swimlanes.direct.insert({ + boardId: board._id, + title: 'Default', + }); + + Cards.find({ boardId: board._id }).forEach((card) => { + if (!card.hasOwnProperty('swimlaneId')) { + Cards.direct.update( + { _id: card._id }, + { $set: { swimlaneId } }, + noValidate + ); + } + }); + }); +}); + +Migrations.add('add-views', () => { + Boards.find().forEach((board) => { + if (!board.hasOwnProperty('view')) { + Boards.direct.update( + { _id: board._id }, + { $set: { view: 'board-view-swimlanes' } }, + noValidate + ); + } + }); +}); diff --git a/server/publications/boards.js b/server/publications/boards.js index f482f619..889bd177 100644 --- a/server/publications/boards.js +++ b/server/publications/boards.js @@ -73,6 +73,7 @@ Meteor.publishRelations('board', function(boardId) { ], }, { limit: 1 }), function(boardId, board) { this.cursor(Lists.find({ boardId })); + this.cursor(Swimlanes.find({ boardId })); this.cursor(Integrations.find({ boardId })); // Cards and cards comments |