Merge branch 'devel' into minicard-editor

Conflicts:
	client/components/lists/listBody.js
This commit is contained in:
Maxime Quandalle 2015-10-31 10:27:20 +01:00
commit 2b134ff7a9
54 changed files with 1027 additions and 310 deletions

10
.editorconfig Normal file
View file

@ -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

137
.eslintrc
View file

@ -1,83 +1,101 @@
ecmaFeatures: ecmaFeatures:
experimentalObjectRestSpread: true experimentalObjectRestSpread: true
plugins:
- meteor
parser: babel-eslint
rules: rules:
accessor-pairs: [2] strict: 0
consistent-return: [2] no-undef: 2
accessor-pairs: 2
comma-dangle: [2, 'always-multiline']
consistent-return: 2
dot-notation: 2
eqeqeq: 2
indent: [2, 2] indent: [2, 2]
semi: [2, always] no-cond-assign: 2
comma-dangle: [2, always-multiline] no-constant-condition: 2
no-eval: 2
no-inner-declarations: [0] no-inner-declarations: [0]
dot-notation: [2] no-unneeded-ternary: 2
eqeqeq: [2] radix: 2
no-eval: [2] semi: [2, always]
radix: [2]
# Stylistic Issues # Stylistic Issues
camelcase: [2] camelcase: 2
comma-spacing: [2] comma-spacing: 2
comma-style: [2] comma-style: 2
new-parens: [2]
no-lonely-if: [2]
no-multiple-empty-lines: [2]
no-nested-ternary: [2]
linebreak-style: [2, unix] 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] quotes: [2, single]
semi-spacing: [2] semi-spacing: 2
space-unary-ops: 2
spaced-comment: [2, always, markers: ['/']] spaced-comment: [2, always, markers: ['/']]
space-unary-ops: [2]
# ECMAScript 6 # ECMAScript 6
arrow-parens: [2] arrow-parens: 2
arrow-spacing: [2] arrow-spacing: 2
no-class-assign: [2] no-class-assign: 2
no-dupe-class-members: [2] no-dupe-class-members: 2
no-var: [2] no-var: 2
object-shorthand: [2] object-shorthand: 2
prefer-const: [2] prefer-const: 2
prefer-template: [2] prefer-spread: 2
prefer-spread: [2] prefer-template: 2
# 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: 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
# Exported by packages we use # Exported by packages we use
'$': false
_: false
autosize: false autosize: false
Avatar: true Avatar: true
Avatars: true Avatars: true
BlazeComponent: false BlazeComponent: false
BlazeLayout: false BlazeLayout: false
DocHead: false
ESSearchResults: false ESSearchResults: false
FastRender: false
FlowRouter: false FlowRouter: false
FS: false FS: false
getSlug: false getSlug: false
Migrations: false Migrations: false
moment: false
Mousetrap: false Mousetrap: false
Picker: false Picker: false
Presence: true Presence: true
@ -90,17 +108,6 @@ globals:
T9n: false T9n: false
TAPi18n: 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 # Our objects
CSSEvents: true CSSEvents: true
EscapeActions: true EscapeActions: true

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
.tx/ .tx/
*.sublime-workspace *.sublime-workspace
tmp/ tmp/
node_modules/

View file

@ -2,9 +2,6 @@
# #
# 'meteor add' and 'meteor remove' will edit this file for you, # 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand. # 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 meteor-base
@ -50,7 +47,9 @@ alethes:pages
arillo:flow-router-helpers arillo:flow-router-helpers
audit-argument-checks audit-argument-checks
kadira:blaze-layout kadira:blaze-layout
kadira:dochead
kadira:flow-router kadira:flow-router
meteorhacks:fast-render
meteorhacks:picker meteorhacks:picker
meteorhacks:subs-manager meteorhacks:subs-manager
mquandalle:autofocus mquandalle:autofocus

View file

@ -1 +1 @@
METEOR@1.2.0.1 METEOR@1.2.1

View file

