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:
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
# 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:
# 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
'$': 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

1
.gitignore vendored
View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,6 @@ language: node_js
node_js:
- "0.10.40"
install:
- "npm install -g eslint"
- "npm install"
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
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
+memberName(user=user)
if($eq activityType 'createBoard')
| {{_ 'activity-created' boardLabel}}.
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 'addAttachment')
| {{{_ 'activity-attached' attachmentLink cardLink}}}.
if($eq activityType 'addBoardMember')
| {{{_ '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')
| {{{_ 'activity-on' cardLink}}}
a.activity-comment(href="{{ card.absoluteUrl }}")
+viewer
= comment.text
if($eq activityType 'addAttachment')
| {{{_ 'activity-attached' attachmentLink cardLink}}}.
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 '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 }}
@ -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}}.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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
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({

View file

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

View file

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

View file

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

View file

@ -1,17 +1,15 @@
let dropdownMenuIsOpened = false;
Template.editor.onRendered(() => {
const $textarea = this.$('textarea');
autosize($textarea);
$textarea.textcomplete([
$textarea.escapeableTextComplete([
// Emojies
{
match: /\B:([\-+\w]*)$/,
search(term, callback) {
callback($.map(Emoji.values, (emoji) => {
return emoji.indexOf(term) === 0 ? emoji : null;
callback(Emoji.values.map((emoji) => {
return emoji.includes(term) ? emoji : null;
}));
},
template(value) {
@ -30,9 +28,9 @@ Template.editor.onRendered(() => {
match: /\B@(\w*)$/,
search(term, callback) {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
callback($.map(currentBoard.members, (member) => {
callback(currentBoard.members.map((member) => {
const username = Users.findOne(member.userId).username;
return username.indexOf(term) === 0 ? username : null;
return username.includes(term) ? username : null;
}));
},
template(value) {
@ -44,30 +42,8 @@ Template.editor.onRendered(() => {
index: 1,
},
]);
// Since commit d474017 jquery-textComplete automatically closes a potential
// opened dropdown menu when the user press Escape. This behavior conflicts
// with our EscapeActions system, but it's too complicated and hacky to
// monkey-pach textComplete to disable it -- I tried. Instead we listen to
// 'open' and 'hide' events, and create a ghost escapeAction when the dropdown
// is opened (and rely on textComplete to execute the actual action).
$textarea.on({
'textComplete:show'() {
dropdownMenuIsOpened = true;
},
'textComplete:hide'() {
Tracker.afterFlush(() => {
dropdownMenuIsOpened = false;
});
},
});
});
EscapeActions.register('textcomplete',
() => {},
() => dropdownMenuIsOpened
);
// XXX I believe we should compute a HTML rendered field on the server that
// would handle markdown, emojies and user mentions. We can simply have two
// fields, one source, and one compiled version (in HTML) and send only the
@ -78,7 +54,7 @@ const at = HTML.CharRef({html: '@', str: '@'});
Blaze.Template.registerHelper('mentions', new Template('mentions', function() {
const view = this;
const currentBoard = Boards.findOne(Session.get('currentBoard'));
const knowedUsers = _.map(currentBoard.members, (member) => {
const knowedUsers = currentBoard.members.map((member) => {
member.username = Users.findOne(member.userId).username;
return member;
});

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

@ -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(),

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-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",

View file

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

View file

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

View file

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

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
// 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,

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',
};
// 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 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) {
@ -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}/, '');
};

View file

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

View file

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

View file

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