merge with /devel

pull/401/head
Xavier Priour 10 years ago
commit a7427b9ae4
  1. 78
      .eslintrc
  2. 1
      .gitignore
  3. 4
      .meteor/packages
  4. 3
      .meteor/versions
  5. 5
      .travis.yml
  6. 8
      History.md
  7. 4
      client/components/activities/activities.js
  8. 10
      client/components/activities/comments.js
  9. 17
      client/components/boards/boardBody.js
  10. 8
      client/components/cards/cardDetails.js
  11. 9
      client/components/forms/forms.styl
  12. 2
      client/components/lists/list.js
  13. 15
      client/components/lists/listBody.jade
  14. 107
      client/components/lists/listBody.js
  15. 6
      client/components/lists/listHeader.js
  16. 8
      client/components/main/editor.js
  17. 2
      client/components/sidebar/sidebar.js
  18. 8
      client/components/users/userHeader.js
  19. 2
      client/lib/modal.js
  20. 32
      client/lib/textComplete.js
  21. 0
      config/accounts.js
  22. 2
      models/attachments.js
  23. 4
      models/boards.js
  24. 32
      models/users.js
  25. 24
      package.json
  26. 37
      sandstorm.js
  27. 7
      server/publications/fast-render.js

@ -1,7 +1,14 @@
ecmaFeatures:
experimentalObjectRestSpread: true
plugins:
- meteor
parser: babel-eslint
rules:
strict: 0
no-undef: 2
accessor-pairs: 2
comma-dangle: [2, 'always-multiline']
consistent-return: 2
@ -43,36 +50,39 @@ rules:
prefer-spread: 2
prefer-template: 2
globals:
# Meteor globals
Meteor: false
DDP: false
Mongo: false
Session: false
Accounts: false
Template: false
Blaze: false
UI: false
Match: false
check: false
Tracker: false
Deps: false
ReactiveVar: false
EJSON: false
HTTP: false
Email: false
Assets: false
Handlebars: false
Package: false
App: false
Npm: false
Tinytest: false
Random: false
HTML: false
# eslint-plugin-meteor
## Meteor API
meteor/globals: 2
meteor/core: 2
meteor/pubsub: 2
meteor/methods: 2
meteor/check: 2
meteor/connections: 2
meteor/collections: 2
meteor/session: [2, 'no-equal']
## Best practices
meteor/no-session: 0
meteor/no-zero-timeout: 2
meteor/no-blaze-lifecycle-assignment: 2
settings:
meteor:
# Our collections
collections:
- AccountsTemplates
- Activities
- Attachments
- Boards
- CardComments
- Cards
- Lists
- UnsavedEditCollection
- Users
globals:
# Exported by packages we use
'$': false
_: false
autosize: false
Avatar: true
Avatars: true
@ -80,6 +90,7 @@ globals:
BlazeLayout: false
DocHead: false
ESSearchResults: false
FastRender: false
FlowRouter: false
FS: false
getSlug: false
@ -97,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

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

@ -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
@ -52,6 +49,7 @@ audit-argument-checks
kadira:blaze-layout
kadira:dochead
kadira:flow-router
meteorhacks:fast-render
meteorhacks:picker
meteorhacks:subs-manager
mquandalle:autofocus

@ -35,6 +35,7 @@ cfs:tempstore@0.1.5
cfs:upload-http@0.0.20
cfs:worker@0.1.4
check@1.1.0
chuangbo:cookie@1.1.0
coffeescript@1.0.11
cosmos:browserify@0.8.3
dburles:collection-helpers@1.0.4
@ -75,6 +76,8 @@ 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

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

@ -3,9 +3,13 @@
This release features:
* Card import from Trello
* Autocompletion in the minicard editor. Start with <kbd>@</kbd> to start the
a board member autocompletion, or <kbd>#</kbd> for a label.
* 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.
Thanks to GitHub users AlexanderS, fisle, FuzzyWuzzie, ndarilek, and
xavierpriour for their contributions.
# v0.9

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

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

@ -34,7 +34,7 @@ BlazeComponent.extendComponent({
},
openNewListForm() {
this.childrenComponents('addListForm')[0].open();
this.childComponents('addListForm')[0].open();
},
// XXX Flow components allow us to avoid creating these two setter methods by
@ -45,7 +45,8 @@ BlazeComponent.extendComponent({
},
scrollLeft(position = 0) {
this.$('.js-lists').animate({
const lists = this.$('.js-lists');
lists && lists.animate({
scrollLeft: position,
});
},
@ -179,22 +180,24 @@ BlazeComponent.extendComponent({
// Proxy
open() {
this.childrenComponents('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();
}
},
}];

@ -13,7 +13,7 @@ BlazeComponent.extendComponent({
},
reachNextPeak() {
const activitiesComponent = this.childrenComponents('activities')[0];
const activitiesComponent = this.childComponents('activities')[0];
activitiesComponent.loadNextPage();
},
@ -75,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);
}
},
@ -106,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());
}

