@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

When moving cards on workboards, treat before/after cards as optional hints, not strict requirements

Summary:
Depends on D20320. Ref T12175. Ref T13074. Currently, when you move a card between columns, the internal transaction takes exactly one `afterPHID` or `beforePHID` and moves the card before or after the specified card.

This is a fairly strict interpretation and causes a number of practical issues, mostly because the user/client view of the board may be out of date and the card they're dragging before or after may no longer exist: another user might have moved or hidden it between the last client update and the current time.

In T13074, we also run into a more subtle issue where a card that incorrectly appears in multiple columns fatals when dropped before or after itself.

In all cases, a better behavior is just to complete the move and accept that the position may not end up exactly like the user specified. We could prompt the user instead:

> You tried to drop this card after card X, but that card has moved since you last loaded the board. Reload the board and try again.

...but this is pretty hostile and probably rarely/never what the user wants.

Instead, accept a list of before/after PHIDs and just try them until we find one that works, or accept a default position if none work. In essentially all cases, this means that the move "just works" like users expect it to instead of fataling in a confusing/disruptive/undesirable (but "technically correct") way.

(A followup will make the client JS send more beforePHIDs/afterPHIDs so this works more often.)

We could eventually add a "strict" mode in the API or something if there's some bot/API use case for precise behavior here, but I suspect none exist today or are (ever?) likely to exist in the future.

Test Plan:
- (T13074) Inserted two conflicting rows to put a card on two columns on the same board. Dropped one version of it underneath the other version. Before: confusing fatal. After: cards merge sensibly into one consistent card.
- (T12175) Opened two views of a board. Moved card A to a different column on the first view. On the second view, dropped card B under card A (still showing in the old column). Before: confusing fatal. After: card ended up in the right column in approximately the right place, very reasonably.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13074, T12175

Differential Revision: https://secure.phabricator.com/D20321

