diff options
Diffstat (limited to 'client')
58 files changed, 1345 insertions, 495 deletions
diff --git a/client/components/activities/activities.jade b/client/components/activities/activities.jade index 85b1276e..28a9f9c9 100644 --- a/client/components/activities/activities.jade +++ b/client/components/activities/activities.jade @@ -14,32 +14,41 @@ template(name="boardActivities") p.activity-desc +memberName(user=user) - if($eq activityType 'createBoard') - | {{_ 'activity-created' boardLabel}}. + if($eq activityType 'addAttachment') + | {{{_ 'activity-attached' attachmentLink cardLink}}}. - if($eq activityType 'createList') - | {{_ 'activity-added' list.title boardLabel}}. + if($eq activityType 'addBoardMember') + | {{{_ 'activity-added' memberLink boardLabel}}}. + + if($eq activityType 'addComment') + | {{{_ 'activity-on' cardLink}}} + a.activity-comment(href="{{ card.absoluteUrl }}") + +viewer + = comment.text + + if($eq activityType 'archivedCard') + | {{{_ 'activity-archived' cardLink}}}. if($eq activityType 'archivedList') | {{_ 'activity-archived' list.title}}. + if($eq activityType 'createBoard') + | {{_ 'activity-created' boardLabel}}. + if($eq activityType 'createCard') | {{{_ 'activity-added' cardLink boardLabel}}}. - if($eq activityType 'archivedCard') - | {{{_ 'activity-archived' cardLink}}}. - - if($eq activityType 'restoredCard') - | {{{_ 'activity-sent' cardLink boardLabel}}}. + if($eq activityType 'createList') + | {{_ 'activity-added' list.title boardLabel}}. - if($eq activityType 'moveCard') - | {{{_ 'activity-moved' cardLink oldList.title list.title}}}. + if($eq activityType 'importBoard') + | {{{_ 'activity-imported-board' boardLabel sourceLink}}}. - if($eq activityType 'addBoardMember') - | {{{_ 'activity-added' memberLink boardLabel}}}. + if($eq activityType 'importCard') + | {{{_ 'activity-imported' cardLink boardLabel sourceLink}}}. - if($eq activityType 'removeBoardMember') - | {{{_ 'activity-excluded' memberLink boardLabel}}}. + if($eq activityType 'importList') + | {{{_ 'activity-imported' listLabel boardLabel sourceLink}}}. if($eq activityType 'joinMember') if($eq currentUser._id member._id) @@ -47,21 +56,21 @@ template(name="boardActivities") else | {{{_ 'activity-added' memberLink cardLink}}}. + if($eq activityType 'moveCard') + | {{{_ 'activity-moved' cardLink oldList.title list.title}}}. + + if($eq activityType 'removeBoardMember') + | {{{_ 'activity-excluded' memberLink boardLabel}}}. + + if($eq activityType 'restoredCard') + | {{{_ 'activity-sent' cardLink boardLabel}}}. + if($eq activityType 'unjoinMember') if($eq currentUser._id member._id) | {{{_ 'activity-unjoined' cardLink}}}. else | {{{_ 'activity-removed' memberLink cardLink}}}. - if($eq activityType 'addComment') - | {{{_ 'activity-on' cardLink}}} - a.activity-comment(href="{{ card.absoluteUrl }}") - +viewer - = comment.text - - if($eq activityType 'addAttachment') - | {{{_ 'activity-attached' attachmentLink cardLink}}}. - span.activity-meta {{ moment createdAt }} template(name="cardActivities") @@ -72,6 +81,8 @@ template(name="cardActivities") +memberName(user=user) if($eq activityType 'createCard') | {{_ 'activity-added' cardLabel list.title}}. + if($eq activityType 'importCard') + | {{{_ 'activity-imported' cardLabel list.title sourceLink}}}. if($eq activityType 'joinMember') if($eq currentUser._id member._id) | {{_ 'activity-joined' cardLabel}}. diff --git a/client/components/activities/activities.js b/client/components/activities/activities.js index 5c5d8370..c1465b04 100644 --- a/client/components/activities/activities.js +++ b/client/components/activities/activities.js @@ -9,7 +9,7 @@ BlazeComponent.extendComponent({ // XXX Should we use ReactiveNumber? this.page = new ReactiveVar(1); this.loadNextPageLocked = false; - const sidebar = this.componentParent(); // XXX for some reason not working + const sidebar = this.parentComponent(); // XXX for some reason not working sidebar.callFirstWith(null, 'resetNextPeak'); this.autorun(() => { const mode = this.data().mode; @@ -55,11 +55,29 @@ BlazeComponent.extendComponent({ cardLink() { const card = this.currentData().card(); return card && Blaze.toHTML(HTML.A({ - href: card.absoluteUrl(), + href: FlowRouter.path(card.absoluteUrl()), 'class': 'action-card', }, card.title)); }, + listLabel() { + return this.currentData().list().title; + }, + + sourceLink() { + const source = this.currentData().source; + if(source) { + if(source.url) { + return Blaze.toHTML(HTML.A({ + href: source.url, + }, source.system)); + } else { + return source.system; + } + } + return null; + }, + memberLink() { return Blaze.toHTMLWithData(Template.memberName, { user: this.currentData().member(), @@ -68,8 +86,9 @@ BlazeComponent.extendComponent({ attachmentLink() { const attachment = this.currentData().attachment(); - return attachment && Blaze.toHTML(HTML.A({ - href: attachment.url({ download: true }), + // trying to display url before file is stored generates js errors + return attachment && attachment.url({ download: true }) && Blaze.toHTML(HTML.A({ + href: FlowRouter.path(attachment.url({ download: true })), target: '_blank', }, attachment.name())); }, @@ -83,9 +102,9 @@ BlazeComponent.extendComponent({ }, 'submit .js-edit-comment'(evt) { evt.preventDefault(); - const commentText = this.currentComponent().getValue(); + const commentText = this.currentComponent().getValue().trim(); const commentId = Template.parentData().commentId; - if ($.trim(commentText)) { + if (commentText) { CardComments.update(commentId, { $set: { text: commentText, diff --git a/client/components/activities/comments.js b/client/components/activities/comments.js index 08401caa..18bf9ef0 100644 --- a/client/components/activities/comments.js +++ b/client/components/activities/comments.js @@ -24,11 +24,12 @@ BlazeComponent.extendComponent({ }, 'submit .js-new-comment-form'(evt) { const input = this.getInput(); - if ($.trim(input.val())) { + const text = input.val().trim(); + if (text) { CardComments.insert({ + text, boardId: this.currentData().boardId, cardId: this.currentData()._id, - text: input.val(), }); resetCommentInput(input); Tracker.flush(); @@ -72,8 +73,9 @@ EscapeActions.register('inlinedForm', docId: Session.get('currentCard'), }; const commentInput = $('.js-new-comment-input'); - if ($.trim(commentInput.val())) { - UnsavedEdits.set(draftKey, commentInput.val()); + const draft = commentInput.val().trim(); + if (draft) { + UnsavedEdits.set(draftKey, draft); } else { UnsavedEdits.reset(draftKey); } diff --git a/client/components/boards/boardArchive.js b/client/components/boards/boardArchive.js index 9d7ca7f2..35f795f3 100644 --- a/client/components/boards/boardArchive.js +++ b/client/components/boards/boardArchive.js @@ -22,13 +22,9 @@ BlazeComponent.extendComponent({ events() { return [{ 'click .js-restore-board'() { - const boardId = this.currentData()._id; - Boards.update(boardId, { - $set: { - archived: false, - }, - }); - Utils.goBoardId(boardId); + const board = this.currentData(); + board.restore(); + Utils.goBoardId(board._id); }, }]; }, diff --git a/client/components/boards/boardBody.js b/client/components/boards/boardBody.js index 95590beb..a601bc2e 100644 --- a/client/components/boards/boardBody.js +++ b/client/components/boards/boardBody.js @@ -34,7 +34,7 @@ BlazeComponent.extendComponent({ }, openNewListForm() { - this.componentChildren('addListForm')[0].open(); + this.childComponents('addListForm')[0].open(); }, // XXX Flow components allow us to avoid creating these two setter methods by @@ -45,7 +45,8 @@ BlazeComponent.extendComponent({ }, scrollLeft(position = 0) { - this.$('.js-lists').animate({ + const lists = this.$('.js-lists'); + lists && lists.animate({ scrollLeft: position, }); }, @@ -133,7 +134,7 @@ Template.boardBody.onRendered(function() { if (!Meteor.user() || !Meteor.user().isBoardMember()) return; - self.$(self.listsDom).sortable({ + $(self.listsDom).sortable({ tolerance: 'pointer', helper: 'clone', handle: '.js-list-header', @@ -145,7 +146,7 @@ Template.boardBody.onRendered(function() { Popup.close(); }, stop() { - self.$('.js-lists').find('.js-list:not(.js-list-composer)').each( + $(self.listsDom).find('.js-list:not(.js-list-composer)').each( (i, list) => { const data = Blaze.getData(list); Lists.update(data._id, { @@ -160,7 +161,7 @@ Template.boardBody.onRendered(function() { // Disable drag-dropping while in multi-selection mode self.autorun(() => { - self.$(self.listsDom).sortable('option', 'disabled', + $(self.listsDom).sortable('option', 'disabled', MultiSelection.isActive()); }); @@ -179,22 +180,24 @@ BlazeComponent.extendComponent({ // Proxy open() { - this.componentChildren('inlinedForm')[0].open(); + this.childComponents('inlinedForm')[0].open(); }, events() { return [{ submit(evt) { evt.preventDefault(); - const title = this.find('.list-name-input'); - if ($.trim(title.value)) { + const titleInput = this.find('.list-name-input'); + const title = titleInput.value.trim(); + if (title) { Lists.insert({ - title: title.value, + title, boardId: Session.get('currentBoard'), sort: $('.list').length, }); - title.value = ''; + titleInput.value = ''; + titleInput.focus(); } }, }]; diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade index 94225730..a0160382 100644 --- a/client/components/boards/boardHeader.jade +++ b/client/components/boards/boardHeader.jade @@ -32,7 +32,7 @@ template(name="headerBoard") title="{{#if MultiSelection.isActive}}{{_ 'filter-on-desc'}}{{/if}}" class="{{#if MultiSelection.isActive}}emphasis{{/if}}") i.fa.fa-check-square-o - span Multi-Selection {{#if MultiSelection.isActive}}is on{{/if}} + span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}} if MultiSelection.isActive a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}") i.fa.fa-times-thin @@ -105,8 +105,11 @@ template(name="createBoardPopup") span.fa.fa-lock.colorful = " " | {{{_ 'board-private-info'}}} - a.js-change-visibility Change. + a.js-change-visibility {{_ 'change'}}. input.primary.wide(type="submit" value="{{_ 'create'}}") + span.quiet + | {{_ 'or'}} + a.js-import {{_ 'import-board'}} template(name="boardChangeTitlePopup") @@ -114,6 +117,9 @@ template(name="boardChangeTitlePopup") label | {{_ 'title'}} input.js-board-name(type="text" value=title autofocus) + label + | {{_ 'description'}} + textarea.js-board-desc= description input.primary.wide(type="submit" value="{{_ 'rename'}}") template(name="archiveBoardPopup") diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js index f259b2a6..3dc6d754 100644 --- a/client/components/boards/boardHeader.js +++ b/client/components/boards/boardHeader.js @@ -6,9 +6,9 @@ Template.boardMenuPopup.events({ }, 'click .js-change-board-color': Popup.open('boardChangeColor'), 'click .js-change-language': Popup.open('changeLanguage'), - 'click .js-archive-board ': Popup.afterConfirm('archiveBoard', () => { - const boardId = Session.get('currentBoard'); - Boards.update(boardId, { $set: { archived: true }}); + 'click .js-archive-board ': Popup.afterConfirm('archiveBoard', function() { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + currentBoard.archive(); // XXX We should have some kind of notification on top of the page to // confirm that the board was successfully archived. FlowRouter.go('home'); @@ -17,13 +17,11 @@ Template.boardMenuPopup.events({ Template.boardChangeTitlePopup.events({ submit(evt, tpl) { - const title = tpl.$('.js-board-name').val().trim(); - if (title) { - Boards.update(this._id, { - $set: { - title, - }, - }); + const newTitle = tpl.$('.js-board-name').val().trim(); + const newDesc = tpl.$('.js-board-desc').val().trim(); + if (newTitle) { + this.rename(newTitle); + this.setDesciption(newDesc); Popup.close(); } evt.preventDefault(); @@ -95,12 +93,9 @@ BlazeComponent.extendComponent({ events() { return [{ 'click .js-select-background'(evt) { - const currentBoardId = Session.get('currentBoard'); - Boards.update(currentBoardId, { - $set: { - color: this.currentData().toString(), - }, - }); + const currentBoard = Boards.findOne(Session.get('currentBoard')); + const newColor = this.currentData().toString(); + currentBoard.setColor(newColor); evt.preventDefault(); }, }]; @@ -152,6 +147,7 @@ BlazeComponent.extendComponent({ this.setVisibility(this.currentData()); }, 'click .js-change-visibility': this.toggleVisibilityMenu, + 'click .js-import': Popup.open('boardImportBoard'), submit: this.onSubmit, }]; }, @@ -168,11 +164,9 @@ BlazeComponent.extendComponent({ }, selectBoardVisibility() { - Boards.update(Session.get('currentBoard'), { - $set: { - permission: this.currentData(), - }, - }); + const currentBoard = Boards.findOne(Session.get('currentBoard')); + const visibility = this.currentData(); + currentBoard.setVisibility(visibility); Popup.close(); }, diff --git a/client/components/boards/boardHeader.styl b/client/components/boards/boardHeader.styl new file mode 100644 index 00000000..adfe4b19 --- /dev/null +++ b/client/components/boards/boardHeader.styl @@ -0,0 +1,2 @@ +a.js-import + text-decoration underline diff --git a/client/components/boards/boardsList.jade b/client/components/boards/boardsList.jade index 11333eee..7099cdc9 100644 --- a/client/components/boards/boardsList.jade +++ b/client/components/boards/boardsList.jade @@ -3,11 +3,23 @@ template(name="boardList") ul.board-list.clearfix each boards li(class="{{#if isStarred}}starred{{/if}}" class=colorClass) - a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}") - span.details - span.board-list-item-name= title - i.fa.js-star-board( - class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" - title="{{_ 'star-board-title'}}") + if isInvited + .board-list-item + span.details + span.board-list-item-name= title + i.fa.js-star-board( + class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" + title="{{_ 'star-board-title'}}") + p.board-list-item-desc {{_ 'just-invited'}} + button.js-accept-invite.primary {{_ 'accept'}} + button.js-decline-invite {{_ 'decline'}} + else + a.js-open-board.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}") + span.details + span.board-list-item-name= title + i.fa.js-star-board( + class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}" + title="{{_ 'star-board-title'}}") + p.board-list-item-desc= description li.js-add-board - a.label {{_ 'add-board'}} + a.board-list-item.label {{_ 'add-board'}} diff --git a/client/components/boards/boardsList.js b/client/components/boards/boardsList.js index 1a2d3c9a..131adf9d 100644 --- a/client/components/boards/boardsList.js +++ b/client/components/boards/boardsList.js @@ -17,6 +17,11 @@ BlazeComponent.extendComponent({ return user && user.hasStarred(this.currentData()._id); }, + isInvited() { + const user = Meteor.user(); + return user && user.isInvitedTo(this.currentData()._id); + }, + events() { return [{ 'click .js-add-board': Popup.open('createBoard'), @@ -25,6 +30,19 @@ BlazeComponent.extendComponent({ Meteor.user().toggleBoardStar(boardId); evt.preventDefault(); }, + 'click .js-accept-invite'() { + const boardId = this.currentData()._id; + Meteor.user().removeInvite(boardId); + }, + 'click .js-decline-invite'() { + const boardId = this.currentData()._id; + Meteor.call('quitBoard', boardId, (err, ret) => { + if (!err && ret) { + Meteor.user().removeInvite(boardId); + FlowRouter.go('home'); + } + }); + }, }]; }, }).register('boardList'); diff --git a/client/components/boards/boardsList.styl b/client/components/boards/boardsList.styl index 9978fab8..e24940a0 100644 --- a/client/components/boards/boardsList.styl +++ b/client/components/boards/boardsList.styl @@ -14,7 +14,7 @@ $spaceBetweenTiles = 16px .fa-star-o opacity: 1 - a + .board-list-item background-color: #999 color: #f6f6f6 height: 90px @@ -40,6 +40,13 @@ $spaceBetweenTiles = 16px font-weight: 400 line-height: 22px + .board-list-item-desc + color: rgba(255, 255, 255, .5) + display: block + font-size: 10px + font-weight: 400 + line-height: 18px + .js-add-board text-align:center diff --git a/client/components/cards/attachments.jade b/client/components/cards/attachments.jade index 59eaf077..2cb3bb85 100644 --- a/client/components/cards/attachments.jade +++ b/client/components/cards/attachments.jade @@ -3,6 +3,16 @@ template(name="cardAttachmentsPopup") li input.js-attach-file.hide(type="file" name="file" multiple) a.js-computer-upload {{_ 'computer'}} + li + a.js-upload-clipboard-image {{_ 'clipboard'}} + +template(name="previewClipboardImagePopup") + p <kbd>Ctrl</kbd>+<kbd>V</kbd> {{_ "paste-or-dragdrop"}} + img.preview-clipboard-image() + button.primary.js-upload-pasted-image {{_ 'upload'}} + +template(name="previewAttachedImagePopup") + img.preview-large-image.js-large-image-clicked(src="{{pathFor url}}") template(name="attachmentDeletePopup") p {{_ "attachment-delete-pop"}} @@ -15,7 +25,7 @@ template(name="attachmentsGalery") .attachment-thumbnail if isUploaded if isImage - img.attachment-thumbnail-img(src=url) + img.attachment-thumbnail-img.js-preview-image(src="{{pathFor url}}") else span.attachment-thumbnail-ext= extension else diff --git a/client/components/cards/attachments.js b/client/components/cards/attachments.js index ba56aa1a..1e5aa03b 100644 --- a/client/components/cards/attachments.js +++ b/client/components/cards/attachments.js @@ -1,7 +1,7 @@ Template.attachmentsGalery.events({ 'click .js-add-attachment': Popup.open('cardAttachments'), 'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', - () => { + function() { Attachments.remove(this._id); Popup.close(); } @@ -15,10 +15,43 @@ Template.attachmentsGalery.events({ // XXX Not implemented! }, 'click .js-add-cover'() { - Cards.update(this.cardId, { $set: { coverId: this._id } }); + Cards.findOne(this.cardId).setCover(this._id); }, 'click .js-remove-cover'() { - Cards.update(this.cardId, { $unset: { coverId: '' } }); + Cards.findOne(this.cardId).unsetCover(); + }, + 'click .js-preview-image'(evt) { + Popup.open('previewAttachedImage').call(this, evt); + // when multiple thumbnails, if click one then another very fast, + // we might get a wrong width from previous img. + // when popup reused, onRendered() won't be called, so we cannot get there. + // here make sure to get correct size when this img fully loaded. + const img = $('img.preview-large-image')[0]; + if (!img) return; + const rePosPopup = () => { + const w = img.width; + const h = img.height; + // if the image is too large, we resize & center the popup. + if (w > 300) { + $('div.pop-over').css({ + width: (w + 20), + position: 'absolute', + left: (window.innerWidth - w)/2, + top: (window.innerHeight - h)/2, + }); + } + }; + const url = $(evt.currentTarget).attr('src'); + if (img.src === url && img.complete) + rePosPopup(); + else + img.onload = rePosPopup; + }, +}); + +Template.previewAttachedImagePopup.events({ + 'click .js-large-image-clicked'(){ + Popup.close(); }, }); @@ -28,7 +61,7 @@ Template.cardAttachmentsPopup.events({ FS.Utility.eachFile(evt, (f) => { const file = new FS.File(f); file.boardId = card.boardId; - file.cardId = card._id; + file.cardId = card._id; Attachments.insert(file); Popup.close(); @@ -38,4 +71,48 @@ Template.cardAttachmentsPopup.events({ tpl.find('.js-attach-file').click(); evt.preventDefault(); }, + 'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'), +}); + +let pastedResults = null; + +Template.previewClipboardImagePopup.onRendered(() => { + // we can paste image from clipboard + $(document.body).pasteImageReader((results) => { + if (results.dataURL.startsWith('data:image/')) { + $('img.preview-clipboard-image').attr('src', results.dataURL); + pastedResults = results; + } + }); + + // we can also drag & drop image file to it + $(document.body).dropImageReader((results) => { + if (results.dataURL.startsWith('data:image/')) { + $('img.preview-clipboard-image').attr('src', results.dataURL); + pastedResults = results; + } + }); +}); + +Template.previewClipboardImagePopup.events({ + 'click .js-upload-pasted-image'() { + const results = pastedResults; + if (results && results.file) { + const card = this; + const file = new FS.File(results.file); + if (!results.name) { + // if no filename, it's from clipboard. then we give it a name, with ext name from MIME type + if (typeof results.file.type === 'string') { + file.name(results.file.type.replace('image/', 'clipboard.')); + } + } + file.updatedAt(new Date()); + file.boardId = card.boardId; + file.cardId = card._id; + Attachments.insert(file); + pastedResults = null; + $(document.body).pasteImageReader(() => {}); + Popup.close(); + } + }, }); diff --git a/client/components/cards/attachments.styl b/client/components/cards/attachments.styl index 5cdf7386..a582f3af 100644 --- a/client/components/cards/attachments.styl +++ b/client/components/cards/attachments.styl @@ -45,3 +45,14 @@ display: block box-shadow: 0 1px 2px rgba(0,0,0,.2) +.preview-large-image + max-width: 1000px + display: block + box-shadow: 0 1px 2px rgba(0,0,0,.2) + +.preview-clipboard-image + width: 280px + height: 200px + display: block + border: 1px solid black + box-shadow: 0 1px 2px rgba(0,0,0,.2) diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index 09c99f4e..b4fdca52 100644 --- a/client/components/cards/cardDetails.js +++ b/client/components/cards/cardDetails.js @@ -13,19 +13,19 @@ BlazeComponent.extendComponent({ }, reachNextPeak() { - const activitiesComponent = this.componentChildren('activities')[0]; + const activitiesComponent = this.childComponents('activities')[0]; activitiesComponent.loadNextPage(); }, onCreated() { this.isLoaded = new ReactiveVar(false); - this.componentParent().showOverlay.set(true); - this.componentParent().mouseHasEnterCardDetails = false; + this.parentComponent().showOverlay.set(true); + this.parentComponent().mouseHasEnterCardDetails = false; }, scrollParentContainer() { const cardPanelWidth = 510; - const bodyBoardComponent = this.componentParent(); + const bodyBoardComponent = this.parentComponent(); const $cardContainer = bodyBoardComponent.$('.js-lists'); const $cardView = this.$(this.firstNode()); @@ -52,13 +52,7 @@ BlazeComponent.extendComponent({ }, onDestroyed() { - this.componentParent().showOverlay.set(false); - }, - - updateCard(modifier) { - Cards.update(this.data()._id, { - $set: modifier, - }); + this.parentComponent().showOverlay.set(false); }, events() { @@ -68,7 +62,8 @@ BlazeComponent.extendComponent({ }, }; - return [_.extend(events, { + return [{ + ...events, 'click .js-close-card-details'() { Utils.goBoardId(this.data().boardId); }, @@ -76,23 +71,23 @@ BlazeComponent.extendComponent({ 'submit .js-card-description'(evt) { evt.preventDefault(); const description = this.currentComponent().getValue(); - this.updateCard({ description }); + this.data().setDescription(description); }, 'submit .js-card-details-title'(evt) { evt.preventDefault(); - const title = this.currentComponent().getValue(); - if ($.trim(title)) { - this.updateCard({ title }); + const title = this.currentComponent().getValue().trim(); + if (title) { + this.data().setTitle(title); } }, 'click .js-member': Popup.open('cardMember'), 'click .js-add-members': Popup.open('cardMembers'), 'click .js-add-labels': Popup.open('cardLabels'), 'mouseenter .js-card-details'() { - this.componentParent().showOverlay.set(true); - this.componentParent().mouseHasEnterCardDetails = true; + this.parentComponent().showOverlay.set(true); + this.parentComponent().mouseHasEnterCardDetails = true; }, - })]; + }]; }, }).register('cardDetails'); @@ -111,7 +106,7 @@ BlazeComponent.extendComponent({ close(isReset = false) { if (this.isOpen.get() && !isReset) { - const draft = $.trim(this.getValue()); + const draft = this.getValue().trim(); if (draft !== Cards.findOne(Session.get('currentCard')).description) { UnsavedEdits.set(this._getUnsavedEditKey(), this.getValue()); } @@ -138,14 +133,9 @@ Template.cardDetailsActionsPopup.events({ 'click .js-labels': Popup.open('cardLabels'), 'click .js-attachments': Popup.open('cardAttachments'), 'click .js-move-card': Popup.open('moveCard'), - // 'click .js-copy': Popup.open(), 'click .js-archive'(evt) { evt.preventDefault(); - Cards.update(this._id, { - $set: { - archived: true, - }, - }); + this.archive(); Popup.close(); }, 'click .js-more': Popup.open('cardMore'), @@ -155,22 +145,18 @@ Template.moveCardPopup.events({ 'click .js-select-list'() { // XXX We should *not* get the currentCard from the global state, but // instead from a “component” state. - const cardId = Session.get('currentCard'); + const card = Cards.findOne(Session.get('currentCard')); const newListId = this._id; - Cards.update(cardId, { - $set: { - listId: newListId, - }, - }); + card.move(newListId); Popup.close(); }, }); Template.cardMorePopup.events({ - 'click .js-delete': Popup.afterConfirm('cardDelete', () => { + 'click .js-delete': Popup.afterConfirm('cardDelete', function() { Popup.close(); Cards.remove(this._id); - Utils.goBoardId(this.board()._id); + Utils.goBoardId(this.boardId); }), }); diff --git a/client/components/cards/labels.jade b/client/components/cards/labels.jade index a868627c..31bd4d06 100644 --- a/client/components/cards/labels.jade +++ b/client/components/cards/labels.jade @@ -18,7 +18,7 @@ template(name="editLabelPopup") form.edit-label +formLabel button.primary.wide.left(type="submit") {{_ 'save'}} - span.right + button.js-delete-label.negate.wide.right {{_ 'delete'}} template(name="deleteLabelPopup") p {{_ "label-delete-pop"}} diff --git a/client/components/cards/labels.js b/client/components/cards/labels.js index 2da3b80b..4e61a0c6 100644 --- a/client/components/cards/labels.js +++ b/client/components/cards/labels.js @@ -13,7 +13,7 @@ BlazeComponent.extendComponent({ }, labels() { - return _.map(labelColors, (color) => { + return labelColors.map((color) => { return { color, name: '' }; }); }, @@ -45,19 +45,9 @@ Template.createLabelPopup.helpers({ Template.cardLabelsPopup.events({ 'click .js-select-label'(evt) { - const cardId = Template.parentData(2).data._id; + const card = Cards.findOne(Session.get('currentCard')); const labelId = this._id; - let operation; - if (Cards.find({ _id: cardId, labelIds: labelId}).count() === 0) - operation = '$addToSet'; - else - operation = '$pull'; - - Cards.update(cardId, { - [operation]: { - labelIds: labelId, - }, - }); + card.toggleLabel(labelId); evt.preventDefault(); }, 'click .js-edit-label': Popup.open('editLabel'), @@ -79,52 +69,27 @@ Template.formLabel.events({ Template.createLabelPopup.events({ // Create the new label 'submit .create-label'(evt, tpl) { + evt.preventDefault(); + const board = Boards.findOne(Session.get('currentBoard')); const name = tpl.$('#labelName').val().trim(); - const boardId = Session.get('currentBoard'); const color = Blaze.getData(tpl.find('.fa-check')).color; - - Boards.update(boardId, { - $push: { - labels: { - name, - color, - _id: Random.id(6), - }, - }, - }); - + board.addLabel(name, color); Popup.back(); - evt.preventDefault(); }, }); Template.editLabelPopup.events({ 'click .js-delete-label': Popup.afterConfirm('deleteLabel', function() { - const boardId = Session.get('currentBoard'); - Boards.update(boardId, { - $pull: { - labels: { - _id: this._id, - }, - }, - }); - + const board = Boards.findOne(Session.get('currentBoard')); + board.removeLabel(this._id); Popup.back(2); }), 'submit .edit-label'(evt, tpl) { evt.preventDefault(); + const board = Boards.findOne(Session.get('currentBoard')); const name = tpl.$('#labelName').val().trim(); - const boardId = Session.get('currentBoard'); - const getLabel = Utils.getLabelIndex(boardId, this._id); const color = Blaze.getData(tpl.find('.fa-check')).color; - - Boards.update(boardId, { - $set: { - [getLabel.key('name')]: name, - [getLabel.key('color')]: color, - }, - }); - + board.editLabel(this._id, name, color); Popup.back(); }, }); diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade index 660b0fa5..573b3da1 100644 --- a/client/components/cards/minicard.jade +++ b/client/components/cards/minicard.jade @@ -2,7 +2,7 @@ template(name="minicard") .minicard if cover .minicard-cover - img(src=cover.url) + img(src="{{pathFor cover.url}}") if labels .minicard-labels each labels diff --git a/client/components/forms/forms.styl b/client/components/forms/forms.styl index 83d25370..9ae95140 100644 --- a/client/components/forms/forms.styl +++ b/client/components/forms/forms.styl @@ -617,8 +617,15 @@ button margin-right: 5px vertical-align: middle + .minicard-label + width: 11px + height: @width + border-radius: 2px + margin: 2px 7px -2px -2px + display: inline-block + &.active background: #005377 - a + a, .quiet color: white diff --git a/client/components/import/import.jade b/client/components/import/import.jade new file mode 100644 index 00000000..74b6ca13 --- /dev/null +++ b/client/components/import/import.jade @@ -0,0 +1,54 @@ +template(name="importPopup") + if error.get + .warning {{_ error.get}} + form + p: label(for='import-textarea') {{_ getLabel}} + textarea#import-textarea.js-import-json(placeholder="{{_ 'import-json-placeholder'}}" autofocus) + | {{jsonText}} + if membersMapping + div + a.show-mapping + | {{_ 'import-show-user-mapping'}} + input.primary.wide(type="submit" value="{{_ 'import'}}") + +template(name="mapMembersPopup") + .map-members + p {{_ 'import-members-map'}} + .mapping-list + each members + .mapping + a.source + div.full-name + = fullName + div.username + | ({{username}}) + .wekan + if wekan + +userAvatar(userId=wekan._id) + else + a.member.add-member.js-add-members + i.fa.fa-plus + form + input.primary.wide(type="submit" value="{{_ 'done'}}") + + template(name="addMemberPopup") + +template(name="mapMembersAddPopup") + .select-member + p + | {{_ 'import-user-select'}} + .js-map-member + +esInput(index="users") + ul.pop-over-list + +esEach(index="users") + li.item.js-member-item + a.name.js-select-import(title="{{profile.name}} ({{username}})" data-id="{{_id}}") + +userAvatar(userId=_id esSearch=true) + span.full-name + = profile.name + | (<span class="username">{{username}}</span>) + +ifEsIsSearching(index='users') + +spinner + +ifEsHasNoResults(index="users") + .manage-member-section + p.quiet {{_ 'no-results'}} diff --git a/client/components/import/import.js b/client/components/import/import.js new file mode 100644 index 00000000..63285e57 --- /dev/null +++ b/client/components/import/import.js @@ -0,0 +1,271 @@ +/// Abstract root for all import popup screens. +/// Descendants must define: +/// - getMethodName(): return the Meteor method to call for import, passing json +/// data decoded as object and additional data (see below); +/// - getAdditionalData(): return object containing additional data passed to +/// Meteor method (like list ID and position for a card import); +/// - getLabel(): i18n key for the text displayed in the popup, usually to +/// explain how to get the data out of the source system. +const ImportPopup = BlazeComponent.extendComponent({ + template() { + return 'importPopup'; + }, + + jsonText() { + return Session.get('import.text'); + }, + + membersMapping() { + return Session.get('import.membersToMap'); + }, + + onCreated() { + this.error = new ReactiveVar(''); + this.dataToImport = ''; + }, + + onFinish() { + Popup.close(); + }, + + onShowMapping(evt) { + this._storeText(evt); + Popup.open('mapMembers')(evt); + }, + + onSubmit(evt){ + evt.preventDefault(); + const dataJson = this._storeText(evt); + let dataObject; + try { + dataObject = JSON.parse(dataJson); + this.setError(''); + } catch (e) { + this.setError('error-json-malformed'); + return; + } + if(this._hasAllNeededData(dataObject)) { + this._import(dataObject); + } else { + this._prepareAdditionalData(dataObject); + Popup.open(this._screenAdditionalData())(evt); + + } + }, + + events() { + return [{ + submit: this.onSubmit, + 'click .show-mapping': this.onShowMapping, + }]; + }, + + setError(error) { + this.error.set(error); + }, + + _import(dataObject) { + const additionalData = this.getAdditionalData(); + const membersMapping = this.membersMapping(); + if (membersMapping) { + const mappingById = {}; + membersMapping.forEach((member) => { + if (member.wekan) { + mappingById[member.id] = member.wekan._id; + } + }); + additionalData.membersMapping = mappingById; + } + Session.set('import.membersToMap', null); + Session.set('import.text', null); + Meteor.call(this.getMethodName(), dataObject, additionalData, + (error, response) => { + if (error) { + this.setError(error.error); + } else { + // ensure will display what we just imported + Filter.addException(response); + this.onFinish(response); + } + } + ); + }, + + _hasAllNeededData(dataObject) { + // import has no members or they are already mapped + return dataObject.members.length === 0 || this.membersMapping(); + }, + + _prepareAdditionalData(dataObject) { + // we will work on the list itself (an ordered array of objects) + // when a mapping is done, we add a 'wekan' field to the object representing the imported member + const membersToMap = dataObject.members; + // auto-map based on username + membersToMap.forEach((importedMember) => { + const wekanUser = Users.findOne({username: importedMember.username}); + if(wekanUser) { + importedMember.wekan = wekanUser; + } + }); + // store members data and mapping in Session + // (we go deep and 2-way, so storing in data context is not a viable option) + Session.set('import.membersToMap', membersToMap); + return membersToMap; + }, + + _screenAdditionalData() { + return 'mapMembers'; + }, + + _storeText() { + const dataJson = this.$('.js-import-json').val(); + Session.set('import.text', dataJson); + return dataJson; + }, +}); + +ImportPopup.extendComponent({ + getAdditionalData() { + const listId = this.currentData()._id; + const selector = `#js-list-${this.currentData()._id} .js-minicard:first`; + const firstCardDom = $(selector).get(0); + const sortIndex = Utils.calculateIndex(null, firstCardDom).base; + const result = {listId, sortIndex}; + return result; + }, + + getMethodName() { + return 'importTrelloCard'; + }, + + getLabel() { + return 'import-card-trello-instruction'; + }, +}).register('listImportCardPopup'); + +ImportPopup.extendComponent({ + getAdditionalData() { + const result = {}; + return result; + }, + + getMethodName() { + return 'importTrelloBoard'; + }, + + getLabel() { + return 'import-board-trello-instruction'; + }, + + onFinish(response) { + Utils.goBoardId(response); + }, +}).register('boardImportBoardPopup'); + +const ImportMapMembers = BlazeComponent.extendComponent({ + members() { + return Session.get('import.membersToMap'); + }, + _refreshMembers(listOfMembers) { + Session.set('import.membersToMap', listOfMembers); + }, + /** + * Will look into the list of members to import for the specified memberId, + * then set its property to the supplied value. + * If unset is true, it will remove the property from the rest of the list as well. + * + * use: + * - memberId = null to use selected member + * - value = null to unset a property + * - unset = true to ensure property is only set on 1 member at a time + */ + _setPropertyForMember(property, value, memberId, unset = false) { + const listOfMembers = this.members(); + let finder = null; + if(memberId) { + finder = (member) => member.id === memberId; + } else { + finder = (member) => member.selected; + } + listOfMembers.forEach((member) => { + if(finder(member)) { + if(value !== null) { + member[property] = value; + } else { + delete member[property]; + } + if(!unset) { + // we shortcut if we don't care about unsetting the others + return false; + } + } else if(unset) { + delete member[property]; + } + return true; + }); + // Session.get gives us a copy, we have to set it back so it sticks + this._refreshMembers(listOfMembers); + }, + setSelectedMember(memberId) { + return this._setPropertyForMember('selected', true, memberId, true); + }, + /** + * returns the member with specified id, + * or the selected member if memberId is not specified + */ + getMember(memberId = null) { + const allMembers = Session.get('import.membersToMap'); + let finder = null; + if(memberId) { + finder = (user) => user.id === memberId; + } else { + finder = (user) => user.selected; + } + return allMembers.find(finder); + }, + mapSelectedMember(wekan) { + return this._setPropertyForMember('wekan', wekan, null); + }, + unmapMember(memberId){ + return this._setPropertyForMember('wekan', null, memberId); + }, +}); + +ImportMapMembers.extendComponent({ + onMapMember(evt) { + const memberToMap = this.currentData(); + if(memberToMap.wekan) { + // todo xxx ask for confirmation? + this.unmapMember(memberToMap.id); + } else { + this.setSelectedMember(memberToMap.id); + Popup.open('mapMembersAdd')(evt); + } + }, + onSubmit(evt) { + evt.preventDefault(); + Popup.back(); + }, + events() { + return [{ + 'submit': this.onSubmit, + 'click .mapping': this.onMapMember, + }]; + }, +}).register('mapMembersPopup'); + +ImportMapMembers.extendComponent({ + onSelectUser(){ + this.mapSelectedMember(this.currentData()); + Popup.back(); + }, + events() { + return [{ + 'click .js-select-import': this.onSelectUser, + }]; + }, + onRendered() { + // todo XXX why do I not get the focus?? + this.find('.js-map-member input').focus(); + }, +}).register('mapMembersAddPopup'); diff --git a/client/components/import/import.styl b/client/components/import/import.styl new file mode 100644 index 00000000..3c6cfdf3 --- /dev/null +++ b/client/components/import/import.styl @@ -0,0 +1,17 @@ +.map-members + .mapping:first-of-type + border-top: solid 1px #999 + .mapping + padding: 10px 0 + border-bottom: solid 1px #999 + .source + display: inline-block + width: 80% + .wekan + display: inline-block + width: 35px + .member + float: none + +a.show-mapping + text-decoration underline diff --git a/client/components/lists/list.js b/client/components/lists/list.js index cdf30fc2..f5410ed0 100644 --- a/client/components/lists/list.js +++ b/client/components/lists/list.js @@ -7,7 +7,7 @@ BlazeComponent.extendComponent({ // Proxy openForm(options) { - this.componentChildren('listBody')[0].openForm(options); + this.childComponents('listBody')[0].openForm(options); }, onCreated() { @@ -25,7 +25,7 @@ BlazeComponent.extendComponent({ if (!Meteor.user() || !Meteor.user().isBoardMember()) return; - const boardComponent = this.componentParent(); + const boardComponent = this.parentComponent(); const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)'; const $cards = this.$('.js-minicards'); $cards.sortable({ @@ -73,23 +73,13 @@ BlazeComponent.extendComponent({ $cards.sortable('cancel'); if (MultiSelection.isActive()) { - Cards.find(MultiSelection.getMongoSelector()).forEach((c, i) => { - Cards.update(c._id, { - $set: { - listId, - sort: sortIndex.base + i * sortIndex.increment, - }, - }); + Cards.find(MultiSelection.getMongoSelector()).forEach((card, i) => { + card.move(listId, sortIndex.base + i * sortIndex.increment); }); } else { const cardDomElement = ui.item.get(0); - const cardId = Blaze.getData(cardDomElement)._id; - Cards.update(cardId, { - $set: { - listId, - sort: sortIndex.base, - }, - }); + const card = Blaze.getData(cardDomElement); + card.move(listId, sortIndex.base); } boardComponent.setIsDragging(false); }, @@ -107,16 +97,15 @@ BlazeComponent.extendComponent({ accept: '.js-member,.js-label', drop(event, ui) { const cardId = Blaze.getData(this)._id; - let addToSet; + const card = Cards.findOne(cardId); if (ui.draggable.hasClass('js-member')) { const memberId = Blaze.getData(ui.draggable.get(0)).userId; - addToSet = { members: memberId }; + card.assignMember(memberId); } else { const labelId = Blaze.getData(ui.draggable.get(0))._id; - addToSet = { labelIds: labelId }; + card.addLabel(labelId); } - Cards.update(cardId, { $addToSet: addToSet }); }, }); }); diff --git a/client/components/lists/listBody.jade b/client/components/lists/listBody.jade index b0a374ea..e659b179 100644 --- a/client/components/lists/listBody.jade +++ b/client/components/lists/listBody.jade @@ -22,9 +22,20 @@ template(name="listBody") template(name="addCardForm") .minicard.minicard-composer.js-composer - .minicard-detailss.clearfix - textarea.minicard-composer-textarea.js-card-title(autofocus) + if getLabels + .minicard-labels + each getLabels + .minicard-label(class="card-label-{{color}}" title="{{name}}") + textarea.minicard-composer-textarea.js-card-title(autofocus) + if members.get .minicard-members.js-minicard-composer-members + each members.get + +userAvatar(userId=this) + .add-controls.clearfix button.primary.confirm(type="submit") {{_ 'add'}} a.fa.fa-times-thin.js-close-inlined-form + +template(name="autocompleteLabelLine") + .minicard-label(class="card-label-{{colorName}}" title=labelName) + span(class="{{#if hasNoName}}quiet{{/if}}")= labelName diff --git a/client/components/lists/listBody.js b/client/components/lists/listBody.js index 2e00cb4f..36b60d06 100644 --- a/client/components/lists/listBody.js +++ b/client/components/lists/listBody.js @@ -11,8 +11,8 @@ BlazeComponent.extendComponent({ options = options || {}; options.position = options.position || 'top'; - const forms = this.componentChildren('inlinedForm'); - let form = _.find(forms, (component) => { + const forms = this.childComponents('inlinedForm'); + let form = forms.find((component) => { return component.data().position === options.position; }); if (!form && forms.length > 0) { @@ -26,8 +26,10 @@ BlazeComponent.extendComponent({ const firstCardDom = this.find('.js-minicard:first'); const lastCardDom = this.find('.js-minicard:last'); const textarea = $(evt.currentTarget).find('textarea'); - const title = textarea.val(); - const position = Blaze.getData(evt.currentTarget).position; + const position = this.currentData().position; + const title = textarea.val().trim(); + + const formComponent = this.childComponents('addCardForm')[0]; let sortIndex; if (position === 'top') { sortIndex = Utils.calculateIndex(null, firstCardDom).base; @@ -35,9 +37,14 @@ BlazeComponent.extendComponent({ sortIndex = Utils.calculateIndex(lastCardDom, null).base; } - if ($.trim(title)) { + const members = formComponent.members.get(); + const labelIds = formComponent.labels.get(); + + if (title) { const _id = Cards.insert({ title, + members, + labelIds, listId: this.data()._id, boardId: this.data().board()._id, sort: sortIndex, @@ -53,6 +60,8 @@ BlazeComponent.extendComponent({ if (position === 'bottom') { this.scrollToBottom(); } + + formComponent.reset(); } }, @@ -100,11 +109,39 @@ BlazeComponent.extendComponent({ }, }).register('listBody'); +function toggleValueInReactiveArray(reactiveValue, value) { + const array = reactiveValue.get(); + const valueIndex = array.indexOf(value); + if (valueIndex === -1) { + array.push(value); + } else { + array.splice(valueIndex, 1); + } + reactiveValue.set(array); +} + BlazeComponent.extendComponent({ template() { return 'addCardForm'; }, + onCreated() { + this.labels = new ReactiveVar([]); + this.members = new ReactiveVar([]); + }, + + reset() { + this.labels.set([]); + this.members.set([]); + }, + + getLabels() { + const currentBoardId = Session.get('currentBoard'); + return Boards.findOne(currentBoardId).labels.filter((label) => { + return this.labels.get().indexOf(label._id) > -1; + }); + }, + pressKey(evt) { // Pressing Enter should submit the card if (evt.keyCode === 13) { @@ -140,4 +177,66 @@ BlazeComponent.extendComponent({ keydown: this.pressKey, }]; }, + + onRendered() { + const editor = this; + this.$('textarea').escapeableTextComplete([ + // User mentions + { + match: /\B@(\w*)$/, + search(term, callback) { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + callback($.map(currentBoard.members, (member) => { + const user = Users.findOne(member.userId); + return user.username.indexOf(term) === 0 ? user : null; + })); + }, + template(user) { + return user.username; + }, + replace(user) { + toggleValueInReactiveArray(editor.members, user._id); + return ''; + }, + index: 1, + }, + + // Labels + { + match: /\B#(\w*)$/, + search(term, callback) { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + callback($.map(currentBoard.labels, (label) => { + if (label.name.indexOf(term) > -1 || + label.color.indexOf(term) > -1) { + return label; + } + })); + }, + template(label) { + return Blaze.toHTMLWithData(Template.autocompleteLabelLine, { + hasNoName: !Boolean(label.name), + colorName: label.color, + labelName: label.name || label.color, + }); + }, + replace(label) { + toggleValueInReactiveArray(editor.labels, label._id); + return ''; + }, + index: 1, + }, + ], { + // When the autocomplete menu is shown we want both a press of both `Tab` + // or `Enter` to validation the auto-completion. We also need to stop the + // event propagation to prevent the card from submitting (on `Enter`) or + // going on the next column (on `Tab`). + onKeydown(evt, commands) { + if (evt.keyCode === 9 || evt.keyCode === 13) { + evt.stopPropagation(); + return commands.KEY_ENTER; + } + }, + }); + }, }).register('addCardForm'); diff --git a/client/components/lists/listHeader.jade b/client/components/lists/listHeader.jade index 7d01f1ba..72cd0fe9 100644 --- a/client/components/lists/listHeader.jade +++ b/client/components/lists/listHeader.jade @@ -25,6 +25,7 @@ template(name="listActionPopup") li: a.js-archive-cards {{_ 'list-archive-cards'}} hr ul.pop-over-list + li: a.js-import-card {{_ 'import-card'}} li: a.js-close-list {{_ 'archive-list'}} template(name="listMoveCardsPopup") diff --git a/client/components/lists/listHeader.js b/client/components/lists/listHeader.js index 9431b461..d660508a 100644 --- a/client/components/lists/listHeader.js +++ b/client/components/lists/listHeader.js @@ -5,14 +5,10 @@ BlazeComponent.extendComponent({ editTitle(evt) { evt.preventDefault(); - const form = this.componentChildren('inlinedForm')[0]; - const newTitle = form.getValue(); - if ($.trim(newTitle)) { - Lists.update(this.currentData()._id, { - $set: { - title: newTitle, - }, - }); + const newTitle = this.childComponents('inlinedForm')[0].getValue().trim(); + const list = this.currentData(); + if (newTitle) { + list.rename(newTitle.trim()); } }, @@ -33,45 +29,32 @@ Template.listActionPopup.events({ }, 'click .js-list-subscribe'() {}, 'click .js-select-cards'() { - const cardIds = Cards.find( - {listId: this._id}, - {fields: { _id: 1 }} - ).map((card) => card._id); + const cardIds = this.allCards().map((card) => card._id); MultiSelection.add(cardIds); Popup.close(); }, + 'click .js-import-card': Popup.open('listImportCard'), 'click .js-move-cards': Popup.open('listMoveCards'), - 'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', () => { - Cards.find({listId: this._id}).forEach((card) => { - Cards.update(card._id, { - $set: { - archived: true, - }, - }); + 'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', function() { + this.allCards().forEach((card) => { + card.archive(); }); Popup.close(); }), + 'click .js-close-list'(evt) { evt.preventDefault(); - Lists.update(this._id, { - $set: { - archived: true, - }, - }); + this.archive(); Popup.close(); }, }); Template.listMoveCardsPopup.events({ 'click .js-select-list'() { - const fromList = Template.parentData(2).data._id; + const fromList = Template.parentData(2).data; const toList = this._id; - Cards.find({ listId: fromList }).forEach((card) => { - Cards.update(card._id, { - $set: { - listId: toList, - }, - }); + fromList.allCards().forEach((card) => { + card.move(toList); }); Popup.close(); }, diff --git a/client/components/main/editor.js b/client/components/main/editor.js index 1d88fe74..82fce641 100644 --- a/client/components/main/editor.js +++ b/client/components/main/editor.js @@ -1,17 +1,15 @@ -let dropdownMenuIsOpened = false; - Template.editor.onRendered(() => { const $textarea = this.$('textarea'); autosize($textarea); - $textarea.textcomplete([ + $textarea.escapeableTextComplete([ // Emojies { match: /\B:([\-+\w]*)$/, search(term, callback) { - callback($.map(Emoji.values, (emoji) => { - return emoji.indexOf(term) === 0 ? emoji : null; + callback(Emoji.values.map((emoji) => { + return emoji.includes(term) ? emoji : null; })); }, template(value) { @@ -30,9 +28,9 @@ Template.editor.onRendered(() => { match: /\B@(\w*)$/, search(term, callback) { const currentBoard = Boards.findOne(Session.get('currentBoard')); - callback($.map(currentBoard.members, (member) => { + callback(currentBoard.members.map((member) => { const username = Users.findOne(member.userId).username; - return username.indexOf(term) === 0 ? username : null; + return username.includes(term) ? username : null; })); }, template(value) { @@ -44,30 +42,8 @@ Template.editor.onRendered(() => { index: 1, }, ]); - - // Since commit d474017 jquery-textComplete automatically closes a potential - // opened dropdown menu when the user press Escape. This behavior conflicts - // with our EscapeActions system, but it's too complicated and hacky to - // monkey-pach textComplete to disable it -- I tried. Instead we listen to - // 'open' and 'hide' events, and create a ghost escapeAction when the dropdown - // is opened (and rely on textComplete to execute the actual action). - $textarea.on({ - 'textComplete:show'() { - dropdownMenuIsOpened = true; - }, - 'textComplete:hide'() { - Tracker.afterFlush(() => { - dropdownMenuIsOpened = false; - }); - }, - }); }); -EscapeActions.register('textcomplete', - () => {}, - () => dropdownMenuIsOpened -); - // XXX I believe we should compute a HTML rendered field on the server that // would handle markdown, emojies and user mentions. We can simply have two // fields, one source, and one compiled version (in HTML) and send only the @@ -78,7 +54,7 @@ const at = HTML.CharRef({html: '@', str: '@'}); Blaze.Template.registerHelper('mentions', new Template('mentions', function() { const view = this; const currentBoard = Boards.findOne(Session.get('currentBoard')); - const knowedUsers = _.map(currentBoard.members, (member) => { + const knowedUsers = currentBoard.members.map((member) => { member.username = Users.findOne(member.userId).username; return member; }); diff --git a/client/components/main/header.jade b/client/components/main/header.jade index 4715bfc8..86dfd6a7 100644 --- a/client/components/main/header.jade +++ b/client/components/main/header.jade @@ -43,10 +43,10 @@ template(name="header") the list of all boards. if isSandstorm .wekan-logo - img(src="/wekan-logo-header.png" alt="Wekan") + img(src="{{pathFor '/wekan-logo-header.png'}}" alt="Wekan") else a.wekan-logo(href="{{pathFor 'home'}}" title="{{_ 'header-logo-title'}}") - img(src="/wekan-logo-header.png" alt="Wekan") + img(src="{{pathFor '/wekan-logo-header.png'}}" alt="Wekan") template(name="headerTitle") h1 {{_ 'my-boards'}} diff --git a/client/components/main/keyboardShortcuts.styl b/client/components/main/keyboardShortcuts.styl index 42e0637b..f77d387f 100644 --- a/client/components/main/keyboardShortcuts.styl +++ b/client/components/main/keyboardShortcuts.styl @@ -14,11 +14,6 @@ padding: 5px 8px margin: 5px font-size: 18px - font-weight: bold - background: white - border-radius: 3px - border: 1px solid darken(white, 10%) - box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.15) .shortcuts-list-item-action font-size: 1.4em diff --git a/client/components/main/layouts.jade b/client/components/main/layouts.jade index f5a8db59..65b53f04 100644 --- a/client/components/main/layouts.jade +++ b/client/components/main/layouts.jade @@ -2,13 +2,24 @@ head title Wekan meta(name="viewport" content="maximum-scale=1.0,width=device-width,initial-scale=1.0,user-scalable=0") + //- XXX We should use pathFor in the following `href` to support the case + where the application is deployed with a path prefix, but it seems to be + difficult to do that cleanly with Blaze -- at least without adding extra + packages. link(rel="shortcut icon" href="/wekan-favicon.png") template(name="userFormsLayout") section.auth-layout h1.at-form-landing-logo - img(src="/wekan-logo.png" alt="Wekan") + img(src="{{pathFor '/wekan-logo.png'}}" alt="Wekan") +Template.dynamic(template=content) + div.at-form-lang + select.select-lang.js-userform-set-language + each languages + if isCurrentLanguage + option(value="{{tag}}" selected="selected") {{name}} + else + option(value="{{tag}}") {{name}} template(name="defaultLayout") +header diff --git a/client/components/main/layouts.js b/client/components/main/layouts.js index ab62e76a..3df17f41 100644 --- a/client/components/main/layouts.js +++ b/client/components/main/layouts.js @@ -2,10 +2,43 @@ Meteor.subscribe('boards'); BlazeLayout.setRoot('body'); +const i18nTagToT9n = (i18nTag) => { + // t9n/i18n tags are same now, see: https://github.com/softwarerero/meteor-accounts-t9n/pull/129 + // but we keep this conversion function here, to be aware that that they are different system. + return i18nTag; +}; + Template.userFormsLayout.onRendered(() => { + const i18nTag = navigator.language; + if (i18nTag) { + T9n.setLanguage(i18nTagToT9n(i18nTag)); + } EscapeActions.executeAll(); }); +Template.userFormsLayout.helpers({ + languages() { + return _.map(TAPi18n.getLanguages(), (lang, tag) => { + const name = lang.name; + return { tag, name }; + }); + }, + + isCurrentLanguage() { + const t9nTag = i18nTagToT9n(this.tag); + const curLang = T9n.getLanguage() || 'en'; + return t9nTag === curLang; + }, +}); + +Template.userFormsLayout.events({ + 'change .js-userform-set-language'(evt) { + const i18nTag = $(evt.currentTarget).val(); + T9n.setLanguage(i18nTagToT9n(i18nTag)); + evt.preventDefault(); + }, +}); + Template.defaultLayout.events({ 'click .js-close-modal': () => { Modal.close(); diff --git a/client/components/main/layouts.styl b/client/components/main/layouts.styl index 1dbefc20..fcc94251 100644 --- a/client/components/main/layouts.styl +++ b/client/components/main/layouts.styl @@ -172,6 +172,15 @@ dl, dt dd margin: 0 0 16px 24px +kbd + padding: 1px 3px + margin: 3px + font-weight: bold + background: darken(white, 2%) + border-radius: 3px + border: 1px solid darken(white, 10%) + box-shadow: 0px 2px 3px rgba(0, 0, 0, 0.15) + .clear clear: both diff --git a/client/components/main/popup.styl b/client/components/main/popup.styl index 3bef4f7d..8a685069 100644 --- a/client/components/main/popup.styl +++ b/client/components/main/popup.styl @@ -17,9 +17,11 @@ $popupWidth = 300px margin: 4px -10px width: $popupWidth + p, + textarea, input[type="text"], input[type="email"], - input[type="password"] + input[type="password"], input[type="file"] margin: 4px 0 12px width: 100% @@ -30,8 +32,6 @@ $popupWidth = 300px textarea height: 72px - margin: 4px 0 12px - width: 100% .header height: 36px diff --git a/client/components/sidebar/sidebar.jade b/client/components/sidebar/sidebar.jade index 7f7519c6..3a5c7fdb 100644 --- a/client/components/sidebar/sidebar.jade +++ b/client/components/sidebar/sidebar.jade @@ -33,6 +33,13 @@ template(name="membersWidget") a.member.add-member.js-manage-board-members i.fa.fa-plus .clearfix + if isInvited + hr + p + i.fa.fa-exclamation-circle + | {{_ 'just-invited'}} + button.js-member-invite-accept.primary {{_ 'accept'}} + button.js-member-invite-decline {{_ 'decline'}} template(name="labelsWidget") .board-widget.board-widget-labels @@ -56,51 +63,58 @@ template(name="memberPopup") h3 .js-profile= user.profile.fullname p.quiet @#{user.username} + if isInvited + p + i.fa.fa-exclamation-circle + | {{_ 'not-accepted-yet'}} - if currentUser.isBoardMember - ul.pop-over-list - li - a.js-filter-member Filter cards + ul.pop-over-list + li + a.js-filter-member {{_ 'filter-cards'}} + if currentUser.isBoardAdmin unless isSandstorm - if currentUser.isBoardAdmin - li - a.js-change-role - | {{_ 'change-permissions'}} - span.quiet (#{memberType}) - li - if $eq currentUser._id userId - //- - XXX Not implemented! - // a.js-leave-member {{_ 'leave-board'}} - else - a.js-remove-member {{_ 'remove-from-board'}} + 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'}} template(name="removeMemberPopup") - p {{_ 'remove-member-pop' name=user.profile.name username=user.username boardTitle=board.title}} + 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="addMemberPopup") .js-search-member +esInput(index="users") - ul.pop-over-list - +esEach(index="users") - li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}") - a.name.js-select-member(title="{{profile.name}} ({{username}})") - +userAvatar(userId=_id esSearch=true) - span.full-name - = profile.name - | (<span class="username">{{username}}</span>) - if isBoardMember - .quiet ({{_ 'joined'}}) + if loading.get + +spinner + else if error.get + .warning {{_ error.get}} + else + ul.pop-over-list + +esEach(index="users") + li.item.js-member-item(class="{{#if isBoardMember}}disabled{{/if}}") + a.name.js-select-member(title="{{profile.name}} ({{username}})") + +userAvatar(userId=_id esSearch=true) + span.full-name + = profile.fullname + | (<span class="username">{{username}}</span>) + if isBoardMember + .quiet ({{_ 'joined'}}) - +ifEsIsSearching(index='users') - +spinner + +ifEsIsSearching(index='users') + +spinner - +ifEsHasNoResults(index="users") - .manage-member-section - p.quiet {{_ 'no-results'}} + +ifEsHasNoResults(index="users") + .manage-member-section + p.quiet {{_ 'no-results'}} + button.js-email-invite.primary.full {{_ 'email-invite'}} template(name="changePermissionsPopup") ul.pop-over-list diff --git a/client/components/sidebar/sidebar.js b/client/components/sidebar/sidebar.js index eff0ef52..5b58dbd9 100644 --- a/client/components/sidebar/sidebar.js +++ b/client/components/sidebar/sidebar.js @@ -54,7 +54,7 @@ BlazeComponent.extendComponent({ }, reachNextPeak() { - const activitiesComponent = this.componentChildren('activities')[0]; + const activitiesComponent = this.childComponents('activities')[0]; activitiesComponent.loadNextPage(); }, @@ -95,10 +95,10 @@ BlazeComponent.extendComponent({ events() { // XXX Hacky, we need some kind of `super` const mixinEvents = this.getMixin(Mixins.InfiniteScrolling).events(); - return mixinEvents.concat([{ + return [...mixinEvents, { 'click .js-toggle-sidebar': this.toggle, 'click .js-back-home': this.setView, - }]); + }]; }, }).register('sidebar'); @@ -109,14 +109,6 @@ EscapeActions.register('sidebarView', () => { return Sidebar && Sidebar.getView() !== defaultView; } ); -function getMemberIndex(board, searchId) { - for (let i = 0; i < board.members.length; i++) { - if (board.members[i].userId === searchId) - return i; - } - throw new Meteor.Error('Member not found'); -} - Template.memberPopup.helpers({ user() { return Users.findOne(this.userId); @@ -125,6 +117,9 @@ Template.memberPopup.helpers({ const type = Users.findOne(this.userId).isBoardAdmin() ? 'admin' : 'normal'; return TAPi18n.__(type).toLowerCase(); }, + isInvited() { + return Users.findOne(this.userId).isInvitedTo(Session.get('currentBoard')); + }, }); Template.memberPopup.events({ @@ -135,24 +130,53 @@ Template.memberPopup.events({ 'click .js-change-role': Popup.open('changePermissions'), 'click .js-remove-member': Popup.afterConfirm('removeMember', function() { const currentBoard = Boards.findOne(Session.get('currentBoard')); - const memberIndex = getMemberIndex(currentBoard, this.userId); - - Boards.update(currentBoard._id, { - $set: { - [`members.${memberIndex}.isActive`]: false, - }, - }); + const memberId = this.userId; + currentBoard.removeMember(memberId); Popup.close(); }), 'click .js-leave-member'() { - // XXX Not implemented - Popup.close(); + const currentBoard = Boards.findOne(Session.get('currentBoard')); + Meteor.call('quitBoard', currentBoard, (err, ret) => { + if (!ret && ret) { + Popup.close(); + FlowRouter.go('home'); + } + }); + }, +}); + +Template.removeMemberPopup.helpers({ + user() { + return Users.findOne(this.userId); + }, + board() { + return Boards.findOne(Session.get('currentBoard')); + }, +}); + +Template.membersWidget.helpers({ + isInvited() { + const user = Meteor.user(); + return user && user.isInvitedTo(Session.get('currentBoard')); }, }); Template.membersWidget.events({ 'click .js-member': Popup.open('member'), 'click .js-manage-board-members': Popup.open('addMember'), + 'click .js-member-invite-accept'() { + const boardId = Session.get('currentBoard'); + Meteor.user().removeInvite(boardId); + }, + 'click .js-member-invite-decline'() { + const boardId = Session.get('currentBoard'); + Meteor.call('quitBoard', boardId, (err, ret) => { + if (!err && ret) { + Meteor.user().removeInvite(boardId); + FlowRouter.go('home'); + } + }); + }, }); Template.labelsWidget.events({ @@ -198,56 +222,83 @@ function draggableMembersLabelsWidgets() { Template.membersWidget.onRendered(draggableMembersLabelsWidgets); Template.labelsWidget.onRendered(draggableMembersLabelsWidgets); -Template.addMemberPopup.helpers({ +BlazeComponent.extendComponent({ + template() { + return 'addMemberPopup'; + }, + + onCreated() { + this.error = new ReactiveVar(''); + this.loading = new ReactiveVar(false); + }, + + onRendered() { + this.find('.js-search-member input').focus(); + this.setLoading(false); + }, + isBoardMember() { - const user = Users.findOne(this._id); + const userId = this.currentData()._id; + const user = Users.findOne(userId); return user && user.isBoardMember(); }, -}); -Template.addMemberPopup.events({ - 'click .js-select-member'() { - const userId = this._id; - const currentBoard = Boards.findOne(Session.get('currentBoard')); - const currentMembersIds = _.pluck(currentBoard.members, 'userId'); - if (currentMembersIds.indexOf(userId) === -1) { - Boards.update(currentBoard._id, { - $push: { - members: { - userId, - isAdmin: false, - isActive: true, - }, - }, - }); - } else { - const memberIndex = getMemberIndex(currentBoard, userId); + isValidEmail(email) { + return SimpleSchema.RegEx.Email.test(email); + }, - Boards.update(currentBoard._id, { - $set: { - [`members.${memberIndex}.isActive`]: true, - }, - }); - } - Popup.close(); + setError(error) { + this.error.set(error); }, -}); -Template.addMemberPopup.onRendered(function() { - this.find('.js-search-member input').focus(); -}); + setLoading(w) { + this.loading.set(w); + }, + + isLoading() { + return this.loading.get(); + }, + + inviteUser(idNameEmail) { + const boardId = Session.get('currentBoard'); + this.setLoading(true); + const self = this; + Meteor.call('inviteUserToBoard', idNameEmail, boardId, (err, ret) => { + self.setLoading(false); + if (err) self.setError(err.error); + else if (ret.email) self.setError('email-sent'); + else Popup.close(); + }); + }, + + events() { + return [{ + 'keyup input'() { + this.setError(''); + }, + 'click .js-select-member'() { + const userId = this.currentData()._id; + const currentBoard = Boards.findOne(Session.get('currentBoard')); + if (currentBoard.memberIndex(userId)<0) { + this.inviteUser(userId); + } + }, + 'click .js-email-invite'() { + const idNameEmail = $('.js-search-member input').val(); + if (idNameEmail.indexOf('@')<0 || this.isValidEmail(idNameEmail)) { + this.inviteUser(idNameEmail); + } else this.setError('email-invalid'); + }, + }]; + }, +}).register('addMemberPopup'); Template.changePermissionsPopup.events({ 'click .js-set-admin, click .js-set-normal'(event) { const currentBoard = Boards.findOne(Session.get('currentBoard')); - const memberIndex = getMemberIndex(currentBoard, this.userId); + const memberId = this.userId; const isAdmin = $(event.currentTarget).hasClass('js-set-admin'); - - Boards.update(currentBoard._id, { - $set: { - [`members.${memberIndex}.isAdmin`]: isAdmin, - }, - }); + currentBoard.setMemberPermission(memberId, isAdmin); Popup.back(1); }, }); diff --git a/client/components/sidebar/sidebarArchives.js b/client/components/sidebar/sidebarArchives.js index f2597c3c..18970267 100644 --- a/client/components/sidebar/sidebarArchives.js +++ b/client/components/sidebar/sidebarArchives.js @@ -11,11 +11,17 @@ BlazeComponent.extendComponent({ }, archivedCards() { - return Cards.find({ archived: true }); + return Cards.find({ + archived: true, + boardId: Session.get('currentBoard'), + }); }, archivedLists() { - return Lists.find({ archived: true }); + return Lists.find({ + archived: true, + boardId: Session.get('currentBoard'), + }); }, cardIsInArchivedList() { @@ -29,8 +35,8 @@ BlazeComponent.extendComponent({ events() { return [{ 'click .js-restore-card'() { - const cardId = this.currentData()._id; - Cards.update(cardId, {$set: {archived: false}}); + const card = this.currentData(); + card.restore(); }, 'click .js-delete-card': Popup.afterConfirm('cardDelete', function() { const cardId = this._id; @@ -38,8 +44,8 @@ BlazeComponent.extendComponent({ Popup.close(); }), 'click .js-restore-list'() { - const listId = this.currentData()._id; - Lists.update(listId, {$set: {archived: false}}); + const list = this.currentData(); + list.restore(); }, }]; }, diff --git a/client/components/sidebar/sidebarFilters.jade b/client/components/sidebar/sidebarFilters.jade index c894bc8b..ef26ef76 100644 --- a/client/components/sidebar/sidebarFilters.jade +++ b/client/components/sidebar/sidebarFilters.jade @@ -13,7 +13,7 @@ template(name="filterSidebar") if name = name else - span.quiet {{_ "label-default" color}} + span.quiet {{_ "label-default" (_ (concat "color-" color))}} if Filter.labelIds.isSelected _id i.fa.fa-check hr @@ -75,8 +75,8 @@ template(name="multiselectionSidebar") template(name="disambiguateMultiLabelPopup") p {{_ 'what-to-do'}} - button.wide.js-remove-label Remove {{_ 'remove-label'}} - button.wide.js-add-label Add {{_ 'add-label'}} + button.wide.js-remove-label {{_ 'remove-label'}} + button.wide.js-add-label {{_ 'add-label'}} template(name="disambiguateMultiMemberPopup") p {{_ 'what-to-do'}} diff --git a/client/components/sidebar/sidebarFilters.js b/client/components/sidebar/sidebarFilters.js index 335cc7d6..bdecd63e 100644 --- a/client/components/sidebar/sidebarFilters.js +++ b/client/components/sidebar/sidebarFilters.js @@ -30,9 +30,9 @@ BlazeComponent.extendComponent({ }, }).register('filterSidebar'); -function updateSelectedCards(query) { +function mutateSelectedCards(mutationName, ...args) { Cards.find(MultiSelection.getMongoSelector()).forEach((card) => { - Cards.update(card._id, query); + card[mutationName](...args); }); } @@ -67,47 +67,34 @@ BlazeComponent.extendComponent({ 'click .js-toggle-label-multiselection'(evt) { const labelId = this.currentData()._id; const mappedSelection = this.mapSelection('label', labelId); - let operation; - if (_.every(mappedSelection)) - operation = '$pull'; - else if (_.every(mappedSelection, (bool) => !bool)) - operation = '$addToSet'; - else { + + if (_.every(mappedSelection)) { + mutateSelectedCards('removeLabel', labelId); + } else if (_.every(mappedSelection, (bool) => !bool)) { + mutateSelectedCards('addLabel', labelId); + } else { const popup = Popup.open('disambiguateMultiLabel'); // XXX We need to have a better integration between the popup and the // UI components systems. return popup.call(this.currentData(), evt); } - - updateSelectedCards({ - [operation]: { - labelIds: labelId, - }, - }); }, 'click .js-toggle-member-multiselection'(evt) { const memberId = this.currentData()._id; const mappedSelection = this.mapSelection('member', memberId); - let operation; - if (_.every(mappedSelection)) - operation = '$pull'; - else if (_.every(mappedSelection, (bool) => !bool)) - operation = '$addToSet'; - else { + if (_.every(mappedSelection)) { + mutateSelectedCards('unassignMember', memberId); + } else if (_.every(mappedSelection, (bool) => !bool)) { + mutateSelectedCards('assignMember', memberId); + } else { const popup = Popup.open('disambiguateMultiMember'); // XXX We need to have a better integration between the popup and the // UI components systems. return popup.call(this.currentData(), evt); } - - updateSelectedCards({ - [operation]: { - members: memberId, - }, - }); }, 'click .js-archive-selection'() { - updateSelectedCards({$set: {archived: true}}); + mutateSelectedCards('archive'); }, }]; }, @@ -115,22 +102,22 @@ BlazeComponent.extendComponent({ Template.disambiguateMultiLabelPopup.events({ 'click .js-remove-label'() { - updateSelectedCards({$pull: {labelIds: this._id}}); + mutateSelectedCards('removeLabel', this._id); Popup.close(); }, 'click .js-add-label'() { - updateSelectedCards({$addToSet: {labelIds: this._id}}); + mutateSelectedCards('addLabel', this._id); Popup.close(); }, }); Template.disambiguateMultiMemberPopup.events({ 'click .js-unassign-member'() { - updateSelectedCards({$pull: {members: this._id}}); + mutateSelectedCards('assignMember', this._id); Popup.close(); }, 'click .js-assign-member'() { - updateSelectedCards({$addToSet: {members: this._id}}); + mutateSelectedCards('unassignMember', this._id); Popup.close(); }, }); diff --git a/client/components/users/userAvatar.jade b/client/components/users/userAvatar.jade index e08666e5..44e899a7 100644 --- a/client/components/users/userAvatar.jade +++ b/client/components/users/userAvatar.jade @@ -1,7 +1,7 @@ template(name="userAvatar") a.member.js-member(title="{{userData.profile.fullname}} ({{userData.username}})") - if userData.profile.avatarUrl - img.avatar.avatar-image(src=userData.profile.avatarUrl) + if userData.getAvatarUrl + img.avatar.avatar-image(src=userData.getAvatarUrl) else +userAvatarInitials(userId=userData._id) diff --git a/client/components/users/userAvatar.js b/client/components/users/userAvatar.js index 04add0a6..1e531882 100644 --- a/client/components/users/userAvatar.js +++ b/client/components/users/userAvatar.js @@ -22,8 +22,11 @@ Template.userAvatar.helpers({ }, presenceStatusClassName() { + const user = Users.findOne(this.userId); const userPresence = presences.findOne({ userId: this.userId }); - if (!userPresence) + if (user && user.isInvitedTo(Session.get('currentBoard'))) + return 'pending'; + else if (!userPresence) return 'disconnected'; else if (Session.equals('currentBoard', userPresence.state.currentBoardId)) return 'active'; @@ -82,11 +85,7 @@ BlazeComponent.extendComponent({ }, setAvatar(avatarUrl) { - Meteor.users.update(Meteor.userId(), { - $set: { - 'profile.avatarUrl': avatarUrl, - }, - }); + Meteor.user().setAvatarUrl(avatarUrl); }, setError(error) { @@ -151,19 +150,9 @@ Template.cardMembersPopup.helpers({ Template.cardMembersPopup.events({ 'click .js-select-member'(evt) { - const cardId = Template.parentData(2).data._id; + const card = Cards.findOne(Session.get('currentCard')); const memberId = this.userId; - let operation; - if (Cards.find({ _id: cardId, members: memberId}).count() === 0) - operation = '$addToSet'; - else - operation = '$pull'; - - Cards.update(cardId, { - [operation]: { - members: memberId, - }, - }); + card.toggleMember(memberId); evt.preventDefault(); }, }); @@ -176,7 +165,7 @@ Template.cardMemberPopup.helpers({ Template.cardMemberPopup.events({ 'click .js-remove-member'() { - Cards.update(this.cardId, {$pull: {members: this.userId}}); + Cards.findOne(this.cardId).unassignMember(this.userId); Popup.close(); }, 'click .js-edit-profile': Popup.open('editProfile'), diff --git a/client/components/users/userAvatar.styl b/client/components/users/userAvatar.styl index 83257792..b962b01c 100644 --- a/client/components/users/userAvatar.styl +++ b/client/components/users/userAvatar.styl @@ -56,6 +56,10 @@ avatar-radius = 50% background: #bdbdbd border-color: #ededed + &.pending + background: #e44242 + border-color: #f1dada + .edit-avatar position: absolute top: 0 diff --git a/client/components/users/userForm.styl b/client/components/users/userForm.styl index 9b6e86ce..dbe62b4e 100644 --- a/client/components/users/userForm.styl +++ b/client/components/users/userForm.styl @@ -45,3 +45,13 @@ .at-signUp, .at-signIn font-weight: bold + + .at-form-lang + margin: auto + width: 275px + padding: 25px + padding-bottom: 10px + + .select-lang + width: 275px + font-size: 1.0em diff --git a/client/components/users/userHeader.js b/client/components/users/userHeader.js index 0f91fd15..a478da0c 100644 --- a/client/components/users/userHeader.js +++ b/client/components/users/userHeader.js @@ -18,9 +18,9 @@ Template.memberMenuPopup.events({ Template.editProfilePopup.events({ submit(evt, tpl) { evt.preventDefault(); - const fullname = $.trim(tpl.find('.js-profile-fullname').value); - const username = $.trim(tpl.find('.js-profile-username').value); - const initials = $.trim(tpl.find('.js-profile-initials').value); + const fullname = tpl.find('.js-profile-fullname').value.trim(); + const username = tpl.find('.js-profile-username').value.trim(); + const initials = tpl.find('.js-profile-initials').value.trim(); Users.update(Meteor.userId(), {$set: { 'profile.fullname': fullname, 'profile.initials': initials, diff --git a/client/config/accounts.js b/client/config/accounts.js deleted file mode 100644 index df0935f7..00000000 --- a/client/config/accounts.js +++ /dev/null @@ -1,48 +0,0 @@ -const passwordField = AccountsTemplates.removeField('password'); -const emailField = AccountsTemplates.removeField('email'); -AccountsTemplates.addFields([{ - _id: 'username', - type: 'text', - displayName: 'username', - required: true, - minLength: 2, -}, emailField, passwordField]); - -AccountsTemplates.configure({ - defaultLayout: 'userFormsLayout', - defaultContentRegion: 'content', - confirmPassword: false, - enablePasswordChange: true, - sendVerificationEmail: true, - showForgotPasswordLink: true, - onLogoutHook() { - const homePage = 'home'; - if (FlowRouter.getRouteName() === homePage) { - FlowRouter.reload(); - } else { - FlowRouter.go(homePage); - } - }, -}); - -_.each(['signIn', 'signUp', 'resetPwd', 'forgotPwd', 'enrollAccount'], - (routeName) => AccountsTemplates.configureRoute(routeName)); - -// We display the form to change the password in a popup window that already -// have a title, so we unset the title automatically displayed by useraccounts. -AccountsTemplates.configure({ - texts: { - title: { - changePwd: '', - }, - }, -}); - -AccountsTemplates.configureRoute('changePwd', { - redirect() { - // XXX We should emit a notification once we have a notification system. - // Currently the user has no indication that his modification has been - // applied. - Popup.back(); - }, -}); diff --git a/client/config/blazeHelpers.js b/client/config/blazeHelpers.js index 12990ed7..adf5ef6a 100644 --- a/client/config/blazeHelpers.js +++ b/client/config/blazeHelpers.js @@ -13,3 +13,7 @@ Blaze.registerHelper('currentCard', () => { }); Blaze.registerHelper('getUser', (userId) => Users.findOne(userId)); + +UI.registerHelper('concat', function (...args) { + return Array.prototype.slice.call(args, 0, -1).join(''); +}); diff --git a/client/config/router.js b/client/config/router.js index 1cac43a0..0a6958d0 100644 --- a/client/config/router.js +++ b/client/config/router.js @@ -88,3 +88,26 @@ _.each(redirections, (newPath, oldPath) => { }], }); }); + +// As it is not possible to use template helpers in the page <head> we create a +// reactive function whose role is to set any page-specific tag in the <head> +// using the `kadira:dochead` package. Currently we only use it to display the +// board title if we are in a board page (see #364) but we may want to support +// some <meta> tags in the future. +const appTitle = 'Wekan'; + +// XXX The `Meteor.startup` should not be necessary -- we don't need to wait for +// the complete DOM to be ready to call `DocHead.setTitle`. But the problem is +// that the global variable `Boards` is undefined when this file loads so we +// wait a bit until hopefully all files are loaded. This will be fixed in a +// clean way once Meteor will support ES6 modules -- hopefully in Meteor 1.3. +Meteor.startup(() => { + Tracker.autorun(() => { + const currentBoard = Boards.findOne(Session.get('currentBoard')); + const titleStack = [appTitle]; + if (currentBoard) { + titleStack.push(currentBoard.title); + } + DocHead.setTitle(titleStack.reverse().join(' - ')); + }); +}); diff --git a/client/lib/accessibility.js b/client/lib/accessibility.js new file mode 100644 index 00000000..52b771d4 --- /dev/null +++ b/client/lib/accessibility.js @@ -0,0 +1,41 @@ +// In this file we define a set of DOM transformations that are specifically +// intended for blind screen readers. +// +// See https://github.com/wekan/wekan/issues/337 for the general accessibility +// considerations. + +// Without an href, links are non-keyboard-focusable and are not presented on +// blind screen readers. We default to the empty anchor `#` href. +function enforceHref(attributes) { + if (!_.has(attributes, 'href')) { + attributes.href = '#'; + } + return attributes; +} + +// `title` is inconsistently used on the web, and is thus inconsistently +// presented by screen readers. `aria-label`, on the other hand, is specific to +// accessibility and is presented in ways that title shouldn't be. +function copyTitleInAriaLabel(attributes) { + if (!_.has(attributes, 'aria-label') && _.has(attributes, 'title')) { + attributes['aria-label'] = attributes.title; + } + return attributes; +} + +// XXX Our implementation relies on overwriting Blaze virtual DOM functions, +// which is a little bit hacky -- but still reasonable with our ES6 usage. If we +// end up switching to React we will probably create lower level small +// components to handle that without overwriting any build-in function. +const { + A: superA, + I: superI, +} = HTML; + +HTML.A = (attributes, ...others) => { + return superA(copyTitleInAriaLabel(enforceHref(attributes)), ...others); +}; + +HTML.I = (attributes, ...others) => { + return superI(copyTitleInAriaLabel(attributes), ...others); +}; diff --git a/client/lib/dropImage.js b/client/lib/dropImage.js new file mode 100644 index 00000000..592d5c8f --- /dev/null +++ b/client/lib/dropImage.js @@ -0,0 +1,62 @@ +/* eslint-disable */ + +// ------------------------------------------------------------------------ +// Created by STRd6 +// MIT License +// https://github.com/distri/jquery-image_reader/blob/master/drop.coffee.md +// +// Raymond re-write it to javascript + +(function($) { + $.event.fix = (function(originalFix) { + return function(event) { + event = originalFix.apply(this, arguments); + if (event.type.indexOf('drag') === 0 || event.type.indexOf('drop') === 0) { + event.dataTransfer = event.originalEvent.dataTransfer; + } + return event; + }; + })($.event.fix); + + const defaults = { + callback: $.noop, + matchType: /image.*/, + }; + + return $.fn.dropImageReader = function(options) { + if (typeof options === 'function') { + options = { + callback: options, + }; + } + options = $.extend({}, defaults, options); + const stopFn = function(event) { + event.stopPropagation(); + return event.preventDefault(); + }; + return this.each(function() { + const element = this; + $(element).bind('dragenter dragover dragleave', stopFn); + return $(element).bind('drop', function(event) { + stopFn(event); + const files = event.dataTransfer.files; + for(let i=0; i<files.length; i++) { + const f = files[i]; + if(f.type.match(options.matchType)) { + const reader = new FileReader(); + reader.onload = function(evt) { + return options.callback.call(element, { + dataURL: evt.target.result, + event: evt, + file: f, + name: f.name, + }); + }; + reader.readAsDataURL(f); + return; + } + } + }); + }); + }; +})(jQuery); diff --git a/client/lib/filter.js b/client/lib/filter.js index f7baf480..74305284 100644 --- a/client/lib/filter.js +++ b/client/lib/filter.js @@ -95,7 +95,7 @@ Filter = { return {}; const filterSelector = {}; - _.forEach(this._fields, (fieldName) => { + this._fields.forEach((fieldName) => { const filter = this[fieldName]; if (filter._isActive()) filterSelector[fieldName] = filter._getMongoSelector(); @@ -116,7 +116,7 @@ Filter = { }, reset() { - _.forEach(this._fields, (fieldName) => { + this._fields.forEach((fieldName) => { const filter = this[fieldName]; filter.reset(); }); diff --git a/client/lib/keyboard.js b/client/lib/keyboard.js index af5fb7a2..f8212c9b 100644 --- a/client/lib/keyboard.js +++ b/client/lib/keyboard.js @@ -23,6 +23,14 @@ Mousetrap.bind('x', () => { } }); +Mousetrap.bind('f', () => { + if (Sidebar.isOpen() && Sidebar.getView() === 'filter') { + Sidebar.toggle(); + } else { + Sidebar.setView('filter'); + } +}); + Mousetrap.bind(['down', 'up'], (evt, key) => { if (!Session.get('currentCard')) { return; @@ -36,6 +44,26 @@ Mousetrap.bind(['down', 'up'], (evt, key) => { } }); +// XXX This shortcut should also work when hovering over a card in board view +Mousetrap.bind('space', (evt) => { + if (!Session.get('currentCard')) { + return; + } + + const currentUserId = Meteor.userId(); + if (currentUserId === null) { + return; + } + + if (Meteor.user().isBoardMember()) { + const card = Cards.findOne(Session.get('currentCard')); + card.toggleMember(currentUserId); + // We should prevent scrolling in card when spacebar is clicked + // This should do it according to Mousetrap docs, but it doesn't + evt.preventDefault(); + } +}); + Template.keyboardShortcuts.helpers({ mapping: [{ keys: ['W'], @@ -44,6 +72,9 @@ Template.keyboardShortcuts.helpers({ keys: ['Q'], action: 'shortcut-filter-my-cards', }, { + keys: ['F'], + action: 'shortcut-toggle-filterbar', + }, { keys: ['X'], action: 'shortcut-clear-filters', }, { @@ -58,5 +89,8 @@ Template.keyboardShortcuts.helpers({ }, { keys: [':'], action: 'shortcut-autocomplete-emojies', + }, { + keys: ['SPACE'], + action: 'shortcut-assign-self', }], }); diff --git a/client/lib/modal.js b/client/lib/modal.js index 5b3392b2..e6301cb5 100644 --- a/client/lib/modal.js +++ b/client/lib/modal.js @@ -21,9 +21,9 @@ window.Modal = new class { } } - open(modalName, options) { + open(modalName, { onCloseGoTo = ''} = {}) { this._currentModal.set(modalName); - this._onCloseGoTo = options && options.onCloseGoTo || ''; + this._onCloseGoTo = onCloseGoTo; } }; diff --git a/client/lib/multiSelection.js b/client/lib/multiSelection.js index c2bb2bbc..eeb2015d 100644 --- a/client/lib/multiSelection.js +++ b/client/lib/multiSelection.js @@ -119,12 +119,13 @@ MultiSelection = { } }, - toggle(cardIds, options) { + toggle(cardIds, options = {}) { cardIds = _.isString(cardIds) ? [cardIds] : cardIds; - options = _.extend({ + options = { add: true, remove: true, - }, options || {}); + ...options, + }; if (!this.isActive()) { this.reset(); @@ -133,7 +134,7 @@ MultiSelection = { const selectedCards = this._selectedCards.get(); - _.each(cardIds, (cardId) => { + cardIds.forEach((cardId) => { const indexOfCard = selectedCards.indexOf(cardId); if (options.remove && indexOfCard > -1) diff --git a/client/lib/pasteImage.js b/client/lib/pasteImage.js new file mode 100644 index 00000000..264d77ac --- /dev/null +++ b/client/lib/pasteImage.js @@ -0,0 +1,57 @@ +/* eslint-disable */ + +// ------------------------------------------------------------------------ +// Created by STRd6 +// MIT License +// https://github.com/distri/jquery-image_reader/blob/master/paste.coffee.md +// +// Raymond re-write it to javascript + +(function($) { + $.event.fix = (function(originalFix) { + return function(event) { + event = originalFix.apply(this, arguments); + if (event.type.indexOf('copy') === 0 || event.type.indexOf('paste') === 0) { + event.clipboardData = event.originalEvent.clipboardData; + } + return event; + }; + })($.event.fix); + + const defaults = { + callback: $.noop, + matchType: /image.*/, + }; + + return $.fn.pasteImageReader = function(options) { + if (typeof options === 'function') { + options = { + callback: options, + }; + } + options = $.extend({}, defaults, options); + return this.each(function() { + const element = this; + return $(element).bind('paste', function(event) { + const types = event.clipboardData.types; + const items = event.clipboardData.items; + for(let i=0; i<types.length; i++) { + if(types[i].match(options.matchType) || items[i].type.match(options.matchType)) { + const f = items[i].getAsFile(); + const reader = new FileReader(); + reader.onload = function(evt) { + return options.callback.call(element, { + dataURL: evt.target.result, + event: evt, + file: f, + name: f.name, + }); + }; + reader.readAsDataURL(f); + return; + } + } + }); + }); + }; +})(jQuery); diff --git a/client/lib/popup.js b/client/lib/popup.js index 3c39af29..7418d938 100644 --- a/client/lib/popup.js +++ b/client/lib/popup.js @@ -91,7 +91,7 @@ window.Popup = new class { if (!self.isOpen()) { self.current = Blaze.renderWithData(self.template, () => { self._dep.depend(); - return _.extend(self._getTopStack(), { stack: self._stack }); + return { ...self._getTopStack(), stack: self._stack }; }, document.body); } else { @@ -191,7 +191,7 @@ window.Popup = new class { // We close a potential opened popup on any left click on the document, or go // one step back by pressing escape. const escapeActions = ['back', 'close']; -_.each(escapeActions, (actionName) => { +escapeActions.forEach((actionName) => { EscapeActions.register(`popup-${actionName}`, () => Popup[actionName](), () => Popup.isOpen(), diff --git a/client/lib/textComplete.js b/client/lib/textComplete.js new file mode 100644 index 00000000..3e69d07f --- /dev/null +++ b/client/lib/textComplete.js @@ -0,0 +1,54 @@ +// We “inherit” the jquery-textcomplete plugin to integrate with our +// EscapeActions system. You should always use `escapeableTextComplete` instead +// of the vanilla `textcomplete`. +let dropdownMenuIsOpened = false; + +$.fn.escapeableTextComplete = function(strategies, options, ...otherArgs) { + // When the autocomplete menu is shown we want both a press of both `Tab` + // or `Enter` to validation the auto-completion. We also need to stop the + // event propagation to prevent EscapeActions side effect, for instance the + // minicard submission (on `Enter`) or going on the next column (on `Tab`). + options = { + onKeydown(evt, commands) { + if (evt.keyCode === 9 || evt.keyCode === 13) { + evt.stopPropagation(); + return commands.KEY_ENTER; + } + }, + ...options, + }; + + // Proxy to the vanilla jQuery component + this.textcomplete(strategies, options, ...otherArgs); + + // Since commit d474017 jquery-textComplete automatically closes a potential + // opened dropdown menu when the user press Escape. This behavior conflicts + // with our EscapeActions system, but it's too complicated and hacky to + // monkey-pach textComplete to disable it -- I tried. Instead we listen to + // 'open' and 'hide' events, and create a ghost escapeAction when the dropdown + // is opened (and rely on textComplete to execute the actual action). + this.on({ + 'textComplete:show'() { + dropdownMenuIsOpened = true; + }, + 'textComplete:hide'() { + Tracker.afterFlush(() => { + // XXX Hack. We unfortunately need to set a setTimeout here to make the + // `noClickEscapeOn` work bellow, otherwise clicking on a autocomplete + // item will close both the autocomplete menu (as expected) but also the + // next item in the stack (for example the minicard editor) which we + // don't want. + setTimeout(() => { + dropdownMenuIsOpened = false; + }, 100); + }); + }, + }); +}; + +EscapeActions.register('textcomplete', + () => {}, + () => dropdownMenuIsOpened, { + noClickEscapeOn: '.textcomplete-dropdown', + } +); diff --git a/client/lib/unsavedEdits.js b/client/lib/unsavedEdits.js index dc267bfb..17bb29b5 100644 --- a/client/lib/unsavedEdits.js +++ b/client/lib/unsavedEdits.js @@ -65,7 +65,7 @@ UnsavedEdits = { }; Blaze.registerHelper('getUnsavedValue', (fieldName, docId, defaultTo) => { - // Workaround some blaze feature that ass a list of keywords arguments as the + // Workaround some blaze feature that pass a list of keywords arguments as the // last parameter (even if the caller didn't specify any). if (!_.isString(defaultTo)) { defaultTo = ''; diff --git a/client/lib/utils.js b/client/lib/utils.js index 0cd93419..6bdd5822 100644 --- a/client/lib/utils.js +++ b/client/lib/utils.js @@ -22,20 +22,6 @@ Utils = { return string.charAt(0).toUpperCase() + string.slice(1); }, - getLabelIndex(boardId, labelId) { - const board = Boards.findOne(boardId); - const labels = {}; - _.each(board.labels, (a, b) => { - labels[a._id] = b; - }); - return { - index: labels[labelId], - key(key) { - return `labels.${labels[labelId]}.${key}`; - }, - }; - }, - // Determine the new sort index calculateIndex(prevCardDomElement, nextCardDomElement, nCards = 1) { let base, increment; |