@ -617,8 +617,15 @@ button
margin-right: 5px
vertical-align: middle
.minicard-label
width: 11px
height: @width
border-radius: 2px
margin: 2px 7px -2px -2px
display: inline-block
&.active
background: #005377
a
a, .quiet
color: white

@ -7,7 +7,7 @@ BlazeComponent.extendComponent({
// Proxy
openForm(options) {
this.childrenComponents('listBody')[0].openForm(options);
this.childComponents('listBody')[0].openForm(options);
},
onCreated() {

@ -22,9 +22,20 @@ template(name="listBody")
template(name="addCardForm")
.minicard.minicard-composer.js-composer
.minicard-detailss.clearfix
textarea.minicard-composer-textarea.js-card-title(autofocus)
if getLabels
.minicard-labels
each getLabels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
textarea.minicard-composer-textarea.js-card-title(autofocus)
if members.get
.minicard-members.js-minicard-composer-members
each members.get
+userAvatar(userId=this)
.add-controls.clearfix
button.primary.confirm(type="submit") {{_ 'add'}}
a.fa.fa-times-thin.js-close-inlined-form
template(name="autocompleteLabelLine")
.minicard-label(class="card-label-{{colorName}}" title=labelName)
span(class="{{#if hasNoName}}quiet{{/if}}")= labelName

@ -11,8 +11,8 @@ BlazeComponent.extendComponent({
options = options || {};
options.position = options.position || 'top';
const forms = this.childrenComponents('inlinedForm');
let form = _.find(forms, (component) => {
const forms = this.childComponents('inlinedForm');
let form = forms.find((component) => {
return component.data().position === options.position;
});
if (!form && forms.length > 0) {
@ -26,8 +26,10 @@ BlazeComponent.extendComponent({
const firstCardDom = this.find('.js-minicard:first');
const lastCardDom = this.find('.js-minicard:last');
const textarea = $(evt.currentTarget).find('textarea');
const title = textarea.val();
const position = this.currentData().position;
const title = textarea.val().trim();
const formComponent = this.childComponents('addCardForm')[0];
let sortIndex;
if (position === 'top') {
sortIndex = Utils.calculateIndex(null, firstCardDom).base;
@ -35,9 +37,14 @@ BlazeComponent.extendComponent({
sortIndex = Utils.calculateIndex(lastCardDom, null).base;
}
if ($.trim(title)) {
const members = formComponent.members.get();
const labelIds = formComponent.labels.get();
if (title) {
const _id = Cards.insert({
title,
members,
labelIds,
listId: this.data()._id,
boardId: this.data().board()._id,
sort: sortIndex,
@ -53,6 +60,8 @@ BlazeComponent.extendComponent({
if (position === 'bottom') {
this.scrollToBottom();
}
formComponent.reset();
}
},
@ -100,11 +109,39 @@ BlazeComponent.extendComponent({
},
}).register('listBody');
function toggleValueInReactiveArray(reactiveValue, value) {
const array = reactiveValue.get();
const valueIndex = array.indexOf(value);
if (valueIndex === -1) {
array.push(value);
} else {
array.splice(valueIndex, 1);
}
reactiveValue.set(array);
}
BlazeComponent.extendComponent({
template() {
return 'addCardForm';
},
onCreated() {
this.labels = new ReactiveVar([]);
this.members = new ReactiveVar([]);
},
reset() {
this.labels.set([]);
this.members.set([]);
},
getLabels() {
const currentBoardId = Session.get('currentBoard');
return Boards.findOne(currentBoardId).labels.filter((label) => {
return this.labels.get().indexOf(label._id) > -1;
});
},
pressKey(evt) {
// Pressing Enter should submit the card
if (evt.keyCode === 13) {
@ -140,4 +177,66 @@ BlazeComponent.extendComponent({
keydown: this.pressKey,
}];
},
onRendered() {
const editor = this;
this.$('textarea').escapeableTextComplete([
// User mentions
{
match: /\B@(\w*)$/,
search(term, callback) {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
callback($.map(currentBoard.members, (member) => {
const user = Users.findOne(member.userId);
return user.username.indexOf(term) === 0 ? user : null;
}));
},
template(user) {
return user.username;
},
replace(user) {
toggleValueInReactiveArray(editor.members, user._id);
return '';
},
index: 1,
},
// Labels
{
match: /\B#(\w*)$/,
search(term, callback) {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
callback($.map(currentBoard.labels, (label) => {
if (label.name.indexOf(term) > -1 ||
label.color.indexOf(term) > -1) {
return label;
}
}));
},
template(label) {
return Blaze.toHTMLWithData(Template.autocompleteLabelLine, {
hasNoName: !Boolean(label.name),
colorName: label.color,
labelName: label.name || label.color,
});
},
replace(label) {
toggleValueInReactiveArray(editor.labels, label._id);
return '';
},
index: 1,
},
], {
// When the autocomplete menu is shown we want both a press of both `Tab`
// or `Enter` to validation the auto-completion. We also need to stop the
// event propagation to prevent the card from submitting (on `Enter`) or
// going on the next column (on `Tab`).
onKeydown(evt, commands) {
if (evt.keyCode === 9 || evt.keyCode === 13) {
evt.stopPropagation();
return commands.KEY_ENTER;
}
},
});
},
}).register('addCardForm');

@ -5,10 +5,10 @@ BlazeComponent.extendComponent({
editTitle(evt) {
evt.preventDefault();
const newTitle = this.childrenComponents('inlinedForm')[0].getValue();
const newTitle = this.childComponents('inlinedForm')[0].getValue().trim();
const list = this.currentData();
if ($.trim(newTitle)) {
list.rename(newTitle);
if (newTitle) {
list.rename(newTitle.trim());
}
},

@ -8,8 +8,8 @@ Template.editor.onRendered(() => {
{
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) {
@ -28,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) {

@ -54,7 +54,7 @@ BlazeComponent.extendComponent({
},
reachNextPeak() {
const activitiesComponent = this.childrenComponents('activities')[0];
const activitiesComponent = this.childComponents('activities')[0];
activitiesComponent.loadNextPage();
},

@ -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,
@ -41,7 +41,7 @@ Template.changePasswordPopup.onRendered(function() {
Template.changeLanguagePopup.helpers({
languages() {
return TAPi18n.getLanguages().map((lang, tag) => {
return _.map(TAPi18n.getLanguages(), (lang, tag) => {
const name = lang.name;
return { tag, name };
});

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

@ -3,8 +3,23 @@
// of the vanilla `textcomplete`.
let dropdownMenuIsOpened = false;
$.fn.escapeableTextComplete = function(...args) {
this.textcomplete(...args);
$.fn.escapeableTextComplete = function(strategies, options, ...otherArgs) {
// When the autocomplete menu is shown we want both a press of both `Tab`
// or `Enter` to validation the auto-completion. We also need to stop the
// event propagation to prevent EscapeActions side effect, for instance the
// minicard submission (on `Enter`) or going on the next column (on `Tab`).
options = {
onKeydown(evt, commands) {
if (evt.keyCode === 9 || evt.keyCode === 13) {
evt.stopPropagation();
return commands.KEY_ENTER;
}
},
...options,
};
// Proxy to the vanilla jQuery component
this.textcomplete(strategies, options, ...otherArgs);
// Since commit d474017 jquery-textComplete automatically closes a potential
// opened dropdown menu when the user press Escape. This behavior conflicts
@ -18,7 +33,14 @@ $.fn.escapeableTextComplete = function(...args) {
},
'textComplete:hide'() {
Tracker.afterFlush(() => {
dropdownMenuIsOpened = false;
// XXX Hack. We unfortunately need to set a setTimeout here to make the
// `noClickEscapeOn` work bellow, otherwise clicking on a autocomplete
// item will close both the autocomplete menu (as expected) but also the
// next item in the stack (for example the minicard editor) which we
// don't want.
setTimeout(() => {
dropdownMenuIsOpened = false;
}, 100);
});
},
});
@ -26,5 +48,7 @@ $.fn.escapeableTextComplete = function(...args) {
EscapeActions.register('textcomplete',
() => {},
() => dropdownMenuIsOpened
() => dropdownMenuIsOpened, {
noClickEscapeOn: '.textcomplete-dropdown',
}
);

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

@ -97,11 +97,11 @@ Boards.helpers({
},
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() {

@ -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,7 +8,23 @@ 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() {
@ -25,18 +41,6 @@ Users.helpers({
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.

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

@ -22,12 +22,12 @@ if (isSandstorm && Meteor.isServer) {
};
function updateUserPermissions(userId, permissions) {
const isActive = permissions.includes('participate');
const isAdmin = permissions.includes('configure');
const isActive = permissions.indexOf('participate') > -1;
const isAdmin = permissions.indexOf('configure') > -1;
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)
@ -78,17 +78,40 @@ 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});
Boards.insert(sandstormBoard, { validate: false });
Activities.update(
{ activityTypeId: sandstormBoard._id },
{ $set: { userId: doc._id }}
);
}
// We rely on username uniqueness for the user mention feature, but
// Sandstorm doesn't enforce this property -- see #352. Our strategy to
// generate unique usernames from the Sandstorm `preferredHandle` is to
// append a number that we increment until we generate a username that no
// one already uses (eg, 'max', 'max1', 'max2').
function generateUniqueUsername(username, appendNumber) {
return username + String(appendNumber === 0 ? '' : appendNumber);
}
const username = doc.services.sandstorm.preferredHandle;
let appendNumber = 0;
while (Users.findOne({
_id: { $ne: doc._id },
username: generateUniqueUsername(username, appendNumber),
})) {
appendNumber += 1;
}
Users.update(doc._id, {
$set: {
username: generateUniqueUsername(username, appendNumber),
'profile.fullname': doc.services.sandstorm.name,
},
});
updateUserPermissions(doc._id, doc.services.sandstorm.permissions);
});
@ -109,7 +132,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}/, '');
};

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