diff options
54 files changed, 1014 insertions, 297 deletions
diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..4ba559c2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# EditorConfig is awesome: http://EditorConfig.org +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 @@ -1,83 +1,101 @@ ecmaFeatures: experimentalObjectRestSpread: true +plugins: + - meteor + +parser: babel-eslint + rules: - accessor-pairs: [2] - consistent-return: [2] + strict: 0 + no-undef: 2 + accessor-pairs: 2 + comma-dangle: [2, 'always-multiline'] + consistent-return: 2 + dot-notation: 2 + eqeqeq: 2 indent: [2, 2] - semi: [2, always] - comma-dangle: [2, always-multiline] + no-cond-assign: 2 + no-constant-condition: 2 + no-eval: 2 no-inner-declarations: [0] - dot-notation: [2] - eqeqeq: [2] - no-eval: [2] - radix: [2] + no-unneeded-ternary: 2 + radix: 2 + semi: [2, always] # Stylistic Issues - camelcase: [2] - comma-spacing: [2] - comma-style: [2] - new-parens: [2] - no-lonely-if: [2] - no-multiple-empty-lines: [2] - no-nested-ternary: [2] + camelcase: 2 + comma-spacing: 2 + comma-style: 2 linebreak-style: [2, unix] + new-parens: 2 + no-lonely-if: 2 + no-multiple-empty-lines: 2 + no-nested-ternary: 2 + no-spaced-func: 2 + operator-linebreak: 2 quotes: [2, single] - semi-spacing: [2] + semi-spacing: 2 + space-unary-ops: 2 spaced-comment: [2, always, markers: ['/']] - space-unary-ops: [2] # ECMAScript 6 - arrow-parens: [2] - arrow-spacing: [2] - no-class-assign: [2] - no-dupe-class-members: [2] - no-var: [2] - object-shorthand: [2] - prefer-const: [2] - prefer-template: [2] - prefer-spread: [2] + arrow-parens: 2 + arrow-spacing: 2 + no-class-assign: 2 + no-dupe-class-members: 2 + no-var: 2 + object-shorthand: 2 + prefer-const: 2 + prefer-spread: 2 + prefer-template: 2 -globals: - # Meteor globals - Meteor: false - DDP: false - Mongo: false - Session: false - Accounts: false - Template: false - Blaze: false - UI: false - Match: false - check: false - Tracker: false - Deps: false - ReactiveVar: false - EJSON: false - HTTP: false - Email: false - Assets: false - Handlebars: false - Package: false - App: false - Npm: false - Tinytest: false - Random: false - HTML: false + # eslint-plugin-meteor + ## Meteor API + meteor/globals: 2 + meteor/core: 2 + meteor/pubsub: 2 + meteor/methods: 2 + meteor/check: 2 + meteor/connections: 2 + meteor/collections: 2 + meteor/session: [2, 'no-equal'] + ## Best practices + meteor/no-session: 0 + meteor/no-zero-timeout: 2 + meteor/no-blaze-lifecycle-assignment: 2 + +settings: + meteor: + + # Our collections + collections: + - AccountsTemplates + - Activities + - Attachments + - Boards + - CardComments + - Cards + - Lists + - UnsavedEditCollection + - Users + +globals: # Exported by packages we use - '$': false - _: false autosize: false Avatar: true Avatars: true BlazeComponent: false BlazeLayout: false + DocHead: false ESSearchResults: false + FastRender: false FlowRouter: false FS: false getSlug: false Migrations: false + moment: false Mousetrap: false Picker: false Presence: true @@ -90,17 +108,6 @@ globals: T9n: false TAPi18n: false - # Our collections - AccountsTemplates: true - Activities: true - Attachments: true - Boards: true - CardComments: true - Cards: true - Lists: true - UnsavedEditCollection: true - Users: true - # Our objects CSSEvents: true EscapeActions: true @@ -4,3 +4,4 @@ .tx/ *.sublime-workspace tmp/ +node_modules/ diff --git a/.meteor/packages b/.meteor/packages index 765932d4..bdb0b60e 100644 --- a/.meteor/packages +++ b/.meteor/packages @@ -2,9 +2,6 @@ # # 'meteor add' and 'meteor remove' will edit this file for you, # but you can also edit it by hand. -# -# XXX Should we replace tmeasday:presence by 3stack:presence? Or maybe the -# packages will merge in the future? meteor-base @@ -50,7 +47,9 @@ alethes:pages arillo:flow-router-helpers audit-argument-checks kadira:blaze-layout +kadira:dochead kadira:flow-router +meteorhacks:fast-render meteorhacks:picker meteorhacks:subs-manager mquandalle:autofocus diff --git a/.meteor/release b/.meteor/release index 238bbcca..3a05e0a2 100644 --- a/.meteor/release +++ b/.meteor/release @@ -1 +1 @@ -METEOR@1.2.0.1 +METEOR@1.2.1 diff --git a/.meteor/versions b/.meteor/versions index 2bdd251a..db5d11e7 100644 --- a/.meteor/versions +++ b/.meteor/versions @@ -1,12 +1,12 @@ -3stack:presence@1.0.3 -accounts-base@1.2.1 -accounts-password@1.1.3 +3stack:presence@1.0.4 +accounts-base@1.2.2 +accounts-password@1.1.4 aldeed:collection2@2.5.0 aldeed:simple-schema@1.3.3 alethes:pages@1.8.4 arillo:flow-router-helpers@0.4.5 audit-argument-checks@1.0.4 -autoupdate@1.2.3 +autoupdate@1.2.4 babel-compiler@5.8.24_1 babel-runtime@0.1.4 base64@1.0.4 @@ -15,7 +15,7 @@ blaze@2.1.3 blaze-tools@1.0.4 boilerplate-generator@1.0.4 caching-compiler@1.0.0 -caching-html-compiler@1.0.1 +caching-html-compiler@1.0.2 callback-hook@1.0.4 cfs:access-point@0.1.49 cfs:base-package@0.0.30 @@ -30,26 +30,27 @@ cfs:power-queue@0.9.11 cfs:reactive-list@0.0.9 cfs:reactive-property@0.0.4 cfs:standard-packages@0.5.9 -cfs:storage-adapter@0.2.2 +cfs:storage-adapter@0.2.3 cfs:tempstore@0.1.5 cfs:upload-http@0.0.20 cfs:worker@0.1.4 -check@1.0.6 -coffeescript@1.0.9 -cosmos:browserify@0.5.1 -dburles:collection-helpers@1.0.3 +check@1.1.0 +chuangbo:cookie@1.1.0 +coffeescript@1.0.11 +cosmos:browserify@0.8.1 +dburles:collection-helpers@1.0.4 ddp@1.2.2 ddp-client@1.2.1 -ddp-common@1.2.1 +ddp-common@1.2.2 ddp-rate-limiter@1.0.0 -ddp-server@1.2.1 +ddp-server@1.2.2 deps@1.0.9 diff-sequence@1.0.1 -ecmascript@0.1.4 -ecmascript-collections@0.1.6 +ecmascript@0.1.6 +ecmascript-runtime@0.2.6 ejson@1.0.7 -email@1.0.7 -es5-shim@4.1.13 +email@1.0.8 +es5-shim@4.1.14 fastclick@1.0.7 fortawesome:fontawesome@4.4.0 geojson-utils@1.0.4 @@ -58,38 +59,40 @@ html-tools@1.0.5 htmljs@1.0.5 http@1.1.1 id-map@1.0.4 -idmontie:migrations@1.0.0 +idmontie:migrations@1.0.1 jquery@1.11.4 -kadira:blaze-layout@2.1.0 -kadira:flow-router@2.6.1 -kenton:accounts-sandstorm@0.1.4 +kadira:blaze-layout@2.2.0 +kadira:dochead@1.3.2 +kadira:flow-router@2.7.0 +kenton:accounts-sandstorm@0.1.7 launch-screen@1.0.4 -less@2.5.0_2 livedata@1.0.15 localstorage@1.0.5 logging@1.0.8 -matb33:collection-hooks@0.8.0 -matteodem:easy-search@1.6.3 -meteor@1.1.7 +matb33:collection-hooks@0.8.1 +matteodem:easy-search@1.6.4 +meteor@1.1.10 meteor-base@1.0.1 meteor-platform@1.2.3 meteorhacks:aggregate@1.3.0 meteorhacks:collection-utils@1.2.0 +meteorhacks:fast-render@2.10.0 +meteorhacks:inject-data@1.4.1 meteorhacks:picker@1.0.3 meteorhacks:subs-manager@1.6.2 meteorspark:util@0.2.0 minifiers@1.1.7 -minimongo@1.0.9 +minimongo@1.0.10 mobile-status-bar@1.0.6 -mongo@1.1.1 +mongo@1.1.3 mongo-id@1.0.1 mongo-livedata@1.0.9 mousetrap:mousetrap@1.4.6_1 mquandalle:autofocus@1.0.0 mquandalle:collection-mutations@0.1.0 -mquandalle:jade@0.4.3_1 -mquandalle:jade-compiler@0.4.3 -mquandalle:jquery-textcomplete@0.3.9_1 +mquandalle:jade@0.4.5 +mquandalle:jade-compiler@0.4.5 +mquandalle:jquery-textcomplete@0.8.0_1 mquandalle:jquery-ui-drag-drop-sort@0.1.0 mquandalle:moment@1.0.0 mquandalle:mousetrap-bindglobal@0.0.1 @@ -101,15 +104,17 @@ observe-sequence@1.0.7 ongoworks:speakingurl@1.1.0 ordered-dict@1.0.4 peerlibrary:assert@0.2.5 -peerlibrary:base-component@0.10.0 -peerlibrary:blaze-components@0.13.0 +peerlibrary:base-component@0.14.0 +peerlibrary:blaze-components@0.15.1 +peerlibrary:computed-field@0.3.0 +peerlibrary:reactive-field@0.1.0 perak:markdown@1.0.5 -promise@0.4.8 +promise@0.5.1 raix:eventemitter@0.1.3 -raix:handlebar-helpers@0.2.4 -random@1.0.4 +raix:handlebar-helpers@0.2.5 +random@1.0.5 rate-limit@1.0.0 -reactive-dict@1.1.1 +reactive-dict@1.1.3 reactive-var@1.0.6 reload@1.1.4 retry@1.0.4 @@ -123,19 +128,19 @@ softwarerero:accounts-t9n@1.1.4 spacebars@1.0.7 spacebars-compiler@1.0.7 srp@1.0.4 -standard-minifiers@1.0.0 -tap:i18n@1.6.1 +standard-minifiers@1.0.2 +tap:i18n@1.7.0 templates:tabs@2.2.0 -templating@1.1.3 +templating@1.1.5 templating-tools@1.0.0 -tracker@1.0.8 +tracker@1.0.9 ui@1.0.8 underscore@1.0.4 url@1.0.5 -useraccounts:core@1.12.3 -useraccounts:flow-routing@1.12.3 -useraccounts:unstyled@1.12.3 +useraccounts:core@1.12.4 +useraccounts:flow-routing@1.12.4 +useraccounts:unstyled@1.12.4 verron:autosize@3.0.8 -webapp@1.2.2 +webapp@1.2.3 webapp-hashing@1.0.5 zimme:active-route@2.3.2 diff --git a/.travis.yml b/.travis.yml index 2499948a..a8724631 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,6 +3,6 @@ language: node_js node_js: - "0.10.40" install: - - "npm install -g eslint" + - "npm install" script: - - "eslint ./" + - "npm test" @@ -1,4 +1,15 @@ -# NEXT — v0.9 +# v0.10 + +This release features: + +* Card import from Trello +* Accelerate the initial page rendering by sending the data on the intial HTTP + response instead of waiting for the DDP connection to open. + +Thanks to GitHub users AlexanderS, fisle, ndarilek, and xavierpriour for their +contributions. + +# v0.9 This release is a large re-write of the previous code base. Despite being relatively similar to v0.8 feature-wise, this release marks the beginning of our 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..64e9865d 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(), @@ -69,7 +87,7 @@ BlazeComponent.extendComponent({ attachmentLink() { const attachment = this.currentData().attachment(); return attachment && Blaze.toHTML(HTML.A({ - href: attachment.url({ download: true }), + href: FlowRouter.path(attachment.url({ download: true })), target: '_blank', }, attachment.name())); }, @@ -83,9 +101,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/boardBody.js b/client/components/boards/boardBody.js index 95590beb..5c1c974f 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 @@ -179,22 +179,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 ffc79143..cb86e9bb 100644 --- a/client/components/boards/boardHeader.jade +++ b/client/components/boards/boardHeader.jade @@ -107,6 +107,9 @@ template(name="createBoardPopup") | {{{_ 'board-private-info'}}} a.js-change-visibility {{_ 'change'}}. input.primary.wide(type="submit" value="{{_ 'create'}}") + span.quiet + | {{_ 'or'}} + a.js-import {{_ 'import-board'}} template(name="boardChangeTitlePopup") diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js index dbd76895..92d5f6d4 100644 --- a/client/components/boards/boardHeader.js +++ b/client/components/boards/boardHeader.js @@ -145,6 +145,7 @@ BlazeComponent.extendComponent({ this.setVisibility(this.currentData()); }, 'click .js-change-visibility': this.toggleVisibilityMenu, + 'click .js-import': Popup.open('boardImportBoard'), submit: this.onSubmit, }]; }, 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/cards/attachments.jade b/client/components/cards/attachments.jade index 59eaf077..168fc2c8 100644 --- a/client/components/cards/attachments.jade +++ b/client/components/cards/attachments.jade @@ -15,7 +15,7 @@ template(name="attachmentsGalery") .attachment-thumbnail if isUploaded if isImage - img.attachment-thumbnail-img(src=url) + img.attachment-thumbnail-img(src="{{pathFor url}}") else span.attachment-thumbnail-ext= extension else diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js index 58ce28e4..fa818c5a 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.childrenComponents('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,7 +52,7 @@ BlazeComponent.extendComponent({ }, onDestroyed() { - this.componentParent().showOverlay.set(false); + this.parentComponent().showOverlay.set(false); }, events() { @@ -62,7 +62,8 @@ BlazeComponent.extendComponent({ }, }; - return [_.extend(events, { + return [{ + ...events, 'click .js-close-card-details'() { Utils.goBoardId(this.data().boardId); }, @@ -74,8 +75,8 @@ BlazeComponent.extendComponent({ }, 'submit .js-card-details-title'(evt) { evt.preventDefault(); - const title = this.currentComponent().getValue(); - if ($.trim(title)) { + const title = this.currentComponent().getValue().trim(); + if (title) { this.data().setTitle(title); } }, @@ -83,10 +84,10 @@ BlazeComponent.extendComponent({ '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'); @@ -105,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()); } 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 d2ee0140..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: '' }; }); }, @@ -69,12 +69,12 @@ 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 color = Blaze.getData(tpl.find('.fa-check')).color; board.addLabel(name, color); Popup.back(); - evt.preventDefault(); }, }); 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/import/import.jade b/client/components/import/import.jade new file mode 100644 index 00000000..f63661af --- /dev/null +++ b/client/components/import/import.jade @@ -0,0 +1,7 @@ +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) + input.primary.wide(type="submit" value="{{_ 'import'}}") diff --git a/client/components/import/import.js b/client/components/import/import.js new file mode 100644 index 00000000..c6957fa9 --- /dev/null +++ b/client/components/import/import.js @@ -0,0 +1,90 @@ +/// 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'; + }, + + events() { + return [{ + 'submit': (evt) => { + evt.preventDefault(); + const dataJson = $(evt.currentTarget).find('.js-import-json').val(); + let dataObject; + try { + dataObject = JSON.parse(dataJson); + } catch (e) { + this.setError('error-json-malformed'); + return; + } + Meteor.call(this.getMethodName(), dataObject, this.getAdditionalData(), + (error, response) => { + if (error) { + this.setError(error.error); + } else { + Filter.addException(response); + this.onFinish(response); + } + } + ); + }, + }]; + }, + + onCreated() { + this.error = new ReactiveVar(''); + }, + + setError(error) { + this.error.set(error); + }, + + onFinish() { + Popup.close(); + }, +}); + +ImportPopup.extendComponent({ + getAdditionalData() { + const listId = this.data()._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'); + diff --git a/client/components/lists/list.js b/client/components/lists/list.js index af9bef98..75e816b5 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.childrenComponents('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({ diff --git a/client/components/lists/listBody.js b/client/components/lists/listBody.js index a60ffe25..2ed5d38a 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.childrenComponents('inlinedForm'); + let form = forms.find((component) => { return component.data().position === options.position; }); if (!form && forms.length > 0) { @@ -26,8 +26,8 @@ BlazeComponent.extendComponent({ const firstCardDom = this.find('.js-minicard:first'); const lastCardDom = this.find('.js-minicard:last'); const textarea = $(evt.currentTarget).find('textarea'); - let title = textarea.val(); - const position = Blaze.getData(evt.currentTarget).position; + const position = this.currentData().position; + let title = textarea.val().trim(); let sortIndex; if (position === 'top') { sortIndex = Utils.calculateIndex(null, firstCardDom).base; @@ -62,7 +62,7 @@ BlazeComponent.extendComponent({ } }); - if ($.trim(title)) { + if (title) { const _id = Cards.insert({ title, listId: this.data()._id, 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 a893e873..dbf9fced 100644 --- a/client/components/lists/listHeader.js +++ b/client/components/lists/listHeader.js @@ -5,10 +5,10 @@ BlazeComponent.extendComponent({ editTitle(evt) { evt.preventDefault(); - const newTitle = this.componentChildren('inlinedForm')[0].getValue(); + const newTitle = this.childrenComponents('inlinedForm')[0].getValue().trim(); const list = this.currentData(); - if ($.trim(newTitle)) { - list.rename(newTitle); + if (newTitle) { + list.rename(newTitle.trim()); } }, @@ -33,6 +33,7 @@ Template.listActionPopup.events({ 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', function() { this.allCards().forEach((card) => { @@ -40,6 +41,7 @@ Template.listActionPopup.events({ }); Popup.close(); }), + 'click .js-close-list'(evt) { evt.preventDefault(); this.archive(); 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/layouts.jade b/client/components/main/layouts.jade index f5a8db59..166f143a 100644 --- a/client/components/main/layouts.jade +++ b/client/components/main/layouts.jade @@ -2,12 +2,16 @@ 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) template(name="defaultLayout") 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.js b/client/components/sidebar/sidebar.js index ff65ad9d..ccb9f2f5 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.childrenComponents('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'); diff --git a/client/components/sidebar/sidebarFilters.jade b/client/components/sidebar/sidebarFilters.jade index d9275314..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 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/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 index df0935f7..d475e6b2 100644 --- a/client/config/accounts.js +++ b/client/config/accounts.js @@ -25,7 +25,7 @@ AccountsTemplates.configure({ }, }); -_.each(['signIn', 'signUp', 'resetPwd', 'forgotPwd', 'enrollAccount'], +['signIn', 'signUp', 'resetPwd', 'forgotPwd', 'enrollAccount'].forEach( (routeName) => AccountsTemplates.configureRoute(routeName)); // We display the form to change the password in a popup window that already 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/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/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/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..e50d7cbc --- /dev/null +++ b/client/lib/textComplete.js @@ -0,0 +1,30 @@ +// 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(...args) { + this.textcomplete(...args); + + // 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(() => { + dropdownMenuIsOpened = false; + }); + }, + }); +}; + +EscapeActions.register('textcomplete', + () => {}, + () => dropdownMenuIsOpened +); diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json index a8c2c5d9..82fb4ccd 100644 --- a/i18n/en.i18n.json +++ b/i18n/en.i18n.json @@ -7,12 +7,14 @@ "activity-attached": "attached %s to %s", "activity-created": "created %s", "activity-excluded": "excluded %s from %s", + "activity-imported": "imported %s into %s from %s", + "activity-imported-board": "imported %s from %s", "activity-joined": "joined %s", "activity-moved": "moved %s from %s to %s", "activity-on": "on %s", "activity-removed": "removed %s from %s", "activity-sent": "sent %s to %s", - "activity-unjoined": "unjoinded %s", + "activity-unjoined": "unjoined %s", "add": "Add", "add-attachment": "Add an attachment", "add-board": "Add a new board", @@ -53,6 +55,7 @@ "boardChangeColorPopup-title": "Change Board Background", "boardChangeTitlePopup-title": "Rename Board", "boardChangeVisibilityPopup-title": "Change Visibility", + "boardImportBoardPopup-title": "Import board from Trello", "boardMenuPopup-title": "Board Menu", "boards": "Boards", "bucket-example": "Like “Bucket List” for example", @@ -87,6 +90,16 @@ "close": "Close", "close-board": "Close Board", "close-board-pop": "You can re-open the board by clicking the “Boards” menu from the header, selecting “View Closed Boards”, finding the board and clicking “Re-open”.", + "color-green": "green", + "color-yellow": "yellow", + "color-orange": "orange", + "color-red": "red", + "color-purple": "purple", + "color-blue": "blue", + "color-sky": "sky", + "color-lime": "lime", + "color-pink": "pink", + "color-black": "black", "comment": "Comment", "comment-placeholder": "Write a comment", "computer": "Computer", @@ -109,6 +122,10 @@ "editLabelPopup-title": "Change Label", "editProfilePopup-title": "Edit Profile", "email": "Email", + "error-board-notAMember": "You need to be a member of this board to do that", + "error-json-malformed": "Your text is not valid JSON", + "error-json-schema": "Your JSON data does not include the proper information in the correct format", + "error-list-doesNotExist": "This list does not exist", "filter": "Filter", "filter-cards": "Filter Cards", "filter-clear": "Clear filter", @@ -118,6 +135,12 @@ "fullname": "Full Name", "header-logo-title": "Go back to your boards page.", "home": "Home", + "import": "Import", + "import-board": "import from Trello", + "import-board-trello-instruction": "In your Trello board, go to 'Menu', then 'More', 'Print and Export', 'Export JSON', and copy the resulting text", + "import-card": "Import a Trello card", + "import-card-trello-instruction": "Go to a Trello card, select 'Share and more...' then 'Export JSON' and copy the resulting text", + "import-json-placeholder": "Paste your valid JSON data here", "info": "Infos", "initials": "Initials", "joined": "joined", @@ -136,6 +159,7 @@ "list-select-cards": "Select all cards in this list", "listActionPopup-title": "List Actions", "listArchiveCardsPopup-title": "Archive All Cards in this List?", + "listImportCardPopup-title": "Import a Trello card", "listMoveCardsPopup-title": "Move All Cards in List", "lists": "Lists", "log-out": "Log Out", @@ -155,6 +179,7 @@ "normal": "Normal", "normal-desc": "Can view and edit cards. Can't change settings.", "optional": "optional", + "or": "or", "page-maybe-private": "This page may be private. You may be able to view it by <a href='%s'>logging in</a>.", "page-not-found": "Page not found.", "password": "Password", diff --git a/models/attachments.js b/models/attachments.js index 8ef0fef0..01e467ff 100644 --- a/models/attachments.js +++ b/models/attachments.js @@ -1,4 +1,4 @@ -Attachments = new FS.Collection('attachments', { +Attachments = new FS.Collection('attachments', { // eslint-disable-line meteor/collections stores: [ // XXX Add a new store for cover thumbnails so we don't load big images in diff --git a/models/boards.js b/models/boards.js index 4baec280..98d6ec77 100644 --- a/models/boards.js +++ b/models/boards.js @@ -92,12 +92,16 @@ Boards.helpers({ return _.where(this.members, {isActive: true}); }, + getLabel(name, color) { + return _.findWhere(this.labels, { name, color }); + }, + labelIndex(labelId) { - return _.indexOf(_.pluck(this.labels, '_id'), labelId); + return _.pluck(this.labels, '_id').indexOf(labelId); }, memberIndex(memberId) { - return _.indexOf(_.pluck(this.members, 'userId'), memberId); + return _.pluck(this.members, 'userId').indexOf(memberId); }, absoluteUrl() { @@ -107,6 +111,14 @@ Boards.helpers({ colorClass() { return `board-color-${this.color}`; }, + + // XXX currently mutations return no value so we have an issue when using addLabel in import + // XXX waiting on https://github.com/mquandalle/meteor-collection-mutations/issues/1 to remove... + pushLabel(name, color) { + const _id = Random.id(6); + Boards.direct.update(this._id, { $push: {labels: { _id, name, color }}}); + return _id; + }, }); Boards.mutations({ @@ -131,18 +143,26 @@ Boards.mutations({ }, addLabel(name, color) { - const _id = Random.id(6); - return { $push: {labels: { _id, name, color }}}; + // If label with the same name and color already exists we don't want to + // create another one because they would be indistinguishable in the UI + // (they would still have different `_id` but that is not exposed to the + // user). + if (!this.getLabel(name, color)) { + const _id = Random.id(6); + return { $push: {labels: { _id, name, color }}}; + } }, editLabel(labelId, name, color) { - const labelIndex = this.labelIndex(labelId); - return { - $set: { - [`labels.${labelIndex}.name`]: name, - [`labels.${labelIndex}.color`]: color, - }, - }; + if (!this.getLabel(name, color)) { + const labelIndex = this.labelIndex(labelId); + return { + $set: { + [`labels.${labelIndex}.name`]: name, + [`labels.${labelIndex}.color`]: color, + }, + }; + } }, removeLabel(labelId) { @@ -259,7 +279,7 @@ Boards.before.insert((userId, doc) => { // Handle labels const colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues; const defaultLabelsColors = _.clone(colors).splice(0, 6); - doc.labels = _.map(defaultLabelsColors, (color) => { + doc.labels = defaultLabelsColors.map((color) => { return { color, _id: Random.id(6), @@ -307,7 +327,7 @@ if (Meteor.isServer) { { boardId: doc._id }, { $pull: { - labels: removedLabelId, + labelIds: removedLabelId, }, }, { multi: true } diff --git a/models/cards.js b/models/cards.js index 95943ae2..2e16583d 100644 --- a/models/cards.js +++ b/models/cards.js @@ -194,8 +194,9 @@ Cards.mutations({ Cards.before.insert((userId, doc) => { doc.createdAt = new Date(); doc.dateLastActivity = new Date(); - doc.archived = false; - + if(!doc.hasOwnProperty('archived')){ + doc.archived = false; + } if (!doc.userId) { doc.userId = userId; } diff --git a/models/import.js b/models/import.js new file mode 100644 index 00000000..a6e9f3d5 --- /dev/null +++ b/models/import.js @@ -0,0 +1,364 @@ +const DateString = Match.Where(function (dateAsString) { + check(dateAsString, String); + return moment(dateAsString, moment.ISO_8601).isValid(); +}); + +class TrelloCreator { + constructor() { + // The object creation dates, indexed by Trello id (so we only parse actions + // once!) + this.createdAt = { + board: null, + cards: {}, + lists: {}, + }; + // Map of labels Trello ID => Wekan ID + this.labels = {}; + // Map of lists Trello ID => Wekan ID + this.lists = {}; + // The comments, indexed by Trello card id (to map when importing cards) + this.comments = {}; + } + + checkActions(trelloActions) { + check(trelloActions, [Match.ObjectIncluding({ + data: Object, + date: DateString, + type: String, + })]); + // XXX we could perform more thorough checks based on action type + } + + checkBoard(trelloBoard) { + check(trelloBoard, Match.ObjectIncluding({ + closed: Boolean, + name: String, + prefs: Match.ObjectIncluding({ + // XXX refine control by validating 'background' against a list of + // allowed values (is it worth the maintenance?) + background: String, + permissionLevel: Match.Where((value) => { + return ['org', 'private', 'public'].indexOf(value)>= 0; + }), + }), + })); + } + + checkCards(trelloCards) { + check(trelloCards, [Match.ObjectIncluding({ + closed: Boolean, + dateLastActivity: DateString, + desc: String, + idLabels: [String], + idMembers: [String], + name: String, + pos: Number, + })]); + } + + checkLabels(trelloLabels) { + check(trelloLabels, [Match.ObjectIncluding({ + // XXX refine control by validating 'color' against a list of allowed + // values (is it worth the maintenance?) + color: String, + name: String, + })]); + } + + checkLists(trelloLists) { + check(trelloLists, [Match.ObjectIncluding({ + closed: Boolean, + name: String, + })]); + } + + // You must call parseActions before calling this one. + createBoardAndLabels(trelloBoard) { + const createdAt = this.createdAt.board; + const boardToCreate = { + archived: trelloBoard.closed, + color: this.getColor(trelloBoard.prefs.background), + createdAt, + labels: [], + members: [{ + userId: Meteor.userId(), + isAdmin: true, + isActive: true, + }], + permission: this.getPermission(trelloBoard.prefs.permissionLevel), + slug: getSlug(trelloBoard.name) || 'board', + stars: 0, + title: trelloBoard.name, + }; + trelloBoard.labels.forEach((label) => { + const labelToCreate = { + _id: Random.id(6), + color: label.color, + name: label.name, + }; + // We need to remember them by Trello ID, as this is the only ref we have + // when importing cards. + this.labels[label.id] = labelToCreate._id; + boardToCreate.labels.push(labelToCreate); + }); + const now = new Date(); + const boardId = Boards.direct.insert(boardToCreate); + Boards.direct.update(boardId, {$set: {modifiedAt: now}}); + // log activity + Activities.direct.insert({ + activityType: 'importBoard', + boardId, + createdAt: now, + source: { + id: trelloBoard.id, + system: 'Trello', + url: trelloBoard.url, + }, + // We attribute the import to current user, not the one from the original + // object. + userId: Meteor.userId(), + }); + return boardId; + } + + // Create labels if they do not exist and load this.labels. + createLabels(trelloLabels, board) { + trelloLabels.forEach((label) => { + const color = label.color; + const name = label.name; + const existingLabel = board.getLabel(name, color); + if (existingLabel) { + this.labels[label.id] = existingLabel._id; + } else { + const idLabelCreated = board.pushLabel(name, color); + this.labels[label.id] = idLabelCreated; + } + }); + } + + createLists(trelloLists, boardId) { + trelloLists.forEach((list) => { + const listToCreate = { + archived: list.closed, + boardId, + // We are being defensing here by providing a default date (now) if the + // creation date wasn't found on the action log. This happen on old + // Trello boards (eg from 2013) that didn't log the 'createList' action + // we require. + createdAt: new Date(this.createdAt.lists[list.id] || Date.now()), + title: list.name, + userId: Meteor.userId(), + }; + const listId = Lists.direct.insert(listToCreate); + const now = new Date(); + Lists.direct.update(listId, {$set: {'updatedAt': now}}); + this.lists[list.id] = listId; + // log activity + Activities.direct.insert({ + activityType: 'importList', + boardId, + createdAt: now, + listId, + source: { + id: list.id, + system: 'Trello', + }, + // We attribute the import to current user, not the one from the + // original object + userId: Meteor.userId(), + }); + }); + } + + createCardsAndComments(trelloCards, boardId) { + const result = []; + trelloCards.forEach((card) => { + const cardToCreate = { + archived: card.closed, + boardId, + createdAt: new Date(this.createdAt.cards[card.id] || Date.now()), + dateLastActivity: new Date(), + description: card.desc, + listId: this.lists[card.idList], + sort: card.pos, + title: card.name, + // XXX use the original user? + userId: Meteor.userId(), + }; + // add labels + if (card.idLabels) { + cardToCreate.labelIds = card.idLabels.map((trelloId) => { + return this.labels[trelloId]; + }); + } + // insert card + const cardId = Cards.direct.insert(cardToCreate); + // log activity + Activities.direct.insert({ + activityType: 'importCard', + boardId, + cardId, + createdAt: new Date(), + listId: cardToCreate.listId, + source: { + id: card.id, + system: 'Trello', + url: card.url, + }, + // we attribute the import to current user, not the one from the + // original card + userId: Meteor.userId(), + }); + // add comments + const comments = this.comments[card.id]; + if (comments) { + comments.forEach((comment) => { + const commentToCreate = { + boardId, + cardId, + createdAt: comment.date, + text: comment.data.text, + // XXX use the original comment user instead + userId: Meteor.userId(), + }; + // dateLastActivity will be set from activity insert, no need to + // update it ourselves + const commentId = CardComments.direct.insert(commentToCreate); + Activities.direct.insert({ + activityType: 'addComment', + boardId: commentToCreate.boardId, + cardId: commentToCreate.cardId, + commentId, + createdAt: commentToCreate.createdAt, + userId: commentToCreate.userId, + }); + }); + } + // XXX add attachments + result.push(cardId); + }); + return result; + } + + getColor(trelloColorCode) { + // trello color name => wekan color + const mapColors = { + 'blue': 'belize', + 'orange': 'pumpkin', + 'green': 'nephritis', + 'red': 'pomegranate', + 'purple': 'wisteria', + 'pink': 'pomegranate', + 'lime': 'nephritis', + 'sky': 'belize', + 'grey': 'midnight', + }; + const wekanColor = mapColors[trelloColorCode]; + return wekanColor || Boards.simpleSchema()._schema.color.allowedValues[0]; + } + + getPermission(trelloPermissionCode) { + if (trelloPermissionCode === 'public') { + return 'public'; + } + // Wekan does NOT have organization level, so we default both 'private' and + // 'org' to private. + return 'private'; + } + + parseActions(trelloActions) { + trelloActions.forEach((action) => { + switch (action.type) { + case 'createBoard': + this.createdAt.board = action.date; + break; + case 'createCard': + const cardId = action.data.card.id; + this.createdAt.cards[cardId] = action.date; + break; + case 'createList': + const listId = action.data.list.id; + this.createdAt.lists[listId] = action.date; + break; + case 'commentCard': + const id = action.data.card.id; + if (this.comments[id]) { + this.comments[id].push(action); + } else { + this.comments[id] = [action]; + } + break; + default: + // do nothing + break; + } + }); + } +} + +Meteor.methods({ + importTrelloBoard(trelloBoard, data) { + const trelloCreator = new TrelloCreator(); + + // 1. check all parameters are ok from a syntax point of view + try { + // we don't use additional data - this should be an empty object + check(data, {}); + trelloCreator.checkActions(trelloBoard.actions); + trelloCreator.checkBoard(trelloBoard); + trelloCreator.checkLabels(trelloBoard.labels); + trelloCreator.checkLists(trelloBoard.lists); + trelloCreator.checkCards(trelloBoard.cards); + } catch (e) { + throw new Meteor.Error('error-json-schema'); + } + + // 2. check parameters are ok from a business point of view (exist & + // authorized) nothing to check, everyone can import boards in their account + + // 3. create all elements + trelloCreator.parseActions(trelloBoard.actions); + const boardId = trelloCreator.createBoardAndLabels(trelloBoard); + trelloCreator.createLists(trelloBoard.lists, boardId); + trelloCreator.createCardsAndComments(trelloBoard.cards, boardId); + // XXX add members + return boardId; + }, + + importTrelloCard(trelloCard, data) { + const trelloCreator = new TrelloCreator(); + + // 1. check parameters are ok from a syntax point of view + try { + check(data, { + listId: String, + sortIndex: Number, + }); + trelloCreator.checkCards([trelloCard]); + trelloCreator.checkLabels(trelloCard.labels); + trelloCreator.checkActions(trelloCard.actions); + } catch(e) { + throw new Meteor.Error('error-json-schema'); + } + + // 2. check parameters are ok from a business point of view (exist & + // authorized) + const list = Lists.findOne(data.listId); + if (!list) { + throw new Meteor.Error('error-list-doesNotExist'); + } + if (Meteor.isServer) { + if (!allowIsBoardMember(Meteor.userId(), Boards.findOne(list.boardId))) { + throw new Meteor.Error('error-board-notAMember'); + } + } + + // 3. create all elements + trelloCreator.lists[trelloCard.idList] = data.listId; + trelloCreator.parseActions(trelloCard.actions); + const board = list.board(); + trelloCreator.createLabels(trelloCard.labels, board); + const cardIds = trelloCreator.createCardsAndComments([trelloCard], board._id); + return cardIds[0]; + }, +}); diff --git a/models/users.js b/models/users.js index 4260dc56..1e69564d 100644 --- a/models/users.js +++ b/models/users.js @@ -1,4 +1,4 @@ -Users = Meteor.users; +Users = Meteor.users; // eslint-disable-line meteor/collections // Search a user in the complete server database by its name or username. This // is used for instance to add a new user to a board. @@ -8,31 +8,50 @@ Users.initEasySearch(searchInFields, { returnFields: [...searchInFields, 'profile.avatarUrl'], }); +if (Meteor.isClient) { + Users.helpers({ + isBoardMember() { + const board = Boards.findOne(Session.get('currentBoard')); + return board && + _.contains(_.pluck(board.members, 'userId'), this._id) && + _.where(board.members, {userId: this._id})[0].isActive; + }, + + isBoardAdmin() { + const board = Boards.findOne(Session.get('currentBoard')); + return board && + this.isBoardMember(board) && + _.where(board.members, {userId: this._id})[0].isAdmin; + }, + }); +} + Users.helpers({ boards() { return Boards.find({ userId: this._id }); }, starredBoards() { - const starredBoardIds = this.profile.starredBoards || []; - return Boards.find({archived: false, _id: {$in: starredBoardIds}}); + const {starredBoards = []} = this.profile; + return Boards.find({archived: false, _id: {$in: starredBoards}}); }, hasStarred(boardId) { - const starredBoardIds = this.profile.starredBoards || []; - return _.contains(starredBoardIds, boardId); + const {starredBoards = []} = this.profile; + return _.contains(starredBoards, boardId); }, - isBoardMember() { - const board = Boards.findOne(Session.get('currentBoard')); - return board && _.contains(_.pluck(board.members, 'userId'), this._id) && - _.where(board.members, {userId: this._id})[0].isActive; - }, - - isBoardAdmin() { - const board = Boards.findOne(Session.get('currentBoard')); - return board && this.isBoardMember(board) && - _.where(board.members, {userId: this._id})[0].isAdmin; + getAvatarUrl() { + // Although we put the avatar picture URL in the `profile` object, we need + // to support Sandstorm which put in the `picture` attribute by default. + // XXX Should we move both cases to `picture`? + if (this.picture) { + return this.picture; + } else if (this.profile && this.profile.avatarUrl) { + return this.profile.avatarUrl; + } else { + return null; + } }, getInitials() { @@ -41,9 +60,9 @@ Users.helpers({ return profile.initials; else if (profile.fullname) { - return _.reduce(profile.fullname.split(/\s+/), (memo, word) => { + return profile.fullname.split(/\s+/).reduce((memo = '', word) => { return memo + word[0]; - }, '').toUpperCase(); + }).toUpperCase(); } else { return this.username[0].toUpperCase(); @@ -117,7 +136,7 @@ if (Meteor.isServer) { // b. We use it to find deleted and newly inserted ids by using it in one // direction and then in the other. function incrementBoards(boardsIds, inc) { - _.forEach(boardsIds, (boardId) => { + boardsIds.forEach((boardId) => { Boards.update(boardId, {$inc: {stars: inc}}); }); } @@ -136,7 +155,7 @@ if (Meteor.isServer) { // Insert the Welcome Board Boards.insert(ExampleBoard, (err, boardId) => { - _.forEach(['Basics', 'Advanced'], (title) => { + ['Basics', 'Advanced'].forEach((title) => { const list = { title, boardId, diff --git a/package.json b/package.json new file mode 100644 index 00000000..77a9fb74 --- /dev/null +++ b/package.json @@ -0,0 +1,24 @@ +{ + "name": "wekan", + "version": "1.0.0", + "description": "The open-source Trello-like kanban", + "private": true, + "scripts": { + "lint": "eslint .", + "test": "npm run --silent lint" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wekan/wekan.git" + }, + "license": "MIT", + "bugs": { + "url": "https://github.com/wekan/wekan/issues" + }, + "homepage": "http://wekan.io", + "devDependencies": { + "babel-eslint": "4.1.3", + "eslint": "1.7.3", + "eslint-plugin-meteor": "1.7.0" + } +} diff --git a/sandstorm.js b/sandstorm.js index 97d42bdf..65f24866 100644 --- a/sandstorm.js +++ b/sandstorm.js @@ -21,20 +21,13 @@ if (isSandstorm && Meteor.isServer) { permission: 'public', }; - // This function should probably be handled by `accounts-sandstorm` but - // apparently meteor-core misses an API to handle that cleanly, cf. - // https://github.com/meteor/meteor/blob/ff783e9a12ffa04af6fd163843a563c9f4bbe8c1/packages/accounts-base/accounts_server.js#L1143 - function updateUserAvatar(userId, avatarUrl) { - Users.findOne(userId).setAvatarUrl(avatarUrl); - } - function updateUserPermissions(userId, permissions) { - const isActive = permissions.indexOf('participate') > -1; - const isAdmin = permissions.indexOf('configure') > -1; + const isActive = permissions.includes('participate'); + const isAdmin = permissions.includes('configure'); const permissionDoc = { userId, isActive, isAdmin }; const boardMembers = Boards.findOne(sandstormBoard._id).members; - const memberIndex = _.indexOf(_.pluck(boardMembers, 'userId'), userId); + const memberIndex = _.pluck(boardMembers, 'userId').indexOf(userId); let modifier; if (memberIndex > -1) @@ -55,7 +48,8 @@ if (isSandstorm && Meteor.isServer) { // and the home page was accessible by pressing the back button of the // browser, a server-side redirection solves both of these issues. // - // XXX Maybe sandstorm manifest could provide some kind of "home URL"? + // XXX Maybe the sandstorm http-bridge could provide some kind of "home URL" + // in the manifest? const base = req.headers['x-sandstorm-base-path']; // XXX If this routing scheme changes, this will break. We should generate // the location URL using the router, but at the time of writing, the @@ -68,20 +62,14 @@ if (isSandstorm && Meteor.isServer) { res.end(); // `accounts-sandstorm` populate the Users collection when new users - // accesses the document, but in case a already known user come back, we + // accesses the document, but in case a already known user comes back, we // need to update his associated document to match the request HTTP headers // informations. const user = Users.findOne({ 'services.sandstorm.id': req.headers['x-sandstorm-user-id'], }); if (user) { - const userId = user._id; - const avatarUrl = req.headers['x-sandstorm-user-picture']; - const permissions = req.headers['x-sandstorm-permissions'].split(',') || []; - - // XXX The user may also change his name, we should handle it. - updateUserAvatar(userId, avatarUrl); - updateUserPermissions(userId, permissions); + updateUserPermissions(user._id, user.permissions); } }); @@ -90,6 +78,8 @@ if (isSandstorm && Meteor.isServer) { // unique board document. Note that when the `Users.after.insert` hook is // called, the user is inserted into the database but not connected. So // despite the appearances `userId` is null in this block. + // + // XXX We should support the `preferredHandle` exposed by Sandstorm Users.after.insert((userId, doc) => { if (!Boards.findOne(sandstormBoard._id)) { Boards.insert(sandstormBoard, {validate: false}); @@ -101,6 +91,15 @@ if (isSandstorm && Meteor.isServer) { updateUserPermissions(doc._id, doc.services.sandstorm.permissions); }); + + // LibreBoard v0.8 didn’t implement the Sandstorm sharing model and instead + // kept the visibility setting (“public” or “private”) in the UI as does the + // main Meteor application. We need to enforce “public” visibility as the + // sharing is now handled by Sandstorm. + // See https://github.com/wekan/wekan/issues/346 + Migrations.add('enforce-public-visibility-for-sandstorm', () => { + Boards.update('sandstorm', { $set: { permission: 'public' }}); + }); } if (isSandstorm && Meteor.isClient) { @@ -110,7 +109,7 @@ if (isSandstorm && Meteor.isClient) { // sandstorm client to return relative paths instead of absolutes. const _absoluteUrl = Meteor.absoluteUrl; const _defaultOptions = Meteor.absoluteUrl.defaultOptions; - Meteor.absoluteUrl = (path, options) => { + Meteor.absoluteUrl = (path, options) => { // eslint-disable-line meteor/core const url = _absoluteUrl(path, options); return url.replace(/^https?:\/\/127\.0\.0\.1:[0-9]{2,5}/, ''); }; diff --git a/server/migrations.js b/server/migrations.js index 05f5ff7d..99125976 100644 --- a/server/migrations.js +++ b/server/migrations.js @@ -4,6 +4,12 @@ // // Migrations.add(name, migrationCallback, optionalOrder); +// Note that we have extra migrations defined in `sandstorm.js` that are +// exclusive to Sandstorm and shouldn’t be executed in the general case. +// XXX I guess if we had ES6 modules we could +// `import { isSandstorm } from sandstorm.js` and define the migration here as +// well, but for now I want to avoid definied too many globals. + // In the context of migration functions we don't want to validate database // mutation queries against the current (ie, latest) collection schema. Doing // that would work at the time we write the migration but would break in the @@ -37,7 +43,7 @@ Migrations.add('board-background-color', () => { }); Migrations.add('lowercase-board-permission', () => { - _.forEach(['Public', 'Private'], (permission) => { + ['Public', 'Private'].forEach((permission) => { Boards.update( { permission }, { $set: { permission: permission.toLowerCase() } }, @@ -110,11 +116,11 @@ Migrations.add('add-member-isactive-field', () => { const formerUsers = _.difference(allUsersWithSomeActivity, currentUsers); const newMemberSet = []; - _.forEach(board.members, (member) => { + board.members.forEach((member) => { member.isActive = true; newMemberSet.push(member); }); - _.forEach(formerUsers, (userId) => { + formerUsers.forEach((userId) => { newMemberSet.push({ userId, isAdmin: false, diff --git a/server/publications/boards.js b/server/publications/boards.js index 403d0084..4ab7a12e 100644 --- a/server/publications/boards.js +++ b/server/publications/boards.js @@ -10,7 +10,7 @@ Meteor.publish('boards', function() { // Defensive programming to verify that starredBoards has the expected // format -- since the field is in the `profile` a user can modify it. - const starredBoards = Users.findOne(this.userId).profile.starredBoards || []; + const {starredBoards = []} = Users.findOne(this.userId).profile; check(starredBoards, [String]); return Boards.find({ diff --git a/server/publications/fast-render.js b/server/publications/fast-render.js new file mode 100644 index 00000000..e28b6f2e --- /dev/null +++ b/server/publications/fast-render.js @@ -0,0 +1,7 @@ +FastRender.onAllRoutes(function() { + this.subscribe('boards'); +}); + +FastRender.route('/b/:id/:slug', function({ id }) { + this.subscribe('board', id); +}); |