diff options
Diffstat (limited to 'client/components')
72 files changed, 2536 insertions, 544 deletions
diff --git a/client/components/activities/activities.jade b/client/components/activities/activities.jade index b52a2981..d3e3d5ba 100644 --- a/client/components/activities/activities.jade +++ b/client/components/activities/activities.jade @@ -31,7 +31,7 @@ template(name="boardActivities") .activity-checklist(href="{{ card.absoluteUrl }}") +viewer = checklist.title - + if($eq activityType 'addChecklistItem') | {{{_ 'activity-checklist-item-added' checklist.title cardLink}}}. .activity-checklist(href="{{ card.absoluteUrl }}") @@ -44,6 +44,9 @@ template(name="boardActivities") if($eq activityType 'archivedList') | {{_ 'activity-archived' list.title}}. + if($eq activityType 'archivedSwimlane') + | {{_ 'activity-archived' swimlane.title}}. + if($eq activityType 'createBoard') | {{_ 'activity-created' boardLabel}}. @@ -56,6 +59,9 @@ template(name="boardActivities") if($eq activityType 'createList') | {{_ 'activity-added' list.title boardLabel}}. + if($eq activityType 'createSwimlane') + | {{_ 'activity-added' swimlane.title boardLabel}}. + if($eq activityType 'removeList') | {{_ 'activity-removed' title boardLabel}}. diff --git a/client/components/activities/activities.styl b/client/components/activities/activities.styl index 2285fc0a..380e7b40 100644 --- a/client/components/activities/activities.styl +++ b/client/components/activities/activities.styl @@ -1,5 +1,10 @@ @import 'nib' +.activity-title + margin: 0 0.5em 0.8em + display: flex + justify-content:space-between + .activities clear: both @@ -12,10 +17,14 @@ height: @width .activity-desc + word-wrap: break-word + overflow: hidden flex: 1 align-self: center margin: 0 margin-left: 3px + overflow: hidden; + word-break: break-word; .activity-comment display: block diff --git a/client/components/boards/boardArchive.jade b/client/components/boards/boardArchive.jade index 724e6569..6576f742 100644 --- a/client/components/boards/boardArchive.jade +++ b/client/components/boards/boardArchive.jade @@ -11,4 +11,4 @@ template(name="archivedBoards") | {{_ 'restore-board'}} = title else - li.no-items-message {{_ 'no-archived-board'}} + li.no-items-message {{_ 'no-archived-boards'}} diff --git a/client/components/boards/boardBody.jade b/client/components/boards/boardBody.jade index fe10c921..29a613b9 100644 --- a/client/components/boards/boardBody.jade +++ b/client/components/boards/boardBody.jade @@ -14,30 +14,14 @@ template(name="board") template(name="boardBody") .board-wrapper(class=currentBoard.colorClass) +sidebar - .board-canvas( + .board-canvas.js-swimlanes.js-perfect-scrollbar( class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}" class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}" class="{{#if draggingActive.get}}is-dragging-active{{/if}}") if showOverlay.get .board-overlay - .lists.js-lists - each currentBoard.lists - +list(this) - if currentCardIsInThisList - +cardDetails(currentCard) - if canSeeAddList - +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..456bf9b3 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -1,9 +1,8 @@ const subManager = new SubsManager(); +const { calculateIndex } = Utils; BlazeComponent.extendComponent({ onCreated() { - this.draggingActive = new ReactiveVar(false); - this.showOverlay = new ReactiveVar(false); this.isBoardReady = new ReactiveVar(false); // The pattern we use to manually handle data loading is described here: @@ -21,40 +20,90 @@ BlazeComponent.extendComponent({ }); }); }); + }, - this._isDragging = false; - this._lastDragPositionX = 0; + onlyShowCurrentCard() { + return Utils.isMiniScreen() && Session.get('currentCard'); + }, + +}).register('board'); +BlazeComponent.extendComponent({ + onCreated() { + this.showOverlay = new ReactiveVar(false); + this.draggingActive = new ReactiveVar(false); + this._isDragging = false; // Used to set the overlay this.mouseHasEnterCardDetails = false; }, + onRendered() { + const boardComponent = this; + const $swimlanesDom = boardComponent.$('.js-swimlanes'); + + $swimlanesDom.sortable({ + tolerance: 'pointer', + appendTo: '.board-canvas', + helper: 'clone', + handle: '.js-swimlane-header', + items: '.js-swimlane:not(.placeholder)', + placeholder: 'swimlane placeholder', + distance: 7, + start(evt, ui) { + ui.placeholder.height(ui.helper.height()); + EscapeActions.executeUpTo('popup-close'); + boardComponent.setIsDragging(true); + }, + stop(evt, ui) { + // To attribute the new index number, we need to get the DOM element + // of the previous and the following card -- if any. + const prevSwimlaneDom = ui.item.prev('.js-swimlane').get(0); + const nextSwimlaneDom = ui.item.next('.js-swimlane').get(0); + const sortIndex = calculateIndex(prevSwimlaneDom, nextSwimlaneDom, 1); + + $swimlanesDom.sortable('cancel'); + const swimlaneDomElement = ui.item.get(0); + const swimlane = Blaze.getData(swimlaneDomElement); + + Swimlanes.update(swimlane._id, { + $set: { + sort: sortIndex.base, + }, + }); - openNewListForm() { - this.childComponents('addListForm')[0].open(); - }, + boardComponent.setIsDragging(false); + }, + }); - // 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); + function userIsMember() { + return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + } + + // 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) { + boardComponent.openNewListForm(); + } }, - scrollLeft(position = 0) { - const lists = this.$('.js-lists'); - lists && lists.animate({ - scrollLeft: position, - }); + isViewSwimlanes() { + const currentUser = Meteor.user(); + return (currentUser.profile.boardView === 'board-view-swimlanes'); }, - currentCardIsInThisList() { - const currentCard = Cards.findOne(Session.get('currentCard')); - const listId = this.currentData()._id; - return currentCard && currentCard.listId === listId; + isViewLists() { + const currentUser = Meteor.user(); + return (currentUser.profile.boardView === 'board-view-lists'); }, - onlyShowCurrentCard() { - return Utils.isMiniScreen() && Session.get('currentCard'); + openNewListForm() { + if (this.isViewSwimlanes()) { + this.childComponents('swimlane')[0] + .childComponents('addListAndSwimlaneForm')[0].open(); + } else if (this.isViewLists()) { + this.childComponents('listsGroup')[0] + .childComponents('addListForm')[0].open(); + } }, events() { @@ -66,147 +115,26 @@ 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(); + // 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); }, - 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(); - } - }, - }]; + scrollLeft(position = 0) { + const swimlanes = this.$('.js-swimlanes'); + swimlanes && swimlanes.animate({ + scrollLeft: position, + }); }, -}).register('addListForm'); -Template.boardBody.helpers({ - canSeeAddList() { - return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); - }, -}); +}).register('boardBody'); diff --git a/client/components/boards/boardBody.styl b/client/components/boards/boardBody.styl index df5696a2..a614c7ed 100644 --- a/client/components/boards/boardBody.styl +++ b/client/components/boards/boardBody.styl @@ -1,8 +1,11 @@ @import 'nib' position() - if arguments[0] == cover - position: absolute + if arguments[0] == cover || arguments[0] == fixed-cover + if arguments[0] == cover + position: absolute + else + position: fixed left: 0 right: 0 top: 0 @@ -12,26 +15,18 @@ position() .board-wrapper position: cover + overflow-y: hidden; .board-canvas position: cover transition: margin .1s + overflow-y: auto; &.is-sibling-sidebar-open margin-right: 248px - .lists - align-items: flex-start - 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 + position: fixed-cover top: -100px right: -400px background: black @@ -43,3 +38,17 @@ position() .open-minicard-composer, .minicard-wrapper.is-checked display: none + +@media screen and (max-width: 800px) + .board-wrapper + + .board-canvas + + .swimlane + border-bottom: 1px solid #CCC + display: flex + flex-direction: column + margin: 0 + padding: 0 40px 0px 0 + overflow-x: hidden + overflow-y: auto diff --git a/client/components/boards/boardColors.styl b/client/components/boards/boardColors.styl index 8e28fcfa..233659ca 100644 --- a/client/components/boards/boardColors.styl +++ b/client/components/boards/boardColors.styl @@ -51,11 +51,25 @@ setBoardColor(color) &:not(.is-checked) + .minicard:hover:not(.minicard-composer) background: lighten(color, 97%) + .toggle-label + + &:after + background-color: darken(color, 20%) + + .toggle-switch:checked ~ .toggle-label + background-color: lighten(color, 20%) + + &:after + background-color: darken(color, 20%) + @media screen and (max-width: 800px) &.pop-over .header background: color color: white + &#header ul li.current, &#header-quick-access ul li.current + border-bottom: 4px solid lighten(color, 20%) + .board-color-nephritis setBoardColor(#27AE60) diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade index 67acdc9e..b4ccd3b3 100644 --- a/client/components/boards/boardHeader.jade +++ b/client/components/boards/boardHeader.jade @@ -2,7 +2,8 @@ template(name="boardHeaderBar") h1.header-board-menu with currentBoard a(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}") - = title + +viewer + = title .board-header-btns.left unless isMiniScreen @@ -71,7 +72,7 @@ template(name="boardHeaderBar") title="{{_ 'log-in'}}") i.fa.fa-sign-in span {{_ 'log-in'}} - + if isSandstorm if currentUser a.board-header-btn.js-open-archived-board @@ -87,6 +88,15 @@ template(name="boardHeaderBar") a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}") i.fa.fa-times-thin + a.board-header-btn.js-open-search-view(title="{{_ 'search'}}") + 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}} + 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 8983c722..e0b19246 100644 --- a/client/components/boards/boardHeader.js +++ b/client/components/boards/boardHeader.js @@ -62,10 +62,6 @@ BlazeComponent.extendComponent({ return user && user.hasStarred(boardId); }, - isMiniScreen() { - return Utils.isMiniScreen(); - }, - // Only show the star counter if the number of star is greater than 2 showStarCounter() { const currentBoard = Boards.findOne(Session.get('currentBoard')); @@ -84,6 +80,14 @@ BlazeComponent.extendComponent({ 'click .js-open-archived-board'() { Modal.open('archivedBoards'); }, + 'click .js-toggle-board-view'() { + const currentUser = Meteor.user(); + if (currentUser.profile.boardView === 'board-view-swimlanes') { + currentUser.setBoardView('board-view-lists'); + } else if (currentUser.profile.boardView === 'board-view-lists') { + currentUser.setBoardView('board-view-swimlanes'); + } + }, 'click .js-open-filter-view'() { Sidebar.setView('filter'); }, @@ -92,6 +96,9 @@ BlazeComponent.extendComponent({ Sidebar.setView(); Filter.reset(); }, + 'click .js-open-search-view'() { + Sidebar.setView('search'); + }, 'click .js-multiselection-activate'() { const currentCard = Session.get('currentCard'); MultiSelection.activate(); @@ -172,6 +179,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/boards/boardsList.jade b/client/components/boards/boardsList.jade index ae82dfa9..95ce3678 100644 --- a/client/components/boards/boardsList.jade +++ b/client/components/boards/boardsList.jade @@ -20,6 +20,12 @@ template(name="boardList") i.fa.js-star-board( class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" title="{{_ 'star-board-title'}}") + + if hasSpentTimeCards + i.fa.js-has-spenttime-cards( + class="fa-circle{{#if hasOvertimeCards}} has-overtime-card-active{{else}} no-overtime-card-active{{/if}}" + title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}") + p.board-list-item-desc= description li.js-add-board a.board-list-item.label {{_ 'add-board'}} diff --git a/client/components/boards/boardsList.js b/client/components/boards/boardsList.js index e4bb050e..1ed88146 100644 --- a/client/components/boards/boardsList.js +++ b/client/components/boards/boardsList.js @@ -1,4 +1,10 @@ +const subManager = new SubsManager(); + BlazeComponent.extendComponent({ + onCreated() { + Meteor.subscribe('setting'); + }, + boards() { return Boards.find({ archived: false, @@ -13,6 +19,16 @@ BlazeComponent.extendComponent({ return user && user.hasStarred(this.currentData()._id); }, + hasOvertimeCards() { + subManager.subscribe('board', this.currentData()._id); + return this.currentData().hasOvertimeCards(); + }, + + hasSpentTimeCards() { + subManager.subscribe('board', this.currentData()._id); + return this.currentData().hasSpentTimeCards(); + }, + isInvited() { const user = Meteor.user(); return user && user.isInvitedTo(this.currentData()._id); diff --git a/client/components/boards/boardsList.styl b/client/components/boards/boardsList.styl index 4b5245f9..80e47685 100644 --- a/client/components/boards/boardsList.styl +++ b/client/components/boards/boardsList.styl @@ -17,6 +17,7 @@ $spaceBetweenTiles = 16px opacity: 1 .board-list-item + overflow: hidden; background-color: #999 color: #f6f6f6 height: 90px @@ -43,9 +44,9 @@ $spaceBetweenTiles = 16px line-height: 22px .board-list-item-desc - color: rgba(255, 255, 255, .5) + color: #fff display: block - font-size: 10px + font-size: 14px font-weight: 400 line-height: 18px @@ -73,6 +74,23 @@ $spaceBetweenTiles = 16px transition-duration: .15s transition-property: color, font-size, background + .fa-circle + bottom: 0; + font-size: 10px; + height: 10px; + line-height: 10px; + padding: 9px 9px; + position: absolute; + right: 0; + transition-duration: .15s + transition-property: color, font-size, background + + .has-overtime-card-active + color: #eb4646 !important + + .no-overtime-card-active + color: #3cb500 !important + .is-star-active color: white diff --git a/client/components/cards/attachments.jade b/client/components/cards/attachments.jade index e35b364a..0f79323b 100644 --- a/client/components/cards/attachments.jade +++ b/client/components/cards/attachments.jade @@ -21,11 +21,11 @@ template(name="attachmentDeletePopup") template(name="attachmentsGalery") .attachments-galery each attachments - a.attachment-item.js-open-viewer(title="{{_ 'added'}} {{ moment uploadedAt }}") - .attachment-thumbnail + .attachment-item + a.attachment-thumbnail.swipebox(href="{{url}}" title="{{name}}") if isUploaded if isImage - img.attachment-thumbnail-img.js-preview-image(src="{{url}}") + img.attachment-thumbnail-img(src="{{url}}") else span.attachment-thumbnail-ext= extension else diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index 95cb9f55..bc7d3979 100644 --- a/client/components/cards/attachments.js +++ b/client/components/cards/attachments.js @@ -11,9 +11,6 @@ Template.attachmentsGalery.events({ 'click .js-download'(event) { event.stopPropagation(); }, - 'click .js-open-viewer'() { - // XXX Not implemented! - }, 'click .js-add-cover'() { Cards.findOne(this.cardId).setCover(this._id); }, @@ -63,7 +60,13 @@ Template.cardAttachmentsPopup.events({ file.boardId = card.boardId; file.cardId = card._id; file.userId = Meteor.userId(); - Attachments.insert(file); + + const attachment = Attachments.insert(file); + + if (attachment && attachment._id && attachment.isImage()) { + card.setCover(attachment._id); + } + Popup.close(); }); }, @@ -110,7 +113,12 @@ Template.previewClipboardImagePopup.events({ file.boardId = card.boardId; file.cardId = card._id; file.userId = Meteor.userId(); - Attachments.insert(file); + const attachment = Attachments.insert(file); + + if (attachment && attachment._id && attachment.isImage()) { + card.setCover(attachment._id); + } + pastedResults = null; $(document.body).pasteImageReader(() => {}); Popup.close(); diff --git a/client/components/cards/attachments.styl b/client/components/cards/attachments.styl index 9a5d0645..4a22fd8a 100644 --- a/client/components/cards/attachments.styl +++ b/client/components/cards/attachments.styl @@ -58,7 +58,28 @@ .preview-clipboard-image width: 280px + max-width: 100%; height: 200px display: block border: 1px solid black box-shadow: 0 1px 2px rgba(0,0,0,.2) + +@media screen and (max-width: 800px) + .attachments-galery + flex-direction + row + .attachment-item + width: 50% - 2% + + .attachment-thumbnail + height: 130px + .attachment-details + font-size: 1.1em + +@media screen and (max-width: 360px) + .attachments-galery + .attachment-item + width: 100% + + .attachment-thumbnail + height: 200px diff --git a/client/components/cards/cardDate.js b/client/components/cards/cardDate.js index 09a6761b..f33e8c19 100644 --- a/client/components/cards/cardDate.js +++ b/client/components/cards/cardDate.js @@ -1,10 +1,114 @@ -// Edit start & due dates +// Edit received, start, due & end dates +const EditCardDate = BlazeComponent.extendComponent({ + template() { + return 'editCardDate'; + }, + + onCreated() { + this.error = new ReactiveVar(''); + this.card = this.data(); + this.date = new ReactiveVar(moment.invalid()); + }, + + onRendered() { + const $picker = this.$('.js-datepicker').datepicker({ + todayHighlight: true, + todayBtn: 'linked', + language: TAPi18n.getLanguage(), + }).on('changeDate', function(evt) { + this.find('#date').value = moment(evt.date).format('L'); + this.error.set(''); + this.find('#time').focus(); + }.bind(this)); + + if (this.date.get().isValid()) { + $picker.datepicker('update', this.date.get().toDate()); + } + }, + + showDate() { + if (this.date.get().isValid()) + return this.date.get().format('L'); + return ''; + }, + showTime() { + if (this.date.get().isValid()) + return this.date.get().format('LT'); + return ''; + }, + dateFormat() { + return moment.localeData().longDateFormat('L'); + }, + timeFormat() { + return moment.localeData().longDateFormat('LT'); + }, + + events() { + return [{ + 'keyup .js-date-field'() { + // parse for localized date format in strict mode + const dateMoment = moment(this.find('#date').value, 'L', true); + if (dateMoment.isValid()) { + this.error.set(''); + this.$('.js-datepicker').datepicker('update', dateMoment.toDate()); + } + }, + 'keyup .js-time-field'() { + // parse for localized time format in strict mode + const dateMoment = moment(this.find('#time').value, 'LT', true); + if (dateMoment.isValid()) { + this.error.set(''); + } + }, + 'submit .edit-date'(evt) { + evt.preventDefault(); + + // if no time was given, init with 12:00 + const time = evt.target.time.value || moment(new Date().setHours(12, 0, 0)).format('LT'); + + const dateString = `${evt.target.date.value} ${time}`; + const newDate = moment(dateString, 'L LT', true); + if (newDate.isValid()) { + this._storeDate(newDate.toDate()); + Popup.close(); + } + else { + this.error.set('invalid-date'); + evt.target.date.focus(); + } + }, + 'click .js-delete-date'(evt) { + evt.preventDefault(); + this._deleteDate(); + Popup.close(); + }, + }]; + }, +}); + Template.dateBadge.helpers({ canModifyCard() { return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); }, }); +// editCardReceivedDatePopup +(class extends EditCardDate { + onCreated() { + super.onCreated(); + this.data().receivedAt && this.date.set(moment(this.data().receivedAt)); + } + + _storeDate(date) { + this.card.setReceived(date); + } + + _deleteDate() { + this.card.unsetReceived(); + } +}).register('editCardReceivedDatePopup'); + + // editCardStartDatePopup (class extends DatePicker { onCreated() { @@ -12,6 +116,13 @@ Template.dateBadge.helpers({ this.data().startAt && this.date.set(moment(this.data().startAt)); } + onRendered() { + super.onRendered(); + if (moment.isDate(this.card.receivedAt)) { + this.$('.js-datepicker').datepicker('setStartDate', this.card.receivedAt); + } + } + _storeDate(date) { this.card.setStart(date); } @@ -44,8 +155,31 @@ Template.dateBadge.helpers({ } }).register('editCardDueDatePopup'); +// editCardEndDatePopup +(class extends EditCardDate { + onCreated() { + super.onCreated(); + this.data().endAt && this.date.set(moment(this.data().endAt)); + } + + onRendered() { + super.onRendered(); + if (moment.isDate(this.card.startAt)) { + this.$('.js-datepicker').datepicker('setStartDate', this.card.startAt); + } + } + + _storeDate(date) { + this.card.setEnd(date); + } + + _deleteDate() { + this.card.unsetEnd(); + } +}).register('editCardEndDatePopup'); -// Display start & due dates + +// Display received, start, due & end dates const CardDate = BlazeComponent.extendComponent({ template() { return 'dateBadge'; @@ -74,6 +208,36 @@ const CardDate = BlazeComponent.extendComponent({ }, }); +class CardReceivedDate extends CardDate { + onCreated() { + super.onCreated(); + const self = this; + self.autorun(() => { + self.date.set(moment(self.data().receivedAt)); + }); + } + + classes() { + let classes = 'received-date' + ' '; + if (this.date.get().isBefore(this.now.get(), 'minute') && + this.now.get().isBefore(this.data().dueAt)) { + classes += 'current'; + } + return classes; + } + + showTitle() { + return `${TAPi18n.__('card-received-on')} ${this.date.get().format('LLLL')}`; + } + + events() { + return super.events().concat({ + 'click .js-edit-date': Popup.open('editCardReceivedDate'), + }); + } +} +CardReceivedDate.register('cardReceivedDate'); + class CardStartDate extends CardDate { onCreated() { super.onCreated(); @@ -84,11 +248,12 @@ class CardStartDate extends CardDate { } classes() { + let classes = 'start-date' + ' '; if (this.date.get().isBefore(this.now.get(), 'minute') && this.now.get().isBefore(this.data().dueAt)) { - return 'current'; + classes += 'current'; } - return ''; + return classes; } showTitle() { @@ -113,13 +278,14 @@ class CardDueDate extends CardDate { } classes() { + let classes = 'due-date' + ' '; if (this.now.get().diff(this.date.get(), 'days') >= 2) - return 'long-overdue'; + classes += 'long-overdue'; else if (this.now.get().diff(this.date.get(), 'minute') >= 0) - return 'due'; + classes += 'due'; else if (this.now.get().diff(this.date.get(), 'days') >= -1) - return 'almost-due'; - return ''; + classes += 'almost-due'; + return classes; } showTitle() { @@ -134,6 +300,44 @@ class CardDueDate extends CardDate { } CardDueDate.register('cardDueDate'); +class CardEndDate extends CardDate { + onCreated() { + super.onCreated(); + const self = this; + self.autorun(() => { + self.date.set(moment(self.data().endAt)); + }); + } + + classes() { + let classes = 'end-date' + ' '; + if (this.data.dueAt.diff(this.date.get(), 'days') >= 2) + classes += 'long-overdue'; + else if (this.data.dueAt.diff(this.date.get(), 'days') >= 0) + classes += 'due'; + else if (this.data.dueAt.diff(this.date.get(), 'days') >= -2) + classes += 'almost-due'; + return classes; + } + + showTitle() { + return `${TAPi18n.__('card-end-on')} ${this.date.get().format('LLLL')}`; + } + + events() { + return super.events().concat({ + 'click .js-edit-date': Popup.open('editCardEndDate'), + }); + } +} +CardEndDate.register('cardEndDate'); + +(class extends CardReceivedDate { + showDate() { + return this.date.get().format('l'); + } +}).register('minicardReceivedDate'); + (class extends CardStartDate { showDate() { return this.date.get().format('l'); @@ -145,3 +349,10 @@ CardDueDate.register('cardDueDate'); return this.date.get().format('l'); } }).register('minicardDueDate'); + +(class extends CardEndDate { + showDate() { + return this.date.get().format('l'); + } +}).register('minicardEndDate'); + diff --git a/client/components/cards/cardDate.styl b/client/components/cards/cardDate.styl index 87a3ed25..9775e82b 100644 --- a/client/components/cards/cardDate.styl +++ b/client/components/cards/cardDate.styl @@ -30,10 +30,30 @@ &:hover, &.is-active background-color: darken(#fd5d47, 7) + &.end-date + time + &::before + content: "\f253" // symbol: fa-hourglass-end + + &.due-date + time + &::before + content: "\f090" // symbol: fa-sign-in + + &.start-date + time + &::before + content: "\f08b" // symbol: fa-sign-out + + &.received-date + time + &::before + content: "\f251" // symbol: fa-hourglass-start + time &::before font: normal normal normal 14px/1 FontAwesome font-size: inherit -webkit-font-smoothing: antialiased - content: "\f017" // clock symbol - margin-right: 0.3em
\ No newline at end of file + margin-right: 0.3em + diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade index f72abe6d..b888210b 100644 --- a/client/components/cards/cardDetails.jade +++ b/client/components/cards/cardDetails.jade @@ -9,14 +9,44 @@ template(name="cardDetails") a.fa.fa-navicon.card-details-menu.js-open-card-details-menu h2.card-details-title.js-card-title( class="{{#if canModifyCard}}js-open-inlined-form is-editable{{/if}}") - = title - if isWatching - i.fa.fa-eye.card-details-watch + +viewer + = title + if isWatching + i.fa.fa-eye.card-details-watch if archived p.warning {{_ 'card-archived'}} .card-details-items + .card-details-item.card-details-item-received + h3.card-details-item-title {{_ 'card-received'}} + if receivedAt + +cardReceivedDate + else + a.js-received-date {{_ 'add'}} + + .card-details-item.card-details-item-start + h3.card-details-item-title {{_ 'card-start'}} + if startAt + +cardStartDate + else + a.js-start-date {{_ 'add'}} + + .card-details-item.card-details-item-due + h3.card-details-item-title {{_ 'card-due'}} + if dueAt + +cardDueDate + else + a.js-due-date {{_ 'add'}} + + .card-details-item.card-details-item-end + h3.card-details-item-title {{_ 'card-end'}} + if endAt + +cardEndDate + else + a.js-end-date {{_ 'add'}} + + .card-details-items .card-details-item.card-details-item-members h3.card-details-item-title {{_ 'members'}} each members @@ -51,6 +81,15 @@ template(name="cardDetails") = definition.name +cardCustomField + .card-details-items + if spentTime + .card-details-item.card-details-item-spent + if isOvertime + h3.card-details-item-title {{_ 'overtime-hours'}} + else + h3.card-details-item-title {{_ 'spent-time-hours'}} + +cardSpentTime + //- XXX We should use "editable" to avoid repetiting ourselves if canModifyCard h3.card-details-item-title {{_ 'description'}} @@ -81,16 +120,24 @@ template(name="cardDetails") hr +checklists(cardId = _id) - if attachments.count - hr - h2 - i.fa.fa-paperclip - | {{_ 'attachments'}} + hr + h3 + i.fa.fa-paperclip + | {{_ 'attachments'}} - +attachmentsGalery + +attachmentsGalery hr - h2 {{ _ 'activity'}} + .activity-title + h3 {{ _ 'activity'}} + if currentUser.isBoardMember + .material-toggle-switch + span.toggle-switch-title {{_ 'hide-system-messages'}} + if hiddenSystemMessages + input.toggle-switch(type="checkbox" id="toggleButton" checked="checked") + else + input.toggle-switch(type="checkbox" id="toggleButton") + label.toggle-label(for="toggleButton") if currentUser.isBoardMember +commentForm if isLoaded.get @@ -112,9 +159,12 @@ template(name="cardDetailsActionsPopup") li: a.js-members {{_ 'card-edit-members'}} li: a.js-labels {{_ 'card-edit-labels'}} li: a.js-attachments {{_ 'card-edit-attachments'}} + li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}} li: a.js-custom-fields {{_ 'card-edit-custom-fields'}} li: a.js-start-date {{_ 'editCardStartDatePopup-title'}} li: a.js-due-date {{_ 'editCardDueDatePopup-title'}} + li: a.js-end-date {{_ 'editCardEndDatePopup-title'}} + li: a.js-spent-time {{_ 'editCardSpentTimePopup-title'}} hr ul.pop-over-list li: a.js-move-card-to-top {{_ 'moveCardToTop-title'}} @@ -123,19 +173,48 @@ template(name="cardDetailsActionsPopup") ul.pop-over-list li: a.js-move-card {{_ 'moveCardPopup-title'}} li: a.js-copy-card {{_ 'copyCardPopup-title'}} + li: a.js-copy-checklist-cards {{_ 'copyChecklistToManyCardsPopup-title'}} unless archived li: a.js-archive {{_ 'archive-card'}} li: a.js-more {{_ 'cardMorePopup-title'}} template(name="moveCardPopup") - +boardLists + +boardsAndLists template(name="copyCardPopup") label(for='copy-card-title') {{_ 'title'}}: textarea#copy-card-title.minicard-composer-textarea.js-card-title(autofocus) = title + +boardsAndLists + + +template(name="copyChecklistToManyCardsPopup") + label(for='copy-checklist-cards-title') {{_ 'copyChecklistToManyCardsPopup-instructions'}}: + textarea#copy-card-title.minicard-composer-textarea.js-card-title(autofocus) + | {{_ 'copyChecklistToManyCardsPopup-format'}} + +boardsAndLists + +template(name="boardsAndLists") + label {{_ 'boards'}}: + select.js-select-boards + each boards + if $eq _id currentBoard._id + option(value="{{_id}}" selected) {{_ 'current'}} + else + option(value="{{_id}}") {{title}} + + label {{_ 'swimlanes'}}: + select.js-select-swimlanes + each swimlanes + option(value="{{_id}}") {{title}} + label {{_ 'lists'}}: - +boardLists + select.js-select-lists + each aBoardLists + option(value="{{_id}}") {{title}} + + .edit-controls.clearfix + button.primary.confirm.js-done {{_ 'done'}} template(name="cardMembersPopup") ul.pop-over-list.js-card-member-list diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index 8d5c478d..26549fda 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -1,3 +1,6 @@ +const subManager = new SubsManager(); +const { calculateIndexData } = Utils; + BlazeComponent.extendComponent({ mixins() { return [Mixins.InfiniteScrolling, Mixins.PerfectScrollbar]; @@ -18,9 +21,11 @@ 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'); }, isWatching() { @@ -28,16 +33,20 @@ BlazeComponent.extendComponent({ return card.findWatcher(Meteor.userId()); }, + hiddenSystemMessages() { + return Meteor.user().hasHiddenSystemMessages(); + }, + canModifyCard() { return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); }, scrollParentContainer() { const cardPanelWidth = 510; - const bodyBoardComponent = this.parentComponent(); + const bodyBoardComponent = this.parentComponent().parentComponent(); - const $cardContainer = bodyBoardComponent.$('.js-lists'); const $cardView = this.$(this.firstNode()); + const $cardContainer = bodyBoardComponent.$('.js-swimlanes'); const cardContainerScroll = $cardContainer.scrollLeft(); const cardContainerWidth = $cardContainer.width(); @@ -58,10 +67,55 @@ BlazeComponent.extendComponent({ onRendered() { if (!Utils.isMiniScreen()) this.scrollParentContainer(); + const $checklistsDom = this.$('.card-checklist-items'); + + $checklistsDom.sortable({ + tolerance: 'pointer', + helper: 'clone', + handle: '.checklist-title', + items: '.js-checklist', + placeholder: 'checklist placeholder', + distance: 7, + start(evt, ui) { + ui.placeholder.height(ui.helper.height()); + EscapeActions.executeUpTo('popup-close'); + }, + stop(evt, ui) { + let prevChecklist = ui.item.prev('.js-checklist').get(0); + if (prevChecklist) { + prevChecklist = Blaze.getData(prevChecklist).checklist; + } + let nextChecklist = ui.item.next('.js-checklist').get(0); + if (nextChecklist) { + nextChecklist = Blaze.getData(nextChecklist).checklist; + } + const sortIndex = calculateIndexData(prevChecklist, nextChecklist, 1); + + $checklistsDom.sortable('cancel'); + const checklist = Blaze.getData(ui.item.get(0)).checklist; + + Checklists.update(checklist._id, { + $set: { + sort: sortIndex.base, + }, + }); + }, + }); + + function userIsMember() { + return Meteor.user() && Meteor.user().isBoardMember(); + } + + // Disable sorting if the current user is not a board member + this.autorun(() => { + if ($checklistsDom.data('sortable')) { + $checklistsDom.sortable('option', 'disabled', !userIsMember()); + } + }); }, onDestroyed() { - this.parentComponent().showOverlay.set(false); + this.parentComponent().parentComponent().showOverlay.set(false); }, events() { @@ -95,9 +149,16 @@ BlazeComponent.extendComponent({ 'click .js-member': Popup.open('cardMember'), 'click .js-add-members': Popup.open('cardMembers'), 'click .js-add-labels': Popup.open('cardLabels'), + 'click .js-received-date': Popup.open('editCardReceivedDate'), + 'click .js-start-date': Popup.open('editCardStartDate'), + 'click .js-due-date': Popup.open('editCardDueDate'), + 'click .js-end-date': Popup.open('editCardEndDate'), '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'); }, }]; }, @@ -154,20 +215,24 @@ Template.cardDetailsActionsPopup.events({ 'click .js-members': Popup.open('cardMembers'), 'click .js-labels': Popup.open('cardLabels'), 'click .js-attachments': Popup.open('cardAttachments'), + 'click .js-received-date': Popup.open('editCardReceivedDate'), 'click .js-custom-fields': Popup.open('cardCustomFields'), 'click .js-start-date': Popup.open('editCardStartDate'), 'click .js-due-date': Popup.open('editCardDueDate'), + 'click .js-end-date': Popup.open('editCardEndDate'), + 'click .js-spent-time': Popup.open('editCardSpentTime'), 'click .js-move-card': Popup.open('moveCard'), 'click .js-copy-card': Popup.open('copyCard'), + 'click .js-copy-checklist-cards': Popup.open('copyChecklistToManyCards'), 'click .js-move-card-to-top' (evt) { evt.preventDefault(); - const minOrder = _.min(this.list().cards().map((c) => c.sort)); - this.move(this.listId, minOrder - 1); + const minOrder = _.min(this.list().cards(this.swimlaneId).map((c) => c.sort)); + this.move(this.swimlaneId, this.listId, minOrder - 1); }, 'click .js-move-card-to-bottom' (evt) { evt.preventDefault(); - const maxOrder = _.max(this.list().cards().map((c) => c.sort)); - this.move(this.listId, maxOrder + 1); + const maxOrder = _.max(this.list().cards(this.swimlaneId).map((c) => c.sort)); + this.move(this.swimlaneId, this.listId, maxOrder + 1); }, 'click .js-archive' (evt) { evt.preventDefault(); @@ -191,36 +256,82 @@ Template.editCardTitleForm.onRendered(function () { Template.editCardTitleForm.events({ 'keydown .js-edit-card-title' (evt) { // If enter key was pressed, submit the data - if (evt.keyCode === 13) { + // Unless the shift key is also being pressed + if (evt.keyCode === 13 && !evt.shiftKey) { $('.js-submit-edit-card-title-form').click(); } }, }); Template.moveCardPopup.events({ - 'click .js-select-list' () { + 'click .js-done' () { // XXX We should *not* get the currentCard from the global state, but // instead from a “component” state. const card = Cards.findOne(Session.get('currentCard')); - const newListId = this._id; - card.move(newListId); + const lSelect = $('.js-select-lists')[0]; + const newListId = lSelect.options[lSelect.selectedIndex].value; + const slSelect = $('.js-select-swimlanes')[0]; + card.swimlaneId = slSelect.options[slSelect.selectedIndex].value; + card.move(card.swimlaneId, newListId, 0); Popup.close(); }, }); +BlazeComponent.extendComponent({ + onCreated() { + subManager.subscribe('board', Session.get('currentBoard')); + this.selectedBoardId = new ReactiveVar(Session.get('currentBoard')); + }, + + boards() { + const boards = Boards.find({ + archived: false, + 'members.userId': Meteor.userId(), + }, { + sort: ['title'], + }); + return boards; + }, + + swimlanes() { + const board = Boards.findOne(this.selectedBoardId.get()); + return board.swimlanes(); + }, + + aBoardLists() { + const board = Boards.findOne(this.selectedBoardId.get()); + return board.lists(); + }, + + events() { + return [{ + 'change .js-select-boards'(evt) { + this.selectedBoardId.set($(evt.currentTarget).val()); + subManager.subscribe('board', this.selectedBoardId.get()); + }, + }]; + }, +}).register('boardsAndLists'); + Template.copyCardPopup.events({ - 'click .js-select-list' (evt) { + 'click .js-done'() { const card = Cards.findOne(Session.get('currentCard')); const oldId = card._id; card._id = null; - card.listId = this._id; - const textarea = $(evt.currentTarget).parents('.content').find('textarea'); + const lSelect = $('.js-select-lists')[0]; + card.listId = lSelect.options[lSelect.selectedIndex].value; + const slSelect = $('.js-select-swimlanes')[0]; + card.swimlaneId = slSelect.options[slSelect.selectedIndex].value; + const bSelect = $('.js-select-boards')[0]; + card.boardId = bSelect.options[bSelect.selectedIndex].value; + const textarea = $('#copy-card-title'); const title = textarea.val().trim(); // insert new card to the bottom of new list - card.sort = Lists.findOne(this._id).cards().count(); + card.sort = Lists.findOne(card.listId).cards().count(); if (title) { card.title = title; + card.coverId = ''; const _id = Cards.insert(card); // 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 @@ -233,9 +344,16 @@ Template.copyCardPopup.events({ cursor.forEach(function() { 'use strict'; const checklist = arguments[0]; + const checklistId = checklist._id; checklist.cardId = _id; checklist._id = null; - Checklists.insert(checklist); + const newChecklistId = Checklists.insert(checklist); + ChecklistItems.find({checklistId}).forEach(function(item) { + item._id = null; + item.checklistId = newChecklistId; + item.cardId = _id; + ChecklistItems.insert(item); + }); }); // copy card comments @@ -252,6 +370,69 @@ Template.copyCardPopup.events({ }, }); +Template.copyChecklistToManyCardsPopup.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]; + card.swimlaneId = slSelect.options[slSelect.selectedIndex].value; + const bSelect = $('.js-select-boards')[0]; + card.boardId = bSelect.options[bSelect.selectedIndex].value; + const textarea = $('#copy-card-title'); + const titleEntry = textarea.val().trim(); + // insert new card to the bottom of new list + card.sort = Lists.findOne(card.listId).cards().count(); + + if (titleEntry) { + const titleList = JSON.parse(titleEntry); + for (let i = 0; i < titleList.length; i++){ + const obj = titleList[i]; + card.title = obj.title; + card.description = obj.description; + card.coverId = ''; + const _id = Cards.insert(card); + // 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() { + 'use strict'; + const checklist = arguments[0]; + 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); + }); + }); + + // 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(); + } + }, +}); + + Template.cardMorePopup.events({ 'click .js-copy-card-link-to-clipboard' () { // Clipboard code from: diff --git a/client/components/cards/cardDetails.styl b/client/components/cards/cardDetails.styl index c981e2a2..e18c07a1 100644 --- a/client/components/cards/cardDetails.styl +++ b/client/components/cards/cardDetails.styl @@ -2,7 +2,6 @@ .card-details padding: 0 20px - height: 100% flex-shrink: 0 flex-basis: 470px will-change: flex-basis @@ -79,15 +78,19 @@ margin-right: 0 &.card-details-item-labels, &.card-details-item-members, + &.card-details-item-received, &.card-details-item-start, &.card-details-item-due, + &.card-details-item-end + width: 50% + flex-shrink: 1 &.card-details-item-customfield max-width: 50% flex-grow: 1 .card-details-item-title - font-size: 14px - color: darken(white, 45%) + font-size: 16px + color: #000 .card-label padding-top: 5px diff --git a/client/components/cards/cardTime.jade b/client/components/cards/cardTime.jade new file mode 100644 index 00000000..dcfc92f0 --- /dev/null +++ b/client/components/cards/cardTime.jade @@ -0,0 +1,22 @@ +template(name="editCardSpentTime") + .edit-card-time + form.edit-time + .fields + label(for="time") {{_ 'time'}} + input.js-time-field#time(type="number" step="0.01" name="time" value="{{card.spentTime}}" placeholder=timeFormat autofocus) + label(for="overtime") {{_ 'overtime'}} + a.js-toggle-overtime + .materialCheckBox#overtime(class="{{#if card.isOvertime}}is-checked{{/if}}" name="overtime") + + if error.get + .warning {{_ error.get}} + button.primary.wide.left.js-submit-time(type="submit") {{_ 'save'}} + button.js-delete-time.negate.wide.right {{_ 'delete'}} + +template(name="timeBadge") + if canModifyCard + a.js-edit-time.card-time(title="{{showTitle}}" class="{{#if isOvertime}}card-label-red{{else}}card-label-green{{/if}}") + | {{showTime}} + else + a.card-time(title="{{showTitle}}" class="{{#if isOvertime}}card-label-red{{else}}card-label-green{{/if}}") + | {{showTime}} diff --git a/client/components/cards/cardTime.js b/client/components/cards/cardTime.js new file mode 100644 index 00000000..eadcc88e --- /dev/null +++ b/client/components/cards/cardTime.js @@ -0,0 +1,81 @@ +BlazeComponent.extendComponent({ + template() { + return 'editCardSpentTime'; + }, + onCreated() { + this.error = new ReactiveVar(''); + this.card = this.data(); + }, + toggleOvertime() { + this.card.isOvertime = !this.card.isOvertime; + $('#overtime .materialCheckBox').toggleClass('is-checked'); + + $('#overtime').toggleClass('is-checked'); + }, + storeTime(spentTime, isOvertime) { + this.card.setSpentTime(spentTime); + this.card.setOvertime(isOvertime); + }, + deleteTime() { + this.card.unsetSpentTime(); + }, + events() { + return [{ + //TODO : need checking this portion + 'submit .edit-time'(evt) { + evt.preventDefault(); + + const spentTime = parseFloat(evt.target.time.value); + const isOvertime = this.card.isOvertime; + + if (spentTime >= 0) { + this.storeTime(spentTime, isOvertime); + Popup.close(); + } else { + this.error.set('invalid-time'); + evt.target.time.focus(); + } + }, + 'click .js-delete-time'(evt) { + evt.preventDefault(); + this.deleteTime(); + Popup.close(); + }, + 'click a.js-toggle-overtime': this.toggleOvertime, + }]; + }, +}).register('editCardSpentTimePopup'); + +BlazeComponent.extendComponent({ + template() { + return 'timeBadge'; + }, + onCreated() { + const self = this; + self.time = ReactiveVar(); + }, + showTitle() { + if (this.data().isOvertime) { + return `${TAPi18n.__('overtime')} ${this.data().spentTime} ${TAPi18n.__('hours')}`; + } else { + return `${TAPi18n.__('card-spent')} ${this.data().spentTime} ${TAPi18n.__('hours')}`; + } + }, + showTime() { + return this.data().spentTime; + }, + isOvertime() { + return this.data().isOvertime; + }, + events() { + return [{ + 'click .js-edit-time': Popup.open('editCardSpentTime'), + }]; + }, +}).register('cardSpentTime'); + +Template.timeBadge.helpers({ + canModifyCard() { + return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + }, +}); diff --git a/client/components/cards/cardTime.styl b/client/components/cards/cardTime.styl new file mode 100644 index 00000000..3c4b43ae --- /dev/null +++ b/client/components/cards/cardTime.styl @@ -0,0 +1,17 @@ +.card-time + display: block + border-radius: 4px + padding: 1px 3px + color: #fff + + background-color: #dbdbdb + &:hover, &.is-active + background-color: #b3b3b3 + + time + &::before + font: normal normal normal 14px/1 FontAwesome + font-size: inherit + -webkit-font-smoothing: antialiased + content: "\f017" // clock symbol + margin-right: 0.3em diff --git a/client/components/cards/checklists.jade b/client/components/cards/checklists.jade index 7ecc5dd3..ae680bd5 100644 --- a/client/components/cards/checklists.jade +++ b/client/components/cards/checklists.jade @@ -1,8 +1,14 @@ template(name="checklists") - h2 {{_ 'checklists'}} + h3 {{_ 'checklists'}} + if toggleDeleteDialog.get + .board-overlay#card-details-overlay + +checklistDeleteDialog(checklist = checklistToDelete) + + .card-checklist-items each checklist in currentCard.checklists +checklistDetail(checklist = checklist) + if canModifyCard +inlinedForm(autoclose=false classNames="js-add-checklist" cardId = cardId) +addChecklistItemForm @@ -12,19 +18,37 @@ template(name="checklists") | {{_ 'add-checklist'}}... template(name="checklistDetail") - +inlinedForm(classNames="js-edit-checklist-title" checklist = checklist) - +editChecklistItemForm(checklist = checklist) - else - .checklist-title - .checkbox.fa.fa-check-square-o - if canModifyCard - a.js-delete-checklist {{_ "delete"}}... - span.checklist-stat(class="{{#if checklist.isFinished}}is-finished{{/if}}") {{checklist.finishedCount}}/{{checklist.itemCount}} - if canModifyCard - h2.title.js-open-inlined-form.is-editable {{checklist.title}} - else - h2.title {{checklist.title}} - +checklistItems(checklist = checklist) + .js-checklist.checklist + +inlinedForm(classNames="js-edit-checklist-title" checklist = checklist) + +editChecklistItemForm(checklist = checklist) + else + .checklist-title + span + if canModifyCard + a.js-delete-checklist.toggle-delete-checklist-dialog {{_ "delete"}}... + + span.checklist-stat(class="{{#if checklist.isFinished}}is-finished{{/if}}") {{checklist.finishedCount}}/{{checklist.itemCount}} + if canModifyCard + h2.title.js-open-inlined-form.is-editable + +viewer + = checklist.title + else + h2.title + +viewer + = checklist.title + +checklistItems(checklist = checklist) + +template(name="checklistDeleteDialog") + .js-confirm-checklist-delete + p + i(class="fa fa-exclamation-triangle" aria-hidden="true") + p + | {{_ 'confirm-checklist-delete-dialog'}} + span {{checklist.title}} + | ? + .js-checklist-delete-buttons + button.confirm-checklist-delete(type="button") {{_ 'delete'}} + button.toggle-delete-checklist-dialog(type="button") {{_ 'cancel'}} template(name="addChecklistItemForm") textarea.js-add-checklist-item(rows='1' autofocus) @@ -47,7 +71,7 @@ template(name="editChecklistItemForm") template(name="checklistItems") .checklist-items.js-checklist-items - each item in checklist.getItems + each item in checklist.items +inlinedForm(classNames="js-edit-checklist-item" item = item checklist = checklist) +editChecklistItemForm(type = 'item' item = item checklist = checklist) else @@ -61,10 +85,14 @@ template(name="checklistItems") | {{_ 'add-checklist-item'}}... template(name='itemDetail') - .item.js-checklist-item + .js-checklist-item.checklist-item if canModifyCard .check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}") - .item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}") {{item.title}} + .item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}") + +viewer + = item.title else .materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}") - .item-title(class="{{#if item.isFinished }}is-checked{{/if}}") {{item.title}} + .item-title(class="{{#if item.isFinished }}is-checked{{/if}}") + +viewer + = item.title diff --git a/client/components/cards/checklists.js b/client/components/cards/checklists.js index bd9d275a..1f05aded 100644 --- a/client/components/cards/checklists.js +++ b/client/components/cards/checklists.js @@ -1,11 +1,14 @@ +const { calculateIndexData } = Utils; + function initSorting(items) { items.sortable({ tolerance: 'pointer', helper: 'clone', items: '.js-checklist-item:not(.placeholder)', - axis: 'y', + connectWith: '.js-checklist-items', + appendTo: '.board-canvas', distance: 7, - placeholder: 'placeholder', + placeholder: 'checklist-item placeholder', scroll: false, start(evt, ui) { ui.placeholder.height(ui.helper.height()); @@ -13,57 +16,57 @@ function initSorting(items) { }, stop(evt, ui) { const parent = ui.item.parents('.js-checklist-items'); - const orderedItems = []; - parent.find('.js-checklist-item').each(function(i, item) { - const checklistItem = Blaze.getData(item).item; - orderedItems.push(checklistItem._id); - }); - items.sortable('cancel'); - const formerParent = ui.item.parents('.js-checklist-items'); - let checklist = Blaze.getData(parent.get(0)).checklist; - const oldChecklist = Blaze.getData(formerParent.get(0)).checklist; - if (oldChecklist._id !== checklist._id) { - const currentItem = Blaze.getData(ui.item.get(0)).item; - for (let i = 0; i < orderedItems.length; i++) { - let itemId = orderedItems[i]; - if (itemId !== currentItem._id) continue; - checklist.addItem(currentItem.title); - checklist = Checklists.findOne({_id: checklist._id}); - itemId = checklist._id + (checklist.newItemIndex - 1); - if (currentItem.finished) { - checklist.finishItem(itemId); - } - orderedItems[i] = itemId; - oldChecklist.removeItem(currentItem._id); - } + const checklistId = Blaze.getData(parent.get(0)).checklist._id; + let prevItem = ui.item.prev('.js-checklist-item').get(0); + if (prevItem) { + prevItem = Blaze.getData(prevItem).item; } - checklist.sortItems(orderedItems); + let nextItem = ui.item.next('.js-checklist-item').get(0); + if (nextItem) { + nextItem = Blaze.getData(nextItem).item; + } + const nItems = 1; + const sortIndex = calculateIndexData(prevItem, nextItem, nItems); + const checklistDomElement = ui.item.get(0); + const checklistData = Blaze.getData(checklistDomElement); + const checklistItem = checklistData.item; + + items.sortable('cancel'); + + checklistItem.move(checklistId, sortIndex.base); }, }); } -Template.checklists.onRendered(function () { - const self = BlazeComponent.getComponentForElement(this.firstNode); - self.itemsDom = this.$('.card-checklist-items'); - initSorting(self.itemsDom); - self.itemsDom.mousedown(function(evt) { - evt.stopPropagation(); - }); +BlazeComponent.extendComponent({ + onRendered() { + const self = this; + self.itemsDom = this.$('.js-checklist-items'); + initSorting(self.itemsDom); + self.itemsDom.mousedown(function(evt) { + evt.stopPropagation(); + }); + + function userIsMember() { + return Meteor.user() && Meteor.user().isBoardMember(); + } - function userIsMember() { - return Meteor.user() && Meteor.user().isBoardMember(); - } + // Disable sorting if the current user is not a board member + self.autorun(() => { + const $itemsDom = $(self.itemsDom); + if ($itemsDom.data('sortable')) { + $(self.itemsDom).sortable('option', 'disabled', !userIsMember()); + } + }); + }, - // Disable sorting if the current user is not a board member - self.autorun(() => { - const $itemsDom = $(self.itemsDom); - if ($itemsDom.data('sortable')) { - $(self.itemsDom).sortable('option', 'disabled', !userIsMember()); - } - }); -}); + canModifyCard() { + return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + }, +}).register('checklistDetail'); BlazeComponent.extendComponent({ + addChecklist(event) { event.preventDefault(); const textarea = this.find('textarea.js-add-checklist-item'); @@ -92,13 +95,38 @@ BlazeComponent.extendComponent({ const checklist = this.currentData().checklist; if (title) { - checklist.addItem(title); + ChecklistItems.insert({ + title, + checklistId: checklist._id, + cardId: checklist.cardId, + sort: checklist.itemCount(), + }); } // We keep the form opened, empty it. textarea.value = ''; textarea.focus(); }, + canModifyCard() { + return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + }, + + deleteChecklist() { + const checklist = this.currentData().checklist; + if (checklist && checklist._id) { + Checklists.remove(checklist._id); + this.toggleDeleteDialog.set(false); + } + }, + + deleteItem() { + const checklist = this.currentData().checklist; + const item = this.currentData().item; + if (checklist && item && item._id) { + ChecklistItems.remove(item._id); + } + }, + editChecklist(event) { event.preventDefault(); const textarea = this.find('textarea.js-edit-checklist-item'); @@ -107,38 +135,24 @@ BlazeComponent.extendComponent({ checklist.setTitle(title); }, - canModifyCard() { - return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); - }, - editChecklistItem(event) { event.preventDefault(); const textarea = this.find('textarea.js-edit-checklist-item'); const title = textarea.value.trim(); - const itemId = this.currentData().item._id; - const checklist = this.currentData().checklist; - checklist.editItem(itemId, title); - }, - - deleteItem() { - const checklist = this.currentData().checklist; const item = this.currentData().item; - if (checklist && item && item._id) { - checklist.removeItem(item._id); - } + item.setTitle(title); }, - deleteChecklist() { - const checklist = this.currentData().checklist; - if (checklist && checklist._id) { - Checklists.remove(checklist._id); - } + onCreated() { + this.toggleDeleteDialog = new ReactiveVar(false); + this.checklistToDelete = null; //Store data context to pass to checklistDeleteDialog template }, pressKey(event) { - //If user press enter key inside a form, submit it, so user doesn't have to leave keyboard to submit a form. - if (event.keyCode === 13) { + //If user press enter key inside a form, submit it + //Unless the user is also holding down the 'shift' key + if (event.keyCode === 13 && !event.shiftKey) { event.preventDefault(); const $form = $(event.currentTarget).closest('form'); $form.find('button[type=submit]').click(); @@ -146,18 +160,50 @@ BlazeComponent.extendComponent({ }, events() { + const events = { + 'click .toggle-delete-checklist-dialog'(event) { + if($(event.target).hasClass('js-delete-checklist')){ + this.checklistToDelete = this.currentData().checklist; //Store data context + } + this.toggleDeleteDialog.set(!this.toggleDeleteDialog.get()); + }, + }; + return [{ + ...events, 'submit .js-add-checklist': this.addChecklist, 'submit .js-edit-checklist-title': this.editChecklist, 'submit .js-add-checklist-item': this.addChecklistItem, 'submit .js-edit-checklist-item': this.editChecklistItem, 'click .js-delete-checklist-item': this.deleteItem, - 'click .js-delete-checklist': this.deleteChecklist, + 'click .confirm-checklist-delete': this.deleteChecklist, keydown: this.pressKey, }]; }, }).register('checklists'); +Template.checklistDeleteDialog.onCreated(() => { + const $cardDetails = this.$('.card-details'); + this.scrollState = { position: $cardDetails.scrollTop(), //save current scroll position + top: false, //required for smooth scroll animation + }; + //Callback's purpose is to only prevent scrolling after animation is complete + $cardDetails.animate({ scrollTop: 0 }, 500, () => { this.scrollState.top = true; }); + + //Prevent scrolling while dialog is open + $cardDetails.on('scroll', () => { + if(this.scrollState.top) { //If it's already in position, keep it there. Otherwise let animation scroll + $cardDetails.scrollTop(0); + } + }); +}); + +Template.checklistDeleteDialog.onDestroyed(() => { + const $cardDetails = this.$('.card-details'); + $cardDetails.off('scroll'); //Reactivate scrolling + $cardDetails.animate( { scrollTop: this.scrollState.position }); +}); + Template.itemDetail.helpers({ canModifyCard() { return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); @@ -169,12 +215,12 @@ BlazeComponent.extendComponent({ const checklist = this.currentData().checklist; const item = this.currentData().item; if (checklist && item && item._id) { - checklist.toggleItem(item._id); + item.toggleItem(); } }, events() { return [{ - 'click .item .check-box': this.toggleItem, + 'click .js-checklist-item .check-box': this.toggleItem, }]; }, }).register('itemDetail'); diff --git a/client/components/cards/checklists.styl b/client/components/cards/checklists.styl index 77668349..d48c1851 100644 --- a/client/components/cards/checklists.styl +++ b/client/components/cards/checklists.styl @@ -26,7 +26,7 @@ textarea.js-add-checklist-item, textarea.js-edit-checklist-item .title font-size: 18px - line-height: 30px + line-height: 25px .checklist-stat margin: 0 0.5em @@ -38,34 +38,102 @@ textarea.js-add-checklist-item, textarea.js-edit-checklist-item .js-delete-checklist @extends .delete-text -.checklist-items - margin: 0 0 0.5em 1.33em - .item - line-height: 25px - font-size: 1.1em - margin-top: 3px - display: flex - &:hover - background-color: darken(white, 8%) - - .check-box - margin-top: 5px - &.is-checked - border-bottom: 2px solid #3cb500 - border-right: 2px solid #3cb500 - - .item-title - flex: 1 - padding-left: 10px; - &.is-checked - color: #8c8c8c - font-style: italic - - .js-delete-checklist-item - @extends .delete-text - padding: 12px 0 0 0 +.js-confirm-checklist-delete + background-color: darken(white, 3%) + position: absolute + float: left; + width: 60% + margin-top: 0 + margin-left: 13% + padding-bottom: 2% + padding-left: 3% + padding-right: 3% + z-index: 17 + border-radius: 3px + + p + position: relative + margin-top: 3% + width: 100% + text-align: center + span + font-weight: bold + + i + font-size: 2em + + .js-checklist-delete-buttons + position: relative + padding: left 2% right 2% + .confirm-checklist-delete + margin-left: 12% + float: left + .toggle-delete-checklist-dialog + margin-right: 12% + float: right + +#card-details-overlay + top: 0 + bottom: -600px + right: 0 + +.checklist + background: darken(white, 3%) + + &.placeholder + background: darken(white, 20%) + border-radius: 2px + + &.ui-sortable-helper + box-shadow: -2px 2px 8px rgba(0, 0, 0, .3), + 0 0 1px rgba(0, 0, 0, .5) + transform: rotate(4deg) + cursor: grabbing + + +.checklist-item + margin: 0 0 0 0.1em + line-height: 18px + font-size: 1.1em + margin-top: 3px + display: flex + background: darken(white, 3%) + + &.placeholder + background: darken(white, 20%) + border-radius: 2px + + &.ui-sortable-helper + box-shadow: -2px 2px 8px rgba(0, 0, 0, .3), + 0 0 1px rgba(0, 0, 0, .5) + transform: rotate(4deg) + cursor: grabbing + + &:hover + background-color: darken(white, 8%) + + .check-box + margin: 0.1em 0 0 0; + &.is-checked + border-bottom: 2px solid #3cb500 + border-right: 2px solid #3cb500 + + .item-title + flex: 1 + padding-left: 10px; + &.is-checked + color: #8c8c8c + font-style: italic + & .viewer + p + margin-bottom: 2px + +.js-delete-checklist-item + margin: 0 0 0.5em 1.33em + @extends .delete-text + padding: 12px 0 0 0 - .add-checklist-item - padding-top: 0.5em - display: inline-block +.add-checklist-item + margin: 0.2em 0 0.5em 1.33em + display: inline-block diff --git a/client/components/cards/labels.jade b/client/components/cards/labels.jade index 31bd4d06..6c6efb08 100644 --- a/client/components/cards/labels.jade +++ b/client/components/cards/labels.jade @@ -34,4 +34,5 @@ template(name="cardLabelsPopup") = name if(isLabelSelected ../_id) i.card-label-selectable-icon.fa.fa-check - a.quiet-button.full.js-add-label {{_ 'label-create'}} + if currentUser.isBoardAdmin + a.quiet-button.full.js-add-label {{_ 'label-create'}} diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade index 6fd83386..9fa4dd57 100644 --- a/client/components/cards/minicard.jade +++ b/client/components/cards/minicard.jade @@ -6,14 +6,20 @@ template(name="minicard") .minicard-labels each labels .minicard-label(class="card-label-{{color}}" title="{{name}}") - .minicard-title= title + .minicard-title + +viewer + = title .dates if startAt - .date - +minicardStartDate + .date + +minicardStartDate if dueAt - .date - +minicardDueDate + .date + +minicardDueDate + if spentTime + .date + +cardSpentTime + if members .minicard-members.js-minicard-members each members diff --git a/client/components/cards/minicard.styl b/client/components/cards/minicard.styl index a6aad896..d59f1f63 100644 --- a/client/components/cards/minicard.styl +++ b/client/components/cards/minicard.styl @@ -77,6 +77,9 @@ height: @width border-radius: 2px margin-left: 3px + .minicard-title + p:last-child + margin-bottom: 0 .dates display: flex; flex-direction: row; diff --git a/client/components/forms/forms.styl b/client/components/forms/forms.styl index 81780a6f..0a905943 100644 --- a/client/components/forms/forms.styl +++ b/client/components/forms/forms.styl @@ -633,6 +633,59 @@ button a, .quiet color: white +// Material Design Toggle Switch +.material-toggle-switch + display: flex + +.toggle-label + position: relative + display: block + height: 20px + width: 44px + background-color: #a6a6a6 + border-radius: 100px + cursor: pointer + transition: all 0.3s ease + + &:after + position: absolute + left: -2px + top: -3px + display: block + width: 26px + height: 26px + border-radius: 100px + background-color: #fff + box-shadow: 0px 3px 3px rgba(0,0,0,0.05) + content: '' + transition: all 0.3s ease + + &:active + &:after + transform: scale(1.15, 0.85) + +.toggle-switch:checked ~ .toggle-label + background-color: #6fbeb5 + + &:after + left: 20px + background-color: #179588 + +.toggle-switch:checked:disabled ~ .toggle-label + background-color: #d5d5d5 + pointer-events: none + + &:after + background-color: #bcbdbc + +.toggle-switch + display: none + +.toggle-switch-title + margin: 0 0.5em + display: flex + + @media screen and (max-width: 800px) .edit-controls, .add-controls diff --git a/client/components/import/import.jade b/client/components/import/import.jade index 5e737cc6..a1fbd83b 100644 --- a/client/components/import/import.jade +++ b/client/components/import/import.jade @@ -16,6 +16,7 @@ template(name="importTextarea") textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus) | {{jsonText}} if isSandstorm + h1.warning DANGER !!! THIS DESTROYS YOUR IMPORTED DATA, CAUSES BOARD NOT FOUND ERROR WHEN YOU OPEN THIS GRAIN AGAIN https://github.com/wekan/wekan/issues/1430 p.warning {{_ 'import-sandstorm-warning'}} input.primary.wide(type="submit" value="{{_ 'import'}}") diff --git a/client/components/lists/list.jade b/client/components/lists/list.jade index c959b87f..c02e0dd6 100644 --- a/client/components/lists/list.jade +++ b/client/components/lists/list.jade @@ -2,3 +2,7 @@ template(name='list') .list.js-list(id="js-list-{{_id}}") +listHeader +listBody + +template(name='miniList') + a.mini-list.js-select-list.js-list(id="js-list-{{_id}}") + +listHeader diff --git a/client/components/lists/list.js b/client/components/lists/list.js index 9c191348..38a87674 100644 --- a/client/components/lists/list.js +++ b/client/components/lists/list.js @@ -18,13 +18,18 @@ 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(); + + function userIsMember() { + return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + } + const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)'; const $cards = this.$('.js-minicards'); $cards.sortable({ - connectWith: '.js-minicards', + connectWith: '.js-minicards:not(.js-list-full)', tolerance: 'pointer', - appendTo: 'body', + appendTo: '.board-canvas', helper(evt, item) { const helper = item.clone(); if (MultiSelection.isActive()) { @@ -40,7 +45,6 @@ BlazeComponent.extendComponent({ }, distance: 7, items: itemsSelector, - scroll: false, placeholder: 'minicard-wrapper placeholder', start(evt, ui) { ui.placeholder.height(ui.helper.height()); @@ -55,6 +59,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,21 +72,17 @@ 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); }, }); - function userIsMember() { - return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); - } - // Disable drag-dropping if the current user is not a board member or is comment only this.autorun(() => { $cards.sortable('option', 'disabled', !userIsMember()); @@ -114,3 +115,10 @@ BlazeComponent.extendComponent({ }); }, }).register('list'); + +Template.miniList.events({ + 'click .js-select-list'() { + const listId = this._id; + Session.set('currentList', listId); + }, +}); diff --git a/client/components/lists/list.styl b/client/components/lists/list.styl index cf939a6e..fa32ff6d 100644 --- a/client/components/lists/list.styl +++ b/client/components/lists/list.styl @@ -9,8 +9,8 @@ // 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%) + border-bottom: 1px solid #CCC padding: 0 float: left @@ -53,6 +53,9 @@ &.ui-sortable-handle cursor: grab + .list-header-left-icon + display: none + .list-header-name display: inline font-size: 16px @@ -69,21 +72,30 @@ padding-left: 10px color: #a6a6a6 - .list-header-menu-icon + .list-header-menu position: absolute padding: 7px margin-top: 1px top: -@padding right: -@padding + .list-header-plus-icon + color: #a6a6a6 + margin-right: 10px + + .highlight + color: #ce1414 + .list-body - flex: 1 + flex: 1 1 auto + flex-direction: column display: flex overflow-y: auto padding: 5px 11px .minicards - flex: 1 + flex-grow: 1 + flex-shrink: 0 form margin-bottom: 9px @@ -107,3 +119,71 @@ background: #fafafa color: #222 box-shadow: 0 1px 2px rgba(0,0,0,.2) + +#js-wip-limit-edit + padding-top: 2% + + p + margin-bottom: 0 + + input + display: inline-block + + .wip-limit-value + width: 20% + margin-right: 5% + + .wip-limit-error + display: none + + .soft-wip-limit + margin-right: 8px + + div + float: left + +@media screen and (max-width: 800px) + .mini-list + flex: 0 0 60px + height: 60px + width: 100% + border-left: 0px + border-bottom: 1px solid darken(white, 20%) + + .list + display: block + width: 100% + border-left: 0px + + &.ui-sortable-helper + flex: 0 0 60px + height: 60px + width: 100% + border-left: 0px + border-bottom: 1px solid darken(white, 20%) + + .list-header.ui-sortable-handle + cursor: grabbing + + &.placeholder + flex: 0 0 60px + height: 60px + width: 100% + border-left: 0px + border-bottom: 1px solid darken(white, 20%) + + .list-header + + .list-header-left-icon + display: inline + padding: 7px + padding-right: 27px + margin-top: 1px + top: -@padding + left: -@padding + + .list-header-menu-icon + position: absolute + padding: 7px + top: -@padding + right: 17px diff --git a/client/components/lists/listBody.jade b/client/components/lists/listBody.jade index 01aa7179..32c6b278 100644 --- a/client/components/lists/listBody.jade +++ b/client/components/lists/listBody.jade @@ -1,10 +1,10 @@ template(name="listBody") .list-body.js-perfect-scrollbar - .minicards.clearfix.js-minicards + .minicards.clearfix.js-minicards(class="{{#if reachedWipLimit}}js-list-full{{/if}}") 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 edac5b03..24e5cf5d 100644 --- a/client/components/lists/listBody.js +++ b/client/components/lists/listBody.js @@ -40,6 +40,14 @@ BlazeComponent.extendComponent({ console.log("labelIds", labelIds); console.log("customFields", customFields); + const boardId = this.data().board()._id; + 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') + swimlaneId = Swimlanes.findOne({boardId})._id; + if (title) { const _id = Cards.insert({ title, @@ -49,6 +57,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 @@ -101,6 +110,22 @@ BlazeComponent.extendComponent({ MultiSelection.toggle(this.currentData()._id); }, + idOrNull(swimlaneId) { + const currentUser = Meteor.user(); + if (currentUser.profile.boardView === 'board-view-swimlanes') + return swimlaneId; + return undefined; + }, + + canSeeAddCard() { + return !this.reachedWipLimit() && Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + }, + + reachedWipLimit() { + const list = Template.currentData(); + return !list.getWipLimit('soft') && list.getWipLimit('enabled') && list.getWipLimit('value') <= list.cards().count(); + }, + events() { return [{ 'click .js-minicard': this.clickOnMiniCard, @@ -246,10 +271,3 @@ BlazeComponent.extendComponent({ }); }, }).register('addCardForm'); - - -Template.listBody.helpers({ - canSeeAddCard() { - return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); - }, -}); diff --git a/client/components/lists/listHeader.jade b/client/components/lists/listHeader.jade index 11905586..61771449 100644 --- a/client/components/lists/listHeader.jade +++ b/client/components/lists/listHeader.jade @@ -3,17 +3,40 @@ template(name="listHeader") +inlinedForm +editListTitleForm else + if isMiniScreen + if currentList + a.list-header-left-icon.fa.fa-angle-left.js-unselect-list h2.list-header-name( class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}") = title + if wipLimit.enabled + | ( + span(class="{{#if reachedWipLimit}}highlight{{/if}}") {{cards.count}} + |/#{wipLimit.value}) + if showCardsCountForList cards.count = cards.count - span.lowercase - | {{_ 'cards'}} - if currentUser.isBoardMember + span + | {{_ 'cards-count'}} + if isMiniScreen + if currentList + if isWatching + i.list-header-watch-icon.fa.fa-eye + div.list-header-menu + unless currentUser.isCommentOnly + if canSeeAddCard + a.js-add-card.fa.fa-plus.list-header-plus-icon + a.fa.fa-navicon.js-open-list-menu + else + a.list-header-menu-icon.fa.fa-angle-right.js-select-list + else if currentUser.isBoardMember if isWatching i.list-header-watch-icon.fa.fa-eye - a.list-header-menu-icon.fa.fa-navicon.js-open-list-menu + div.list-header-menu + unless currentUser.isCommentOnly + if canSeeAddCard + a.js-add-card.fa.fa-plus.list-header-plus-icon + a.fa.fa-navicon.js-open-list-menu template(name="editListTitleForm") .list-composer @@ -28,10 +51,13 @@ template(name="listActionPopup") unless currentUser.isCommentOnly hr ul.pop-over-list - li: a.js-add-card {{_ 'add-card'}} if cards.count li: a.js-select-cards {{_ 'list-select-cards'}} - hr + hr + if currentUser.isBoardAdmin + ul.pop-over-list + li: a.js-set-wip-limit {{#if isWipLimitEnabled }}{{_ 'edit-wip-limit'}}{{else}}{{_ 'setWipLimitPopup-title'}}{{/if}} + hr ul.pop-over-list li: a.js-close-list {{_ 'archive-list'}} hr @@ -63,3 +89,26 @@ template(name="listDeletePopup") unless archived p {{_ "list-delete-suggest-archive"}} button.js-confirm.negate.full(type="submit") {{_ 'delete'}} + +template(name="setWipLimitPopup") + #js-wip-limit-edit + label {{_ 'set-wip-limit-value'}} + ul.pop-over-list + li: a.js-enable-wip-limit {{_ 'enable-wip-limit'}} + if isWipLimitEnabled + i.fa.fa-check + if isWipLimitEnabled + p + input.wip-limit-value(type="number" value="{{ wipLimitValue }}" min="1" max="99") + input.wip-limit-apply(type="submit" value="{{_ 'apply'}}") + input.wip-limit-error + p + .soft-wip-limit + .materialCheckBox(class="{{#if isWipLimitSoft}}is-checked{{/if}}") + label {{_ 'soft-wip-limit'}} + +template(name="wipLimitErrorPopup") + .wip-limit-invalid + p {{_ 'wipLimitErrorPopup-dialog-pt1'}} + p {{_ 'wipLimitErrorPopup-dialog-pt2'}} + button.full.js-back-view(type="submit") {{_ 'cancel'}} diff --git a/client/components/lists/listHeader.js b/client/components/lists/listHeader.js index 1ad9f9dd..4b6bf196 100644 --- a/client/components/lists/listHeader.js +++ b/client/components/lists/listHeader.js @@ -1,4 +1,9 @@ BlazeComponent.extendComponent({ + canSeeAddCard() { + const list = Template.currentData(); + return !list.getWipLimit('enabled') || list.getWipLimit('soft') || !this.reachedWipLimit(); + }, + editTitle(evt) { evt.preventDefault(); const newTitle = this.childComponents('inlinedForm')[0].getValue().trim(); @@ -17,33 +22,45 @@ BlazeComponent.extendComponent({ return Meteor.user().getLimitToShowCardsCount(); }, + reachedWipLimit() { + const list = Template.currentData(); + return list.getWipLimit('enabled') && list.getWipLimit('value') <= list.cards().count(); + }, + showCardsCountForList(count) { - return count > this.limitToShowCardsCount(); + const limit = this.limitToShowCardsCount(); + return limit > 0 && count > limit; }, events() { return [{ 'click .js-open-list-menu': Popup.open('listAction'), + 'click .js-add-card' (evt) { + const listDom = $(evt.target).parents(`#js-list-${this.currentData()._id}`)[0]; + const listComponent = BlazeComponent.getComponentForElement(listDom); + listComponent.openForm({ + position: 'top', + }); + }, + 'click .js-unselect-list'() { + Session.set('currentList', null); + }, submit: this.editTitle, }]; }, }).register('listHeader'); Template.listActionPopup.helpers({ + isWipLimitEnabled() { + return Template.currentData().getWipLimit('enabled'); + }, + isWatching() { return this.findWatcher(Meteor.userId()); }, }); Template.listActionPopup.events({ - 'click .js-add-card' () { - const listDom = document.getElementById(`js-list-${this._id}`); - const listComponent = BlazeComponent.getComponentForElement(listDom); - listComponent.openForm({ - position: 'top', - }); - Popup.close(); - }, 'click .js-list-subscribe' () {}, 'click .js-select-cards' () { const cardIds = this.allCards().map((card) => card._id); @@ -62,9 +79,63 @@ Template.listActionPopup.events({ this.archive(); Popup.close(); }, + 'click .js-set-wip-limit': Popup.open('setWipLimit'), 'click .js-more': Popup.open('listMore'), }); +BlazeComponent.extendComponent({ + applyWipLimit() { + const list = Template.currentData(); + const limit = parseInt(Template.instance().$('.wip-limit-value').val(), 10); + + if(limit < list.cards().count() && !list.getWipLimit('soft')){ + Template.instance().$('.wip-limit-error').click(); + } else { + Meteor.call('applyWipLimit', list._id, limit); + Popup.back(); + } + }, + + enableSoftLimit() { + const list = Template.currentData(); + + if(list.getWipLimit('soft') && list.getWipLimit('value') < list.cards().count()){ + list.setWipLimit(list.cards().count()); + } + Meteor.call('enableSoftLimit', Template.currentData()._id); + }, + + enableWipLimit() { + const list = Template.currentData(); + // Prevent user from using previously stored wipLimit.value if it is less than the current number of cards in the list + if(!list.getWipLimit('enabled') && list.getWipLimit('value') < list.cards().count()){ + list.setWipLimit(list.cards().count()); + } + Meteor.call('enableWipLimit', list._id); + }, + + isWipLimitSoft() { + return Template.currentData().getWipLimit('soft'); + }, + + isWipLimitEnabled() { + return Template.currentData().getWipLimit('enabled'); + }, + + wipLimitValue(){ + return Template.currentData().getWipLimit('value'); + }, + + events() { + return [{ + 'click .js-enable-wip-limit': this.enableWipLimit, + 'click .wip-limit-apply': this.applyWipLimit, + 'click .wip-limit-error': Popup.open('wipLimitError'), + 'click .materialCheckBox': this.enableSoftLimit, + }]; + }, +}).register('setWipLimitPopup'); + Template.listMorePopup.events({ 'click .js-delete': Popup.afterConfirm('listDelete', function () { Popup.close(); diff --git a/client/components/main/editor.jade b/client/components/main/editor.jade index f10d64a1..31f533e6 100644 --- a/client/components/main/editor.jade +++ b/client/components/main/editor.jade @@ -9,6 +9,5 @@ template(name="editor") template(name="viewer") .viewer +mentions - +emoji - +markdown - {{> UI.contentBlock }} + +markdown + {{> UI.contentBlock }} diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 5987b772..888fbe00 100755 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -4,25 +4,6 @@ Template.editor.onRendered(() => { autosize($textarea); $textarea.escapeableTextComplete([ - // Emoji - { - match: /\B:([\-+\w]*)$/, - search(term, callback) { - callback(Emoji.values.map((emoji) => { - return emoji.includes(term) ? emoji : null; - }).filter(Boolean)); - }, - template(value) { - const imgSrc = Emoji.baseImagePath + value; - const image = `<img src="${imgSrc}.png" />`; - return image + value; - }, - replace(value) { - return `:${value}:`; - }, - index: 1, - }, - // User mentions { match: /\B@([\w.]*)$/, @@ -47,7 +28,7 @@ Template.editor.onRendered(() => { import sanitizeXss from 'xss'; // XXX I believe we should compute a HTML rendered field on the server that -// would handle markdown, emoji and user mentions. We can simply have two +// would handle markdown and user mentions. We can simply have two // fields, one source, and one compiled version (in HTML) and send only the // compiled version to most users -- who don't need to edit. // In the meantime, all the transformation are done on the client using the diff --git a/client/components/main/header.jade b/client/components/main/header.jade index bd0af880..dd071b3e 100644 --- a/client/components/main/header.jade +++ b/client/components/main/header.jade @@ -7,19 +7,31 @@ template(name="header") unless isSandstorm if currentUser #header-quick-access(class=currentBoard.colorClass) - ul - li - a(href="{{pathFor 'home'}}") - span.fa.fa-home - | {{_ 'all-boards'}} - each currentUser.starredBoards - li.separator - - li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}") - a(href="{{pathFor 'board' id=_id slug=slug}}") - = title - else - li.current {{_ 'quick-access-description'}} + if isMiniScreen + ul + li + a(href="{{pathFor 'home'}}") + span.fa.fa-home + if currentList + each currentBoard.lists + li(class="{{#if $.Session.equals 'currentList' _id}}current{{/if}}") + a.js-select-list + = title + #header-new-board-icon + else + ul + li + a(href="{{pathFor 'home'}}") + span.fa.fa-home + | {{_ 'all-boards'}} + each currentUser.starredBoards + li.separator - + li(class="{{#if $.Session.equals 'currentBoard' _id}}current{{/if}}") + a(href="{{pathFor 'board' id=_id slug=slug}}") + = title + else + li.current {{_ 'quick-access-description'}} a#header-new-board-icon.js-create-board i.fa.fa-plus(title="Create a new board") @@ -49,6 +61,14 @@ template(name="header") if appIsOffline +offlineWarning + if currentUser.isBoardMember + if hasAnnouncement + .announcement + p + i.fa.fa-bullhorn + | #{announcement} + i.fa.fa-times-circle.js-close-announcement + template(name="offlineWarning") .offline-warning p diff --git a/client/components/main/header.js b/client/components/main/header.js index 49acbfef..7fbc5716 100644 --- a/client/components/main/header.js +++ b/client/components/main/header.js @@ -1,3 +1,6 @@ +Meteor.subscribe('user-admin'); +Meteor.subscribe('boards'); + Template.header.helpers({ wrappedHeader() { return !Session.get('currentBoard'); @@ -10,8 +13,26 @@ Template.header.helpers({ appIsOffline() { return !Meteor.status().connected; }, + + hasAnnouncement() { + const announcements = Announcements.findOne(); + return announcements && announcements.enabled; + }, + + announcement() { + $('.announcement').show(); + const announcements = Announcements.findOne(); + return announcements && announcements.body; + }, }); Template.header.events({ 'click .js-create-board': Popup.open('headerBarCreateBoard'), + 'click .js-close-announcement'() { + $('.announcement').hide(); + }, + 'click .js-select-list'() { + Session.set('currentList', this._id); + Session.set('currentCard', null); + }, }); diff --git a/client/components/main/header.styl b/client/components/main/header.styl index 0e35d38a..f9455f8e 100644 --- a/client/components/main/header.styl +++ b/client/components/main/header.styl @@ -4,6 +4,7 @@ color: white transition: background-color 0.4s background: #2980B9 + z-index: 17 #header-main-bar height: 40px @@ -28,13 +29,12 @@ font-size: 0.9em margin-right: 10px - .wekan-logo - display: block - margin: 3px auto 0 + margin: 3px auto auto width: 97px opacity: 0.6 transition: opacity 0.15s + float: right &:hover opacity: 0.9 @@ -99,6 +99,7 @@ height: 28px font-size: 12px display: flex + z-index: 17 #header-user-bar, #header-new-board-icon, @@ -191,12 +192,35 @@ bottom: 0px ul - width: calc(100% - 150px) + width: calc(100% - 60px) overflow: ellipsis + padding: 10px + margin: -10px li - height: 28px + height: 100% + padding: 12px 0px + margin: -10px 0px + + a + height: 100% + padding: 12px 10px + margin: -10px 0px + + .fa-home + font-size: 26px + margin-top: -2px + + #header-new-board-icon + display: none + + #header-user-bar + position: absolute + right: 0px + padding: 10px + margin: -10px +.announcement, .offline-warning width: 100% text-align: center diff --git a/client/components/main/layouts.jade b/client/components/main/layouts.jade index a51a35e0..4d76aabb 100644 --- a/client/components/main/layouts.jade +++ b/client/components/main/layouts.jade @@ -8,6 +8,7 @@ head difficult to do that cleanly with Blaze -- at least without adding extra packages. link(rel="shortcut icon" href="/wekan-favicon.png") + link(rel="apple-touch-icon" href="/wekan-favicon.png") link(rel="manifest" href="/wekan-manifest.json") template(name="userFormsLayout") diff --git a/client/components/main/layouts.js b/client/components/main/layouts.js index 1936d7aa..f12718a7 100644 --- a/client/components/main/layouts.js +++ b/client/components/main/layouts.js @@ -1,7 +1,3 @@ -Meteor.subscribe('boards'); -Meteor.subscribe('setting'); -Meteor.subscribe('user-admin'); - BlazeLayout.setRoot('body'); const i18nTagToT9n = (i18nTag) => { @@ -21,10 +17,14 @@ Template.userFormsLayout.onRendered(() => { Template.userFormsLayout.helpers({ languages() { return _.map(TAPi18n.getLanguages(), (lang, code) => { - return { - tag: code, - name: lang.name === 'br' ? 'Brezhoneg' : lang.name, - }; + const tag = code; + let name = lang.name; + if (lang.name === 'br') { + name = 'Brezhoneg'; + } else if (lang.name === 'ig') { + name = 'Igbo'; + } + return { tag, name }; }).sort(function(a, b) { if (a.name === b.name) { return 0; diff --git a/client/components/main/layouts.styl b/client/components/main/layouts.styl index 734a5e83..a79ff337 100644 --- a/client/components/main/layouts.styl +++ b/client/components/main/layouts.styl @@ -270,11 +270,6 @@ kbd .grabbing cursor: grabbing -.emoji - height: 18px - width: 18px - vertical-align: text-bottom - // Implement a thiner close icon as suggested in // https://github.com/FortAwesome/Font-Awesome/issues/1540#issuecomment-68689950 .fa.fa-times-thin:before diff --git a/client/components/settings/invitationCode.js b/client/components/settings/invitationCode.js index a403d8ab..c02f860f 100644 --- a/client/components/settings/invitationCode.js +++ b/client/components/settings/invitationCode.js @@ -1,6 +1,6 @@ Template.invitationCode.onRendered(() => { const setting = Settings.findOne(); - if (!setting || !setting.disableRegistration) { + if (setting || setting.disableRegistration) { $('#invitationcode').hide(); } }); diff --git a/client/components/settings/peopleBody.jade b/client/components/settings/peopleBody.jade new file mode 100644 index 00000000..a3506a24 --- /dev/null +++ b/client/components/settings/peopleBody.jade @@ -0,0 +1,90 @@ +template(name="people") + .setting-content + unless currentUser.isAdmin + | {{_ 'error-notAuthorized'}} + else + .content-title + span {{_ 'people'}} + .content-body + .side-menu + ul + li.active + a.js-setting-menu(data-id="people-setting") {{_ 'people'}} + .main-body + if loading.get + +spinner + else if people.get + +peopleGeneral + +template(name="peopleGeneral") + table + tbody + tr + th {{_ 'username'}} + th {{_ 'fullname'}} + th {{_ 'admin'}} + th {{_ 'email'}} + th {{_ 'verified'}} + th {{_ 'createdAt'}} + th {{_ 'active'}} + th + each user in peopleList + +peopleRow(userId=user._id) + +template(name="peopleRow") + tr + td.username {{ userData.username }} + td {{ userData.profile.fullname }} + td + if userData.isAdmin + | {{_ 'yes'}} + else + | {{_ 'no'}} + td {{ userData.emails.[0].address }} + td + if userData.emails.[0].verified + | {{_ 'yes'}} + else + | {{_ 'no'}} + td {{ moment userData.createdAt 'LLL' }} + td + if userData.loginDisabled + | {{_ 'no'}} + else + | {{_ 'yes'}} + td + a.edit-user + | {{_ 'edit'}} + +template(name="editUserPopup") + form + label.hide.userId(type="text" value=user._id) + label + | {{_ 'fullname'}} + input.js-profile-fullname(type="text" value=user.profile.fullname autofocus) + label + | {{_ 'username'}} + span.error.hide.username-taken + | {{_ 'error-username-taken'}} + input.js-profile-username(type="text" value=user.username) + label + | {{_ 'email'}} + span.error.hide.email-taken + | {{_ 'error-email-taken'}} + input.js-profile-email(type="email" value="{{user.emails.[0].address}}") + label + | {{_ 'admin'}} + select.select-role.js-profile-isadmin + option(value="false") {{_ 'no'}} + option(value="true" selected="{{user.isAdmin}}") {{_ 'yes'}} + label + | {{_ 'active'}} + select.select-active.js-profile-isactive + option(value="false") {{_ 'yes'}} + option(value="true" selected="{{user.loginDisabled}}") {{_ 'no'}} + hr + label + | {{_ 'password'}} + input.js-profile-password(type="password") + + input.primary.wide(type="submit" value="{{_ 'save'}}") diff --git a/client/components/settings/peopleBody.js b/client/components/settings/peopleBody.js new file mode 100644 index 00000000..7cc992f2 --- /dev/null +++ b/client/components/settings/peopleBody.js @@ -0,0 +1,158 @@ +const usersPerPage = 25; + +BlazeComponent.extendComponent({ + mixins() { + return [Mixins.InfiniteScrolling]; + }, + onCreated() { + this.error = new ReactiveVar(''); + this.loading = new ReactiveVar(false); + this.people = new ReactiveVar(true); + + this.page = new ReactiveVar(1); + this.loadNextPageLocked = false; + this.callFirstWith(null, 'resetNextPeak'); + this.autorun(() => { + const limit = this.page.get() * usersPerPage; + + this.subscribe('people', limit, () => { + this.loadNextPageLocked = false; + const nextPeakBefore = this.callFirstWith(null, 'getNextPeak'); + this.calculateNextPeak(); + const nextPeakAfter = this.callFirstWith(null, 'getNextPeak'); + if (nextPeakBefore === nextPeakAfter) { + this.callFirstWith(null, 'resetNextPeak'); + } + }); + }); + }, + loadNextPage() { + if (this.loadNextPageLocked === false) { + this.page.set(this.page.get() + 1); + this.loadNextPageLocked = true; + } + }, + calculateNextPeak() { + const element = this.find('.main-body'); + if (element) { + const altitude = element.scrollHeight; + this.callFirstWith(this, 'setNextPeak', altitude); + } + }, + reachNextPeak() { + this.loadNextPage(); + }, + setError(error) { + this.error.set(error); + }, + setLoading(w) { + this.loading.set(w); + }, + peopleList() { + return Users.find({}, { + fields: {_id: true}, + }); + }, +}).register('people'); + +Template.peopleRow.helpers({ + userData() { + const userCollection = this.esSearch ? ESSearchResults : Users; + return userCollection.findOne(this.userId); + }, +}); + +Template.editUserPopup.helpers({ + user() { + return Users.findOne(this.userId); + }, +}); + +BlazeComponent.extendComponent({ + onCreated() { + }, + user() { + return Users.findOne(this.userId); + }, + events() { + return [{ + 'click a.edit-user': Popup.open('editUser'), + }]; + }, +}).register('peopleRow'); + +Template.editUserPopup.events({ + submit(evt, tpl) { + evt.preventDefault(); + const user = Users.findOne(this.userId); + const fullname = tpl.find('.js-profile-fullname').value.trim(); + const username = tpl.find('.js-profile-username').value.trim(); + const password = tpl.find('.js-profile-password').value; + const isAdmin = tpl.find('.js-profile-isadmin').value.trim(); + const isActive = tpl.find('.js-profile-isactive').value.trim(); + const email = tpl.find('.js-profile-email').value.trim(); + + const isChangePassword = password.length > 0; + const isChangeUserName = username !== user.username; + const isChangeEmail = email.toLowerCase() !== user.emails[0].address.toLowerCase(); + + Users.update(this.userId, { + $set: { + 'profile.fullname': fullname, + 'isAdmin': isAdmin === 'true', + 'loginDisabled': isActive === 'true', + }, + }); + + if(isChangePassword){ + Meteor.call('setPassword', password, this.userId); + } + + if (isChangeUserName && isChangeEmail) { + Meteor.call('setUsernameAndEmail', username, email.toLowerCase(), this.userId, function (error) { + const usernameMessageElement = tpl.$('.username-taken'); + const emailMessageElement = tpl.$('.email-taken'); + if (error) { + const errorElement = error.error; + if (errorElement === 'username-already-taken') { + usernameMessageElement.show(); + emailMessageElement.hide(); + } else if (errorElement === 'email-already-taken') { + usernameMessageElement.hide(); + emailMessageElement.show(); + } + } else { + usernameMessageElement.hide(); + emailMessageElement.hide(); + Popup.close(); + } + }); + } else if (isChangeUserName) { + Meteor.call('setUsername', username, this.userId, function (error) { + const usernameMessageElement = tpl.$('.username-taken'); + if (error) { + const errorElement = error.error; + if (errorElement === 'username-already-taken') { + usernameMessageElement.show(); + } + } else { + usernameMessageElement.hide(); + Popup.close(); + } + }); + } else if (isChangeEmail) { + Meteor.call('setEmail', email.toLowerCase(), this.userId, function (error) { + const emailMessageElement = tpl.$('.email-taken'); + if (error) { + const errorElement = error.error; + if (errorElement === 'email-already-taken') { + emailMessageElement.show(); + } + } else { + emailMessageElement.hide(); + Popup.close(); + } + }); + } else Popup.close(); + }, +}); diff --git a/client/components/settings/peopleBody.styl b/client/components/settings/peopleBody.styl new file mode 100644 index 00000000..84db44a7 --- /dev/null +++ b/client/components/settings/peopleBody.styl @@ -0,0 +1,15 @@ +.main-body + overflow: scroll; + +table + border-collapse: collapse; + width: 100%; + color: #000; + + td, th + border: 1px solid #d2d0d0; + text-align: left; + padding: 8px; + + tr:nth-child(even) + background-color: #dddddd; diff --git a/client/components/settings/settingBody.jade b/client/components/settings/settingBody.jade index 5864efd5..5bc7972d 100644 --- a/client/components/settings/settingBody.jade +++ b/client/components/settings/settingBody.jade @@ -14,6 +14,8 @@ template(name="setting") a.js-setting-menu(data-id="email-setting") {{_ 'email'}} li a.js-setting-menu(data-id="account-setting") {{_ 'accounts'}} + li + a.js-setting-menu(data-id="announcement-setting") {{_ 'admin-announcement'}} .main-body if loading.get +spinner @@ -23,6 +25,8 @@ template(name="setting") +email else if accountSetting.get +accountSettings + else if announcementSetting.get + +announcementSettings template(name="general") ul#registration-setting.setting-detail @@ -85,9 +89,12 @@ template(name='email') li button.js-save.primary {{_ 'save'}} + li + button.js-send-smtp-test-email.primary {{_ 'send-smtp-test'}} + template(name='accountSettings') ul#account-setting.setting-detail - li.smtp-form + li.accounts-form .title {{_ 'accounts-allowEmailChange'}} .form-group.flex input.form-control#accounts-allowEmailChange(type="radio" name="allowEmailChange" value="true" checked="{{#if allowEmailChange}}checked{{/if}}") @@ -95,4 +102,28 @@ template(name='accountSettings') input.form-control#accounts-allowEmailChange(type="radio" name="allowEmailChange" value="false" checked="{{#unless allowEmailChange}}checked{{/unless}}") span {{_ 'no'}} li + li.accounts-form + .title {{_ 'accounts-allowUserNameChange'}} + .form-group.flex + input.form-control#accounts-allowUserNameChange(type="radio" name="allowUserNameChange" value="true" checked="{{#if allowUserNameChange}}checked{{/if}}") + span {{_ 'yes'}} + input.form-control#accounts-allowUserNameChange(type="radio" name="allowUserNameChange" value="false" checked="{{#unless allowUserNameChange}}checked{{/unless}}") + span {{_ 'no'}} + li button.js-accounts-save.primary {{_ 'save'}} + +template(name='announcementSettings') + ul#announcement-setting.setting-detail + li + a.flex.js-toggle-activemessage + .materialCheckBox(class="{{#if currentSetting.enabled}}is-checked{{/if}}") + + span {{_ 'admin-announcement-active'}} + li + .admin-announcement(class="{{#if currentSetting.enabled}}{{else}}hide{{/if}}") + ul + li + .title {{_ 'admin-announcement-title'}} + textarea#admin-announcement.form-control= currentSetting.body + li + button.js-announcement-save.primary {{_ 'save'}} diff --git a/client/components/settings/settingBody.js b/client/components/settings/settingBody.js index a2993426..7230d893 100644 --- a/client/components/settings/settingBody.js +++ b/client/components/settings/settingBody.js @@ -1,7 +1,3 @@ -Meteor.subscribe('setting'); -Meteor.subscribe('mailServer'); -Meteor.subscribe('accountSettings'); - BlazeComponent.extendComponent({ onCreated() { this.error = new ReactiveVar(''); @@ -9,6 +5,12 @@ BlazeComponent.extendComponent({ this.generalSetting = new ReactiveVar(true); this.emailSetting = new ReactiveVar(false); this.accountSetting = new ReactiveVar(false); + this.announcementSetting = new ReactiveVar(false); + + Meteor.subscribe('setting'); + Meteor.subscribe('mailServer'); + Meteor.subscribe('accountSettings'); + Meteor.subscribe('announcements'); }, setError(error) { @@ -21,7 +23,7 @@ BlazeComponent.extendComponent({ checkField(selector) { const value = $(selector).val(); - if(!value || value.trim() === ''){ + if (!value || value.trim() === '') { $(selector).parents('li.smtp-form').addClass('has-error'); throw Error('blank field'); } else { @@ -29,7 +31,7 @@ BlazeComponent.extendComponent({ } }, - currentSetting(){ + currentSetting() { return Settings.findOne(); }, @@ -42,35 +44,36 @@ BlazeComponent.extendComponent({ sort: ['title'], }); }, - toggleRegistration(){ + toggleRegistration() { this.setLoading(true); const registrationClosed = this.currentSetting().disableRegistration; - Settings.update(Settings.findOne()._id, {$set:{disableRegistration: !registrationClosed}}); + Settings.update(Settings.findOne()._id, {$set: {disableRegistration: !registrationClosed}}); this.setLoading(false); - if(registrationClosed){ + if (registrationClosed) { $('.invite-people').slideUp(); - }else{ + } else { $('.invite-people').slideDown(); } }, - toggleTLS(){ + toggleTLS() { $('#mail-server-tls').toggleClass('is-checked'); }, - switchMenu(event){ + switchMenu(event) { const target = $(event.target); - if(!target.hasClass('active')){ + if (!target.hasClass('active')) { $('.side-menu li.active').removeClass('active'); target.parent().addClass('active'); const targetID = target.data('id'); this.generalSetting.set('registration-setting' === targetID); this.emailSetting.set('email-setting' === targetID); this.accountSetting.set('account-setting' === targetID); + this.announcementSetting.set('announcement-setting' === targetID); } }, - checkBoard(event){ + checkBoard(event) { let target = $(event.target); - if(!target.hasClass('js-toggle-board-choose')){ + if (!target.hasClass('js-toggle-board-choose')) { target = target.parent(); } const checkboxId = target.attr('id'); @@ -78,7 +81,7 @@ BlazeComponent.extendComponent({ $(`#${checkboxId}`).toggleClass('is-checked'); }, - inviteThroughEmail(){ + inviteThroughEmail() { const emails = $('#email-to-invite').val().trim().split('\n').join(',').split(','); const boardsToInvite = []; $('.js-toggle-board-choose .materialCheckBox.is-checked').each(function () { @@ -101,19 +104,23 @@ BlazeComponent.extendComponent({ } }, - saveMailServerInfo(){ + saveMailServerInfo() { this.setLoading(true); $('li').removeClass('has-error'); - try{ + try { const host = this.checkField('#mail-server-host'); const port = this.checkField('#mail-server-port'); const username = $('#mail-server-username').val().trim(); const password = $('#mail-server-password').val().trim(); const from = this.checkField('#mail-server-from'); const tls = $('#mail-server-tls.is-checked').length > 0; - Settings.update(Settings.findOne()._id, {$set:{'mailServer.host':host, 'mailServer.port': port, 'mailServer.username': username, - 'mailServer.password': password, 'mailServer.enableTLS': tls, 'mailServer.from': from}}); + Settings.update(Settings.findOne()._id, { + $set: { + 'mailServer.host': host, 'mailServer.port': port, 'mailServer.username': username, + 'mailServer.password': password, 'mailServer.enableTLS': tls, 'mailServer.from': from, + }, + }); } catch (e) { return; } finally { @@ -122,7 +129,23 @@ BlazeComponent.extendComponent({ }, - events(){ + sendSMTPTestEmail() { + Meteor.call('sendSMTPTestEmail', (err, ret) => { + if (!err && ret) { /* eslint-disable no-console */ + const message = `${TAPi18n.__(ret.message)}: ${ret.email}`; + console.log(message); + alert(message); + } else { + const reason = err.reason || ''; + const message = `${TAPi18n.__(err.error)}\n${reason}`; + console.log(message, err); + alert(message); + } + /* eslint-enable no-console */ + }); + }, + + events() { return [{ 'click a.js-toggle-registration': this.toggleRegistration, 'click a.js-toggle-tls': this.toggleTLS, @@ -130,25 +153,76 @@ BlazeComponent.extendComponent({ 'click a.js-toggle-board-choose': this.checkBoard, 'click button.js-email-invite': this.inviteThroughEmail, 'click button.js-save': this.saveMailServerInfo, + 'click button.js-send-smtp-test-email': this.sendSMTPTestEmail, }]; }, }).register('setting'); BlazeComponent.extendComponent({ - saveAllowEmailChange() { + + saveAccountsChange() { const allowEmailChange = ($('input[name=allowEmailChange]:checked').val() === 'true'); + const allowUserNameChange = ($('input[name=allowUserNameChange]:checked').val() === 'true'); AccountSettings.update('accounts-allowEmailChange', { - $set: { 'booleanValue': allowEmailChange }, + $set: {'booleanValue': allowEmailChange}, + }); + AccountSettings.update('accounts-allowUserNameChange', { + $set: {'booleanValue': allowUserNameChange}, }); }, allowEmailChange() { return AccountSettings.findOne('accounts-allowEmailChange').booleanValue; }, + allowUserNameChange() { + return AccountSettings.findOne('accounts-allowUserNameChange').booleanValue; + }, events() { return [{ - 'click button.js-accounts-save': this.saveAllowEmailChange, + 'click button.js-accounts-save': this.saveAccountsChange, }]; }, }).register('accountSettings'); + +BlazeComponent.extendComponent({ + onCreated() { + this.loading = new ReactiveVar(false); + }, + + setLoading(w) { + this.loading.set(w); + }, + + currentSetting() { + return Announcements.findOne(); + }, + + saveMessage() { + const message = $('#admin-announcement').val().trim(); + Announcements.update(Announcements.findOne()._id, { + $set: {'body': message}, + }); + }, + + toggleActive() { + this.setLoading(true); + const isActive = this.currentSetting().enabled; + Announcements.update(Announcements.findOne()._id, { + $set: {'enabled': !isActive}, + }); + this.setLoading(false); + if (isActive) { + $('.admin-announcement').slideUp(); + } else { + $('.admin-announcement').slideDown(); + } + }, + + events() { + return [{ + 'click a.js-toggle-activemessage': this.toggleActive, + 'click button.js-announcement-save': this.saveMessage, + }]; + }, +}).register('announcementSettings'); diff --git a/client/components/settings/settingBody.styl b/client/components/settings/settingBody.styl index 118d364c..fec64cee 100644 --- a/client/components/settings/settingBody.styl +++ b/client/components/settings/settingBody.styl @@ -61,10 +61,11 @@ .is-checked border-bottom: 2px solid #2980b9; border-right: 2px solid #2980b9; - - span + + span padding: 0 0.5rem - + + .admin-announcement, .invite-people padding-left 20px; li diff --git a/client/components/settings/settingHeader.jade b/client/components/settings/settingHeader.jade index c22cf5c6..c2d4db3a 100644 --- a/client/components/settings/settingHeader.jade +++ b/client/components/settings/settingHeader.jade @@ -9,13 +9,14 @@ template(name="settingHeaderBar") a.setting-header-btn.settings(href="{{pathFor 'setting'}}") i.fa(class="fa-cog") span {{_ 'settings'}} + + a.setting-header-btn.people(href="{{pathFor 'people'}}") + i.fa(class="fa-users") + span {{_ 'people'}} + a.setting-header-btn.informations(href="{{pathFor 'information'}}") i.fa(class="fa-info-circle") span {{_ 'info'}} -//TODO -// a.setting-header-btn.people -// i.fa(class="fa-users") -// span {{_ 'people'}} else a.setting-header-btn.js-log-in( diff --git a/client/components/settings/settingHeader.styl b/client/components/settings/settingHeader.styl index 995ed26d..3699f180 100644 --- a/client/components/settings/settingHeader.styl +++ b/client/components/settings/settingHeader.styl @@ -22,4 +22,4 @@ + span display: inline-block margin-top: 1px - margin-right: 10px
\ No newline at end of file + margin-right: 10px diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 6045b371..6085c2ad 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -82,23 +82,27 @@ template(name="memberPopup") ul.pop-over-list li a.js-filter-member {{_ 'filter-cards'}} - if currentUser.isBoardAdmin - unless isSandstorm + unless isSandstorm + if currentUser.isBoardAdmin li a.js-change-role | {{_ 'change-permissions'}} span.quiet (#{memberType}) - li - if $eq currentUser._id userId - a.js-leave-member {{_ 'leave-board'}} - else - a.js-remove-member {{_ 'remove-from-board'}} + li + if $eq currentUser._id userId + a.js-leave-member {{_ 'leave-board'}} + else if currentUser.isBoardAdmin + a.js-remove-member {{_ 'remove-from-board'}} template(name="removeMemberPopup") p {{_ 'remove-member-pop' name=user.profile.fullname username=user.username boardTitle=board.title}} button.js-confirm.negate.full(type="submit") {{_ 'remove-member'}} +template(name="leaveBoardPopup") + p {{_ 'leave-board-pop' boardTitle=board.title}} + button.js-confirm.negate.full(type="submit") {{_ 'leave-board'}} + template(name="addMemberPopup") .js-search-member +esInput(index="users") diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js index 59a2b42c..5a9de74b 100644 --- a/client/components/sidebar/sidebar.js +++ b/client/components/sidebar/sidebar.js @@ -4,6 +4,7 @@ const defaultView = 'home'; const viewTitles = { filter: 'filter-cards', + search: 'search-cards', multiselection: 'multi-selection', customFields: 'custom-fields', archives: 'archives', @@ -154,15 +155,13 @@ Template.memberPopup.events({ Boards.findOne(boardId).removeMember(memberId); Popup.close(); }), - 'click .js-leave-member'() { + 'click .js-leave-member': Popup.afterConfirm('leaveBoard', () => { const boardId = Session.get('currentBoard'); - Meteor.call('quitBoard', boardId, (err, ret) => { - if (!ret && ret) { - Popup.close(); - FlowRouter.go('home'); - } + Meteor.call('quitBoard', boardId, () => { + Popup.close(); + FlowRouter.go('home'); }); - }, + }), }); Template.removeMemberPopup.helpers({ @@ -174,6 +173,12 @@ Template.removeMemberPopup.helpers({ }, }); +Template.leaveBoardPopup.helpers({ + board() { + return Boards.findOne(Session.get('currentBoard')); + }, +}); + Template.membersWidget.helpers({ isInvited() { const user = Meteor.user(); diff --git a/client/components/sidebar/sidebarArchives.jade b/client/components/sidebar/sidebarArchives.jade index aa14fc30..ee6cac01 100644 --- a/client/components/sidebar/sidebarArchives.jade +++ b/client/components/sidebar/sidebarArchives.jade @@ -25,3 +25,14 @@ template(name="archivesSidebar") = title else li.no-items-message {{_ 'no-archived-lists'}} + + +tabContent(slug="swimlanes") + ul.archived-lists + each archivedSwimlanes + li.archived-lists-item + if currentUser.isBoardMember + button.js-restore-swimlane + i.fa.fa-undo + = title + else + li.no-items-message {{_ 'no-archived-swimlanes'}} diff --git a/client/components/sidebar/sidebarArchives.js b/client/components/sidebar/sidebarArchives.js index c8196f23..6102bf11 100644 --- a/client/components/sidebar/sidebarArchives.js +++ b/client/components/sidebar/sidebarArchives.js @@ -3,6 +3,7 @@ BlazeComponent.extendComponent({ return [ { name: TAPi18n.__('cards'), slug: 'cards' }, { name: TAPi18n.__('lists'), slug: 'lists' }, + { name: TAPi18n.__('swimlanes'), slug: 'swimlanes' }, ]; }, @@ -20,6 +21,13 @@ BlazeComponent.extendComponent({ }); }, + archivedSwimlanes() { + return Swimlanes.find({ + archived: true, + boardId: Session.get('currentBoard'), + }); + }, + cardIsInArchivedList() { return this.currentData().list().archived; }, @@ -32,7 +40,9 @@ BlazeComponent.extendComponent({ return [{ 'click .js-restore-card'() { const card = this.currentData(); - card.restore(); + if(card.canBeRestored()){ + card.restore(); + } }, 'click .js-delete-card': Popup.afterConfirm('cardDelete', function() { const cardId = this._id; @@ -43,6 +53,10 @@ BlazeComponent.extendComponent({ const list = this.currentData(); list.restore(); }, + 'click .js-restore-swimlane'() { + const swimlane = this.currentData(); + swimlane.restore(); + }, }]; }, }).register('archivesSidebar'); diff --git a/client/components/sidebar/sidebarFilters.jade b/client/components/sidebar/sidebarFilters.jade index 9a9774bb..273df8c2 100644 --- a/client/components/sidebar/sidebarFilters.jade +++ b/client/components/sidebar/sidebarFilters.jade @@ -59,7 +59,7 @@ template(name="multiselectionSidebar") if name = name else - span.quiet {{_ "label-default" color}} + span.quiet {{_ "label-default" (_ (concat "color-" color))}} if allSelectedElementHave 'label' _id i.fa.fa-check else if someSelectedElementHave 'label' _id diff --git a/client/components/sidebar/sidebarSearches.jade b/client/components/sidebar/sidebarSearches.jade new file mode 100644 index 00000000..2ad5b00f --- /dev/null +++ b/client/components/sidebar/sidebarSearches.jade @@ -0,0 +1,8 @@ +template(name="searchSidebar") + form.js-search-term-form + input(type="text" name="searchTerm" placeholder="{{_ 'search-example'}}" autofocus) + .list-body.js-perfect-scrollbar + .minicards.clearfix.js-minicards + each (results) + a.minicard-wrapper.js-minicard(href=absoluteUrl) + +minicard(this) diff --git a/client/components/sidebar/sidebarSearches.js b/client/components/sidebar/sidebarSearches.js new file mode 100644 index 00000000..111a86b8 --- /dev/null +++ b/client/components/sidebar/sidebarSearches.js @@ -0,0 +1,19 @@ +BlazeComponent.extendComponent({ + onCreated() { + this.term = new ReactiveVar(''); + }, + + results() { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + return currentBoard.searchCards(this.term.get()); + }, + + events() { + return [{ + 'submit .js-search-term-form'(evt) { + evt.preventDefault(); + this.term.set(evt.target.searchTerm.value); + }, + }]; + }, +}).register('searchSidebar'); diff --git a/client/components/sidebar/sidebarSearches.styl b/client/components/sidebar/sidebarSearches.styl new file mode 100644 index 00000000..6b8ad904 --- /dev/null +++ b/client/components/sidebar/sidebarSearches.styl @@ -0,0 +1,2 @@ +input + max-width: 100% diff --git a/client/components/swimlanes/swimlaneHeader.jade b/client/components/swimlanes/swimlaneHeader.jade new file mode 100644 index 00000000..483de06f --- /dev/null +++ b/client/components/swimlanes/swimlaneHeader.jade @@ -0,0 +1,23 @@ +template(name="swimlaneHeader") + .swimlane-header-wrap.js-swimlane-header + +inlinedForm + +editSwimlaneTitleForm + else + .swimlane-header( + class="{{#if currentUser.isBoardMember}}js-open-inlined-form is-editable{{/if}}") + = title + .swimlane-header-menu + unless currentUser.isCommentOnly + a.fa.fa-navicon.js-open-swimlane-menu + +template(name="editSwimlaneTitleForm") + .list-composer + input.list-name-input.full-line(type="text" value=title autofocus) + .edit-controls.clearfix + button.primary.confirm(type="submit") {{_ 'save'}} + a.fa.fa-times-thin.js-close-inlined-form + +template(name="swimlaneActionPopup") + unless currentUser.isCommentOnly + ul.pop-over-list + li: a.js-close-swimlane {{_ 'archive-swimlane'}} diff --git a/client/components/swimlanes/swimlaneHeader.js b/client/components/swimlanes/swimlaneHeader.js new file mode 100644 index 00000000..50635f86 --- /dev/null +++ b/client/components/swimlanes/swimlaneHeader.js @@ -0,0 +1,25 @@ +BlazeComponent.extendComponent({ + editTitle(evt) { + evt.preventDefault(); + const newTitle = this.childComponents('inlinedForm')[0].getValue().trim(); + const swimlane = this.currentData(); + if (newTitle) { + swimlane.rename(newTitle.trim()); + } + }, + + events() { + return [{ + 'click .js-open-swimlane-menu': Popup.open('swimlaneAction'), + submit: this.editTitle, + }]; + }, +}).register('swimlaneHeader'); + +Template.swimlaneActionPopup.events({ + 'click .js-close-swimlane' (evt) { + evt.preventDefault(); + this.archive(); + Popup.close(); + }, +}); diff --git a/client/components/swimlanes/swimlanes.jade b/client/components/swimlanes/swimlanes.jade new file mode 100644 index 00000000..76f54c66 --- /dev/null +++ b/client/components/swimlanes/swimlanes.jade @@ -0,0 +1,75 @@ +template(name="swimlane") + .swimlane.js-lists.js-swimlane + +swimlaneHeader + if isMiniScreen + if currentList + +list(currentList) + else + each currentBoard.lists + +miniList(this) + if currentUser.isBoardMember + +addListForm + else + each currentBoard.lists + +list(this) + if currentCardIsInThisList _id ../_id + +cardDetails(currentCard) + if currentUser.isBoardMember + +addListAndSwimlaneForm + +template(name="listsGroup") + .swimlane.list-group.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 _id null + +cardDetails(currentCard) + if currentUser.isBoardMember + +addListForm + +template(name="addListAndSwimlaneForm") + .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.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..7965c2bc --- /dev/null +++ b/client/components/swimlanes/swimlanes.js @@ -0,0 +1,235 @@ +const { calculateIndex } = Utils; + +function currentCardIsInThisList(listId, swimlaneId) { + const currentCard = Cards.findOne(Session.get('currentCard')); + const currentUser = Meteor.user(); + if (currentUser.profile.boardView === 'board-view-lists') + return currentCard && currentCard.listId === listId; + else if (currentUser.profile.boardView === 'board-view-swimlanes') + return currentCard && currentCard.listId === listId && currentCard.swimlaneId === swimlaneId; + else + return false; +} + +function initSortable(boardComponent, $listsDom) { + // We want to animate the card details window closing. We rely on CSS + // transition for the actual animation. + $listsDom._uihooks = { + removeElement(node) { + const removeNode = _.once(() => { + node.parentNode.removeChild(node); + }); + if ($(node).hasClass('js-card-details')) { + $(node).css({ + flexBasis: 0, + padding: 0, + }); + $listsDom.one(CSSEvents.transitionend, removeNode); + } else { + removeNode(); + } + }, + }; + + $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()); + EscapeActions.executeUpTo('popup-close'); + boardComponent.setIsDragging(true); + }, + stop(evt, ui) { + // To attribute the new index number, we need to get the DOM element + // of the previous and the following card -- if any. + const prevListDom = ui.item.prev('.js-list').get(0); + const nextListDom = ui.item.next('.js-list').get(0); + const sortIndex = calculateIndex(prevListDom, nextListDom, 1); + + $listsDom.sortable('cancel'); + const listDomElement = ui.item.get(0); + const list = Blaze.getData(listDomElement); + + Lists.update(list._id, { + $set: { + sort: sortIndex.base, + }, + }); + + boardComponent.setIsDragging(false); + }, + }); + + function userIsMember() { + return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly(); + } + + // Disable drag-dropping while in multi-selection mode, or if the current user + // is not a board member + boardComponent.autorun(() => { + const $listDom = $listsDom; + if ($listDom.data('sortable')) { + $listsDom.sortable('option', 'disabled', + MultiSelection.isActive() || !userIsMember()); + } + }); +} + +BlazeComponent.extendComponent({ + onRendered() { + const boardComponent = this.parentComponent(); + const $listsDom = this.$('.js-lists'); + + if (!Session.get('currentCard')) { + boardComponent.scrollLeft(); + } + + initSortable(boardComponent, $listsDom); + }, + onCreated() { + this.draggingActive = new ReactiveVar(false); + + this._isDragging = false; + this._lastDragPositionX = 0; + }, + + id() { + return this._id; + }, + + currentCardIsInThisList(listId, swimlaneId) { + return currentCardIsInThisList(listId, swimlaneId); + }, + + 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'); + +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(); + }, +}); + +BlazeComponent.extendComponent({ + currentCardIsInThisList(listId, swimlaneId) { + return currentCardIsInThisList(listId, swimlaneId); + }, + onRendered() { + const boardComponent = this.parentComponent(); + const $listsDom = this.$('.js-lists'); + + if (!Session.get('currentCard')) { + boardComponent.scrollLeft(); + } + + initSortable(boardComponent, $listsDom); + }, +}).register('listsGroup'); diff --git a/client/components/swimlanes/swimlanes.styl b/client/components/swimlanes/swimlanes.styl new file mode 100644 index 00000000..dce298b0 --- /dev/null +++ b/client/components/swimlanes/swimlanes.styl @@ -0,0 +1,54 @@ +@import 'nib' + +.swimlane + // Even if this background color is the same as the body we can't leave it + // transparent, because that won't work during a swimlane drag. + background: darken(white, 13%) + display: flex + flex-direction: row + overflow: 0; + max-height: 100% + + &.placeholder + background-color: rgba(0, 0, 0, .2) + border-color: transparent + box-shadow: none + height: 100px + + &.ui-sortable-helper + box-shadow: -2px 2px 8px rgba(0, 0, 0, .3), + 0 0 1px rgba(0, 0, 0, .5) + transform: rotate(2deg) + cursor: grabbing + + .swimlane-header.ui-sortable-handle + cursor: grabbing + + .swimlane-header-wrap + display: flex; + flex-direction: row; + flex: 0 0 50px; + padding-bottom: 30px; + border-bottom: 1px solid #CCC + + .swimlane-header + writing-mode: vertical-rl; + transform: rotate(180deg); + font-size: 14px; + line-height: 50px; + margin-top: 50px; + font-weight: bold; + min-height: 9px; + width: 50px; + overflow: hidden; + -o-text-overflow: ellipsis; + text-overflow: ellipsis; + word-wrap: break-word; + text-align: center; + + .swimlane-header-menu + position: absolute + padding: 20px 20px + +.list-group + height: 100% diff --git a/client/components/users/userAvatar.jade b/client/components/users/userAvatar.jade index c61f2365..83e2c8d0 100644 --- a/client/components/users/userAvatar.jade +++ b/client/components/users/userAvatar.jade @@ -71,10 +71,10 @@ template(name="cardMemberPopup") .info h3= user.profile.fullname p.quiet @{{ user.username }} - if currentUser.isBoardMember - ul.pop-over-list - li: a.js-remove-member {{_ 'remove-member-from-card'}} + ul.pop-over-list + if currentUser.isNotCommentOnly + li: a.js-remove-member {{_ 'remove-member-from-card'}} - if $eq currentUser._id user._id - with currentUser - li: a.js-edit-profile {{_ 'edit-profile'}} + if $eq currentUser._id user._id + with currentUser + li: a.js-edit-profile {{_ 'edit-profile'}} diff --git a/client/components/users/userAvatar.js b/client/components/users/userAvatar.js index 1066c632..be7a85d2 100644 --- a/client/components/users/userAvatar.js +++ b/client/components/users/userAvatar.js @@ -1,5 +1,3 @@ -Meteor.subscribe('my-avatars'); - Template.userAvatar.helpers({ userData() { // We need to handle a special case for the search results provided by the @@ -54,6 +52,8 @@ Template.userAvatarInitials.helpers({ BlazeComponent.extendComponent({ onCreated() { this.error = new ReactiveVar(''); + + Meteor.subscribe('my-avatars'); }, avatarUrlOptions() { diff --git a/client/components/users/userHeader.jade b/client/components/users/userHeader.jade index f67f82ee..a8fdb143 100644 --- a/client/components/users/userHeader.jade +++ b/client/components/users/userHeader.jade @@ -3,10 +3,11 @@ template(name="headerUserBar") a.header-user-bar-name.js-open-header-member-menu .header-user-bar-avatar +userAvatar(userId=currentUser._id) - if currentUser.profile.fullname - = currentUser.profile.fullname - else - = currentUser.username + unless isMiniScreen + if currentUser.profile.fullname + = currentUser.profile.fullname + else + = currentUser.username template(name="memberMenuPopup") ul.pop-over-list @@ -32,7 +33,10 @@ template(name="editProfilePopup") | {{_ 'username'}} span.error.hide.username-taken | {{_ 'error-username-taken'}} - input.js-profile-username(type="text" value=username) + if allowUserNameChange + input.js-profile-username(type="text" value=username) + else + input.js-profile-username(type="text" value=username readonly) label | {{_ 'initials'}} input.js-profile-initials(type="text" value=profile.initials) @@ -85,5 +89,5 @@ template(name="changeSettingsPopup") li label.bold | {{_ 'show-cards-minimum-count'}} - input#show-cards-count-at.inline-input.left(type="number" value="#{showCardsCountAt}" min="1" max="99" onkeydown="return false") + input#show-cards-count-at.inline-input.left(type="number" value="#{showCardsCountAt}" min="0" max="99" onkeydown="return false") input.js-apply-show-cards-at.left(type="submit" value="{{_ 'apply'}}") diff --git a/client/components/users/userHeader.js b/client/components/users/userHeader.js index 90205ee1..d96a9b3d 100644 --- a/client/components/users/userHeader.js +++ b/client/components/users/userHeader.js @@ -24,6 +24,9 @@ Template.editProfilePopup.helpers({ allowEmailChange() { return AccountSettings.findOne('accounts-allowEmailChange').booleanValue; }, + allowUserNameChange() { + return AccountSettings.findOne('accounts-allowUserNameChange').booleanValue; + }, }); Template.editProfilePopup.events({ @@ -35,14 +38,16 @@ Template.editProfilePopup.events({ const email = tpl.find('.js-profile-email').value.trim(); let isChangeUserName = false; let isChangeEmail = false; - Users.update(Meteor.userId(), {$set: { - 'profile.fullname': fullname, - 'profile.initials': initials, - }}); + Users.update(Meteor.userId(), { + $set: { + 'profile.fullname': fullname, + 'profile.initials': initials, + }, + }); isChangeUserName = username !== Meteor.user().username; isChangeEmail = email.toLowerCase() !== Meteor.user().emails[0].address.toLowerCase(); if (isChangeUserName && isChangeEmail) { - Meteor.call('setUsernameAndEmail', username, email.toLowerCase(), function(error) { + Meteor.call('setUsernameAndEmail', username, email.toLowerCase(), Meteor.userId(), function (error) { const usernameMessageElement = tpl.$('.username-taken'); const emailMessageElement = tpl.$('.email-taken'); if (error) { @@ -61,7 +66,7 @@ Template.editProfilePopup.events({ } }); } else if (isChangeUserName) { - Meteor.call('setUsername', username, function(error) { + Meteor.call('setUsername', username, Meteor.userId(), function (error) { const messageElement = tpl.$('.username-taken'); if (error) { messageElement.show(); @@ -71,7 +76,7 @@ Template.editProfilePopup.events({ } }); } else if (isChangeEmail) { - Meteor.call('setEmail', email.toLowerCase(), function(error) { + Meteor.call('setEmail', email.toLowerCase(), Meteor.userId(), function (error) { const messageElement = tpl.$('.email-taken'); if (error) { messageElement.show(); @@ -105,18 +110,24 @@ Template.editNotificationPopup.events({ // XXX For some reason the useraccounts autofocus isnt working in this case. // See https://github.com/meteor-useraccounts/core/issues/384 -Template.changePasswordPopup.onRendered(function() { +Template.changePasswordPopup.onRendered(function () { this.find('#at-field-current_password').focus(); }); Template.changeLanguagePopup.helpers({ languages() { return _.map(TAPi18n.getLanguages(), (lang, code) => { - return { - tag: code, - name: lang.name === 'br' ? 'Brezhoneg' : lang.name, - }; - }).sort(function(a, b) { + // Same code in /client/components/main/layouts.js + // TODO : Make code reusable + const tag = code; + let name = lang.name; + if (lang.name === 'br') { + name = 'Brezhoneg'; + } else if (lang.name === 'ig') { + name = 'Igbo'; + } + return { tag, name }; + }).sort(function (a, b) { if (a.name === b.name) { return 0; } else { |