@ -1,12 +1,12 @@
3stack:presence@1.0.3 3stack:presence@1.0.4
accounts-base@1.2.1 accounts-base@1.2.2
accounts-password@1.1.3 accounts-password@1.1.4
aldeed:collection2@2.5.0 aldeed:collection2@2.5.0
aldeed:simple-schema@1.3.3 aldeed:simple-schema@1.3.3
alethes:pages@1.8.4 alethes:pages@1.8.4
arillo:flow-router-helpers@0.4.5 arillo:flow-router-helpers@0.4.5
audit-argument-checks@1.0.4 audit-argument-checks@1.0.4
autoupdate@1.2.3 autoupdate@1.2.4
babel-compiler@5.8.24_1 babel-compiler@5.8.24_1
babel-runtime@0.1.4 babel-runtime@0.1.4
base64@1.0.4 base64@1.0.4
@ -15,7 +15,7 @@ blaze@2.1.3
blaze-tools@1.0.4 blaze-tools@1.0.4
boilerplate-generator@1.0.4 boilerplate-generator@1.0.4
caching-compiler@1.0.0 caching-compiler@1.0.0
caching-html-compiler@1.0.1 caching-html-compiler@1.0.2
callback-hook@1.0.4 callback-hook@1.0.4
cfs:access-point@0.1.49 cfs:access-point@0.1.49
cfs:base-package@0.0.30 cfs:base-package@0.0.30
@ -30,26 +30,27 @@ cfs:power-queue@0.9.11
cfs:reactive-list@0.0.9 cfs:reactive-list@0.0.9
cfs:reactive-property@0.0.4 cfs:reactive-property@0.0.4
cfs:standard-packages@0.5.9 cfs:standard-packages@0.5.9
cfs:storage-adapter@0.2.2 cfs:storage-adapter@0.2.3
cfs:tempstore@0.1.5 cfs:tempstore@0.1.5
cfs:upload-http@0.0.20 cfs:upload-http@0.0.20
cfs:worker@0.1.4 cfs:worker@0.1.4
check@1.0.6 check@1.1.0
coffeescript@1.0.9 chuangbo:cookie@1.1.0
cosmos:browserify@0.5.1 coffeescript@1.0.11
dburles:collection-helpers@1.0.3 cosmos:browserify@0.8.1
dburles:collection-helpers@1.0.4
ddp@1.2.2 ddp@1.2.2
ddp-client@1.2.1 ddp-client@1.2.1
ddp-common@1.2.1 ddp-common@1.2.2
ddp-rate-limiter@1.0.0 ddp-rate-limiter@1.0.0
ddp-server@1.2.1 ddp-server@1.2.2
deps@1.0.9 deps@1.0.9
diff-sequence@1.0.1 diff-sequence@1.0.1
ecmascript@0.1.4 ecmascript@0.1.6
ecmascript-collections@0.1.6 ecmascript-runtime@0.2.6
ejson@1.0.7 ejson@1.0.7
email@1.0.7 email@1.0.8
es5-shim@4.1.13 es5-shim@4.1.14
fastclick@1.0.7 fastclick@1.0.7
fortawesome:fontawesome@4.4.0 fortawesome:fontawesome@4.4.0
geojson-utils@1.0.4 geojson-utils@1.0.4
@ -58,38 +59,40 @@ html-tools@1.0.5
htmljs@1.0.5 htmljs@1.0.5
http@1.1.1 http@1.1.1
id-map@1.0.4 id-map@1.0.4
idmontie:migrations@1.0.0 idmontie:migrations@1.0.1
jquery@1.11.4 jquery@1.11.4
kadira:blaze-layout@2.1.0 kadira:blaze-layout@2.2.0
kadira:flow-router@2.6.1 kadira:dochead@1.3.2
kenton:accounts-sandstorm@0.1.4 kadira:flow-router@2.7.0
kenton:accounts-sandstorm@0.1.7
launch-screen@1.0.4 launch-screen@1.0.4
less@2.5.0_2
livedata@1.0.15 livedata@1.0.15
localstorage@1.0.5 localstorage@1.0.5
logging@1.0.8 logging@1.0.8
matb33:collection-hooks@0.8.0 matb33:collection-hooks@0.8.1
matteodem:easy-search@1.6.3 matteodem:easy-search@1.6.4
meteor@1.1.7 meteor@1.1.10
meteor-base@1.0.1 meteor-base@1.0.1
meteor-platform@1.2.3 meteor-platform@1.2.3
meteorhacks:aggregate@1.3.0 meteorhacks:aggregate@1.3.0
meteorhacks:collection-utils@1.2.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:picker@1.0.3
meteorhacks:subs-manager@1.6.2 meteorhacks:subs-manager@1.6.2
meteorspark:util@0.2.0 meteorspark:util@0.2.0
minifiers@1.1.7 minifiers@1.1.7
minimongo@1.0.9 minimongo@1.0.10
mobile-status-bar@1.0.6 mobile-status-bar@1.0.6
mongo@1.1.1 mongo@1.1.3
mongo-id@1.0.1 mongo-id@1.0.1
mongo-livedata@1.0.9 mongo-livedata@1.0.9
mousetrap:mousetrap@1.4.6_1 mousetrap:mousetrap@1.4.6_1
mquandalle:autofocus@1.0.0 mquandalle:autofocus@1.0.0
mquandalle:collection-mutations@0.1.0 mquandalle:collection-mutations@0.1.0
mquandalle:jade@0.4.3_1 mquandalle:jade@0.4.5
mquandalle:jade-compiler@0.4.3 mquandalle:jade-compiler@0.4.5
mquandalle:jquery-textcomplete@0.3.9_1 mquandalle:jquery-textcomplete@0.8.0_1
mquandalle:jquery-ui-drag-drop-sort@0.1.0 mquandalle:jquery-ui-drag-drop-sort@0.1.0
mquandalle:moment@1.0.0 mquandalle:moment@1.0.0
mquandalle:mousetrap-bindglobal@0.0.1 mquandalle:mousetrap-bindglobal@0.0.1
@ -101,15 +104,17 @@ observe-sequence@1.0.7
ongoworks:speakingurl@1.1.0 ongoworks:speakingurl@1.1.0
ordered-dict@1.0.4 ordered-dict@1.0.4
peerlibrary:assert@0.2.5 peerlibrary:assert@0.2.5
peerlibrary:base-component@0.10.0 peerlibrary:base-component@0.14.0
peerlibrary:blaze-components@0.13.0 peerlibrary:blaze-components@0.15.1
peerlibrary:computed-field@0.3.0
peerlibrary:reactive-field@0.1.0
perak:markdown@1.0.5 perak:markdown@1.0.5
promise@0.4.8 promise@0.5.1
raix:eventemitter@0.1.3 raix:eventemitter@0.1.3
raix:handlebar-helpers@0.2.4 raix:handlebar-helpers@0.2.5
random@1.0.4 random@1.0.5
rate-limit@1.0.0 rate-limit@1.0.0
reactive-dict@1.1.1 reactive-dict@1.1.3
reactive-var@1.0.6 reactive-var@1.0.6
reload@1.1.4 reload@1.1.4
retry@1.0.4 retry@1.0.4
@ -123,19 +128,19 @@ softwarerero:accounts-t9n@1.1.4
spacebars@1.0.7 spacebars@1.0.7
spacebars-compiler@1.0.7 spacebars-compiler@1.0.7
srp@1.0.4 srp@1.0.4
standard-minifiers@1.0.0 standard-minifiers@1.0.2
tap:i18n@1.6.1 tap:i18n@1.7.0
templates:tabs@2.2.0 templates:tabs@2.2.0
templating@1.1.3 templating@1.1.5
templating-tools@1.0.0 templating-tools@1.0.0
tracker@1.0.8 tracker@1.0.9
ui@1.0.8 ui@1.0.8
underscore@1.0.4 underscore@1.0.4
url@1.0.5 url@1.0.5
useraccounts:core@1.12.3 useraccounts:core@1.12.4
useraccounts:flow-routing@1.12.3 useraccounts:flow-routing@1.12.4
useraccounts:unstyled@1.12.3 useraccounts:unstyled@1.12.4
verron:autosize@3.0.8 verron:autosize@3.0.8
webapp@1.2.2 webapp@1.2.3
webapp-hashing@1.0.5 webapp-hashing@1.0.5
zimme:active-route@2.3.2 zimme:active-route@2.3.2

View file

@ -3,6 +3,6 @@ language: node_js
node_js: node_js:
- "0.10.40" - "0.10.40"
install: install:
- "npm install -g eslint" - "npm install"
script: script:
- "eslint ./" - "npm test"

View file

@ -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 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 relatively similar to v0.8 feature-wise, this release marks the beginning of our

View file

