Make drag-and-drop on workboards interact with priority column headers

Summary:
Ref T10333. Ref T8135. Depends on D20247. Allow users to drag-and-drop cards on a priority-sorted workboard under headers, even if the header has no other cards.

As of D20247, headers show up but they aren't really interactive. Now, you can drag cards directly underneath a header (instead of only between other cards). For example, if a column has only one "Wishlist" task, you may drag it under the "High", "Normal", or "Low" priority headers to select a specific priority.

(Some of this code still feels a little rough, but I think it will generalize once other types of sorting are available.)

Test Plan: Dragged cards within and between priority groups, saw appropriate priority edits applied in every case I could come up with.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T10333, T8135

Differential Revision: https://secure.phabricator.com/D20248
This commit is contained in:
epriestley
2019-03-05 07:38:35 -08:00
parent 14a433c773
commit 40af472ff5
11 changed files with 208 additions and 79 deletions

View File

@@ -409,13 +409,13 @@ return array(
'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f',
'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9',
'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172',
'rsrc/js/application/projects/WorkboardBoard.js' => 'e4e2d107', 'rsrc/js/application/projects/WorkboardBoard.js' => 'a4f1e85d',
'rsrc/js/application/projects/WorkboardCard.js' => 'c23ddfde', 'rsrc/js/application/projects/WorkboardCard.js' => '887ef74f',
'rsrc/js/application/projects/WorkboardColumn.js' => 'fd9cb972', 'rsrc/js/application/projects/WorkboardColumn.js' => 'ca444dca',
'rsrc/js/application/projects/WorkboardController.js' => '42c7a5a7', 'rsrc/js/application/projects/WorkboardController.js' => '42c7a5a7',
'rsrc/js/application/projects/WorkboardHeader.js' => '354c5c0e', 'rsrc/js/application/projects/WorkboardHeader.js' => '6e75daea',
'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => '9b86cd0d', 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => '2d641f7d',
'rsrc/js/application/projects/behavior-project-boards.js' => 'a3f6b67f', 'rsrc/js/application/projects/behavior-project-boards.js' => 'e2730b90',
'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422',
'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9',
'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68',
@@ -657,7 +657,7 @@ return array(
'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-phuix-example' => 'c2c500a7',
'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-control' => '0eaa33a9',
'javelin-behavior-policy-rule-editor' => '9347f172', 'javelin-behavior-policy-rule-editor' => '9347f172',
'javelin-behavior-project-boards' => 'a3f6b67f', 'javelin-behavior-project-boards' => 'e2730b90',
'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-project-create' => '34c53422',
'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06',
'javelin-behavior-read-only-warning' => 'b9109f8f', 'javelin-behavior-read-only-warning' => 'b9109f8f',
@@ -729,12 +729,12 @@ return array(
'javelin-view-renderer' => '9aae2b66', 'javelin-view-renderer' => '9aae2b66',
'javelin-view-visitor' => '308f9fe4', 'javelin-view-visitor' => '308f9fe4',
'javelin-websocket' => 'fdc13e4e', 'javelin-websocket' => 'fdc13e4e',
'javelin-workboard-board' => 'e4e2d107', 'javelin-workboard-board' => 'a4f1e85d',
'javelin-workboard-card' => 'c23ddfde', 'javelin-workboard-card' => '887ef74f',
'javelin-workboard-column' => 'fd9cb972', 'javelin-workboard-column' => 'ca444dca',
'javelin-workboard-controller' => '42c7a5a7', 'javelin-workboard-controller' => '42c7a5a7',
'javelin-workboard-header' => '354c5c0e', 'javelin-workboard-header' => '6e75daea',
'javelin-workboard-header-template' => '9b86cd0d', 'javelin-workboard-header-template' => '2d641f7d',
'javelin-workflow' => '958e9045', 'javelin-workflow' => '958e9045',
'maniphest-report-css' => '3d53188b', 'maniphest-report-css' => '3d53188b',
'maniphest-task-edit-css' => '272daa84', 'maniphest-task-edit-css' => '272daa84',
@@ -1125,6 +1125,9 @@ return array(
'javelin-dom', 'javelin-dom',
'phabricator-keyboard-shortcut', 'phabricator-keyboard-shortcut',
), ),
'2d641f7d' => array(
'javelin-install',
),
'2e255291' => array( '2e255291' => array(
'javelin-install', 'javelin-install',
'javelin-util', 'javelin-util',
@@ -1163,9 +1166,6 @@ return array(
'javelin-stratcom', 'javelin-stratcom',
'javelin-workflow', 'javelin-workflow',
), ),
'354c5c0e' => array(
'javelin-install',
),
'37b8a04a' => array( '37b8a04a' => array(
'javelin-install', 'javelin-install',
'javelin-util', 'javelin-util',
@@ -1458,6 +1458,9 @@ return array(
'javelin-install', 'javelin-install',
'javelin-util', 'javelin-util',
), ),
'6e75daea' => array(
'javelin-install',
),
70245195 => array( 70245195 => array(
'javelin-behavior', 'javelin-behavior',
'javelin-stratcom', 'javelin-stratcom',
@@ -1566,6 +1569,9 @@ return array(
'javelin-install', 'javelin-install',
'javelin-dom', 'javelin-dom',
), ),
'887ef74f' => array(
'javelin-install',
),
'89a1ae3a' => array( '89a1ae3a' => array(
'javelin-dom', 'javelin-dom',
'javelin-util', 'javelin-util',
@@ -1701,9 +1707,6 @@ return array(
'javelin-dom', 'javelin-dom',
'javelin-stratcom', 'javelin-stratcom',
), ),
'9b86cd0d' => array(
'javelin-install',
),
'9cec214e' => array( '9cec214e' => array(
'javelin-behavior', 'javelin-behavior',
'javelin-stratcom', 'javelin-stratcom',
@@ -1728,15 +1731,6 @@ return array(
'a241536a' => array( 'a241536a' => array(
'javelin-install', 'javelin-install',
), ),
'a3f6b67f' => array(
'javelin-behavior',
'javelin-dom',
'javelin-util',
'javelin-vector',
'javelin-stratcom',
'javelin-workflow',
'javelin-workboard-controller',
),
'a4356cde' => array( 'a4356cde' => array(
'javelin-install', 'javelin-install',
'javelin-dom', 'javelin-dom',
@@ -1762,6 +1756,16 @@ return array(
'javelin-request', 'javelin-request',
'javelin-util', 'javelin-util',
), ),
'a4f1e85d' => array(
'javelin-install',
'javelin-dom',
'javelin-util',
'javelin-stratcom',
'javelin-workflow',
'phabricator-draggable-list',
'javelin-workboard-column',
'javelin-workboard-header-template',
),
'a5257c4e' => array( 'a5257c4e' => array(
'javelin-install', 'javelin-install',
'javelin-dom', 'javelin-dom',
@@ -1906,9 +1910,6 @@ return array(
'javelin-stratcom', 'javelin-stratcom',
'javelin-uri', 'javelin-uri',
), ),
'c23ddfde' => array(
'javelin-install',
),
'c2c500a7' => array( 'c2c500a7' => array(
'javelin-install', 'javelin-install',
'javelin-dom', 'javelin-dom',
@@ -1959,6 +1960,11 @@ return array(
'javelin-util', 'javelin-util',
'phabricator-keyboard-shortcut-manager', 'phabricator-keyboard-shortcut-manager',
), ),
'ca444dca' => array(
'javelin-install',
'javelin-workboard-card',
'javelin-workboard-header',
),
'cf32921f' => array( 'cf32921f' => array(
'javelin-behavior', 'javelin-behavior',
'javelin-dom', 'javelin-dom',
@@ -2025,15 +2031,14 @@ return array(
'javelin-dom', 'javelin-dom',
'javelin-history', 'javelin-history',
), ),
'e4e2d107' => array( 'e2730b90' => array(
'javelin-install', 'javelin-behavior',
'javelin-dom', 'javelin-dom',
'javelin-util', 'javelin-util',
'javelin-vector',
'javelin-stratcom', 'javelin-stratcom',
'javelin-workflow', 'javelin-workflow',
'phabricator-draggable-list', 'javelin-workboard-controller',
'javelin-workboard-column',
'javelin-workboard-header-template',
), ),
'e562708c' => array( 'e562708c' => array(
'javelin-install', 'javelin-install',
@@ -2136,11 +2141,6 @@ return array(
'javelin-magical-init', 'javelin-magical-init',
'javelin-util', 'javelin-util',
), ),
'fd9cb972' => array(
'javelin-install',
'javelin-workboard-card',
'javelin-workboard-header',
),
'fdc13e4e' => array( 'fdc13e4e' => array(
'javelin-install', 'javelin-install',
), ),

View File

@@ -252,6 +252,7 @@ final class ManiphestTask extends ManiphestDAO
return array( return array(
PhabricatorProjectColumn::ORDER_PRIORITY => array( PhabricatorProjectColumn::ORDER_PRIORITY => array(
(int)-$this->getPriority(), (int)-$this->getPriority(),
PhabricatorProjectColumn::NODETYPE_CARD,
(double)-$this->getSubpriority(), (double)-$this->getSubpriority(),
(int)-$this->getID(), (int)-$this->getID(),
), ),

View File

@@ -651,11 +651,15 @@ final class PhabricatorProjectBoardViewController
)); ));
$headers[] = array( $headers[] = array(
'order' => 'priority', 'order' => PhabricatorProjectColumn::ORDER_PRIORITY,
'key' => $header_key, 'key' => $header_key,
'template' => hsprintf('%s', $template), 'template' => hsprintf('%s', $template),
'vector' => array( 'vector' => array(
(int)-$priority, (int)-$priority,
PhabricatorProjectColumn::NODETYPE_HEADER,
),
'editProperties' => array(
PhabricatorProjectColumn::ORDER_PRIORITY => (int)$priority,
), ),
); );
} }

View File

@@ -15,6 +15,14 @@ final class PhabricatorProjectMoveController
$before_phid = $request->getStr('beforePHID'); $before_phid = $request->getStr('beforePHID');
$order = $request->getStr('order', PhabricatorProjectColumn::DEFAULT_ORDER); $order = $request->getStr('order', PhabricatorProjectColumn::DEFAULT_ORDER);
$edit_header = null;
$raw_header = $request->getStr('header');
if (strlen($raw_header)) {
$edit_header = phutil_json_decode($raw_header);
} else {
$edit_header = array();
}
$project = id(new PhabricatorProjectQuery()) $project = id(new PhabricatorProjectQuery())
->setViewer($viewer) ->setViewer($viewer)
->requireCapabilities( ->requireCapabilities(
@@ -87,10 +95,14 @@ final class PhabricatorProjectMoveController
)); ));
if ($order == PhabricatorProjectColumn::ORDER_PRIORITY) { if ($order == PhabricatorProjectColumn::ORDER_PRIORITY) {
$header_priority = idx(
$edit_header,
PhabricatorProjectColumn::ORDER_PRIORITY);
$priority_xactions = $this->getPriorityTransactions( $priority_xactions = $this->getPriorityTransactions(
$object, $object,
$after_phid, $after_phid,
$before_phid); $before_phid,
$header_priority);
foreach ($priority_xactions as $xaction) { foreach ($priority_xactions as $xaction) {
$xactions[] = $xaction; $xactions[] = $xaction;
} }
@@ -110,13 +122,33 @@ final class PhabricatorProjectMoveController
private function getPriorityTransactions( private function getPriorityTransactions(
ManiphestTask $task, ManiphestTask $task,
$after_phid, $after_phid,
$before_phid) { $before_phid,
$header_priority) {
$xactions = array();
$must_move = false;
if ($header_priority !== null) {
if ($task->getPriority() !== $header_priority) {
$task = id(clone $task)
->setPriority($header_priority);
$keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap();
$keyword = head(idx($keyword_map, $header_priority));
$xactions[] = id(new ManiphestTransaction())
->setTransactionType(
ManiphestTaskPriorityTransaction::TRANSACTIONTYPE)
->setNewValue($keyword);
$must_move = true;
}
}
list($after_task, $before_task) = $this->loadPriorityTasks( list($after_task, $before_task) = $this->loadPriorityTasks(
$after_phid, $after_phid,
$before_phid); $before_phid);
$must_move = false;
if ($after_task && !$task->isLowerPriorityThan($after_task)) { if ($after_task && !$task->isLowerPriorityThan($after_task)) {
$must_move = true; $must_move = true;
} }
@@ -125,10 +157,10 @@ final class PhabricatorProjectMoveController
$must_move = true; $must_move = true;
} }
// The move doesn't require a priority change to be valid, so don't // The move doesn't require a subpriority change to be valid, so don't
// change the priority since we are not being forced to. // change the subpriority since we are not being forced to.
if (!$must_move) { if (!$must_move) {
return array(); return $xactions;
} }
$try = array( $try = array(
@@ -139,28 +171,41 @@ final class PhabricatorProjectMoveController
$pri = null; $pri = null;
$sub = null; $sub = null;
foreach ($try as $spec) { foreach ($try as $spec) {
list($task, $is_after) = $spec; list($nearby_task, $is_after) = $spec;
if (!$task) { if (!$nearby_task) {
continue; continue;
} }
list($pri, $sub) = ManiphestTransactionEditor::getAdjacentSubpriority( list($pri, $sub) = ManiphestTransactionEditor::getAdjacentSubpriority(
$task, $nearby_task,
$is_after); $is_after);
// If we drag under a "Low" header between a "Normal" task and a "Low"
// task, we don't want to accept a subpriority assignment which changes
// our priority to "Normal". Only accept a subpriority that keeps us in
// the right primary priority.
if ($header_priority !== null) {
if ($pri !== $header_priority) {
continue;
}
}
// If we find a priority on the first try, don't keep going. // If we find a priority on the first try, don't keep going.
break; break;
} }
if ($pri !== null) {
if ($header_priority === null) {
$keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap(); $keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap();
$keyword = head(idx($keyword_map, $pri)); $keyword = head(idx($keyword_map, $pri));
$xactions = array();
if ($pri !== null) {
$xactions[] = id(new ManiphestTransaction()) $xactions[] = id(new ManiphestTransaction())
->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) ->setTransactionType(
ManiphestTaskPriorityTransaction::TRANSACTIONTYPE)
->setNewValue($keyword); ->setNewValue($keyword);
}
$xactions[] = id(new ManiphestTransaction()) $xactions[] = id(new ManiphestTransaction())
->setTransactionType( ->setTransactionType(
ManiphestTaskSubpriorityTransaction::TRANSACTIONTYPE) ManiphestTaskSubpriorityTransaction::TRANSACTIONTYPE)

View File

@@ -16,6 +16,9 @@ final class PhabricatorProjectColumn
const ORDER_NATURAL = 'natural'; const ORDER_NATURAL = 'natural';
const ORDER_PRIORITY = 'priority'; const ORDER_PRIORITY = 'priority';
const NODETYPE_HEADER = 0;
const NODETYPE_CARD = 1;
protected $name; protected $name;
protected $status; protected $status;
protected $projectPHID; protected $projectPHID;

View File

@@ -161,11 +161,15 @@ JX.install('WorkboardBoard', {
var list = new JX.DraggableList('project-card', column.getRoot()) var list = new JX.DraggableList('project-card', column.getRoot())
.setOuterContainer(this.getRoot()) .setOuterContainer(this.getRoot())
.setFindItemsHandler(JX.bind(column, column.getCardNodes)) .setFindItemsHandler(JX.bind(column, column.getDropTargetNodes))
.setCanDragX(true) .setCanDragX(true)
.setHasInfiniteHeight(true) .setHasInfiniteHeight(true)
.setIsDropTargetHandler(JX.bind(column, column.setIsDropTarget)); .setIsDropTargetHandler(JX.bind(column, column.setIsDropTarget));
var default_handler = list.getGhostHandler();
list.setGhostHandler(
JX.bind(column, column.handleDragGhost, default_handler));
if (this.getOrder() !== 'natural') { if (this.getOrder() !== 'natural') {
list.setCompareHandler(JX.bind(column, column.compareHandler)); list.setCompareHandler(JX.bind(column, column.compareHandler));
} }
@@ -198,16 +202,39 @@ JX.install('WorkboardBoard', {
order: this.getOrder() order: this.getOrder()
}; };
if (after_node) { var after_data;
data.afterPHID = JX.Stratcom.getData(after_node).objectPHID; var after_card = after_node;
while (after_card) {
after_data = JX.Stratcom.getData(after_card);
if (after_data.objectPHID) {
break;
}
after_card = after_card.previousSibling;
} }
var before_node = item.nextSibling; if (after_data) {
if (before_node) { data.afterPHID = after_data.objectPHID;
var before_phid = JX.Stratcom.getData(before_node).objectPHID;
if (before_phid) {
data.beforePHID = before_phid;
} }
var before_data;
var before_card = item.nextSibling;
while (before_card) {
before_data = JX.Stratcom.getData(before_card);
if (before_data.objectPHID) {
break;
}
before_card = before_card.nextSibling;
}
if (before_data) {
data.beforePHID = before_data.objectPHID;
}
var header_key = JX.Stratcom.getData(after_node).headerKey;
if (header_key) {
var properties = this.getHeaderTemplate(header_key)
.getEditProperties();
data.header = JX.JSON.stringify(properties);
} }
var visible_phids = []; var visible_phids = [];

View File

@@ -55,6 +55,10 @@ JX.install('WorkboardCard', {
return this._root; return this._root;
}, },
isWorkboardHeader: function() {
return false;
},
redraw: function() { redraw: function() {
var old_node = this._root; var old_node = this._root;
this._root = null; this._root = null;

View File

@@ -52,6 +52,10 @@ JX.install('WorkboardColumn', {
return this._cards; return this._cards;
}, },
_getObjects: function() {
return this._objects;
},
getCard: function(phid) { getCard: function(phid) {
return this._cards[phid]; return this._cards[phid];
}, },
@@ -126,12 +130,13 @@ JX.install('WorkboardColumn', {
return this; return this;
}, },
getCardNodes: function() { getDropTargetNodes: function() {
var cards = this.getCards(); var objects = this._getObjects();
var nodes = []; var nodes = [];
for (var k in cards) { for (var ii = 0; ii < objects.length; ii++) {
nodes.push(cards[k].getNode()); var object = objects[ii];
nodes.push(object.getNode());
} }
return nodes; return nodes;
@@ -160,6 +165,32 @@ JX.install('WorkboardColumn', {
return this._headers[key]; return this._headers[key];
}, },
handleDragGhost: function(default_handler, ghost, node) {
// If the column has headers, don't let the user drag a card above
// the topmost header: for example, you can't change a task to have
// a priority higher than the highest possible priority.
if (this._hasColumnHeaders()) {
if (!node) {
return false;
}
}
return default_handler(ghost, node);
},
_hasColumnHeaders: function() {
var board = this.getBoard();
var order = board.getOrder();
switch (order) {
case 'natural':
return false;
}
return true;
},
_getCardHeaderKey: function(card, order) { _getCardHeaderKey: function(card, order) {
switch (order) { switch (order) {
case 'priority': case 'priority':
@@ -174,18 +205,16 @@ JX.install('WorkboardColumn', {
var order = board.getOrder(); var order = board.getOrder();
var list; var list;
var has_headers;
if (order == 'natural') { if (order == 'natural') {
list = this._getCardsSortedNaturally(); list = this._getCardsSortedNaturally();
has_headers = false;
} else { } else {
list = this._getCardsSortedByKey(order); list = this._getCardsSortedByKey(order);
has_headers = true;
} }
var ii; var ii;
var objects = []; var objects = [];
var has_headers = this._hasColumnHeaders();
var header_keys = []; var header_keys = [];
var seen_headers = {}; var seen_headers = {};
if (has_headers) { if (has_headers) {
@@ -245,15 +274,23 @@ JX.install('WorkboardColumn', {
var board = this.getBoard(); var board = this.getBoard();
var order = board.getOrder(); var order = board.getOrder();
var src_phid = JX.Stratcom.getData(src_node).objectPHID; var u_vec = this._getNodeOrderVector(src_node, order);
var dst_phid = JX.Stratcom.getData(dst_node).objectPHID; var v_vec = this._getNodeOrderVector(dst_node, order);
var u_vec = board.getOrderVector(src_phid, order);
var v_vec = board.getOrderVector(dst_phid, order);
return board.compareVectors(u_vec, v_vec); return board.compareVectors(u_vec, v_vec);
}, },
_getNodeOrderVector: function(node, order) {
var board = this.getBoard();
var data = JX.Stratcom.getData(node);
if (data.objectPHID) {
return board.getOrderVector(data.objectPHID, order);
}
return board.getHeaderTemplate(data.headerKey).getVector();
},
setIsDropTarget: function(is_target) { setIsDropTarget: function(is_target) {
var node = this.getWorkpanelNode(); var node = this.getWorkpanelNode();
JX.DOM.alterClass(node, 'workboard-column-drop-target', is_target); JX.DOM.alterClass(node, 'workboard-column-drop-target', is_target);

View File

@@ -30,8 +30,14 @@ JX.install('WorkboardHeader', {
var board = this.getColumn().getBoard(); var board = this.getColumn().getBoard();
var template = board.getHeaderTemplate(header_key).getTemplate(); var template = board.getHeaderTemplate(header_key).getTemplate();
this._root = JX.$H(template).getFragment().firstChild; this._root = JX.$H(template).getFragment().firstChild;
JX.Stratcom.getData(this._root).headerKey = header_key;
} }
return this._root; return this._root;
},
isWorkboardHeader: function() {
return true;
} }
} }

View File

@@ -13,7 +13,8 @@ JX.install('WorkboardHeaderTemplate', {
properties: { properties: {
template: null, template: null,
order: null, order: null,
vector: null vector: null,
editProperties: null
}, },
members: { members: {

View File

@@ -112,7 +112,8 @@ JX.behavior('project-boards', function(config, statics) {
board.getHeaderTemplate(header.key) board.getHeaderTemplate(header.key)
.setOrder(header.order) .setOrder(header.order)
.setTemplate(header.template) .setTemplate(header.template)
.setVector(header.vector); .setVector(header.vector)
.setEditProperties(header.editProperties);
} }
board.start(); board.start();