Merge branch 'nested-tasks' of https://github.com/TNick/wekan into TNick-nested-tasks

This commit is contained in:
Lauri Ojansivu 2018-07-05 22:48:43 +03:00
commit 06423164fb
21 changed files with 1163 additions and 83 deletions

View file

@ -134,6 +134,7 @@
"Announcements": true,
"Swimlanes": true,
"ChecklistItems": true,
"Subtasks": true,
"Npm": true
}
}

View file

@ -130,6 +130,10 @@ template(name="boardMenuPopup")
li: a(href="{{exportUrl}}", download="{{exportFilename}}") {{_ 'export-board'}}
li: a.js-archive-board {{_ 'archive-board'}}
li: a.js-outgoing-webhooks {{_ 'outgoing-webhooks'}}
hr
ul.pop-over-list
li: a.js-subtask-settings {{_ 'subtask-settings'}}
if isSandstorm
hr
ul.pop-over-list
@ -193,6 +197,56 @@ template(name="boardChangeColorPopup")
if isSelected
i.fa.fa-check
template(name="boardSubtaskSettingsPopup")
form.board-subtask-settings
h3 {{_ 'show-parent-in-minicard'}}
a#prefix-with-full-path.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'prefix-with-full-path'}}is-checked{{/if}}")
.materialCheckBox(class="{{#if $eq presentParentTask 'prefix-with-full-path'}}is-checked{{/if}}")
span {{_ 'prefix-with-full-path'}}
a#prefix-with-parent.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'prefix-with-parent'}}is-checked{{/if}}")
.materialCheckBox(class="{{#if $eq presentParentTask 'prefix-with-parent'}}is-checked{{/if}}")
span {{_ 'prefix-with-parent'}}
a#subtext-with-full-path.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'subtext-with-full-path'}}is-checked{{/if}}")
.materialCheckBox(class="{{#if $eq presentParentTask 'subtext-with-full-path'}}is-checked{{/if}}")
span {{_ 'subtext-with-full-path'}}
a#subtext-with-parent.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'subtext-with-parent'}}is-checked{{/if}}")
.materialCheckBox(class="{{#if $eq presentParentTask 'subtext-with-parent'}}is-checked{{/if}}")
span {{_ 'subtext-with-parent'}}
a#no-parent.flex.js-field-show-parent-in-minicard(class="{{#if $eq presentParentTask 'no-parent'}}is-checked{{/if}}")
.materialCheckBox(class="{{#if $eq presentParentTask 'no-parent'}}is-checked{{/if}}")
span {{_ 'no-parent'}}
div
hr
div.check-div
a.flex.js-field-has-subtasks(class="{{#if allowsSubtasks}}is-checked{{/if}}")
.materialCheckBox(class="{{#if allowsSubtasks}}is-checked{{/if}}")
span {{_ 'show-subtasks-field'}}
label
| {{_ 'deposit-subtasks-board'}}
select.js-field-deposit-board(disabled="{{#unless allowsSubtasks}}disabled{{/unless}}")
each boards
if isBoardSelected
option(value=_id selected="selected") {{title}}
else
option(value=_id) {{title}}
if isNullBoardSelected
option(value='null' selected="selected") {{_ 'custom-field-dropdown-none'}}
else
option(value='null') {{_ 'custom-field-dropdown-none'}}
div
hr
label
| {{_ 'deposit-subtasks-list'}}
select.js-field-deposit-list(disabled="{{#unless hasLists}}disabled{{/unless}}")
each lists
if isListSelected
option(value=_id selected="selected") {{title}}
else
option(value=_id) {{title}}
template(name="createBoard")
form
label

View file

