summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMaxime Quandalle <maxime@quandalle.com>2015-10-31 10:27:20 +0100
committerMaxime Quandalle <maxime@quandalle.com>2015-10-31 12:49:58 +0100
commit2b134ff7a986eb97f8ec360321be284a7a8ea11e (patch)
tree0ff579909b402db5916b66ec7bc7469534881a15
parent3507c6565bb16b5f45c6f269f7376902f8b1ff37 (diff)
parent41b23f88aea0f421226f92b081cdb1b61c64bde4 (diff)
downloadwekan-2b134ff7a986eb97f8ec360321be284a7a8ea11e.tar.gz
wekan-2b134ff7a986eb97f8ec360321be284a7a8ea11e.tar.bz2
wekan-2b134ff7a986eb97f8ec360321be284a7a8ea11e.zip
Merge branch 'devel' into minicard-editor
Conflicts: client/components/lists/listBody.js
-rw-r--r--.editorconfig10
-rw-r--r--.eslintrc137
-rw-r--r--.gitignore1
-rw-r--r--.meteor/packages5
-rw-r--r--.meteor/release2
-rw-r--r--.meteor/versions91
-rw-r--r--.travis.yml4
-rw-r--r--History.md13
-rw-r--r--client/components/activities/activities.jade59
-rw-r--r--client/components/activities/activities.js28
-rw-r--r--client/components/activities/comments.js10
-rw-r--r--client/components/boards/boardBody.js14
-rw-r--r--client/components/boards/boardHeader.jade3
-rw-r--r--client/components/boards/boardHeader.js1
-rw-r--r--client/components/boards/boardHeader.styl2
-rw-r--r--client/components/cards/attachments.jade2
-rw-r--r--client/components/cards/cardDetails.js25
-rw-r--r--client/components/cards/labels.jade2
-rw-r--r--client/components/cards/labels.js4
-rw-r--r--client/components/cards/minicard.jade2
-rw-r--r--client/components/import/import.jade7
-rw-r--r--client/components/import/import.js90
-rw-r--r--client/components/lists/list.js4
-rw-r--r--client/components/lists/listBody.js10
-rw-r--r--client/components/lists/listHeader.jade1
-rw-r--r--client/components/lists/listHeader.js8
-rw-r--r--client/components/main/editor.js36
-rw-r--r--client/components/main/header.jade4
-rw-r--r--client/components/main/layouts.jade6
-rw-r--r--client/components/main/popup.styl6
-rw-r--r--client/components/sidebar/sidebar.js6
-rw-r--r--client/components/sidebar/sidebarFilters.jade2
-rw-r--r--client/components/users/userAvatar.jade4
-rw-r--r--client/components/users/userHeader.js6
-rw-r--r--client/config/accounts.js2
-rw-r--r--client/config/blazeHelpers.js4
-rw-r--r--client/config/router.js23
-rw-r--r--client/lib/accessibility.js41
-rw-r--r--client/lib/filter.js4
-rw-r--r--client/lib/modal.js4
-rw-r--r--client/lib/multiSelection.js9
-rw-r--r--client/lib/popup.js4
-rw-r--r--client/lib/textComplete.js30
-rw-r--r--i18n/en.i18n.json27
-rw-r--r--models/attachments.js2
-rw-r--r--models/boards.js46
-rw-r--r--models/cards.js5
-rw-r--r--models/import.js364
-rw-r--r--models/users.js57
-rw-r--r--package.json24
-rw-r--r--sandstorm.js39
-rw-r--r--server/migrations.js12
-rw-r--r--server/publications/boards.js2
-rw-r--r--server/publications/fast-render.js7
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
diff --git a/.eslintrc b/.eslintrc
index 2187cf75..f9321bfb 100644
--- a/.eslintrc
+++ b/.eslintrc
@@ -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
diff --git a/.gitignore b/.gitignore
index 986a8b46..7c40fcd4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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"
diff --git a/History.md b/History.md
index ddf7032f..bc8ff5ca 100644
--- a/History.md
+++ b/History.md
@@ -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: '&commat;', 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);
+});