summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.json1
-rw-r--r--client/components/boards/boardHeader.jade54
-rw-r--r--client/components/boards/boardHeader.js97
-rw-r--r--client/components/boards/boardHeader.styl19
-rw-r--r--client/components/cards/cardDetails.jade37
-rw-r--r--client/components/cards/cardDetails.js258
-rw-r--r--client/components/cards/checklists.jade5
-rw-r--r--client/components/cards/checklists.js4
-rw-r--r--client/components/cards/minicard.jade15
-rw-r--r--client/components/cards/minicard.styl7
-rw-r--r--client/components/cards/subtasks.jade97
-rw-r--r--client/components/cards/subtasks.js145
-rw-r--r--client/components/cards/subtasks.styl142
-rw-r--r--client/components/lists/listBody.js9
-rw-r--r--i18n/en.i18n.json25
-rw-r--r--models/activities.js3
-rw-r--r--models/boards.js102
-rw-r--r--models/cards.js131
-rw-r--r--models/export.js2
-rw-r--r--server/migrations.js90
-rw-r--r--server/publications/boards.js1
21 files changed, 1162 insertions, 82 deletions
diff --git a/.eslintrc.json b/.eslintrc.json
index 255e00ba..1adaa623 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -134,6 +134,7 @@
"Announcements": true,
"Swimlanes": true,
"ChecklistItems": true,
+ "Subtasks": true,
"Npm": true
}
}
diff --git a/client/components/boards/boardHeader.jade b/client/components/boards/boardHeader.jade
index b4ccd3b3..a4abfac6 100644
--- a/client/components/boards/boardHeader.jade
+++ b/client/components/boards/boardHeader.jade
@@ -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
diff --git a/client/components/boards/boardHeader.js b/client/components/boards/boardHeader.js
index 222cc404..2dfd58c1 100644
--- a/client/components/boards/boardHeader.js
+++ b/client/components/boards/boardHeader.js
@@ -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';
diff --git a/client/components/boards/boardHeader.styl b/client/components/boards/boardHeader.styl
index 0abdb5bd..402b4f1e 100644
--- a/client/components/boards/boardHeader.styl
+++ b/client/components/boards/boardHeader.styl
@@ -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;
diff --git a/client/components/cards/cardDetails.jade b/client/components/cards/cardDetails.jade
index aa4829a9..aaad7c7c 100644
--- a/client/components/cards/cardDetails.jade
+++ b/client/components/cards/cardDetails.jade
@@ -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
diff --git a/client/components/cards/cardDetails.js b/client/components/cards/cardDetails.js
index 72ed678b..5fee1680 100644
--- a/client/components/cards/cardDetails.js
+++ b/client/components/cards/cardDetails.js
@@ -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'));
@@ -393,18 +470,17 @@ 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
@@ -454,18 +530,17 @@ 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({
},
});
+BlazeComponent.extendComponent({
+ onCreated() {
+ this.currentCard = this.currentData();
+ this.parentCard = this.currentCard.parentCard();
+ if (this.parentCard) {
+ this.parentBoard = this.parentCard.board();
+ } else {
+ this.parentBoard = null;
+ }
+ },
-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();
+ boards() {
+ const boards = Boards.find({
+ archived: false,
+ 'members.userId': Meteor.userId(),
+ }, {
+ sort: ['title'],
+ });
+ return boards;
+ },
+
+ cards() {
+ if (this.parentBoard) {
+ return this.parentBoard.cards();
} 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();
- }
+ return [];
}
},
- 'click .js-delete': Popup.afterConfirm('cardDelete', function () {
- Popup.close();
- Cards.remove(this._id);
- Utils.goBoardId(this.boardId);
- }),
-});
+
+ 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',
diff --git a/client/components/cards/checklists.jade b/client/components/cards/checklists.jade
index ae680bd5..e45e7ad9 100644
--- a/client/components/cards/checklists.jade
+++ b/client/components/cards/checklists.jade
@@ -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}}")
diff --git a/client/components/cards/checklists.js b/client/components/cards/checklists.js
index 1f05aded..519af629 100644
--- a/client/components/cards/checklists.js
+++ b/client/components/cards/checklists.js
@@ -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');
diff --git a/client/components/cards/minicard.jade b/client/components/cards/minicard.jade
index 2a8e95ab..57913669 100644
--- a/client/components/cards/minicard.jade
+++ b/client/components/cards/minicard.jade
@@ -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
diff --git a/client/components/cards/minicard.styl b/client/components/cards/minicard.styl
index b89805be..5624787c 100644
--- a/client/components/cards/minicard.styl
+++ b/client/components/cards/minicard.styl
@@ -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 &
diff --git a/client/components/cards/subtasks.jade b/client/components/cards/subtasks.jade
new file mode 100644
index 00000000..b0ef2f33
--- /dev/null
+++ b/client/components/cards/subtasks.jade
@@ -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
diff --git a/client/components/cards/subtasks.js b/client/components/cards/subtasks.js
new file mode 100644
index 00000000..9c6f265e
--- /dev/null
+++ b/client/components/cards/subtasks.js
@@ -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');
diff --git a/client/components/cards/subtasks.styl b/client/components/cards/subtasks.styl
new file mode 100644
index 00000000..c2f09aa1
--- /dev/null
+++ b/client/components/cards/subtasks.styl
@@ -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
diff --git a/client/components/lists/listBody.js b/client/components/lists/listBody.js
index adb2fadb..0a10f7d5 100644
--- a/client/components/lists/listBody.js
+++ b/client/components/lists/listBody.js
@@ -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,
});
diff --git a/i18n/en.i18n.json b/i18n/en.i18n.json
index 51a9b4cc..9afadd29 100644
--- a/i18n/en.i18n.json
+++ b/i18n/en.i18n.json
@@ -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"
+
}
diff --git a/models/activities.js b/models/activities.js
index f64b53f8..5b54759c 100644
--- a/models/activities.js
+++ b/models/activities.js
@@ -44,6 +44,9 @@ Activities.helpers({
checklistItem() {
return ChecklistItems.findOne(this.checklistItemId);
},
+ subtasks() {
+ return Cards.findOne(this.subtaskId);
+ },
customField() {
return CustomFields.findOne(this.customFieldId);
},
diff --git a/models/boards.js b/models/boards.js
index 3b6c280b..76a8f704 100644
--- a/models/boards.js
+++ b/models/boards.js
@@ -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) {
diff --git a/models/cards.js b/models/cards.js
index 00ec14c2..b6a7b4c6 100644
--- a/models/cards.js
+++ b/models/cards.js
@@ -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,
});
diff --git a/models/export.js b/models/export.js
index aff66801..8c4c29d4 100644
--- a/models/export.js
+++ b/models/export.js
@@ -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
diff --git a/server/migrations.js b/server/migrations.js
index a1a5c65a..10097d41 100644
--- a/server/migrations.js
+++ b/server/migrations.js
@@ -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);
+});
+
diff --git a/server/publications/boards.js b/server/publications/boards.js
index b52ac49f..5d095c17 100644
--- a/server/publications/boards.js
+++ b/server/publications/boards.js
@@ -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) {