@ -25,6 +25,7 @@ Template.boardMenuPopup.events({
}),
'click .js-outgoing-webhooks': Popup.open('outgoingWebhooks'),
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-subtask-settings': Popup.open('boardSubtaskSettings'),
});
Template.boardMenuPopup.helpers({
@ -153,6 +154,102 @@ BlazeComponent.extendComponent({
},
}).register('boardChangeColorPopup');
BlazeComponent.extendComponent({
onCreated() {
this.currentBoard = Boards.findOne(Session.get('currentBoard'));
},
allowsSubtasks() {
return this.currentBoard.allowsSubtasks;
},
isBoardSelected() {
return this.currentBoard.subtasksDefaultBoardId === this.currentData()._id;
},
isNullBoardSelected() {
return (this.currentBoard.subtasksDefaultBoardId === null) || (this.currentBoard.subtasksDefaultBoardId === undefined);
},
boards() {
return Boards.find({
archived: false,
'members.userId': Meteor.userId(),
}, {
sort: ['title'],
});
},
lists() {
return Lists.find({
boardId: this.currentBoard._id,
archived: false,
}, {
sort: ['title'],
});
},
hasLists() {
return this.lists().count() > 0;
},
isListSelected() {
return this.currentBoard.subtasksDefaultBoardId === this.currentData()._id;
},
presentParentTask() {
let result = this.currentBoard.presentParentTask;
if ((result === null) || (result === undefined)) {
result = 'no-parent';
}
return result;
},
events() {
return [{
'click .js-field-has-subtasks'(evt) {
evt.preventDefault();
this.currentBoard.allowsSubtasks = !this.currentBoard.allowsSubtasks;
this.currentBoard.setAllowsSubtasks(this.currentBoard.allowsSubtasks);
$('.js-field-has-subtasks .materialCheckBox').toggleClass('is-checked', this.currentBoard.allowsSubtasks);
$('.js-field-has-subtasks').toggleClass('is-checked', this.currentBoard.allowsSubtasks);
$('.js-field-deposit-board').prop('disabled', !this.currentBoard.allowsSubtasks);
},
'change .js-field-deposit-board'(evt) {
let value = evt.target.value;
if (value === 'null') {
value = null;
}
this.currentBoard.setSubtasksDefaultBoardId(value);
evt.preventDefault();
},
'change .js-field-deposit-list'(evt) {
this.currentBoard.setSubtasksDefaultListId(evt.target.value);
evt.preventDefault();
},
'click .js-field-show-parent-in-minicard'(evt) {
const value = evt.target.id || $(evt.target).parent()[0].id || $(evt.target).parent()[0].parent()[0].id;
const options = [
'prefix-with-full-path',
'prefix-with-parent',
'subtext-with-full-path',
'subtext-with-parent',
'no-parent'];
options.forEach(function(element) {
if (element !== value) {
$(`#${element} .materialCheckBox`).toggleClass('is-checked', false);
$(`#${element}`).toggleClass('is-checked', false);
}
});
$(`#${value} .materialCheckBox`).toggleClass('is-checked', true);
$(`#${value}`).toggleClass('is-checked', true);
this.currentBoard.setPresentParentTask(value);
evt.preventDefault();
},
}];
},
}).register('boardSubtaskSettingsPopup');
const CreateBoard = BlazeComponent.extendComponent({
template() {
return 'createBoard';

View file

@ -1,3 +1,22 @@
.integration-form
padding: 5px
border-bottom: 1px solid #ccc
.flex
display: -webkit-box
display: -moz-box
display: -webkit-flex
display: -moz-flex
display: -ms-flexbox
display: flex
.option
@extends .flex
-webkit-border-radius: 3px;
border-radius: 3px;
background: #fff;
text-decoration: none;
-webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.2);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
margin-top: 5px;
padding: 5px;

View file

@ -13,6 +13,12 @@ template(name="cardDetails")
= title
if isWatching
i.fa.fa-eye.card-details-watch
.card-details-path
each parentList
|   >  
a.js-parent-card(href=linkForCard) {{title}}
// else
{{_ 'top-level-card'}}
if archived
p.warning {{_ 'card-archived'}}
@ -144,6 +150,10 @@ template(name="cardDetails")
hr
+checklists(cardId = _id)
if currentBoard.allowsSubtasks
hr
+subtasks(cardId = _id)
hr
h3
i.fa.fa-paperclip
@ -273,10 +283,37 @@ template(name="cardMorePopup")
button.js-copy-card-link-to-clipboard(class="btn") {{_ 'copy-card-link-to-clipboard'}}
span.clearfix
br
h2 {{_ 'change-card-parent'}}
label {{_ 'source-board'}}:
select.js-field-parent-board
each boards
if isParentBoard
option(value="{{_id}}" selected) {{title}}
else
option(value="{{_id}}") {{title}}
if isTopLevel
option(value="none" selected) {{_ 'custom-field-dropdown-none'}}
else
option(value="none") {{_ 'custom-field-dropdown-none'}}
label {{_ 'parent-card'}}:
select.js-field-parent-card
if isTopLevel
option(value="none" selected) {{_ 'custom-field-dropdown-none'}}
else
each cards
if isParentCard
option(value="{{_id}}" selected) {{title}}
else
option(value="{{_id}}") {{title}}
option(value="none") {{_ 'custom-field-dropdown-none'}}
br
| {{_ 'added'}}
span.date(title=card.createdAt) {{ moment createdAt 'LLL' }}
a.js-delete(title="{{_ 'card-delete-notice'}}") {{_ 'delete'}}
template(name="cardDeletePopup")
p {{_ "card-delete-pop"}}
unless archived

View file

@ -20,10 +20,11 @@ BlazeComponent.extendComponent({
},
onCreated() {
this.currentBoard = Boards.findOne(Session.get('currentBoard'));
this.isLoaded = new ReactiveVar(false);
const boardBody = this.parentComponent().parentComponent();
//in Miniview parent is Board, not BoardBody.
if (boardBody !== null){
if (boardBody !== null) {
boardBody.showOverlay.set(true);
boardBody.mouseHasEnterCardDetails = false;
}
@ -70,6 +71,30 @@ BlazeComponent.extendComponent({
}
},
presentParentTask() {
let result = this.currentBoard.presentParentTask;
if ((result === null) || (result === undefined)) {
result = 'no-parent';
}
return result;
},
linkForCard() {
const card = this.currentData();
let result = '#';
if (card) {
const board = Boards.findOne(card.boardId);
if (board) {
result = FlowRouter.url('card', {
boardId: card.boardId,
slug: board.slug,
cardId: card._id,
});
}
}
return result;
},
onRendered() {
if (!Utils.isMiniScreen()) this.scrollParentContainer();
const $checklistsDom = this.$('.card-checklist-items');
@ -107,6 +132,41 @@ BlazeComponent.extendComponent({
},
});
const $subtasksDom = this.$('.card-subtasks-items');
$subtasksDom.sortable({
tolerance: 'pointer',
helper: 'clone',
handle: '.subtask-title',
items: '.js-subtasks',
placeholder: 'subtasks placeholder',
distance: 7,
start(evt, ui) {
ui.placeholder.height(ui.helper.height());
EscapeActions.executeUpTo('popup-close');
},
stop(evt, ui) {
let prevChecklist = ui.item.prev('.js-subtasks').get(0);
if (prevChecklist) {
prevChecklist = Blaze.getData(prevChecklist).subtask;
}
let nextChecklist = ui.item.next('.js-subtasks').get(0);
if (nextChecklist) {
nextChecklist = Blaze.getData(nextChecklist).subtask;
}
const sortIndex = calculateIndexData(prevChecklist, nextChecklist, 1);
$subtasksDom.sortable('cancel');
const subtask = Blaze.getData(ui.item.get(0)).subtask;
Subtasks.update(subtask._id, {
$set: {
subtaskSort: sortIndex.base,
},
});
},
});
function userIsMember() {
return Meteor.user() && Meteor.user().isBoardMember();
}
@ -116,6 +176,9 @@ BlazeComponent.extendComponent({
if ($checklistsDom.data('sortable')) {
$checklistsDom.sortable('option', 'disabled', !userIsMember());
}
if ($subtasksDom.data('sortable')) {
$subtasksDom.sortable('option', 'disabled', !userIsMember());
}
});
},
@ -327,7 +390,6 @@ Template.moveCardPopup.events({
Popup.close();
},
});
BlazeComponent.extendComponent({
onCreated() {
subManager.subscribe('board', Session.get('currentBoard'));
@ -364,6 +426,21 @@ BlazeComponent.extendComponent({
},
}).register('boardsAndLists');
function cloneCheckList(_id, checklist) {
'use strict';
const checklistId = checklist._id;
checklist.cardId = _id;
checklist._id = null;
const newChecklistId = Checklists.insert(checklist);
ChecklistItems.find({checklistId}).forEach(function(item) {
item._id = null;
item.checklistId = newChecklistId;
item.cardId = _id;
ChecklistItems.insert(item);
});
}
Template.copyCardPopup.events({
'click .js-done'() {
const card = Cards.findOne(Session.get('currentCard'));
@ -392,19 +469,18 @@ Template.copyCardPopup.events({
// copy checklists
let cursor = Checklists.find({cardId: oldId});
cursor.forEach(function() {
cloneCheckList(_id, arguments[0]);
});
// copy subtasks
cursor = Cards.find({parentId: oldId});
cursor.forEach(function() {
'use strict';
const checklist = arguments[0];
const checklistId = checklist._id;
checklist.cardId = _id;
checklist._id = null;
const newChecklistId = Checklists.insert(checklist);
ChecklistItems.find({checklistId}).forEach(function(item) {
item._id = null;
item.checklistId = newChecklistId;
item.cardId = _id;
ChecklistItems.insert(item);
});
const subtask = arguments[0];
subtask.parentId = _id;
subtask._id = null;
/* const newSubtaskId = */ Cards.insert(subtask);
});
// copy card comments
@ -453,19 +529,18 @@ Template.copyChecklistToManyCardsPopup.events({
// copy checklists
let cursor = Checklists.find({cardId: oldId});
cursor.forEach(function() {
cloneCheckList(_id, arguments[0]);
});
// copy subtasks
cursor = Cards.find({parentId: oldId});
cursor.forEach(function() {
'use strict';
const checklist = arguments[0];
const checklistId = checklist._id;
checklist.cardId = _id;
checklist._id = null;
const newChecklistId = Checklists.insert(checklist);
ChecklistItems.find({checklistId}).forEach(function(item) {
item._id = null;
item.checklistId = newChecklistId;
item.cardId = _id;
ChecklistItems.insert(item);
});
const subtask = arguments[0];
subtask.parentId = _id;
subtask._id = null;
/* const newSubtaskId = */ Cards.insert(subtask);
});
// copy card comments
@ -483,36 +558,119 @@ Template.copyChecklistToManyCardsPopup.events({
},
});
Template.cardMorePopup.events({
'click .js-copy-card-link-to-clipboard' () {
// Clipboard code from:
// https://stackoverflow.com/questions/6300213/copy-selected-text-to-the-clipboard-without-using-flash-must-be-cross-browser
const StringToCopyElement = document.getElementById('cardURL');
StringToCopyElement.select();
if (document.execCommand('copy')) {
StringToCopyElement.blur();
BlazeComponent.extendComponent({
onCreated() {
this.currentCard = this.currentData();
this.parentCard = this.currentCard.parentCard();
if (this.parentCard) {
this.parentBoard = this.parentCard.board();
} else {
document.getElementById('cardURL').selectionStart = 0;
document.getElementById('cardURL').selectionEnd = 999;
document.execCommand('copy');
if (window.getSelection) {
if (window.getSelection().empty) { // Chrome
window.getSelection().empty();
} else if (window.getSelection().removeAllRanges) { // Firefox
window.getSelection().removeAllRanges();
}
} else if (document.selection) { // IE?
document.selection.empty();
}
this.parentBoard = null;
}
},
'click .js-delete': Popup.afterConfirm('cardDelete', function () {
Popup.close();
Cards.remove(this._id);
Utils.goBoardId(this.boardId);
}),
});
boards() {
const boards = Boards.find({
archived: false,
'members.userId': Meteor.userId(),
}, {
sort: ['title'],
});
return boards;
},
cards() {
if (this.parentBoard) {
return this.parentBoard.cards();
} else {
return [];
}
},
isParentBoard() {
const board = this.currentData();
if (this.parentBoard) {
return board._id === this.parentBoard;
}
return false;
},
isParentCard() {
const card = this.currentData();
if (this.parentCard) {
return card._id === this.parentCard;
}
return false;
},
setParentCardId(cardId) {
if (cardId === 'null') {
cardId = null;
this.parentCard = null;
} else {
this.parentCard = Cards.findOne(cardId);
}
this.currentCard.setParentId(cardId);
},
events() {
return [{
'click .js-copy-card-link-to-clipboard' () {
// Clipboard code from:
// https://stackoverflow.com/questions/6300213/copy-selected-text-to-the-clipboard-without-using-flash-must-be-cross-browser
const StringToCopyElement = document.getElementById('cardURL');
StringToCopyElement.select();
if (document.execCommand('copy')) {
StringToCopyElement.blur();
} else {
document.getElementById('cardURL').selectionStart = 0;
document.getElementById('cardURL').selectionEnd = 999;
document.execCommand('copy');
if (window.getSelection) {
if (window.getSelection().empty) { // Chrome
window.getSelection().empty();
} else if (window.getSelection().removeAllRanges) { // Firefox
window.getSelection().removeAllRanges();
}
} else if (document.selection) { // IE?
document.selection.empty();
}
}
},
'click .js-delete': Popup.afterConfirm('cardDelete', function () {
Popup.close();
Cards.remove(this._id);
Utils.goBoardId(this.boardId);
}),
'change .js-field-parent-board'(evt) {
const selection = $(evt.currentTarget).val();
const list = $('.js-field-parent-card');
list.empty();
if (selection === 'none') {
this.parentBoard = null;
list.prop('disabled', true);
} else {
this.parentBoard = Boards.findOne(selection);
this.parentBoard.cards().forEach(function(card) {
list.append(
$('<option></option>').val(card._id).html(card.title)
);
});
list.prop('disabled', false);
}
list.append(
`<option value='none' selected='selected'>${TAPi18n.__('custom-field-dropdown-none')}</option>`
);
this.setParentCardId('null');
},
'change .js-field-parent-card'(evt) {
const selection = $(evt.currentTarget).val();
this.setParentCardId(selection);
},
}];
},
}).register('cardMorePopup');
// Close the card details pane by pressing escape
EscapeActions.register('detailsPane',

View file

@ -27,7 +27,6 @@ template(name="checklistDetail")
if canModifyCard
a.js-delete-checklist.toggle-delete-checklist-dialog {{_ "delete"}}...
span.checklist-stat(class="{{#if checklist.isFinished}}is-finished{{/if}}") {{checklist.finishedCount}}/{{checklist.itemCount}}
if canModifyCard
h2.title.js-open-inlined-form.is-editable
+viewer
@ -75,7 +74,7 @@ template(name="checklistItems")
+inlinedForm(classNames="js-edit-checklist-item" item = item checklist = checklist)
+editChecklistItemForm(type = 'item' item = item checklist = checklist)
else
+itemDetail(item = item checklist = checklist)
+checklistItemDetail(item = item checklist = checklist)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist = checklist)
+addChecklistItemForm
@ -84,7 +83,7 @@ template(name="checklistItems")
i.fa.fa-plus
| {{_ 'add-checklist-item'}}...
template(name='itemDetail')
template(name='checklistItemDetail')
.js-checklist-item.checklist-item
if canModifyCard
.check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")

View file

@ -204,7 +204,7 @@ Template.checklistDeleteDialog.onDestroyed(() => {
$cardDetails.animate( { scrollTop: this.scrollState.position });
});
Template.itemDetail.helpers({
Template.checklistItemDetail.helpers({
canModifyCard() {
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
},
@ -223,4 +223,4 @@ BlazeComponent.extendComponent({
'click .js-checklist-item .check-box': this.toggleItem,
}];
},
}).register('itemDetail');
}).register('checklistItemDetail');

View file

@ -7,8 +7,21 @@ template(name="minicard")
each labels
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-title
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix
| {{ parentString ' > ' }}
if $eq 'prefix-with-parent' currentBoard.presentParentTask
.parent-prefix
| {{ parentCardName }}
+viewer
= title
{{ title }}
if $eq 'subtext-with-full-path' currentBoard.presentParentTask
.parent-subtext
| {{ parentString ' > ' }}
if $eq 'subtext-with-parent' currentBoard.presentParentTask
.parent-subtext
| {{ parentCardName }}
.dates
if receivedAt
unless startAt

View file

@ -162,6 +162,13 @@
margin-bottom: 20px
overflow-y: auto
.parent-prefix
color: darken(white, 30%)
font-size: 0.9em
.parent-subtext
color: darken(white, 30%)
font-size: 0.9em
@media screen and (max-width: 800px)
.minicard
.is-selected &

View file

@ -0,0 +1,97 @@
template(name="subtasks")
h3 {{_ 'subtasks'}}
if toggleDeleteDialog.get
.board-overlay#card-details-overlay
+subtaskDeleteDialog(subtask = subtaskToDelete)
.card-subtasks-items
each subtask in currentCard.subtasks
+subtaskDetail(subtask = subtask)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-subtask" cardId = cardId)
+addSubtaskItemForm
else
a.js-open-inlined-form
i.fa.fa-plus
| {{_ 'add-subtask'}}...
template(name="subtaskDetail")
.js-subtasks.subtask
+inlinedForm(classNames="js-edit-subtask-title" subtask = subtask)
+editSubtaskItemForm(subtask = subtask)
else
.subtask-title
span
a.js-view-subtask(title="{{ subtask.title }}") {{_ "view-it"}}
if canModifyCard
a.js-delete-subtask.toggle-delete-subtask-dialog {{_ "delete"}}...
if canModifyCard
h2.title.js-open-inlined-form.is-editable
+viewer
= subtask.title
else
h2.title
+viewer
= subtask.title
template(name="subtaskDeleteDialog")
.js-confirm-subtask-delete
p
i(class="fa fa-exclamation-triangle" aria-hidden="true")
p
| {{_ 'confirm-subtask-delete-dialog'}}
span {{subtask.title}}
| ?
.js-subtask-delete-buttons
button.confirm-subtask-delete(type="button") {{_ 'delete'}}
button.toggle-delete-subtask-dialog(type="button") {{_ 'cancel'}}
template(name="addSubtaskItemForm")
textarea.js-add-subtask-item(rows='1' autofocus)
.edit-controls.clearfix
button.primary.confirm.js-submit-add-subtask-item-form(type="submit") {{_ 'save'}}
a.fa.fa-times-thin.js-close-inlined-form
template(name="editSubtaskItemForm")
textarea.js-edit-subtask-item(rows='1' autofocus)
if $eq type 'item'
= item.title
else
= subtask.title
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-subtask-item-form(type="submit") {{_ 'save'}}
a.fa.fa-times-thin.js-close-inlined-form
span(title=createdAt) {{ moment createdAt }}
if canModifyCard
a.js-delete-subtask-item {{_ "delete"}}...
template(name="subtasksItems")
.subtasks-items.js-subtasks-items
each item in subtasks.items
+inlinedForm(classNames="js-edit-subtask-item" item = item subtasks = subtasks)
+editSubtaskItemForm(type = 'item' item = item subtasks = subtasks)
else
+subtaskItemDetail(item = item subtasks = subtasks)
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-subtask-item" subtasks = subtasks)
+addSubtaskItemForm
else
a.add-subtask-item.js-open-inlined-form
i.fa.fa-plus
| {{_ 'add-subtask-item'}}...
template(name='subtaskItemDetail')
.js-subtasks-item.subtasks-item
if canModifyCard
.check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
else
.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title

View file

@ -0,0 +1,145 @@
BlazeComponent.extendComponent({
canModifyCard() {
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
},
}).register('subtaskDetail');
BlazeComponent.extendComponent({
addSubtask(event) {
event.preventDefault();
const textarea = this.find('textarea.js-add-subtask-item');
const title = textarea.value.trim();
const cardId = this.currentData().cardId;
const card = Cards.findOne(cardId);
const sortIndex = -1;
const crtBoard = Boards.findOne(card.boardId);
const targetBoard = crtBoard.getDefaultSubtasksBoard();
const listId = targetBoard.getDefaultSubtasksListId();
const swimlaneId = targetBoard.getDefaultSwimline()._id;
if (title) {
const _id = Cards.insert({
title,
parentId: cardId,
members: [],
labelIds: [],
customFields: [],
listId,
boardId: targetBoard._id,
sort: sortIndex,
swimlaneId,
});
// In case the filter is active we need to add the newly inserted card in
// the list of exceptions -- cards that are not filtered. Otherwise the
// card will disappear instantly.
// See https://github.com/wekan/wekan/issues/80
Filter.addException(_id);
setTimeout(() => {
this.$('.add-subtask-item').last().click();
}, 100);
}
textarea.value = '';
textarea.focus();
},
canModifyCard() {
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
},
deleteSubtask() {
const subtask = this.currentData().subtask;
if (subtask && subtask._id) {
subtask.archive();
this.toggleDeleteDialog.set(false);
}
},
editSubtask(event) {
event.preventDefault();
const textarea = this.find('textarea.js-edit-subtask-item');
const title = textarea.value.trim();
const subtask = this.currentData().subtask;
subtask.setTitle(title);
},
onCreated() {
this.toggleDeleteDialog = new ReactiveVar(false);
this.subtaskToDelete = null; //Store data context to pass to subtaskDeleteDialog template
},
pressKey(event) {
//If user press enter key inside a form, submit it
//Unless the user is also holding down the 'shift' key
if (event.keyCode === 13 && !event.shiftKey) {
event.preventDefault();
const $form = $(event.currentTarget).closest('form');
$form.find('button[type=submit]').click();
}
},
events() {
const events = {
'click .toggle-delete-subtask-dialog'(event) {
if($(event.target).hasClass('js-delete-subtask')){
this.subtaskToDelete = this.currentData().subtask; //Store data context
}
this.toggleDeleteDialog.set(!this.toggleDeleteDialog.get());
},
'click .js-view-subtask'(event) {
if($(event.target).hasClass('js-view-subtask')){
const subtask = this.currentData().subtask;
const board = subtask.board();
FlowRouter.go('card', {
boardId: board._id,
slug: board.slug,
cardId: subtask._id,
});
}
},
};
return [{
...events,
'submit .js-add-subtask': this.addSubtask,
'submit .js-edit-subtask-title': this.editSubtask,
'click .confirm-subtask-delete': this.deleteSubtask,
keydown: this.pressKey,
}];
},
}).register('subtasks');
Template.subtaskDeleteDialog.onCreated(() => {
const $cardDetails = this.$('.card-details');
this.scrollState = { position: $cardDetails.scrollTop(), //save current scroll position
top: false, //required for smooth scroll animation
};
//Callback's purpose is to only prevent scrolling after animation is complete
$cardDetails.animate({ scrollTop: 0 }, 500, () => { this.scrollState.top = true; });
//Prevent scrolling while dialog is open
$cardDetails.on('scroll', () => {
if(this.scrollState.top) { //If it's already in position, keep it there. Otherwise let animation scroll
$cardDetails.scrollTop(0);
}
});
});
Template.subtaskDeleteDialog.onDestroyed(() => {
const $cardDetails = this.$('.card-details');
$cardDetails.off('scroll'); //Reactivate scrolling
$cardDetails.animate( { scrollTop: this.scrollState.position });
});
Template.subtaskItemDetail.helpers({
canModifyCard() {
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
},
});
BlazeComponent.extendComponent({
// ...
}).register('subtaskItemDetail');

View file

@ -0,0 +1,142 @@
.js-add-subtask
color: #8c8c8c
textarea.js-add-subtask-item, textarea.js-edit-subtask-item
overflow: hidden
word-wrap: break-word
resize: none
height: 34px
.delete-text
color: #8c8c8c
text-decoration: underline
word-wrap: break-word
float: right
padding-top: 6px
&:hover
color: inherit
.subtask-title
.checkbox
float: left
width: 30px
height 30px
font-size: 18px
line-height: 30px
.title
font-size: 18px
line-height: 25px
.subtasks-stat
margin: 0 0.5em
float: right
padding-top: 6px
&.is-finished
color: #3cb500
.js-delete-subtask
@extends .delete-text
margin: 0 0.5em
.js-view-subtask
@extends .delete-text
.js-confirm-subtask-delete
background-color: darken(white, 3%)
position: absolute
float: left;
width: 60%
margin-top: 0
margin-left: 13%
padding-bottom: 2%
padding-left: 3%
padding-right: 3%
z-index: 17
border-radius: 3px
p
position: relative
margin-top: 3%
width: 100%
text-align: center
span
font-weight: bold
i
font-size: 2em
.js-subtask-delete-buttons
position: relative
padding: left 2% right 2%
.confirm-subtask-delete
margin-left: 12%
float: left
.toggle-delete-subtask-dialog
margin-right: 12%
float: right
#card-details-overlay
top: 0
bottom: -600px
right: 0
.subtasks
background: darken(white, 3%)
&.placeholder
background: darken(white, 20%)
border-radius: 2px
&.ui-sortable-helper
box-shadow: -2px 2px 8px rgba(0, 0, 0, .3),
0 0 1px rgba(0, 0, 0, .5)
transform: rotate(4deg)
cursor: grabbing
.subtasks-item
margin: 0 0 0 0.1em
line-height: 18px
font-size: 1.1em
margin-top: 3px
display: flex
background: darken(white, 3%)
&.placeholder
background: darken(white, 20%)
border-radius: 2px
&.ui-sortable-helper
box-shadow: -2px 2px 8px rgba(0, 0, 0, .3),
0 0 1px rgba(0, 0, 0, .5)
transform: rotate(4deg)
cursor: grabbing
&:hover
background-color: darken(white, 8%)
.check-box
margin: 0.1em 0 0 0;
&.is-checked
border-bottom: 2px solid #3cb500
border-right: 2px solid #3cb500
.item-title
flex: 1
padding-left: 10px;
&.is-checked
color: #8c8c8c
font-style: italic
& .viewer
p
margin-bottom: 2px
.js-delete-subtask-item
margin: 0 0 0.5em 1.33em
@extends .delete-text
padding: 12px 0 0 0
.add-subtask-item
margin: 0.2em 0 0.5em 1.33em
display: inline-block

View file

@ -36,17 +36,14 @@ BlazeComponent.extendComponent({
const members = formComponent.members.get();
const labelIds = formComponent.labels.get();
const customFields = formComponent.customFields.get();
//console.log('members', members);
//console.log('labelIds', labelIds);
//console.log('customFields', customFields);
const boardId = this.data().board()._id;
const boardId = this.data().board();
let swimlaneId = '';
const boardView = Meteor.user().profile.boardView;
if (boardView === 'board-view-swimlanes')
swimlaneId = this.parentComponent().parentComponent().data()._id;
else if ((boardView === 'board-view-lists') || (boardView === 'board-view-cal'))
swimlaneId = Swimlanes.findOne({boardId})._id;
swimlaneId = boardId.getDefaultSwimline()._id;
if (title) {
const _id = Cards.insert({
@ -55,7 +52,7 @@ BlazeComponent.extendComponent({
labelIds,
customFields,
listId: this.data()._id,
boardId: this.data().board()._id,
boardId: boardId._id,
sort: sortIndex,
swimlaneId,
});

View file

@ -2,6 +2,7 @@
"accept": "Accept",
"act-activity-notify": "[Wekan] Activity Notification",
"act-addAttachment": "attached __attachment__ to __card__",
"act-addSubtask": "added subtask __checklist__ to __card__",
"act-addChecklist": "added checklist __checklist__ to __card__",
"act-addChecklistItem": "added __checklistItem__ to checklist __checklist__ on __card__",
"act-addComment": "commented on __card__: __comment__",
@ -41,6 +42,7 @@
"activity-removed": "removed %s from %s",
"activity-sent": "sent %s to %s",
"activity-unjoined": "unjoined %s",
"activity-subtask-added": "added subtask to %s",
"activity-checklist-added": "added checklist to %s",
"activity-checklist-item-added": "added checklist item to '%s' in %s",
"add": "Add",
@ -48,6 +50,7 @@
"add-board": "Add Board",
"add-card": "Add Card",
"add-swimlane": "Add Swimlane",
"add-subtask": "Add Subtask",
"add-checklist": "Add Checklist",
"add-checklist-item": "Add an item to checklist",
"add-cover": "Add Cover",
@ -141,6 +144,7 @@
"changePasswordPopup-title": "Change Password",
"changePermissionsPopup-title": "Change Permissions",
"changeSettingsPopup-title": "Change Settings",
"subtasks": "Subtasks",
"checklists": "Checklists",
"click-to-star": "Click to star this board.",
"click-to-unstar": "Click to unstar this board.",
@ -163,6 +167,7 @@
"comment-only": "Comment only",
"comment-only-desc": "Can comment on cards only.",
"computer": "Computer",
"confirm-subtask-delete-dialog": "Are you sure you want to delete subtask",
"confirm-checklist-delete-dialog": "Are you sure you want to delete checklist",
"copy-card-link-to-clipboard": "Copy card link to clipboard",
"copyCardPopup-title": "Copy Card",
@ -475,5 +480,23 @@
"board-delete-notice": "Deleting is permanent. You will lose all lists, cards and actions associated with this board.",
"delete-board-confirm-popup": "All lists, cards, labels, and activities will be deleted and you won't be able to recover the board contents. There is no undo.",
"boardDeletePopup-title": "Delete Board?",
"delete-board": "Delete Board"
"delete-board": "Delete Board",
"default-subtasks-board": "Subtasks for __board__ board",
"default": "Default",
"queue": "Queue",
"subtask-settings": "Subtasks Settings",
"boardSubtaskSettingsPopup-title": "Board Subtasks Settings",
"show-subtasks-field": "Cards can have subtasks",
"deposit-subtasks-board": "Deposit subtasks to this board:",
"deposit-subtasks-list": "Landing list for subtasks deposited here:",
"show-parent-in-minicard": "Show parent in minicard:",
"prefix-with-full-path": "Prefix with full path",
"prefix-with-parent": "Prefix with parent",
"subtext-with-full-path": "Subtext with full path",
"subtext-with-parent": "Subtext with parent",
"change-card-parent": "Change card's parent",
"parent-card": "Parent card",
"source-board": "Source board",
"no-parent": "Don't show parent"
}

View file

@ -44,6 +44,9 @@ Activities.helpers({
checklistItem() {
return ChecklistItems.findOne(this.checklistItemId);
},
subtasks() {
return Cards.findOne(this.subtaskId);
},
customField() {
return CustomFields.findOne(this.customFieldId);
},

View file

@ -151,6 +151,32 @@ Boards.attachSchema(new SimpleSchema({
type: String,
optional: true,
},
subtasksDefaultBoardId: {
type: String,
optional: true,
defaultValue: null,
},
subtasksDefaultListId: {
type: String,
optional: true,
defaultValue: null,
},
allowsSubtasks: {
type: Boolean,
defaultValue: true,
},
presentParentTask: {
type: String,
allowedValues: [
'prefix-with-full-path',
'prefix-with-parent',
'subtext-with-full-path',
'subtext-with-parent',
'no-parent',
],
optional: true,
defaultValue: 'no-parent',
},
}));
@ -194,6 +220,10 @@ Boards.helpers({
return Swimlanes.find({ boardId: this._id, archived: false }, { sort: { sort: 1 } });
},
cards() {
return Cards.find({ boardId: this._id, archived: false }, { sort: { sort: 1 } });
},
hasOvertimeCards(){
const card = Cards.findOne({isOvertime: true, boardId: this._id, archived: false} );
return card !== undefined;
@ -284,6 +314,61 @@ Boards.helpers({
return Cards.find(query, projection);
},
// A board alwasy has another board where it deposits subtasks of thasks
// that belong to itself.
getDefaultSubtasksBoardId() {
if ((this.subtasksDefaultBoardId === null) || (this.subtasksDefaultBoardId === undefined)) {
this.subtasksDefaultBoardId = Boards.insert({
title: `^${this.title}^`,
permission: this.permission,
members: this.members,
color: this.color,
description: TAPi18n.__('default-subtasks-board', {board: this.title}),
});
Swimlanes.insert({
title: TAPi18n.__('default'),
boardId: this.subtasksDefaultBoardId,
});
Boards.update(this._id, {$set: {
subtasksDefaultBoardId: this.subtasksDefaultBoardId,
}});
}
return this.subtasksDefaultBoardId;
},
getDefaultSubtasksBoard() {
return Boards.findOne(this.getDefaultSubtasksBoardId());
},
getDefaultSubtasksListId() {
if ((this.subtasksDefaultListId === null) || (this.subtasksDefaultListId === undefined)) {
this.subtasksDefaultListId = Lists.insert({
title: TAPi18n.__('queue'),
boardId: this._id,
});
Boards.update(this._id, {$set: {
subtasksDefaultListId: this.subtasksDefaultListId,
}});
}
return this.subtasksDefaultListId;
},
getDefaultSubtasksList() {
return Lists.findOne(this.getDefaultSubtasksListId());
},
getDefaultSwimline() {
let result = Swimlanes.findOne({boardId: this._id});
if (result === undefined) {
Swimlanes.insert({
title: TAPi18n.__('default'),
boardId: this._id,
});
result = Swimlanes.findOne({boardId: this._id});
}
return result;
},
cardsInInterval(start, end) {
return Cards.find({
@ -313,6 +398,7 @@ Boards.helpers({
});
Boards.mutations({
archive() {
return { $set: { archived: true } };
@ -434,6 +520,22 @@ Boards.mutations({
},
};
},
setAllowsSubtasks(allowsSubtasks) {
return { $set: { allowsSubtasks } };
},
setSubtasksDefaultBoardId(subtasksDefaultBoardId) {
return { $set: { subtasksDefaultBoardId } };
},
setSubtasksDefaultListId(subtasksDefaultListId) {
return { $set: { subtasksDefaultListId } };
},
setPresentParentTask(presentParentTask) {
return { $set: { presentParentTask } };
},
});
if (Meteor.isServer) {

View file

@ -15,6 +15,11 @@ Cards.attachSchema(new SimpleSchema({
}
},
},
parentId: {
type: String,
optional: true,
defaultValue: '',
},
listId: {
type: String,
},
@ -122,6 +127,12 @@ Cards.attachSchema(new SimpleSchema({
type: Number,
decimal: true,
},
subtaskSort: {
type: Number,
decimal: true,
defaultValue: -1,
optional: true,
},
}));
Cards.allow({
@ -215,6 +226,42 @@ Cards.helpers({
return this.checklistItemCount() !== 0;
},
subtasks() {
return Cards.find({
parentId: this._id,
archived: false,
}, {sort: { sort: 1 } });
},
allSubtasks() {
return Cards.find({
parentId: this._id,
archived: false,
}, {sort: { sort: 1 } });
},
subtasksCount() {
return Cards.find({
parentId: this._id,
archived: false,
}).count();
},
subtasksFinishedCount() {
return Cards.find({
parentId: this._id,
archived: true}).count();
},
subtasksFinished() {
const finishCount = this.subtasksFinishedCount();
return finishCount > 0 && this.subtasksCount() === finishCount;
},
allowsSubtasks() {
return this.subtasksCount() !== 0;
},
customFieldIndex(customFieldId) {
return _.pluck(this.customFields, '_id').indexOf(customFieldId);
},
@ -271,14 +318,90 @@ Cards.helpers({
}
return true;
},
parentCard() {
if (this.parentId === '') {
return null;
}
return Cards.findOne(this.parentId);
},
parentCardName() {
let result = '';
if (this.parentId !== '') {
const card = Cards.findOne(this.parentId);
if (card) {
result = card.title;
}
}
return result;
},
parentListId() {
const result = [];
let crtParentId = this.parentId;
while (crtParentId !== '') {
const crt = Cards.findOne(crtParentId);
if ((crt === null) || (crt === undefined)) {
// maybe it has been deleted
break;
}
if (crtParentId in result) {
// circular reference
break;
}
result.unshift(crtParentId);
crtParentId = crt.parentId;
}
return result;
},
parentList() {
const resultId = [];
const result = [];
let crtParentId = this.parentId;
while (crtParentId !== '') {
const crt = Cards.findOne(crtParentId);
if ((crt === null) || (crt === undefined)) {
// maybe it has been deleted
break;
}
if (crtParentId in resultId) {
// circular reference
break;
}
resultId.unshift(crtParentId);
result.unshift(crt);
crtParentId = crt.parentId;
}
return result;
},
parentString(sep) {
return this.parentList().map(function(elem){
return elem.title;
}).join(sep);
},
isTopLevel() {
return this.parentId === '';
},
});
Cards.mutations({
applyToChildren(funct) {
Cards.find({ parentId: this._id }).forEach((card) => {
funct(card);
});
},
archive() {
this.applyToChildren((card) => { return card.archive(); });
return {$set: {archived: true}};
},
restore() {
this.applyToChildren((card) => { return card.restore(); });
return {$set: {archived: false}};
},
@ -422,6 +545,11 @@ Cards.mutations({
unsetSpentTime() {
return {$unset: {spentTime: '', isOvertime: false}};
},
setParentId(parentId) {
return {$set: {parentId}};
},
});
@ -513,6 +641,9 @@ function cardRemover(userId, doc) {
Checklists.remove({
cardId: doc._id,
});
Subtasks.remove({
cardId: doc._id,
});
CardComments.remove({
cardId: doc._id,
});

View file

@ -58,9 +58,11 @@ class Exporter {
result.activities = Activities.find(byBoard, noBoardId).fetch();
result.checklists = [];
result.checklistItems = [];
result.subtaskItems = [];
result.cards.forEach((card) => {
result.checklists.push(...Checklists.find({ cardId: card._id }).fetch());
result.checklistItems.push(...ChecklistItems.find({ cardId: card._id }).fetch());
result.subtaskItems.push(...Cards.find({ parentid: card._id }).fetch());
});
// [Old] for attachments we only export IDs and absolute url to original doc

View file

@ -55,7 +55,7 @@ Migrations.add('lowercase-board-permission', () => {
// Security migration: see https://github.com/wekan/wekan/issues/99
Migrations.add('change-attachments-type-for-non-images', () => {
const newTypeForNonImage = 'application/octet-stream';
Attachments.find().forEach((file) => {
Attachments.forEach((file) => {
if (!file.isImage()) {
Attachments.update(file._id, {
$set: {
@ -68,7 +68,7 @@ Migrations.add('change-attachments-type-for-non-images', () => {
});
Migrations.add('card-covers', () => {
Cards.find().forEach((card) => {
Cards.forEach((card) => {
const cover = Attachments.findOne({ cardId: card._id, cover: true });
if (cover) {
Cards.update(card._id, {$set: {coverId: cover._id}}, noValidate);
@ -86,7 +86,7 @@ Migrations.add('use-css-class-for-boards-colors', () => {
'#2C3E50': 'midnight',
'#E67E22': 'pumpkin',
};
Boards.find().forEach((board) => {
Boards.forEach((board) => {
const oldBoardColor = board.background.color;
const newBoardColor = associationTable[oldBoardColor];
Boards.update(board._id, {
@ -97,7 +97,7 @@ Migrations.add('use-css-class-for-boards-colors', () => {
});
Migrations.add('denormalize-star-number-per-board', () => {
Boards.find().forEach((board) => {
Boards.forEach((board) => {
const nStars = Users.find({'profile.starredBoards': board._id}).count();
Boards.update(board._id, {$set: {stars: nStars}}, noValidate);
});
@ -132,7 +132,7 @@ Migrations.add('add-member-isactive-field', () => {
});
Migrations.add('add-sort-checklists', () => {
Checklists.find().forEach((checklist, index) => {
Checklists.forEach((checklist, index) => {
if (!checklist.hasOwnProperty('sort')) {
Checklists.direct.update(
checklist._id,
@ -153,17 +153,8 @@ Migrations.add('add-sort-checklists', () => {
});
Migrations.add('add-swimlanes', () => {
Boards.find().forEach((board) => {
const swimlane = Swimlanes.findOne({ boardId: board._id });
let swimlaneId = '';
if (swimlane)
swimlaneId = swimlane._id;
else
swimlaneId = Swimlanes.direct.insert({
boardId: board._id,
title: 'Default',
});
Boards.forEach((board) => {
const swimlaneId = board.getDefaultSwimline()._id;
Cards.find({ boardId: board._id }).forEach((card) => {
if (!card.hasOwnProperty('swimlaneId')) {
Cards.direct.update(
@ -177,7 +168,7 @@ Migrations.add('add-swimlanes', () => {
});
Migrations.add('add-views', () => {
Boards.find().forEach((board) => {
Boards.forEach((board) => {
if (!board.hasOwnProperty('view')) {
Boards.direct.update(
{ _id: board._id },
@ -189,7 +180,7 @@ Migrations.add('add-views', () => {
});
Migrations.add('add-checklist-items', () => {
Checklists.find().forEach((checklist) => {
Checklists.forEach((checklist) => {
// Create new items
_.sortBy(checklist.items, 'sort').forEach((item, index) => {
ChecklistItems.direct.insert({
@ -210,7 +201,7 @@ Migrations.add('add-checklist-items', () => {
});
Migrations.add('add-profile-view', () => {
Users.find().forEach((user) => {
Users.forEach((user) => {
if (!user.hasOwnProperty('profile.boardView')) {
// Set default view
Users.direct.update(
@ -258,3 +249,64 @@ Migrations.add('add-assigner-field', () => {
}, noValidateMulti);
});
Migrations.add('add-parent-field-to-cards', () => {
Cards.update({
parentId: {
$exists: false,
},
}, {
$set: {
parentId:'',
},
}, noValidateMulti);
});
Migrations.add('add-subtasks-boards', () => {
Boards.update({
subtasksDefaultBoardId: {
$exists: false,
},
}, {
$set: {
subtasksDefaultBoardId: null,
subtasksDefaultListId: null,
},
}, noValidateMulti);
});
Migrations.add('add-subtasks-sort', () => {
Boards.update({
subtaskSort: {
$exists: false,
},
}, {
$set: {
subtaskSort: -1,
},
}, noValidateMulti);
});
Migrations.add('add-subtasks-allowed', () => {
Boards.update({
allowsSubtasks: {
$exists: false,
},
}, {
$set: {
allowsSubtasks: true,
},
}, noValidateMulti);
});
Migrations.add('add-subtasks-allowed', () => {
Boards.update({
presentParentTask: {
$exists: false,
},
}, {
$set: {
presentParentTask: 'no-parent',
},
}, noValidateMulti);
});

View file

@ -103,6 +103,7 @@ Meteor.publishRelations('board', function(boardId) {
this.cursor(Attachments.find({ cardId }));
this.cursor(Checklists.find({ cardId }));
this.cursor(ChecklistItems.find({ cardId }));
this.cursor(Cards.find({ parentId: cardId }));
});
if (board.members) {