@ -14,53 +14,62 @@ template(name="boardActivities")
p.activity-desc p.activity-desc
+memberName(user=user) +memberName(user=user)
if($eq activityType 'createBoard') if($eq activityType 'addAttachment')
| {{_ 'activity-created' boardLabel}}. | {{{_ 'activity-attached' attachmentLink cardLink}}}.
if($eq activityType 'createList')
| {{_ 'activity-added' list.title boardLabel}}.
if($eq activityType 'archivedList')
| {{_ 'activity-archived' list.title}}.
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 'moveCard')
| {{{_ 'activity-moved' cardLink oldList.title list.title}}}.
if($eq activityType 'addBoardMember') if($eq activityType 'addBoardMember')
| {{{_ 'activity-added' memberLink boardLabel}}}. | {{{_ 'activity-added' memberLink boardLabel}}}.
if($eq activityType 'removeBoardMember')
| {{{_ 'activity-excluded' memberLink boardLabel}}}.
if($eq activityType 'joinMember')
if($eq currentUser._id member._id)
| {{{_ 'activity-joined' cardLink}}}.
else
| {{{_ 'activity-added' memberLink cardLink}}}.
if($eq activityType 'unjoinMember')
if($eq currentUser._id member._id)
| {{{_ 'activity-unjoined' cardLink}}}.
else
| {{{_ 'activity-removed' memberLink cardLink}}}.
if($eq activityType 'addComment') if($eq activityType 'addComment')
| {{{_ 'activity-on' cardLink}}} | {{{_ 'activity-on' cardLink}}}
a.activity-comment(href="{{ card.absoluteUrl }}") a.activity-comment(href="{{ card.absoluteUrl }}")
+viewer +viewer
= comment.text = comment.text
if($eq activityType 'addAttachment') if($eq activityType 'archivedCard')
| {{{_ 'activity-attached' attachmentLink cardLink}}}. | {{{_ '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 'createList')
| {{_ 'activity-added' list.title boardLabel}}.
if($eq activityType 'importBoard')
| {{{_ 'activity-imported-board' boardLabel sourceLink}}}.
if($eq activityType 'importCard')
| {{{_ 'activity-imported' cardLink boardLabel sourceLink}}}.
if($eq activityType 'importList')
| {{{_ 'activity-imported' listLabel boardLabel sourceLink}}}.
if($eq activityType 'joinMember')
if($eq currentUser._id member._id)
| {{{_ 'activity-joined' cardLink}}}.
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}}}.
span.activity-meta {{ moment createdAt }} span.activity-meta {{ moment createdAt }}
@ -72,6 +81,8 @@ template(name="cardActivities")
+memberName(user=user) +memberName(user=user)
if($eq activityType 'createCard') if($eq activityType 'createCard')
| {{_ 'activity-added' cardLabel list.title}}. | {{_ 'activity-added' cardLabel list.title}}.
if($eq activityType 'importCard')
| {{{_ 'activity-imported' cardLabel list.title sourceLink}}}.
if($eq activityType 'joinMember') if($eq activityType 'joinMember')
if($eq currentUser._id member._id) if($eq currentUser._id member._id)
| {{_ 'activity-joined' cardLabel}}. | {{_ 'activity-joined' cardLabel}}.

View file

