Improve the multi-selection experience

New features:
- select all filtered cards
- assign or unassign a member to selected cards
- archive selected cards

This commit also fix the card sort indexes calculation when a multi-
selection is drag-dropped.
This commit is contained in:
Maxime Quandalle 2015-06-15 17:16:56 +02:00
parent a41e07b37e
commit 5478fc93db
12 changed files with 146 additions and 53 deletions

View file

@ -32,7 +32,8 @@ position()
&.is-dragging-active
.list-composer,
.open-minicard-composer
.open-minicard-composer,
.minicard-wrapper.is-checked
display: none
.lists

View file

@ -21,24 +21,20 @@ template(name="headerBoard")
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{/if}}"
class="{{#if Filter.isActive}}emphasis{{/if}}")
i.fa.fa-filter
span {{#if Filter.isActive}}{{_ 'filter-on'}}{{else}}{{_ 'filter'}}{{/if}}
if Filter.isActive
span {{_ 'filter-on'}}
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
else
span {{_ 'filter'}}
if currentUser.isBoardMember
a.board-header-btn.js-multiselection-activate(
title="{{#if MultiSelection.isActive}}{{_ 'filter-on-desc'}}{{/if}}"
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
span Multi-Selection {{#if MultiSelection.isActive}}is on{{/if}}
if MultiSelection.isActive
span Multi-Selection is on
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
else
span Multi-Selection
.separator
a.board-header-btn.js-open-board-menu

View file

@ -19,6 +19,7 @@ setBoardColor(color)
background-color: darken(color, 20%)
&.pop-over .pop-over-list li a:hover,
.sidebar .sidebar-content .sidebar-btn:hover,
.sidebar-list li a:hover
background-color: lighten(color, 10%)

View file

@ -27,10 +27,12 @@ BlazeComponent.extendComponent({
var title = textarea.val();
var position = Blaze.getData(evt.currentTarget).position;
var sortIndex;
var firstCard = this.find('.js-minicard:first');
var lastCard = this.find('.js-minicard:last');
if (position === 'top') {
sortIndex = Utils.getSortIndex(null, this.find('.js-minicard:first'));
sortIndex = Utils.calculateIndex(null, firstCard).base;
} else if (position === 'bottom') {
sortIndex = Utils.getSortIndex(this.find('.js-minicard:last'), null);
sortIndex = Utils.calculateIndex(lastCard, null).base;
}
if ($.trim(title)) {

View file

@ -56,22 +56,23 @@ BlazeComponent.extendComponent({
stop: function(evt, ui) {
// To attribute the new index number, we need to get the dom element
// of the previous and the following card -- if any.
var cardDomElement = ui.item.get(0);
var prevCardDomElement = ui.item.prev('.js-minicard').get(0);
var nextCardDomElement = ui.item.next('.js-minicard').get(0);
var sort = Utils.getSortIndex(prevCardDomElement, nextCardDomElement);
var prevCardDom = ui.item.prev('.js-minicard').get(0);
var nextCardDom = ui.item.next('.js-minicard').get(0);
var nCards = MultiSelection.isActive() ? MultiSelection.count() : 1;
var sortIndex = Utils.calculateIndex(prevCardDom, nextCardDom, nCards);
var listId = Blaze.getData(ui.item.parents('.list').get(0))._id;
if (MultiSelection.isActive()) {
Cards.find(MultiSelection.getMongoSelector()).forEach(function(c) {
Cards.find(MultiSelection.getMongoSelector()).forEach(function(c, i) {
Cards.update(c._id, {
$set: {
listId: listId,
sort: sort
sort: sortIndex.base + i * sortIndex.increment
}
});
});
} else {
var cardDomElement = ui.item.get(0);
var cardId = Blaze.getData(cardDomElement)._id;
Cards.update(cardId, {
$set: {
@ -79,7 +80,7 @@ BlazeComponent.extendComponent({
// XXX Using the same sort index for multiple cards is
// unacceptable. Keep that only until we figure out if we want to
// refactor the whole sorting mecanism or do something more basic.
sort: sort
sort: sortIndex.base
}
});
}

View file

@ -51,6 +51,19 @@
.fa.fa-check
margin: 0 4px
.sidebar-btn
display: block
margin: 5px 0
padding: 10px
border-radius: 3px
background: darken(white, 10%)
&:hover *
color: white
i.fa
margin-right: 10px
.board-sidebar
width: 248px
right: -@width

View file

@ -1,7 +1,7 @@
//-
XXX There is a *lot* of code duplication in the above templates and in the
XXX There is a *lot* of code duplication in the below templates and in the
corresponding JavaScript components. We will probably need the upcoming #let
and #each x in y constructors.
and #each x in y constructors to fix this.
template(name="filterSidebar")
ul.sidebar-list
@ -16,22 +16,27 @@ template(name="filterSidebar")
span.quiet {{_ "label-default" color}}
if Filter.labelIds.isSelected _id
i.fa.fa-check
hr
ul.sidebar-list
each currentBoard.members
if isActive
with getUser userId
li(class="{{#if Filter.members.isSelected _id}}active{{/if}}")
a.name.js-toogle-member-filter
+userAvatar(userId=this._id)
span.sidebar-list-item-description
= profile.name
| (<span class="username">{{ username }}</span>)
if Filter.members.isSelected _id
i.fa.fa-check
if Filter.isActive
hr
ul.sidebar-list
each currentBoard.members
if isActive
with getUser userId
li(class="{{#if Filter.members.isSelected _id}}active{{/if}}")
a.name.js-toogle-member-filter
+userAvatar(userId=this._id)
span.sidebar-list-item-description
= profile.name
| (<span class="username">{{ username }}</span>)
if Filter.members.isSelected _id
i.fa.fa-check
hr
a.js-clear-all(class="{{#unless Filter.isActive}}disabled{{/unless}}")
| {{_ 'filter-clear'}}
a.sidebar-btn.js-clear-all
i.fa.fa-filter
span {{_ 'filter-clear'}}
a.sidebar-btn.js-filter-to-selection
i.fa.fa-check-square-o
span Filter to selection
template(name="multiselectionSidebar")
ul.sidebar-list
@ -48,10 +53,32 @@ template(name="multiselectionSidebar")
i.fa.fa-check
else if someSelectedElementHave 'label' _id
i.fa.fa-ellipsis-h
//-
XXX We should be able to assign a member to the list of selected cards.
hr
ul.sidebar-list
each currentBoard.members
if isActive
with getUser userId
li(class="{{#if Filter.members.isSelected _id}}active{{/if}}")
a.name.js-toogle-member-multiselection
+userAvatar(userId=this._id)
span.sidebar-list-item-description
= profile.name
| (<span class="username">{{ username }}</span>)
if allSelectedElementHave 'member' _id
i.fa.fa-check
else if someSelectedElementHave 'member' _id
i.fa.fa-ellipsis-h
hr
a.sidebar-btn.js-archive-selection
i.fa.fa-archive
span Archive selection
template(name="disambiguateMultiLabelPopup")
p What do you want to do?
button.wide.js-remove-label Remove the label
button.wide.js-add-label Add the label
template(name="disambiguateMultiMemberPopup")
p What do you want to do?
button.wide.js-unassign-member Unassign member
button.wide.js-assign-member Assign member

View file

@ -5,19 +5,26 @@ BlazeComponent.extendComponent({
events: function() {
return [{
'click .js-toggle-label-filter': function(event) {
'click .js-toggle-label-filter': function(evt) {
evt.preventDefault();
Filter.labelIds.toogle(this.currentData()._id);
Filter.resetExceptions();
event.preventDefault();
},
'click .js-toogle-member-filter': function(event) {
'click .js-toogle-member-filter': function(evt) {
evt.preventDefault();
Filter.members.toogle(this.currentData()._id);
Filter.resetExceptions();
event.preventDefault();
},
'click .js-clear-all': function(event) {
'click .js-clear-all': function(evt) {
evt.preventDefault();
Filter.reset();
event.preventDefault();
},
'click .js-filter-to-selection': function(evt) {
evt.preventDefault();
var selectedCards = Cards.find(Filter.mongoSelector()).map(function(c) {
return c._id;
});
MultiSelection.add(selectedCards);
}
}];
}
@ -57,7 +64,7 @@ BlazeComponent.extendComponent({
events: function() {
return [{
'click .js-toggle-label-multiselection': function(evt, tpl) {
'click .js-toggle-label-multiselection': function(evt) {
var labelId = this.currentData()._id;
var mappedSelection = this.mapSelection('label', labelId);
var operation;
@ -69,7 +76,7 @@ BlazeComponent.extendComponent({
var popup = Popup.open('disambiguateMultiLabel');
// XXX We need to have a better integration between the popup and the
// UI components systems.
return popup.call(this.currentData(), evt, tpl);
return popup.call(this.currentData(), evt);
}
var query = {};
@ -77,6 +84,30 @@ BlazeComponent.extendComponent({
labelIds: labelId
};
updateSelectedCards(query);
},
'click .js-toogle-member-multiselection': function(evt) {
var memberId = this.currentData()._id;
var mappedSelection = this.mapSelection('member', memberId);
var operation;
if (_.every(mappedSelection))
operation = '$pull';
else if (_.every(mappedSelection, function(bool) { return ! bool; }))
operation = '$addToSet';
else {
var popup = Popup.open('disambiguateMultiMember');
// XXX We need to have a better integration between the popup and the
// UI components systems.
return popup.call(this.currentData(), evt);
}
var query = {};
query[operation] = {
members: memberId
};
updateSelectedCards(query);
},
'click .js-archive-selection': function() {
updateSelectedCards({$set: {archived: true}});
}
}];
}
@ -92,3 +123,14 @@ Template.disambiguateMultiLabelPopup.events({
Popup.close();
}
});
Template.disambiguateMultiMemberPopup.events({
'click .js-unassign-member': function() {
updateSelectedCards({$pull: {members: this._id}});
Popup.close();
},
'click .js-assign-member': function() {
updateSelectedCards({$addToSet: {members: this._id}});
Popup.close();
}
});

View file

@ -153,6 +153,6 @@ Mousetrap.bindGlobal('esc', function() {
$(document).on('click', function(evt) {
if (evt.which === 1 &&
$(evt.target).closest('a,button,.is-editable').length === 0) {
EscapeActions.clickExecute(evt, 'detailsPane');
EscapeActions.clickExecute(evt, 'multiselection');
}
});

View file

@ -72,17 +72,21 @@ MultiSelection = {
return this._isActive.get();
},
count: function() {
return Cards.find(this.getMongoSelector()).count();
},
isEmpty: function() {
return this._selectedCards.get().length === 0;
return this.count() === 0;
},
activate: function() {
if (! this.isActive()) {
EscapeActions.executeUpTo('detailsPane');
this._isActive.set(true);
Sidebar.setView(this.sidebarView);
Tracker.flush();
}
Sidebar.setView(this.sidebarView);
},
disable: function() {
@ -152,5 +156,7 @@ Blaze.registerHelper('MultiSelection', MultiSelection);
EscapeActions.register('multiselection',
function() { MultiSelection.disable(); },
function() { return MultiSelection.isActive(); }
function() { return MultiSelection.isActive(); }, {
noClickEscapeOn: '.js-minicard,.js-board-sidebar-content'
}
);

View file

@ -37,23 +37,26 @@ Utils = {
},
// Determine the new sort index
getSortIndex: function(prevCardDomElement, nextCardDomElement) {
calculateIndex: function(prevCardDomElement, nextCardDomElement, nCards) {
nCards = nCards || 1;
// If we drop the card to an empty column
if (! prevCardDomElement && ! nextCardDomElement) {
return 0;
return {base: 0, increment: 1};
// If we drop the card in the first position
} else if (! prevCardDomElement) {
return Blaze.getData(nextCardDomElement).sort - 1;
return {base: Blaze.getData(nextCardDomElement).sort - 1, increment: -1};
// If we drop the card in the last position
} else if (! nextCardDomElement) {
return Blaze.getData(prevCardDomElement).sort + 1;
return {base: Blaze.getData(prevCardDomElement).sort + 1, increment: 1};
}
// In the general case take the average of the previous and next element
// sort indexes.
else {
var prevSortIndex = Blaze.getData(prevCardDomElement).sort;
var nextSortIndex = Blaze.getData(nextCardDomElement).sort;
return (prevSortIndex + nextSortIndex) / 2;
var increment = (nextSortIndex - prevSortIndex) / (nCards + 1);
return {base: prevSortIndex + increment, increment: increment};
}
}
};

View file

@ -190,5 +190,6 @@
"changeAvatarPopup-title": "Change Avatar",
"changePasswordPopup-title": "Change Password",
"cardDetailsActionsPopup-title": "Card Actions",
"disambiguateMultiLabelPopup-title": "Disambiguate Label Action"
"disambiguateMultiLabelPopup-title": "Disambiguate Label Action",
"disambiguateMultiMemberPopup-title": "Disambiguate Member Action"
}