mirror of https://github.com/wekan/wekan
Conflicts: client/components/lists/listBody.jspull/389/merge
commit
2b134ff7a9
@ -0,0 +1,10 @@ |
||||
# EditorConfig is awesome: http://EditorConfig.org |
||||
# top-most EditorConfig file |
||||
root = true |
||||
|
||||
# Unix-style newlines with a newline ending every file |
||||
[*] |
||||
end_of_line = lf |
||||
insert_final_newline = true |
||||
indent_style = space |
||||
indent_size = 2 |
@ -1 +1 @@ |
||||
METEOR@1.2.0.1 |
||||
METEOR@1.2.1 |
||||
|
@ -0,0 +1,2 @@ |
||||
a.js-import |
||||
text-decoration underline |
@ -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'}}") |
@ -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'); |
||||
|
@ -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); |
||||
}; |
@ -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 |
||||
); |
@ -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]; |
||||
}, |
||||
}); |
@ -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" |
||||
} |
||||
} |
@ -0,0 +1,7 @@ |
||||
FastRender.onAllRoutes(function() { |
||||
this.subscribe('boards'); |
||||
}); |
||||
|
||||
FastRender.route('/b/:id/:slug', function({ id }) { |
||||
this.subscribe('board', id); |
||||
}); |
Loading…
Reference in new issue