@ -9,7 +9,7 @@ BlazeComponent.extendComponent({
// XXX Should we use ReactiveNumber? // XXX Should we use ReactiveNumber?
this.page = new ReactiveVar(1); this.page = new ReactiveVar(1);
this.loadNextPageLocked = false; 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'); sidebar.callFirstWith(null, 'resetNextPeak');
this.autorun(() => { this.autorun(() => {
const mode = this.data().mode; const mode = this.data().mode;
@ -55,11 +55,29 @@ BlazeComponent.extendComponent({
cardLink() { cardLink() {
const card = this.currentData().card(); const card = this.currentData().card();
return card && Blaze.toHTML(HTML.A({ return card && Blaze.toHTML(HTML.A({
href: card.absoluteUrl(), href: FlowRouter.path(card.absoluteUrl()),
'class': 'action-card', 'class': 'action-card',
}, card.title)); }, 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() { memberLink() {
return Blaze.toHTMLWithData(Template.memberName, { return Blaze.toHTMLWithData(Template.memberName, {
user: this.currentData().member(), user: this.currentData().member(),
@ -69,7 +87,7 @@ BlazeComponent.extendComponent({
attachmentLink() { attachmentLink() {
const attachment = this.currentData().attachment(); const attachment = this.currentData().attachment();
return attachment && Blaze.toHTML(HTML.A({ return attachment && Blaze.toHTML(HTML.A({
href: attachment.url({ download: true }), href: FlowRouter.path(attachment.url({ download: true })),
target: '_blank', target: '_blank',
}, attachment.name())); }, attachment.name()));
}, },
@ -83,9 +101,9 @@ BlazeComponent.extendComponent({
}, },
'submit .js-edit-comment'(evt) { 'submit .js-edit-comment'(evt) {
evt.preventDefault(); evt.preventDefault();
const commentText = this.currentComponent().getValue(); const commentText = this.currentComponent().getValue().trim();
const commentId = Template.parentData().commentId; const commentId = Template.parentData().commentId;
if ($.trim(commentText)) { if (commentText) {
CardComments.update(commentId, { CardComments.update(commentId, {
$set: { $set: {
text: commentText, text: commentText,

View file

@ -24,11 +24,12 @@ BlazeComponent.extendComponent({
}, },
'submit .js-new-comment-form'(evt) { 'submit .js-new-comment-form'(evt) {
const input = this.getInput(); const input = this.getInput();
if ($.trim(input.val())) { const text = input.val().trim();
if (text) {
CardComments.insert({ CardComments.insert({
text,
boardId: this.currentData().boardId, boardId: this.currentData().boardId,
cardId: this.currentData()._id, cardId: this.currentData()._id,
text: input.val(),
}); });
resetCommentInput(input); resetCommentInput(input);
Tracker.flush(); Tracker.flush();
@ -72,8 +73,9 @@ EscapeActions.register('inlinedForm',
docId: Session.get('currentCard'), docId: Session.get('currentCard'),
}; };
const commentInput = $('.js-new-comment-input'); const commentInput = $('.js-new-comment-input');
if ($.trim(commentInput.val())) { const draft = commentInput.val().trim();
UnsavedEdits.set(draftKey, commentInput.val()); if (draft) {
UnsavedEdits.set(draftKey, draft);
} else { } else {
UnsavedEdits.reset(draftKey); UnsavedEdits.reset(draftKey);
} }

View file

@ -34,7 +34,7 @@ BlazeComponent.extendComponent({
}, },
openNewListForm() { openNewListForm() {
this.componentChildren('addListForm')[0].open(); this.childComponents('addListForm')[0].open();
}, },
// XXX Flow components allow us to avoid creating these two setter methods by // XXX Flow components allow us to avoid creating these two setter methods by
@ -179,22 +179,24 @@ BlazeComponent.extendComponent({
// Proxy // Proxy
open() { open() {
this.componentChildren('inlinedForm')[0].open(); this.childComponents('inlinedForm')[0].open();
}, },
events() { events() {
return [{ return [{
submit(evt) { submit(evt) {
evt.preventDefault(); evt.preventDefault();
const title = this.find('.list-name-input'); const titleInput = this.find('.list-name-input');
if ($.trim(title.value)) { const title = titleInput.value.trim();
if (title) {
Lists.insert({ Lists.insert({
title: title.value, title,
boardId: Session.get('currentBoard'), boardId: Session.get('currentBoard'),
sort: $('.list').length, sort: $('.list').length,
}); });
title.value = ''; titleInput.value = '';
titleInput.focus();
} }
}, },
}]; }];

View file

@ -107,6 +107,9 @@ template(name="createBoardPopup")
| {{{_ 'board-private-info'}}} | {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}. a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}") input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import {{_ 'import-board'}}
template(name="boardChangeTitlePopup") template(name="boardChangeTitlePopup")

View file

@ -145,6 +145,7 @@ BlazeComponent.extendComponent({
this.setVisibility(this.currentData()); this.setVisibility(this.currentData());
}, },
'click .js-change-visibility': this.toggleVisibilityMenu, 'click .js-change-visibility': this.toggleVisibilityMenu,
'click .js-import': Popup.open('boardImportBoard'),
submit: this.onSubmit, submit: this.onSubmit,
}]; }];
}, },

View file

@ -0,0 +1,2 @@
a.js-import
text-decoration underline

View file

@ -15,7 +15,7 @@ template(name="attachmentsGalery")
.attachment-thumbnail .attachment-thumbnail
if isUploaded if isUploaded
if isImage if isImage
img.attachment-thumbnail-img(src=url) img.attachment-thumbnail-img(src="{{pathFor url}}")
else else
span.attachment-thumbnail-ext= extension span.attachment-thumbnail-ext= extension
else else

View file

@ -13,19 +13,19 @@ BlazeComponent.extendComponent({
}, },
reachNextPeak() { reachNextPeak() {
const activitiesComponent = this.componentChildren('activities')[0]; const activitiesComponent = this.childrenComponents('activities')[0];
activitiesComponent.loadNextPage(); activitiesComponent.loadNextPage();
}, },
onCreated() { onCreated() {
this.isLoaded = new ReactiveVar(false); this.isLoaded = new ReactiveVar(false);
this.componentParent().showOverlay.set(true); this.parentComponent().showOverlay.set(true);
this.componentParent().mouseHasEnterCardDetails = false; this.parentComponent().mouseHasEnterCardDetails = false;
}, },
scrollParentContainer() { scrollParentContainer() {
const cardPanelWidth = 510; const cardPanelWidth = 510;
const bodyBoardComponent = this.componentParent(); const bodyBoardComponent = this.parentComponent();
const $cardContainer = bodyBoardComponent.$('.js-lists'); const $cardContainer = bodyBoardComponent.$('.js-lists');
const $cardView = this.$(this.firstNode()); const $cardView = this.$(this.firstNode());
@ -52,7 +52,7 @@ BlazeComponent.extendComponent({
}, },
onDestroyed() { onDestroyed() {
this.componentParent().showOverlay.set(false); this.parentComponent().showOverlay.set(false);
}, },
events() { events() {
@ -62,7 +62,8 @@ BlazeComponent.extendComponent({
}, },
}; };
return [_.extend(events, { return [{
...events,
'click .js-close-card-details'() { 'click .js-close-card-details'() {
Utils.goBoardId(this.data().boardId); Utils.goBoardId(this.data().boardId);
}, },
@ -74,8 +75,8 @@ BlazeComponent.extendComponent({
}, },
'submit .js-card-details-title'(evt) { 'submit .js-card-details-title'(evt) {
evt.preventDefault(); evt.preventDefault();
const title = this.currentComponent().getValue(); const title = this.currentComponent().getValue().trim();
if ($.trim(title)) { if (title) {
this.data().setTitle(title); this.data().setTitle(title);
} }
}, },
@ -83,10 +84,10 @@ BlazeComponent.extendComponent({
'click .js-add-members': Popup.open('cardMembers'), 'click .js-add-members': Popup.open('cardMembers'),
'click .js-add-labels': Popup.open('cardLabels'), 'click .js-add-labels': Popup.open('cardLabels'),
'mouseenter .js-card-details'() { 'mouseenter .js-card-details'() {
this.componentParent().showOverlay.set(true); this.parentComponent().showOverlay.set(true);
this.componentParent().mouseHasEnterCardDetails = true; this.parentComponent().mouseHasEnterCardDetails = true;
}, },
})]; }];
}, },
}).register('cardDetails'); }).register('cardDetails');
@ -105,7 +106,7 @@ BlazeComponent.extendComponent({
close(isReset = false) { close(isReset = false) {
if (this.isOpen.get() && !isReset) { if (this.isOpen.get() && !isReset) {
const draft = $.trim(this.getValue()); const draft = this.getValue().trim();
if (draft !== Cards.findOne(Session.get('currentCard')).description) { if (draft !== Cards.findOne(Session.get('currentCard')).description) {
UnsavedEdits.set(this._getUnsavedEditKey(), this.getValue()); UnsavedEdits.set(this._getUnsavedEditKey(), this.getValue());
} }

View file

@ -18,7 +18,7 @@ template(name="editLabelPopup")
form.edit-label form.edit-label
+formLabel +formLabel
button.primary.wide.left(type="submit") {{_ 'save'}} button.primary.wide.left(type="submit") {{_ 'save'}}
span.right button.js-delete-label.negate.wide.right {{_ 'delete'}}
template(name="deleteLabelPopup") template(name="deleteLabelPopup")
p {{_ "label-delete-pop"}} p {{_ "label-delete-pop"}}

View file

@ -13,7 +13,7 @@ BlazeComponent.extendComponent({
}, },
labels() { labels() {
return _.map(labelColors, (color) => { return labelColors.map((color) => {
return { color, name: '' }; return { color, name: '' };
}); });
}, },
@ -69,12 +69,12 @@ Template.formLabel.events({
Template.createLabelPopup.events({ Template.createLabelPopup.events({
// Create the new label // Create the new label
'submit .create-label'(evt, tpl) { 'submit .create-label'(evt, tpl) {
evt.preventDefault();
const board = Boards.findOne(Session.get('currentBoard')); const board = Boards.findOne(Session.get('currentBoard'));
const name = tpl.$('#labelName').val().trim(); const name = tpl.$('#labelName').val().trim();
const color = Blaze.getData(tpl.find('.fa-check')).color; const color = Blaze.getData(tpl.find('.fa-check')).color;
board.addLabel(name, color); board.addLabel(name, color);
Popup.back(); Popup.back();
evt.preventDefault();
}, },
}); });

View file

@ -2,7 +2,7 @@ template(name="minicard")
.minicard .minicard
if cover if cover
.minicard-cover .minicard-cover
img(src=cover.url) img(src="{{pathFor cover.url}}")
if labels if labels
.minicard-labels .minicard-labels
each labels each labels

View file

@ -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'}}")

View file

@ -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');

View file

@ -7,7 +7,7 @@ BlazeComponent.extendComponent({
// Proxy // Proxy
openForm(options) { openForm(options) {
this.componentChildren('listBody')[0].openForm(options); this.childrenComponents('listBody')[0].openForm(options);
}, },
onCreated() { onCreated() {
@ -25,7 +25,7 @@ BlazeComponent.extendComponent({
if (!Meteor.user() || !Meteor.user().isBoardMember()) if (!Meteor.user() || !Meteor.user().isBoardMember())
return; return;
const boardComponent = this.componentParent(); const boardComponent = this.parentComponent();
const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)'; const itemsSelector = '.js-minicard:not(.placeholder, .js-card-composer)';
const $cards = this.$('.js-minicards'); const $cards = this.$('.js-minicards');
$cards.sortable({ $cards.sortable({

View file

@ -11,8 +11,8 @@ BlazeComponent.extendComponent({
options = options || {}; options = options || {};
options.position = options.position || 'top'; options.position = options.position || 'top';
const forms = this.componentChildren('inlinedForm'); const forms = this.childrenComponents('inlinedForm');
let form = _.find(forms, (component) => { let form = forms.find((component) => {
return component.data().position === options.position; return component.data().position === options.position;
}); });
if (!form && forms.length > 0) { if (!form && forms.length > 0) {
@ -26,8 +26,8 @@ BlazeComponent.extendComponent({
const firstCardDom = this.find('.js-minicard:first'); const firstCardDom = this.find('.js-minicard:first');
const lastCardDom = this.find('.js-minicard:last'); const lastCardDom = this.find('.js-minicard:last');
const textarea = $(evt.currentTarget).find('textarea'); const textarea = $(evt.currentTarget).find('textarea');
let title = textarea.val(); const position = this.currentData().position;
const position = Blaze.getData(evt.currentTarget).position; let title = textarea.val().trim();
let sortIndex; let sortIndex;
if (position === 'top') { if (position === 'top') {
sortIndex = Utils.calculateIndex(null, firstCardDom).base; sortIndex = Utils.calculateIndex(null, firstCardDom).base;
@ -62,7 +62,7 @@ BlazeComponent.extendComponent({
} }
}); });
if ($.trim(title)) { if (title) {
const _id = Cards.insert({ const _id = Cards.insert({
title, title,
listId: this.data()._id, listId: this.data()._id,

View file

@ -25,6 +25,7 @@ template(name="listActionPopup")
li: a.js-archive-cards {{_ 'list-archive-cards'}} li: a.js-archive-cards {{_ 'list-archive-cards'}}
hr hr
ul.pop-over-list ul.pop-over-list
li: a.js-import-card {{_ 'import-card'}}
li: a.js-close-list {{_ 'archive-list'}} li: a.js-close-list {{_ 'archive-list'}}
template(name="listMoveCardsPopup") template(name="listMoveCardsPopup")

View file

@ -5,10 +5,10 @@ BlazeComponent.extendComponent({
editTitle(evt) { editTitle(evt) {
evt.preventDefault(); evt.preventDefault();
const newTitle = this.componentChildren('inlinedForm')[0].getValue(); const newTitle = this.childrenComponents('inlinedForm')[0].getValue().trim();
const list = this.currentData(); const list = this.currentData();
if ($.trim(newTitle)) { if (newTitle) {
list.rename(newTitle); list.rename(newTitle.trim());
} }
}, },
@ -33,6 +33,7 @@ Template.listActionPopup.events({
MultiSelection.add(cardIds); MultiSelection.add(cardIds);
Popup.close(); Popup.close();
}, },
'click .js-import-card': Popup.open('listImportCard'),
'click .js-move-cards': Popup.open('listMoveCards'), 'click .js-move-cards': Popup.open('listMoveCards'),
'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', function() { 'click .js-archive-cards': Popup.afterConfirm('listArchiveCards', function() {
this.allCards().forEach((card) => { this.allCards().forEach((card) => {
@ -40,6 +41,7 @@ Template.listActionPopup.events({
}); });
Popup.close(); Popup.close();
}), }),
'click .js-close-list'(evt) { 'click .js-close-list'(evt) {
evt.preventDefault(); evt.preventDefault();
this.archive(); this.archive();

View file

@ -1,17 +1,15 @@
let dropdownMenuIsOpened = false;
Template.editor.onRendered(() => { Template.editor.onRendered(() => {
const $textarea = this.$('textarea'); const $textarea = this.$('textarea');
autosize($textarea); autosize($textarea);
$textarea.textcomplete([ $textarea.escapeableTextComplete([
// Emojies // Emojies
{ {
match: /\B:([\-+\w]*)$/, match: /\B:([\-+\w]*)$/,
search(term, callback) { search(term, callback) {
callback($.map(Emoji.values, (emoji) => { callback(Emoji.values.map((emoji) => {
return emoji.indexOf(term) === 0 ? emoji : null; return emoji.includes(term) ? emoji : null;
})); }));
}, },
template(value) { template(value) {
@ -30,9 +28,9 @@ Template.editor.onRendered(() => {
match: /\B@(\w*)$/, match: /\B@(\w*)$/,
search(term, callback) { search(term, callback) {
const currentBoard = Boards.findOne(Session.get('currentBoard')); const currentBoard = Boards.findOne(Session.get('currentBoard'));
callback($.map(currentBoard.members, (member) => { callback(currentBoard.members.map((member) => {
const username = Users.findOne(member.userId).username; const username = Users.findOne(member.userId).username;
return username.indexOf(term) === 0 ? username : null; return username.includes(term) ? username : null;
})); }));
}, },
template(value) { template(value) {
@ -44,30 +42,8 @@ Template.editor.onRendered(() => {
index: 1, 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 // 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 // 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 // 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() { Blaze.Template.registerHelper('mentions', new Template('mentions', function() {
const view = this; const view = this;
const currentBoard = Boards.findOne(Session.get('currentBoard')); 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; member.username = Users.findOne(member.userId).username;
return member; return member;
}); });

View file

@ -43,10 +43,10 @@ template(name="header")
the list of all boards. the list of all boards.
if isSandstorm if isSandstorm
.wekan-logo .wekan-logo
img(src="/wekan-logo-header.png" alt="Wekan") img(src="{{pathFor '/wekan-logo-header.png'}}" alt="Wekan")
else else
a.wekan-logo(href="{{pathFor 'home'}}" title="{{_ 'header-logo-title'}}") 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") template(name="headerTitle")
h1 {{_ 'my-boards'}} h1 {{_ 'my-boards'}}

View file

@ -2,12 +2,16 @@ head
title Wekan title Wekan
meta(name="viewport" meta(name="viewport"
content="maximum-scale=1.0,width=device-width,initial-scale=1.0,user-scalable=0") 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") link(rel="shortcut icon" href="/wekan-favicon.png")
template(name="userFormsLayout") template(name="userFormsLayout")
section.auth-layout section.auth-layout
h1.at-form-landing-logo 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.dynamic(template=content)
template(name="defaultLayout") template(name="defaultLayout")

View file

@ -17,9 +17,11 @@ $popupWidth = 300px
margin: 4px -10px margin: 4px -10px
width: $popupWidth width: $popupWidth
p,
textarea,
input[type="text"], input[type="text"],
input[type="email"], input[type="email"],
input[type="password"] input[type="password"],
input[type="file"] input[type="file"]
margin: 4px 0 12px margin: 4px 0 12px
width: 100% width: 100%
@ -30,8 +32,6 @@ $popupWidth = 300px
textarea textarea
height: 72px height: 72px
margin: 4px 0 12px
width: 100%
.header .header
height: 36px height: 36px

View file

@ -54,7 +54,7 @@ BlazeComponent.extendComponent({
}, },
reachNextPeak() { reachNextPeak() {
const activitiesComponent = this.componentChildren('activities')[0]; const activitiesComponent = this.childrenComponents('activities')[0];
activitiesComponent.loadNextPage(); activitiesComponent.loadNextPage();
}, },
@ -95,10 +95,10 @@ BlazeComponent.extendComponent({
events() { events() {
// XXX Hacky, we need some kind of `super` // XXX Hacky, we need some kind of `super`
const mixinEvents = this.getMixin(Mixins.InfiniteScrolling).events(); const mixinEvents = this.getMixin(Mixins.InfiniteScrolling).events();
return mixinEvents.concat([{ return [...mixinEvents, {
'click .js-toggle-sidebar': this.toggle, 'click .js-toggle-sidebar': this.toggle,
'click .js-back-home': this.setView, 'click .js-back-home': this.setView,
}]); }];
}, },
}).register('sidebar'); }).register('sidebar');

View file

@ -13,7 +13,7 @@ template(name="filterSidebar")
if name if name
= name = name
else else
span.quiet {{_ "label-default" color}} span.quiet {{_ "label-default" (_ (concat "color-" color))}}
if Filter.labelIds.isSelected _id if Filter.labelIds.isSelected _id
i.fa.fa-check i.fa.fa-check
hr hr

View file

@ -1,7 +1,7 @@
template(name="userAvatar") template(name="userAvatar")
a.member.js-member(title="{{userData.profile.fullname}} ({{userData.username}})") a.member.js-member(title="{{userData.profile.fullname}} ({{userData.username}})")
if userData.profile.avatarUrl if userData.getAvatarUrl
img.avatar.avatar-image(src=userData.profile.avatarUrl) img.avatar.avatar-image(src=userData.getAvatarUrl)
else else
+userAvatarInitials(userId=userData._id) +userAvatarInitials(userId=userData._id)

View file

@ -18,9 +18,9 @@ Template.memberMenuPopup.events({
Template.editProfilePopup.events({ Template.editProfilePopup.events({
submit(evt, tpl) { submit(evt, tpl) {
evt.preventDefault(); evt.preventDefault();
const fullname = $.trim(tpl.find('.js-profile-fullname').value); const fullname = tpl.find('.js-profile-fullname').value.trim();
const username = $.trim(tpl.find('.js-profile-username').value); const username = tpl.find('.js-profile-username').value.trim();
const initials = $.trim(tpl.find('.js-profile-initials').value); const initials = tpl.find('.js-profile-initials').value.trim();
Users.update(Meteor.userId(), {$set: { Users.update(Meteor.userId(), {$set: {
'profile.fullname': fullname, 'profile.fullname': fullname,
'profile.initials': initials, 'profile.initials': initials,

View file

@ -25,7 +25,7 @@ AccountsTemplates.configure({
}, },
}); });
_.each(['signIn', 'signUp', 'resetPwd', 'forgotPwd', 'enrollAccount'], ['signIn', 'signUp', 'resetPwd', 'forgotPwd', 'enrollAccount'].forEach(
(routeName) => AccountsTemplates.configureRoute(routeName)); (routeName) => AccountsTemplates.configureRoute(routeName));
// We display the form to change the password in a popup window that already // We display the form to change the password in a popup window that already

View file

@ -13,3 +13,7 @@ Blaze.registerHelper('currentCard', () => {
}); });
Blaze.registerHelper('getUser', (userId) => Users.findOne(userId)); Blaze.registerHelper('getUser', (userId) => Users.findOne(userId));
UI.registerHelper('concat', function (...args) {
return Array.prototype.slice.call(args, 0, -1).join('');
});

View file

@ -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(' - '));
});
});

View file

@ -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);
};

View file

@ -95,7 +95,7 @@ Filter = {
return {}; return {};
const filterSelector = {}; const filterSelector = {};
_.forEach(this._fields, (fieldName) => { this._fields.forEach((fieldName) => {
const filter = this[fieldName]; const filter = this[fieldName];
if (filter._isActive()) if (filter._isActive())
filterSelector[fieldName] = filter._getMongoSelector(); filterSelector[fieldName] = filter._getMongoSelector();
@ -116,7 +116,7 @@ Filter = {
}, },
reset() { reset() {
_.forEach(this._fields, (fieldName) => { this._fields.forEach((fieldName) => {
const filter = this[fieldName]; const filter = this[fieldName];
filter.reset(); filter.reset();
}); });

View file

@ -21,9 +21,9 @@ window.Modal = new class {
} }
} }
open(modalName, options) { open(modalName, { onCloseGoTo = ''} = {}) {
this._currentModal.set(modalName); this._currentModal.set(modalName);
this._onCloseGoTo = options && options.onCloseGoTo || ''; this._onCloseGoTo = onCloseGoTo;
} }
}; };

View file

@ -119,12 +119,13 @@ MultiSelection = {
} }
}, },
toggle(cardIds, options) { toggle(cardIds, options = {}) {
cardIds = _.isString(cardIds) ? [cardIds] : cardIds; cardIds = _.isString(cardIds) ? [cardIds] : cardIds;
options = _.extend({ options = {
add: true, add: true,
remove: true, remove: true,
}, options || {}); ...options,
};
if (!this.isActive()) { if (!this.isActive()) {
this.reset(); this.reset();
@ -133,7 +134,7 @@ MultiSelection = {
const selectedCards = this._selectedCards.get(); const selectedCards = this._selectedCards.get();
_.each(cardIds, (cardId) => { cardIds.forEach((cardId) => {
const indexOfCard = selectedCards.indexOf(cardId); const indexOfCard = selectedCards.indexOf(cardId);
if (options.remove && indexOfCard > -1) if (options.remove && indexOfCard > -1)

View file

@ -91,7 +91,7 @@ window.Popup = new class {
if (!self.isOpen()) { if (!self.isOpen()) {
self.current = Blaze.renderWithData(self.template, () => { self.current = Blaze.renderWithData(self.template, () => {
self._dep.depend(); self._dep.depend();
return _.extend(self._getTopStack(), { stack: self._stack }); return { ...self._getTopStack(), stack: self._stack };
}, document.body); }, document.body);
} else { } else {
@ -191,7 +191,7 @@ window.Popup = new class {
// We close a potential opened popup on any left click on the document, or go // We close a potential opened popup on any left click on the document, or go
// one step back by pressing escape. // one step back by pressing escape.
const escapeActions = ['back', 'close']; const escapeActions = ['back', 'close'];
_.each(escapeActions, (actionName) => { escapeActions.forEach((actionName) => {
EscapeActions.register(`popup-${actionName}`, EscapeActions.register(`popup-${actionName}`,
() => Popup[actionName](), () => Popup[actionName](),
() => Popup.isOpen(), () => Popup.isOpen(),

View file

@ -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
);

View file

@ -7,12 +7,14 @@
"activity-attached": "attached %s to %s", "activity-attached": "attached %s to %s",
"activity-created": "created %s", "activity-created": "created %s",
"activity-excluded": "excluded %s from %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-joined": "joined %s",
"activity-moved": "moved %s from %s to %s", "activity-moved": "moved %s from %s to %s",
"activity-on": "on %s", "activity-on": "on %s",
"activity-removed": "removed %s from %s", "activity-removed": "removed %s from %s",
"activity-sent": "sent %s to %s", "activity-sent": "sent %s to %s",
"activity-unjoined": "unjoinded %s", "activity-unjoined": "unjoined %s",
"add": "Add", "add": "Add",
"add-attachment": "Add an attachment", "add-attachment": "Add an attachment",
"add-board": "Add a new board", "add-board": "Add a new board",
@ -53,6 +55,7 @@
"boardChangeColorPopup-title": "Change Board Background", "boardChangeColorPopup-title": "Change Board Background",
"boardChangeTitlePopup-title": "Rename Board", "boardChangeTitlePopup-title": "Rename Board",
"boardChangeVisibilityPopup-title": "Change Visibility", "boardChangeVisibilityPopup-title": "Change Visibility",
"boardImportBoardPopup-title": "Import board from Trello",
"boardMenuPopup-title": "Board Menu", "boardMenuPopup-title": "Board Menu",
"boards": "Boards", "boards": "Boards",
"bucket-example": "Like “Bucket List” for example", "bucket-example": "Like “Bucket List” for example",
@ -87,6 +90,16 @@
"close": "Close", "close": "Close",
"close-board": "Close Board", "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”.", "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": "Comment",
"comment-placeholder": "Write a comment", "comment-placeholder": "Write a comment",
"computer": "Computer", "computer": "Computer",
@ -109,6 +122,10 @@
"editLabelPopup-title": "Change Label", "editLabelPopup-title": "Change Label",
"editProfilePopup-title": "Edit Profile", "editProfilePopup-title": "Edit Profile",
"email": "Email", "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": "Filter",
"filter-cards": "Filter Cards", "filter-cards": "Filter Cards",
"filter-clear": "Clear filter", "filter-clear": "Clear filter",
@ -118,6 +135,12 @@
"fullname": "Full Name", "fullname": "Full Name",
"header-logo-title": "Go back to your boards page.", "header-logo-title": "Go back to your boards page.",
"home": "Home", "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", "info": "Infos",
"initials": "Initials", "initials": "Initials",
"joined": "joined", "joined": "joined",
@ -136,6 +159,7 @@
"list-select-cards": "Select all cards in this list", "list-select-cards": "Select all cards in this list",
"listActionPopup-title": "List Actions", "listActionPopup-title": "List Actions",
"listArchiveCardsPopup-title": "Archive All Cards in this List?", "listArchiveCardsPopup-title": "Archive All Cards in this List?",
"listImportCardPopup-title": "Import a Trello card",
"listMoveCardsPopup-title": "Move All Cards in List", "listMoveCardsPopup-title": "Move All Cards in List",
"lists": "Lists", "lists": "Lists",
"log-out": "Log Out", "log-out": "Log Out",
@ -155,6 +179,7 @@
"normal": "Normal", "normal": "Normal",
"normal-desc": "Can view and edit cards. Can't change settings.", "normal-desc": "Can view and edit cards. Can't change settings.",
"optional": "optional", "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-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.", "page-not-found": "Page not found.",
"password": "Password", "password": "Password",

View file

@ -1,4 +1,4 @@
Attachments = new FS.Collection('attachments', { Attachments = new FS.Collection('attachments', { // eslint-disable-line meteor/collections
stores: [ stores: [
// XXX Add a new store for cover thumbnails so we don't load big images in // XXX Add a new store for cover thumbnails so we don't load big images in

View file

@ -92,12 +92,16 @@ Boards.helpers({
return _.where(this.members, {isActive: true}); return _.where(this.members, {isActive: true});
}, },
getLabel(name, color) {
return _.findWhere(this.labels, { name, color });
},
labelIndex(labelId) { labelIndex(labelId) {
return _.indexOf(_.pluck(this.labels, '_id'), labelId); return _.pluck(this.labels, '_id').indexOf(labelId);
}, },
memberIndex(memberId) { memberIndex(memberId) {
return _.indexOf(_.pluck(this.members, 'userId'), memberId); return _.pluck(this.members, 'userId').indexOf(memberId);
}, },
absoluteUrl() { absoluteUrl() {
@ -107,6 +111,14 @@ Boards.helpers({
colorClass() { colorClass() {
return `board-color-${this.color}`; 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({ Boards.mutations({
@ -131,18 +143,26 @@ Boards.mutations({
}, },
addLabel(name, color) { addLabel(name, color) {
const _id = Random.id(6); // If label with the same name and color already exists we don't want to
return { $push: {labels: { _id, name, color }}}; // 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) { editLabel(labelId, name, color) {
const labelIndex = this.labelIndex(labelId); if (!this.getLabel(name, color)) {
return { const labelIndex = this.labelIndex(labelId);
$set: { return {
[`labels.${labelIndex}.name`]: name, $set: {
[`labels.${labelIndex}.color`]: color, [`labels.${labelIndex}.name`]: name,
}, [`labels.${labelIndex}.color`]: color,
}; },
};
}
}, },
removeLabel(labelId) { removeLabel(labelId) {
@ -259,7 +279,7 @@ Boards.before.insert((userId, doc) => {
// Handle labels // Handle labels
const colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues; const colors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues;
const defaultLabelsColors = _.clone(colors).splice(0, 6); const defaultLabelsColors = _.clone(colors).splice(0, 6);
doc.labels = _.map(defaultLabelsColors, (color) => { doc.labels = defaultLabelsColors.map((color) => {
return { return {
color, color,
_id: Random.id(6), _id: Random.id(6),
@ -307,7 +327,7 @@ if (Meteor.isServer) {
{ boardId: doc._id }, { boardId: doc._id },
{ {
$pull: { $pull: {
labels: removedLabelId, labelIds: removedLabelId,
}, },
}, },
{ multi: true } { multi: true }

View file

@ -194,8 +194,9 @@ Cards.mutations({
Cards.before.insert((userId, doc) => { Cards.before.insert((userId, doc) => {
doc.createdAt = new Date(); doc.createdAt = new Date();
doc.dateLastActivity = new Date(); doc.dateLastActivity = new Date();
doc.archived = false; if(!doc.hasOwnProperty('archived')){
doc.archived = false;
}
if (!doc.userId) { if (!doc.userId) {
doc.userId = userId; doc.userId = userId;
} }

364
models/import.js Normal file
View file

@ -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];
},
});

View file

@ -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 // 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. // is used for instance to add a new user to a board.
@ -8,31 +8,50 @@ Users.initEasySearch(searchInFields, {
returnFields: [...searchInFields, 'profile.avatarUrl'], 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({ Users.helpers({
boards() { boards() {
return Boards.find({ userId: this._id }); return Boards.find({ userId: this._id });
}, },
starredBoards() { starredBoards() {
const starredBoardIds = this.profile.starredBoards || []; const {starredBoards = []} = this.profile;
return Boards.find({archived: false, _id: {$in: starredBoardIds}}); return Boards.find({archived: false, _id: {$in: starredBoards}});
}, },
hasStarred(boardId) { hasStarred(boardId) {
const starredBoardIds = this.profile.starredBoards || []; const {starredBoards = []} = this.profile;
return _.contains(starredBoardIds, boardId); return _.contains(starredBoards, boardId);
}, },
isBoardMember() { getAvatarUrl() {
const board = Boards.findOne(Session.get('currentBoard')); // Although we put the avatar picture URL in the `profile` object, we need
return board && _.contains(_.pluck(board.members, 'userId'), this._id) && // to support Sandstorm which put in the `picture` attribute by default.
_.where(board.members, {userId: this._id})[0].isActive; // XXX Should we move both cases to `picture`?
}, if (this.picture) {
return this.picture;
isBoardAdmin() { } else if (this.profile && this.profile.avatarUrl) {
const board = Boards.findOne(Session.get('currentBoard')); return this.profile.avatarUrl;
return board && this.isBoardMember(board) && } else {
_.where(board.members, {userId: this._id})[0].isAdmin; return null;
}
}, },
getInitials() { getInitials() {
@ -41,9 +60,9 @@ Users.helpers({
return profile.initials; return profile.initials;
else if (profile.fullname) { else if (profile.fullname) {
return _.reduce(profile.fullname.split(/\s+/), (memo, word) => { return profile.fullname.split(/\s+/).reduce((memo = '', word) => {
return memo + word[0]; return memo + word[0];
}, '').toUpperCase(); }).toUpperCase();
} else { } else {
return this.username[0].toUpperCase(); 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 // b. We use it to find deleted and newly inserted ids by using it in one
// direction and then in the other. // direction and then in the other.
function incrementBoards(boardsIds, inc) { function incrementBoards(boardsIds, inc) {
_.forEach(boardsIds, (boardId) => { boardsIds.forEach((boardId) => {
Boards.update(boardId, {$inc: {stars: inc}}); Boards.update(boardId, {$inc: {stars: inc}});
}); });
} }
@ -136,7 +155,7 @@ if (Meteor.isServer) {
// Insert the Welcome Board // Insert the Welcome Board
Boards.insert(ExampleBoard, (err, boardId) => { Boards.insert(ExampleBoard, (err, boardId) => {
_.forEach(['Basics', 'Advanced'], (title) => { ['Basics', 'Advanced'].forEach((title) => {
const list = { const list = {
title, title,
boardId, boardId,

24
package.json Normal file
View file

@ -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"
}
}

View file

@ -21,20 +21,13 @@ if (isSandstorm && Meteor.isServer) {
permission: 'public', 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) { function updateUserPermissions(userId, permissions) {
const isActive = permissions.indexOf('participate') > -1; const isActive = permissions.includes('participate');
const isAdmin = permissions.indexOf('configure') > -1; const isAdmin = permissions.includes('configure');
const permissionDoc = { userId, isActive, isAdmin }; const permissionDoc = { userId, isActive, isAdmin };
const boardMembers = Boards.findOne(sandstormBoard._id).members; const boardMembers = Boards.findOne(sandstormBoard._id).members;
const memberIndex = _.indexOf(_.pluck(boardMembers, 'userId'), userId); const memberIndex = _.pluck(boardMembers, 'userId').indexOf(userId);
let modifier; let modifier;
if (memberIndex > -1) if (memberIndex > -1)
@ -55,7 +48,8 @@ if (isSandstorm && Meteor.isServer) {
// and the home page was accessible by pressing the back button of the // and the home page was accessible by pressing the back button of the
// browser, a server-side redirection solves both of these issues. // 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']; const base = req.headers['x-sandstorm-base-path'];
// XXX If this routing scheme changes, this will break. We should generate // 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 // the location URL using the router, but at the time of writing, the
@ -68,20 +62,14 @@ if (isSandstorm && Meteor.isServer) {
res.end(); res.end();
// `accounts-sandstorm` populate the Users collection when new users // `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 // need to update his associated document to match the request HTTP headers
// informations. // informations.
const user = Users.findOne({ const user = Users.findOne({
'services.sandstorm.id': req.headers['x-sandstorm-user-id'], 'services.sandstorm.id': req.headers['x-sandstorm-user-id'],
}); });
if (user) { if (user) {
const userId = user._id; updateUserPermissions(user._id, user.permissions);
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);
} }
}); });
@ -90,6 +78,8 @@ if (isSandstorm && Meteor.isServer) {
// unique board document. Note that when the `Users.after.insert` hook is // unique board document. Note that when the `Users.after.insert` hook is
// called, the user is inserted into the database but not connected. So // called, the user is inserted into the database but not connected. So
// despite the appearances `userId` is null in this block. // despite the appearances `userId` is null in this block.
//
// XXX We should support the `preferredHandle` exposed by Sandstorm
Users.after.insert((userId, doc) => { Users.after.insert((userId, doc) => {
if (!Boards.findOne(sandstormBoard._id)) { if (!Boards.findOne(sandstormBoard._id)) {
Boards.insert(sandstormBoard, {validate: false}); Boards.insert(sandstormBoard, {validate: false});
@ -101,6 +91,15 @@ if (isSandstorm && Meteor.isServer) {
updateUserPermissions(doc._id, doc.services.sandstorm.permissions); updateUserPermissions(doc._id, doc.services.sandstorm.permissions);
}); });
// LibreBoard v0.8 didnt 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) { if (isSandstorm && Meteor.isClient) {
@ -110,7 +109,7 @@ if (isSandstorm && Meteor.isClient) {
// sandstorm client to return relative paths instead of absolutes. // sandstorm client to return relative paths instead of absolutes.
const _absoluteUrl = Meteor.absoluteUrl; const _absoluteUrl = Meteor.absoluteUrl;
const _defaultOptions = Meteor.absoluteUrl.defaultOptions; const _defaultOptions = Meteor.absoluteUrl.defaultOptions;
Meteor.absoluteUrl = (path, options) => { Meteor.absoluteUrl = (path, options) => { // eslint-disable-line meteor/core
const url = _absoluteUrl(path, options); const url = _absoluteUrl(path, options);
return url.replace(/^https?:\/\/127\.0\.0\.1:[0-9]{2,5}/, ''); return url.replace(/^https?:\/\/127\.0\.0\.1:[0-9]{2,5}/, '');
}; };

View file

@ -4,6 +4,12 @@
// //
// Migrations.add(name, migrationCallback, optionalOrder); // Migrations.add(name, migrationCallback, optionalOrder);
// Note that we have extra migrations defined in `sandstorm.js` that are
// exclusive to Sandstorm and shouldnt 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 // In the context of migration functions we don't want to validate database
// mutation queries against the current (ie, latest) collection schema. Doing // 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 // 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', () => { Migrations.add('lowercase-board-permission', () => {
_.forEach(['Public', 'Private'], (permission) => { ['Public', 'Private'].forEach((permission) => {
Boards.update( Boards.update(
{ permission }, { permission },
{ $set: { permission: permission.toLowerCase() } }, { $set: { permission: permission.toLowerCase() } },
@ -110,11 +116,11 @@ Migrations.add('add-member-isactive-field', () => {
const formerUsers = _.difference(allUsersWithSomeActivity, currentUsers); const formerUsers = _.difference(allUsersWithSomeActivity, currentUsers);
const newMemberSet = []; const newMemberSet = [];
_.forEach(board.members, (member) => { board.members.forEach((member) => {
member.isActive = true; member.isActive = true;
newMemberSet.push(member); newMemberSet.push(member);
}); });
_.forEach(formerUsers, (userId) => { formerUsers.forEach((userId) => {
newMemberSet.push({ newMemberSet.push({
userId, userId,
isAdmin: false, isAdmin: false,

View file

@ -10,7 +10,7 @@ Meteor.publish('boards', function() {
// Defensive programming to verify that starredBoards has the expected // Defensive programming to verify that starredBoards has the expected
// format -- since the field is in the `profile` a user can modify it. // 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]); check(starredBoards, [String]);
return Boards.find({ return Boards.find({

View file

@ -0,0 +1,7 @@
FastRender.onAllRoutes(function() {
this.subscribe('boards');
});
FastRender.route('/b/:id/:slug', function({ id }) {
this.subscribe('board', id);
});