+188 -183
+14 -14
resources/celerity/map.php
··· 409 409 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 410 410 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 411 411 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', 412 - 'rsrc/js/application/projects/WorkboardBoard.js' => '223af34e', 412 + 'rsrc/js/application/projects/WorkboardBoard.js' => '106d870f', 413 413 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', 414 414 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', 415 415 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', ··· 737 737 'javelin-view-renderer' => '9aae2b66', 738 738 'javelin-view-visitor' => '308f9fe4', 739 739 'javelin-websocket' => 'fdc13e4e', 740 - 'javelin-workboard-board' => '223af34e', 740 + 'javelin-workboard-board' => '106d870f', 741 741 'javelin-workboard-card' => '0392a5d8', 742 742 'javelin-workboard-card-template' => '2a61f8d4', 743 743 'javelin-workboard-column' => 'c3d24e63', ··· 1015 1015 'javelin-workflow', 1016 1016 'phuix-icon-view', 1017 1017 ), 1018 + '106d870f' => array( 1019 + 'javelin-install', 1020 + 'javelin-dom', 1021 + 'javelin-util', 1022 + 'javelin-stratcom', 1023 + 'javelin-workflow', 1024 + 'phabricator-draggable-list', 1025 + 'javelin-workboard-column', 1026 + 'javelin-workboard-header-template', 1027 + 'javelin-workboard-card-template', 1028 + 'javelin-workboard-order-template', 1029 + ), 1018 1030 '111bfd2d' => array( 1019 1031 'javelin-install', 1020 1032 ), ··· 1072 1084 '202bfa3f' => array( 1073 1085 'javelin-behavior', 1074 1086 'javelin-request', 1075 - ), 1076 - '223af34e' => array( 1077 - 'javelin-install', 1078 - 'javelin-dom', 1079 - 'javelin-util', 1080 - 'javelin-stratcom', 1081 - 'javelin-workflow', 1082 - 'phabricator-draggable-list', 1083 - 'javelin-workboard-column', 1084 - 'javelin-workboard-header-template', 1085 - 'javelin-workboard-card-template', 1086 - 'javelin-workboard-order-template', 1087 1087 ), 1088 1088 '225bbb98' => array( 1089 1089 'javelin-install',
+8 -7
src/applications/maniphest/editor/ManiphestEditEngine.php
··· 123 123 column. 124 124 125 125 The target column should be identified as `columnPHID`, and you may select a 126 - position by passing either `beforePHID` or `afterPHID`, specifying the PHID of 127 - a task currently in the column that you want to move this task before or after: 126 + position by passing either `beforePHIDs` or `afterPHIDs`, specifying the PHIDs 127 + of tasks currently in the column that you want to move this task before or 128 + after: 128 129 129 130 ```lang=json 130 131 [ 131 132 { 132 133 "columnPHID": "PHID-PCOL-4444", 133 - "beforePHID": "PHID-TASK-5555" 134 + "beforePHIDs": ["PHID-TASK-5555"] 134 135 } 135 136 ] 136 137 ``` 137 138 138 - Note that this affects only the "natural" position of the task. The task 139 - position when the board is sorted by some other attribute (like priority) 140 - depends on that attribute value: change a task's priority to move it on 141 - priority-sorted boards. 139 + When you specify multiple PHIDs, the task will be moved adjacent to the first 140 + valid PHID found in either of the lists. This allows positional moves to 141 + generally work as users expect even if the client view of the board has fallen 142 + out of date and some of the nearby tasks have moved elsewhere. 142 143 EODOCS 143 144 ); 144 145
+85 -79
src/applications/maniphest/editor/ManiphestTransactionEditor.php
··· 428 428 private function buildMoveTransaction( 429 429 PhabricatorLiskDAO $object, 430 430 PhabricatorApplicationTransaction $xaction) { 431 + $actor = $this->getActor(); 431 432 432 433 $new = $xaction->getNewValue(); 433 434 if (!is_array($new)) { ··· 435 436 $new = array($new); 436 437 } 437 438 438 - $nearby_phids = array(); 439 + $relative_phids = array(); 439 440 foreach ($new as $key => $value) { 440 441 if (!is_array($value)) { 441 442 $this->validateColumnPHID($value); ··· 448 449 $value, 449 450 array( 450 451 'columnPHID' => 'string', 452 + 'beforePHIDs' => 'optional list<string>', 453 + 'afterPHIDs' => 'optional list<string>', 454 + 455 + // Deprecated older variations of "beforePHIDs" and "afterPHIDs". 451 456 'beforePHID' => 'optional string', 452 457 'afterPHID' => 'optional string', 453 458 )); 454 459 455 - $new[$key] = $value; 460 + $value = $value + array( 461 + 'beforePHIDs' => array(), 462 + 'afterPHIDs' => array(), 463 + ); 464 + 465 + // Normalize the legacy keys "beforePHID" and "afterPHID" keys to the 466 + // modern format. 467 + if (!empty($value['afterPHID'])) { 468 + if ($value['afterPHIDs']) { 469 + throw new Exception( 470 + pht( 471 + 'Transaction specifies both "afterPHID" and "afterPHIDs". '. 472 + 'Specify only "afterPHIDs".')); 473 + } 474 + $value['afterPHIDs'] = array($value['afterPHID']); 475 + unset($value['afterPHID']); 476 + } 456 477 457 - if (!empty($value['beforePHID'])) { 458 - $nearby_phids[] = $value['beforePHID']; 478 + if (isset($value['beforePHID'])) { 479 + if ($value['beforePHIDs']) { 480 + throw new Exception( 481 + pht( 482 + 'Transaction specifies both "beforePHID" and "beforePHIDs". '. 483 + 'Specify only "beforePHIDs".')); 484 + } 485 + $value['beforePHIDs'] = array($value['beforePHID']); 486 + unset($value['beforePHID']); 459 487 } 460 488 461 - if (!empty($value['afterPHID'])) { 462 - $nearby_phids[] = $value['afterPHID']; 489 + foreach ($value['beforePHIDs'] as $phid) { 490 + $relative_phids[] = $phid; 463 491 } 492 + 493 + foreach ($value['afterPHIDs'] as $phid) { 494 + $relative_phids[] = $phid; 495 + } 496 + 497 + $new[$key] = $value; 464 498 } 465 499 466 - if ($nearby_phids) { 467 - $nearby_objects = id(new PhabricatorObjectQuery()) 468 - ->setViewer($this->getActor()) 469 - ->withPHIDs($nearby_phids) 500 + // We require that objects you specify in "beforePHIDs" or "afterPHIDs" 501 + // are real objects which exist and which you have permission to view. 502 + // If you provide other objects, we remove them from the specification. 503 + 504 + if ($relative_phids) { 505 + $objects = id(new PhabricatorObjectQuery()) 506 + ->setViewer($actor) 507 + ->withPHIDs($relative_phids) 470 508 ->execute(); 471 - $nearby_objects = mpull($nearby_objects, null, 'getPHID'); 509 + $objects = mpull($objects, null, 'getPHID'); 472 510 } else { 473 - $nearby_objects = array(); 511 + $objects = array(); 512 + } 513 + 514 + foreach ($new as $key => $value) { 515 + $value['afterPHIDs'] = $this->filterValidPHIDs( 516 + $value['afterPHIDs'], 517 + $objects); 518 + $value['beforePHIDs'] = $this->filterValidPHIDs( 519 + $value['beforePHIDs'], 520 + $objects); 521 + 522 + $new[$key] = $value; 474 523 } 475 524 476 525 $column_phids = ipull($new, 'columnPHID'); 477 526 if ($column_phids) { 478 527 $columns = id(new PhabricatorProjectColumnQuery()) 479 - ->setViewer($this->getActor()) 528 + ->setViewer($actor) 480 529 ->withPHIDs($column_phids) 481 530 ->execute(); 482 531 $columns = mpull($columns, null, 'getPHID'); ··· 487 536 $board_phids = mpull($columns, 'getProjectPHID'); 488 537 $object_phid = $object->getPHID(); 489 538 490 - $object_phids = $nearby_phids; 491 - 492 539 // Note that we may not have an object PHID if we're creating a new 493 540 // object. 541 + $object_phids = array(); 494 542 if ($object_phid) { 495 543 $object_phids[] = $object_phid; 496 544 } ··· 517 565 518 566 $board_phid = $column->getProjectPHID(); 519 567 520 - $nearby = array(); 521 - 522 - if (!empty($spec['beforePHID'])) { 523 - $nearby['beforePHID'] = $spec['beforePHID']; 524 - } 525 - 526 - if (!empty($spec['afterPHID'])) { 527 - $nearby['afterPHID'] = $spec['afterPHID']; 528 - } 529 - 530 - if (count($nearby) > 1) { 531 - throw new Exception( 532 - pht( 533 - 'Column move transaction moves object to multiple positions. '. 534 - 'Specify only "beforePHID" or "afterPHID", not both.')); 535 - } 536 - 537 - foreach ($nearby as $where => $nearby_phid) { 538 - if (empty($nearby_objects[$nearby_phid])) { 539 - throw new Exception( 540 - pht( 541 - 'Column move transaction specifies object "%s" as "%s", but '. 542 - 'there is no corresponding object with this PHID.', 543 - $object_phid, 544 - $where)); 545 - } 546 - 547 - $nearby_columns = $layout_engine->getObjectColumns( 548 - $board_phid, 549 - $nearby_phid); 550 - $nearby_columns = mpull($nearby_columns, null, 'getPHID'); 551 - 552 - if (empty($nearby_columns[$column_phid])) { 553 - throw new Exception( 554 - pht( 555 - 'Column move transaction specifies object "%s" as "%s" in '. 556 - 'column "%s", but this object is not in that column!', 557 - $nearby_phid, 558 - $where, 559 - $column_phid)); 560 - } 561 - } 562 - 563 568 if ($object_phid) { 564 569 $old_columns = $layout_engine->getObjectColumns( 565 570 $board_phid, ··· 578 583 // We can just drop this column change if it has no effect. 579 584 $from_map = array_fuse($spec['fromColumnPHIDs']); 580 585 $already_here = isset($from_map[$column_phid]); 581 - $is_reordering = (bool)$nearby; 582 586 587 + $is_reordering = ($spec['afterPHIDs'] || $spec['beforePHIDs']); 583 588 if ($already_here && !$is_reordering) { 584 589 unset($new[$key]); 585 590 } else { ··· 677 682 private function applyBoardMove($object, array $move) { 678 683 $board_phid = $move['boardPHID']; 679 684 $column_phid = $move['columnPHID']; 680 - $before_phid = idx($move, 'beforePHID'); 681 - $after_phid = idx($move, 'afterPHID'); 685 + 686 + $before_phids = $move['beforePHIDs']; 687 + $after_phids = $move['afterPHIDs']; 682 688 683 689 $object_phid = $object->getPHID(); 684 690 ··· 730 736 $object_phid); 731 737 } 732 738 733 - if ($before_phid) { 734 - $engine->queueAddPositionBefore( 735 - $board_phid, 736 - $column_phid, 737 - $object_phid, 738 - $before_phid); 739 - } else if ($after_phid) { 740 - $engine->queueAddPositionAfter( 741 - $board_phid, 742 - $column_phid, 743 - $object_phid, 744 - $after_phid); 745 - } else { 746 - $engine->queueAddPosition( 747 - $board_phid, 748 - $column_phid, 749 - $object_phid); 750 - } 739 + $engine->queueAddPosition( 740 + $board_phid, 741 + $column_phid, 742 + $object_phid, 743 + $after_phids, 744 + $before_phids); 751 745 752 746 $engine->applyPositionUpdates(); 753 747 } ··· 847 841 } 848 842 849 843 return $errors; 844 + } 845 + 846 + private function filterValidPHIDs($phid_list, array $object_map) { 847 + foreach ($phid_list as $key => $phid) { 848 + if (isset($object_map[$phid])) { 849 + continue; 850 + } 851 + 852 + unset($phid_list[$key]); 853 + } 854 + 855 + return array_values($phid_list); 850 856 } 851 857 852 858 }
+11 -7
src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php
··· 1008 1008 $task2->getPHID(), 1009 1009 $task1->getPHID(), 1010 1010 ); 1011 - $this->assertTasksInColumn($expect, $user, $board, $column); 1011 + $label = pht('Simple move'); 1012 + $this->assertTasksInColumn($expect, $user, $board, $column, $label); 1012 1013 1013 1014 // Move the second task after the first task. 1014 1015 $options = array( 1015 - 'afterPHID' => $task1->getPHID(), 1016 + 'afterPHIDs' => array($task1->getPHID()), 1016 1017 ); 1017 1018 $this->moveToColumn($user, $board, $task2, $column, $column, $options); 1018 1019 $expect = array( 1019 1020 $task1->getPHID(), 1020 1021 $task2->getPHID(), 1021 1022 ); 1022 - $this->assertTasksInColumn($expect, $user, $board, $column); 1023 + $label = pht('With afterPHIDs'); 1024 + $this->assertTasksInColumn($expect, $user, $board, $column, $label); 1023 1025 1024 1026 // Move the second task before the first task. 1025 1027 $options = array( 1026 - 'beforePHID' => $task1->getPHID(), 1028 + 'beforePHIDs' => array($task1->getPHID()), 1027 1029 ); 1028 1030 $this->moveToColumn($user, $board, $task2, $column, $column, $options); 1029 1031 $expect = array( 1030 1032 $task2->getPHID(), 1031 1033 $task1->getPHID(), 1032 1034 ); 1033 - $this->assertTasksInColumn($expect, $user, $board, $column); 1035 + $label = pht('With beforePHIDs'); 1036 + $this->assertTasksInColumn($expect, $user, $board, $column, $label); 1034 1037 } 1035 1038 1036 1039 public function testMilestoneMoves() { ··· 1333 1336 array $expect, 1334 1337 PhabricatorUser $viewer, 1335 1338 PhabricatorProject $board, 1336 - PhabricatorProjectColumn $column) { 1339 + PhabricatorProjectColumn $column, 1340 + $label = null) { 1337 1341 1338 1342 $engine = id(new PhabricatorBoardLayoutEngine()) 1339 1343 ->setViewer($viewer) ··· 1346 1350 $column->getPHID()); 1347 1351 $object_phids = array_values($object_phids); 1348 1352 1349 - $this->assertEqual($expect, $object_phids); 1353 + $this->assertEqual($expect, $object_phids, $label); 1350 1354 } 1351 1355 1352 1356 private function addColumn(
+15 -3
src/applications/project/controller/PhabricatorProjectMoveController.php
··· 11 11 12 12 $column_phid = $request->getStr('columnPHID'); 13 13 $object_phid = $request->getStr('objectPHID'); 14 + 14 15 $after_phid = $request->getStr('afterPHID'); 15 16 $before_phid = $request->getStr('beforePHID'); 17 + 18 + $after_phids = array(); 19 + if ($after_phid) { 20 + $after_phids[] = $after_phid; 21 + } 22 + 23 + $before_phids = array(); 24 + if ($before_phid) { 25 + $before_phids[] = $before_phid; 26 + } 16 27 17 28 $order = $request->getStr('order'); 18 29 if (!strlen($order)) { ··· 89 100 90 101 $order_params = array(); 91 102 if ($after_phid) { 92 - $order_params['afterPHID'] = $after_phid; 93 - } else if ($before_phid) { 94 - $order_params['beforePHID'] = $before_phid; 103 + $order_params['afterPHIDs'] = $after_phids; 104 + } 105 + if ($before_phid) { 106 + $order_params['beforePHIDs'] = $before_phids; 95 107 } 96 108 97 109 $xactions = array();
+54 -72
src/applications/project/engine/PhabricatorBoardLayoutEngine.php
··· 135 135 return $this; 136 136 } 137 137 138 - public function queueAddPositionBefore( 139 - $board_phid, 140 - $column_phid, 141 - $object_phid, 142 - $before_phid) { 143 - 144 - return $this->queueAddPositionRelative( 145 - $board_phid, 146 - $column_phid, 147 - $object_phid, 148 - $before_phid, 149 - true); 150 - } 151 - 152 - public function queueAddPositionAfter( 153 - $board_phid, 154 - $column_phid, 155 - $object_phid, 156 - $after_phid) { 157 - 158 - return $this->queueAddPositionRelative( 159 - $board_phid, 160 - $column_phid, 161 - $object_phid, 162 - $after_phid, 163 - false); 164 - } 165 - 166 138 public function queueAddPosition( 167 139 $board_phid, 168 140 $column_phid, 169 - $object_phid) { 170 - return $this->queueAddPositionRelative( 171 - $board_phid, 172 - $column_phid, 173 - $object_phid, 174 - null, 175 - true); 176 - } 177 - 178 - private function queueAddPositionRelative( 179 - $board_phid, 180 - $column_phid, 181 141 $object_phid, 182 - $relative_phid, 183 - $is_before) { 142 + array $after_phids, 143 + array $before_phids) { 184 144 185 145 $board_layout = idx($this->boardLayout, $board_phid, array()); 186 146 $positions = idx($board_layout, $column_phid, array()); ··· 196 156 ->setObjectPHID($object_phid); 197 157 } 198 158 199 - $found = false; 200 159 if (!$positions) { 201 160 $object_position->setSequence(0); 202 161 } else { 203 - foreach ($positions as $position) { 204 - if (!$found) { 205 - if ($relative_phid === null) { 206 - $is_match = true; 207 - } else { 208 - $position_phid = $position->getObjectPHID(); 209 - $is_match = ($relative_phid == $position_phid); 210 - } 162 + // The user's view of the board may fall out of date, so they might 163 + // try to drop a card under a different card which is no longer where 164 + // they thought it was. 211 165 212 - if ($is_match) { 213 - $found = true; 166 + // When this happens, we perform the move anyway, since this is almost 167 + // certainly what users want when interacting with the UI. We'l try to 168 + // fall back to another nearby card if the client provided us one. If 169 + // we don't find any of the cards the client specified in the column, 170 + // we'll just move the card to the default position. 214 171 215 - $sequence = $position->getSequence(); 172 + $search_phids = array(); 173 + foreach ($after_phids as $after_phid) { 174 + $search_phids[] = array($after_phid, false); 175 + } 216 176 217 - if (!$is_before) { 218 - $sequence++; 177 + foreach ($before_phids as $before_phid) { 178 + $search_phids[] = array($before_phid, true); 179 + } 180 + 181 + // This makes us fall back to the default position if we fail every 182 + // candidate position. The default position counts as a "before" position 183 + // because we want to put the new card at the top of the column. 184 + $search_phids[] = array(null, true); 185 + 186 + $found = false; 187 + foreach ($search_phids as $search_position) { 188 + list($relative_phid, $is_before) = $search_position; 189 + foreach ($positions as $position) { 190 + if (!$found) { 191 + if ($relative_phid === null) { 192 + $is_match = true; 193 + } else { 194 + $position_phid = $position->getObjectPHID(); 195 + $is_match = ($relative_phid === $position_phid); 219 196 } 220 197 221 - $object_position->setSequence($sequence++); 198 + if ($is_match) { 199 + $found = true; 200 + 201 + $sequence = $position->getSequence(); 202 + 203 + if (!$is_before) { 204 + $sequence++; 205 + } 222 206 223 - if (!$is_before) { 224 - // If we're inserting after this position, continue the loop so 225 - // we don't update it. 226 - continue; 207 + $object_position->setSequence($sequence++); 208 + 209 + if (!$is_before) { 210 + // If we're inserting after this position, continue the loop so 211 + // we don't update it. 212 + continue; 213 + } 227 214 } 228 215 } 216 + 217 + if ($found) { 218 + $position->setSequence($sequence++); 219 + $this->addQueue[] = $position; 220 + } 229 221 } 230 222 231 223 if ($found) { 232 - $position->setSequence($sequence++); 233 - $this->addQueue[] = $position; 224 + break; 234 225 } 235 226 } 236 - } 237 - 238 - if ($relative_phid && !$found) { 239 - throw new Exception( 240 - pht( 241 - 'Unable to find object "%s" in column "%s" on board "%s".', 242 - $relative_phid, 243 - $column_phid, 244 - $board_phid)); 245 227 } 246 228 247 229 $this->addQueue[] = $object_position;
+1 -1
webroot/rsrc/js/application/projects/WorkboardBoard.js
··· 495 495 order: this.getOrder() 496 496 }; 497 497 498 - var context = this._getDropContext(after_node); 498 + var context = this._getDropContext(after_node, item); 499 499 500 500 if (context.afterPHID) { 501 501 data.afterPHID = context.afterPHID;