@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

Rebuild the bulk editor on SearchEngine

Summary:
Depends on D18805. Ref T13025. Fixes T10268.

Instead of using a list of IDs for the bulk editor, power it with SearchEngine queries. This gives us the full power of SearchEngine and lets us use a query key instead of a list of 20,000 IDs to avoid issues with URL lengths.

Also, split it into a base `BulkEngine` and per-application subclasses. This moves us toward T10005 and universal support for bulk operations.

Also:

- Renames most of "batch" to "bulk": we're curently inconsitent about this, I like "bulk" better since I think it's more clear if you don't regularly interact with `.bat` files, and newer stuff mostly uses "bulk".
- When objects in the result set can't be edited because you don't have permission, show the status more clearly.

This probably breaks some stuff a bit since I refactored so heavily, but it seems mostly OK from poking around. I'll clean up anything I missed in followups to deal with remaining items on T13025.

Test Plan:
{F5302300}

- Bulk edited from Maniphest.
- Bulk edited from a workboard (no more giant `?ids=....` in the URL).
- Hit most of the error conditions, I think?
- Clicked the "Cancel" button.

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13025, T10268

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

+612 -293
+12 -12
resources/celerity/map.php
··· 9 9 'names' => array( 10 10 'conpherence.pkg.css' => 'e68cf1fa', 11 11 'conpherence.pkg.js' => '15191c65', 12 - 'core.pkg.css' => '5be8063f', 12 + 'core.pkg.css' => '075f9867', 13 13 'core.pkg.js' => '4c79d74f', 14 14 'darkconsole.pkg.js' => '1f9a31bc', 15 15 'differential.pkg.css' => '45951e9e', ··· 18 18 'diffusion.pkg.js' => '6134c5a1', 19 19 'favicon.ico' => '30672e08', 20 20 'maniphest.pkg.css' => '4845691a', 21 - 'maniphest.pkg.js' => '5ab2753f', 21 + 'maniphest.pkg.js' => '4d7e79c8', 22 22 'rsrc/audio/basic/alert.mp3' => '98461568', 23 23 'rsrc/audio/basic/bing.mp3' => 'ab8603a5', 24 24 'rsrc/audio/basic/pock.mp3' => '0cc772f5', ··· 135 135 'rsrc/css/phui/object-item/phui-oi-color.css' => 'cd2b9b77', 136 136 'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => '08f4ccc3', 137 137 'rsrc/css/phui/object-item/phui-oi-flush-ui.css' => '9d9685d6', 138 - 'rsrc/css/phui/object-item/phui-oi-list-view.css' => '73c5f5c4', 138 + 'rsrc/css/phui/object-item/phui-oi-list-view.css' => '6ae18df0', 139 139 'rsrc/css/phui/object-item/phui-oi-simple-ui.css' => 'a8beebea', 140 140 'rsrc/css/phui/phui-action-list.css' => 'f7f61a34', 141 141 'rsrc/css/phui/phui-action-panel.css' => 'b4798122', ··· 420 420 'rsrc/js/application/herald/PathTypeahead.js' => 'f7fc67ec', 421 421 'rsrc/js/application/herald/herald-rule-editor.js' => '7ebaeed3', 422 422 'rsrc/js/application/maniphest/behavior-batch-editor.js' => '782ab6e7', 423 - 'rsrc/js/application/maniphest/behavior-batch-selector.js' => '0825c27a', 423 + 'rsrc/js/application/maniphest/behavior-batch-selector.js' => 'ad54037e', 424 424 'rsrc/js/application/maniphest/behavior-line-chart.js' => 'e4232876', 425 425 'rsrc/js/application/maniphest/behavior-list-edit.js' => 'a9f88de2', 426 426 'rsrc/js/application/maniphest/behavior-subpriorityeditor.js' => '71237763', ··· 643 643 'javelin-behavior-line-chart' => 'e4232876', 644 644 'javelin-behavior-load-blame' => '42126667', 645 645 'javelin-behavior-maniphest-batch-editor' => '782ab6e7', 646 - 'javelin-behavior-maniphest-batch-selector' => '0825c27a', 646 + 'javelin-behavior-maniphest-batch-selector' => 'ad54037e', 647 647 'javelin-behavior-maniphest-list-editor' => 'a9f88de2', 648 648 'javelin-behavior-maniphest-subpriority-editor' => '71237763', 649 649 'javelin-behavior-owners-path-editor' => '7a68dda3', ··· 862 862 'phui-oi-color-css' => 'cd2b9b77', 863 863 'phui-oi-drag-ui-css' => '08f4ccc3', 864 864 'phui-oi-flush-ui-css' => '9d9685d6', 865 - 'phui-oi-list-view-css' => '73c5f5c4', 865 + 'phui-oi-list-view-css' => '6ae18df0', 866 866 'phui-oi-simple-ui-css' => 'a8beebea', 867 867 'phui-pager-css' => 'edcbc226', 868 868 'phui-pinboard-view-css' => '2495140e', ··· 960 960 'javelin-stratcom', 961 961 'javelin-workflow', 962 962 ), 963 - '0825c27a' => array( 964 - 'javelin-behavior', 965 - 'javelin-dom', 966 - 'javelin-stratcom', 967 - 'javelin-util', 968 - ), 969 963 '08f4ccc3' => array( 970 964 'phui-oi-list-view-css', 971 965 ), ··· 1814 1808 'javelin-vector', 1815 1809 'phuix-autocomplete', 1816 1810 'javelin-mask', 1811 + ), 1812 + 'ad54037e' => array( 1813 + 'javelin-behavior', 1814 + 'javelin-dom', 1815 + 'javelin-stratcom', 1816 + 'javelin-util', 1817 1817 ), 1818 1818 'b003d4fb' => array( 1819 1819 'javelin-behavior',
+6 -2
src/__phutil_library_map__.php
··· 1487 1487 'MacroQueryConduitAPIMethod' => 'applications/macro/conduit/MacroQueryConduitAPIMethod.php', 1488 1488 'ManiphestAssignEmailCommand' => 'applications/maniphest/command/ManiphestAssignEmailCommand.php', 1489 1489 'ManiphestAssigneeDatasource' => 'applications/maniphest/typeahead/ManiphestAssigneeDatasource.php', 1490 - 'ManiphestBatchEditController' => 'applications/maniphest/controller/ManiphestBatchEditController.php', 1491 1490 'ManiphestBulkEditCapability' => 'applications/maniphest/capability/ManiphestBulkEditCapability.php', 1491 + 'ManiphestBulkEditController' => 'applications/maniphest/controller/ManiphestBulkEditController.php', 1492 1492 'ManiphestClaimEmailCommand' => 'applications/maniphest/command/ManiphestClaimEmailCommand.php', 1493 1493 'ManiphestCloseEmailCommand' => 'applications/maniphest/command/ManiphestCloseEmailCommand.php', 1494 1494 'ManiphestConduitAPIMethod' => 'applications/maniphest/conduit/ManiphestConduitAPIMethod.php', ··· 1547 1547 'ManiphestTaskAttachTransaction' => 'applications/maniphest/xaction/ManiphestTaskAttachTransaction.php', 1548 1548 'ManiphestTaskAuthorHeraldField' => 'applications/maniphest/herald/ManiphestTaskAuthorHeraldField.php', 1549 1549 'ManiphestTaskAuthorPolicyRule' => 'applications/maniphest/policyrule/ManiphestTaskAuthorPolicyRule.php', 1550 + 'ManiphestTaskBulkEngine' => 'applications/maniphest/bulk/ManiphestTaskBulkEngine.php', 1550 1551 'ManiphestTaskCloseAsDuplicateRelationship' => 'applications/maniphest/relationship/ManiphestTaskCloseAsDuplicateRelationship.php', 1551 1552 'ManiphestTaskClosedStatusDatasource' => 'applications/maniphest/typeahead/ManiphestTaskClosedStatusDatasource.php', 1552 1553 'ManiphestTaskCoverImageTransaction' => 'applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php', ··· 2198 2199 'PhabricatorBuiltinFileCachePurger' => 'applications/cache/purger/PhabricatorBuiltinFileCachePurger.php', 2199 2200 'PhabricatorBuiltinPatchList' => 'infrastructure/storage/patch/PhabricatorBuiltinPatchList.php', 2200 2201 'PhabricatorBulkContentSource' => 'infrastructure/daemon/contentsource/PhabricatorBulkContentSource.php', 2202 + 'PhabricatorBulkEngine' => 'applications/transactions/bulk/PhabricatorBulkEngine.php', 2201 2203 'PhabricatorCacheDAO' => 'applications/cache/storage/PhabricatorCacheDAO.php', 2202 2204 'PhabricatorCacheEngine' => 'applications/system/engine/PhabricatorCacheEngine.php', 2203 2205 'PhabricatorCacheEngineExtension' => 'applications/system/engine/PhabricatorCacheEngineExtension.php', ··· 6678 6680 'MacroQueryConduitAPIMethod' => 'MacroConduitAPIMethod', 6679 6681 'ManiphestAssignEmailCommand' => 'ManiphestEmailCommand', 6680 6682 'ManiphestAssigneeDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 6681 - 'ManiphestBatchEditController' => 'ManiphestController', 6682 6683 'ManiphestBulkEditCapability' => 'PhabricatorPolicyCapability', 6684 + 'ManiphestBulkEditController' => 'ManiphestController', 6683 6685 'ManiphestClaimEmailCommand' => 'ManiphestEmailCommand', 6684 6686 'ManiphestCloseEmailCommand' => 'ManiphestEmailCommand', 6685 6687 'ManiphestConduitAPIMethod' => 'ConduitAPIMethod', ··· 6761 6763 'ManiphestTaskAttachTransaction' => 'ManiphestTaskTransactionType', 6762 6764 'ManiphestTaskAuthorHeraldField' => 'ManiphestTaskHeraldField', 6763 6765 'ManiphestTaskAuthorPolicyRule' => 'PhabricatorPolicyRule', 6766 + 'ManiphestTaskBulkEngine' => 'PhabricatorBulkEngine', 6764 6767 'ManiphestTaskCloseAsDuplicateRelationship' => 'ManiphestTaskRelationship', 6765 6768 'ManiphestTaskClosedStatusDatasource' => 'PhabricatorTypeaheadDatasource', 6766 6769 'ManiphestTaskCoverImageTransaction' => 'ManiphestTaskTransactionType', ··· 7487 7490 'PhabricatorBuiltinFileCachePurger' => 'PhabricatorCachePurger', 7488 7491 'PhabricatorBuiltinPatchList' => 'PhabricatorSQLPatchList', 7489 7492 'PhabricatorBulkContentSource' => 'PhabricatorContentSource', 7493 + 'PhabricatorBulkEngine' => 'Phobject', 7490 7494 'PhabricatorCacheDAO' => 'PhabricatorLiskDAO', 7491 7495 'PhabricatorCacheEngine' => 'Phobject', 7492 7496 'PhabricatorCacheEngineExtension' => 'Phobject',
+4
src/applications/base/PhabricatorApplication.php
··· 618 618 ')?'; 619 619 } 620 620 621 + protected function getBulkRoutePattern($base = null) { 622 + return $base.'(?:query/(?P<queryKey>[^/]+)/)?'; 623 + } 624 + 621 625 protected function getQueryRoutePattern($base = null) { 622 626 return $base.'(?:query/(?P<queryKey>[^/]+)/)?'; 623 627 }
+1 -1
src/applications/maniphest/application/PhabricatorManiphestApplication.php
··· 52 52 '/maniphest/' => array( 53 53 '(?:query/(?P<queryKey>[^/]+)/)?' => 'ManiphestTaskListController', 54 54 'report/(?:(?P<view>\w+)/)?' => 'ManiphestReportController', 55 - 'batch/' => 'ManiphestBatchEditController', 55 + $this->getBulkRoutePattern('bulk/') => 'ManiphestBulkEditController', 56 56 'task/' => array( 57 57 $this->getEditRoutePattern('edit/') 58 58 => 'ManiphestTaskEditController',
+50
src/applications/maniphest/bulk/ManiphestTaskBulkEngine.php
··· 1 + <?php 2 + 3 + final class ManiphestTaskBulkEngine 4 + extends PhabricatorBulkEngine { 5 + 6 + private $workboard; 7 + 8 + public function setWorkboard(PhabricatorProject $workboard) { 9 + $this->workboard = $workboard; 10 + return $this; 11 + } 12 + 13 + public function getWorkboard() { 14 + return $this->workboard; 15 + } 16 + 17 + public function newSearchEngine() { 18 + return new ManiphestTaskSearchEngine(); 19 + } 20 + 21 + public function getDoneURI() { 22 + $board_uri = $this->getBoardURI(); 23 + if ($board_uri) { 24 + return $board_uri; 25 + } 26 + 27 + return parent::getDoneURI(); 28 + } 29 + 30 + public function getCancelURI() { 31 + $board_uri = $this->getBoardURI(); 32 + if ($board_uri) { 33 + return $board_uri; 34 + } 35 + 36 + return parent::getCancelURI(); 37 + } 38 + 39 + private function getBoardURI() { 40 + $workboard = $this->getWorkboard(); 41 + 42 + if ($workboard) { 43 + $project_id = $workboard->getID(); 44 + return "/project/board/{$project_id}/"; 45 + } 46 + 47 + return null; 48 + } 49 + 50 + }
-255
src/applications/maniphest/controller/ManiphestBatchEditController.php
··· 1 - <?php 2 - 3 - final class ManiphestBatchEditController extends ManiphestController { 4 - 5 - public function handleRequest(AphrontRequest $request) { 6 - $viewer = $this->getViewer(); 7 - 8 - $this->requireApplicationCapability( 9 - ManiphestBulkEditCapability::CAPABILITY); 10 - 11 - $project = null; 12 - $board_id = $request->getInt('board'); 13 - if ($board_id) { 14 - $project = id(new PhabricatorProjectQuery()) 15 - ->setViewer($viewer) 16 - ->withIDs(array($board_id)) 17 - ->executeOne(); 18 - if (!$project) { 19 - return new Aphront404Response(); 20 - } 21 - } 22 - 23 - $task_ids = $request->getArr('batch'); 24 - if (!$task_ids) { 25 - $task_ids = $request->getStrList('batch'); 26 - } 27 - 28 - if (!$task_ids) { 29 - throw new Exception( 30 - pht( 31 - 'No tasks are selected.')); 32 - } 33 - 34 - $tasks = id(new ManiphestTaskQuery()) 35 - ->setViewer($viewer) 36 - ->withIDs($task_ids) 37 - ->requireCapabilities( 38 - array( 39 - PhabricatorPolicyCapability::CAN_VIEW, 40 - PhabricatorPolicyCapability::CAN_EDIT, 41 - )) 42 - ->needSubscriberPHIDs(true) 43 - ->needProjectPHIDs(true) 44 - ->execute(); 45 - 46 - if (!$tasks) { 47 - throw new Exception( 48 - pht("You don't have permission to edit any of the selected tasks.")); 49 - } 50 - 51 - if ($project) { 52 - $cancel_uri = '/project/board/'.$project->getID().'/'; 53 - $redirect_uri = $cancel_uri; 54 - } else { 55 - $cancel_uri = '/maniphest/'; 56 - $redirect_uri = '/maniphest/?ids='.implode(',', mpull($tasks, 'getID')); 57 - } 58 - 59 - $actions = $request->getStr('actions'); 60 - if ($actions) { 61 - $actions = phutil_json_decode($actions); 62 - } 63 - 64 - if ($request->isFormPost() && $actions) { 65 - $job = PhabricatorWorkerBulkJob::initializeNewJob( 66 - $viewer, 67 - new ManiphestTaskEditBulkJobType(), 68 - array( 69 - 'taskPHIDs' => mpull($tasks, 'getPHID'), 70 - 'actions' => $actions, 71 - 'cancelURI' => $cancel_uri, 72 - 'doneURI' => $redirect_uri, 73 - )); 74 - 75 - $type_status = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS; 76 - 77 - $xactions = array(); 78 - $xactions[] = id(new PhabricatorWorkerBulkJobTransaction()) 79 - ->setTransactionType($type_status) 80 - ->setNewValue(PhabricatorWorkerBulkJob::STATUS_CONFIRM); 81 - 82 - $editor = id(new PhabricatorWorkerBulkJobEditor()) 83 - ->setActor($viewer) 84 - ->setContentSourceFromRequest($request) 85 - ->setContinueOnMissingFields(true) 86 - ->applyTransactions($job, $xactions); 87 - 88 - return id(new AphrontRedirectResponse()) 89 - ->setURI($job->getMonitorURI()); 90 - } 91 - 92 - $list = $this->newBulkObjectList($tasks); 93 - 94 - $template = new AphrontTokenizerTemplateView(); 95 - $template = $template->render(); 96 - 97 - $projects_source = new PhabricatorProjectDatasource(); 98 - $mailable_source = new PhabricatorMetaMTAMailableDatasource(); 99 - $mailable_source->setViewer($viewer); 100 - $owner_source = new ManiphestAssigneeDatasource(); 101 - $owner_source->setViewer($viewer); 102 - $spaces_source = id(new PhabricatorSpacesNamespaceDatasource()) 103 - ->setViewer($viewer); 104 - 105 - require_celerity_resource('maniphest-batch-editor'); 106 - Javelin::initBehavior( 107 - 'maniphest-batch-editor', 108 - array( 109 - 'root' => 'maniphest-batch-edit-form', 110 - 'tokenizerTemplate' => $template, 111 - 'sources' => array( 112 - 'project' => array( 113 - 'src' => $projects_source->getDatasourceURI(), 114 - 'placeholder' => $projects_source->getPlaceholderText(), 115 - 'browseURI' => $projects_source->getBrowseURI(), 116 - ), 117 - 'owner' => array( 118 - 'src' => $owner_source->getDatasourceURI(), 119 - 'placeholder' => $owner_source->getPlaceholderText(), 120 - 'browseURI' => $owner_source->getBrowseURI(), 121 - 'limit' => 1, 122 - ), 123 - 'cc' => array( 124 - 'src' => $mailable_source->getDatasourceURI(), 125 - 'placeholder' => $mailable_source->getPlaceholderText(), 126 - 'browseURI' => $mailable_source->getBrowseURI(), 127 - ), 128 - 'spaces' => array( 129 - 'src' => $spaces_source->getDatasourceURI(), 130 - 'placeholder' => $spaces_source->getPlaceholderText(), 131 - 'browseURI' => $spaces_source->getBrowseURI(), 132 - 'limit' => 1, 133 - ), 134 - ), 135 - 'input' => 'batch-form-actions', 136 - 'priorityMap' => ManiphestTaskPriority::getTaskPriorityMap(), 137 - 'statusMap' => ManiphestTaskStatus::getTaskStatusMap(), 138 - )); 139 - 140 - $form = id(new PHUIFormLayoutView()) 141 - ->setUser($viewer); 142 - 143 - $form->appendChild( 144 - phutil_tag( 145 - 'input', 146 - array( 147 - 'type' => 'hidden', 148 - 'name' => 'actions', 149 - 'id' => 'batch-form-actions', 150 - ))); 151 - 152 - $form->appendChild( 153 - id(new PHUIFormInsetView()) 154 - ->setTitle(pht('Actions')) 155 - ->setRightButton(javelin_tag( 156 - 'a', 157 - array( 158 - 'href' => '#', 159 - 'class' => 'button button-green', 160 - 'sigil' => 'add-action', 161 - 'mustcapture' => true, 162 - ), 163 - pht('Add Another Action'))) 164 - ->setContent(javelin_tag( 165 - 'table', 166 - array( 167 - 'sigil' => 'maniphest-batch-actions', 168 - 'class' => 'maniphest-batch-actions-table', 169 - ), 170 - ''))) 171 - ->appendChild( 172 - id(new AphrontFormSubmitControl()) 173 - ->setValue(pht('Update Tasks')) 174 - ->addCancelButton($cancel_uri)); 175 - 176 - $title = pht('Batch Editor'); 177 - 178 - $crumbs = $this->buildApplicationCrumbs(); 179 - $crumbs->addTextCrumb($title); 180 - $crumbs->setBorder(true); 181 - 182 - $header = id(new PHUIHeaderView()) 183 - ->setHeader(pht('Batch Editor')) 184 - ->setHeaderIcon('fa-pencil-square-o'); 185 - 186 - $task_box = id(new PHUIObjectBoxView()) 187 - ->setHeaderText(pht('Selected Tasks')) 188 - ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 189 - ->setObjectList($list); 190 - 191 - $form_box = id(new PHUIObjectBoxView()) 192 - ->setHeaderText(pht('Actions')) 193 - ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 194 - ->setForm($form); 195 - 196 - 197 - $complete_form = phabricator_form( 198 - $viewer, 199 - array( 200 - 'action' => $request->getRequestURI(), 201 - 'method' => 'POST', 202 - 'id' => 'maniphest-batch-edit-form', 203 - ), 204 - array( 205 - phutil_tag( 206 - 'input', 207 - array( 208 - 'type' => 'hidden', 209 - 'name' => 'board', 210 - 'value' => $board_id, 211 - )), 212 - $task_box, 213 - $form_box, 214 - )); 215 - 216 - $view = id(new PHUITwoColumnView()) 217 - ->setHeader($header) 218 - ->setFooter($complete_form); 219 - 220 - return $this->newPage() 221 - ->setTitle($title) 222 - ->setCrumbs($crumbs) 223 - ->appendChild($view); 224 - } 225 - 226 - private function newBulkObjectList(array $objects) { 227 - $viewer = $this->getViewer(); 228 - $objects = mpull($objects, null, 'getPHID'); 229 - 230 - $handles = $viewer->loadHandles(array_keys($objects)); 231 - 232 - $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; 233 - 234 - $list = id(new PHUIObjectItemListView()) 235 - ->setViewer($viewer) 236 - ->setFlush(true); 237 - 238 - foreach ($objects as $phid => $object) { 239 - $handle = $handles[$phid]; 240 - 241 - $is_closed = ($handle->getStatus() === $status_closed); 242 - 243 - $item = id(new PHUIObjectItemView()) 244 - ->setHeader($handle->getFullName()) 245 - ->setHref($handle->getURI()) 246 - ->setDisabled($is_closed) 247 - ->setSelectable('batch[]', $object->getID(), true); 248 - 249 - $list->addItem($item); 250 - } 251 - 252 - return $list; 253 - } 254 - 255 - }
+32
src/applications/maniphest/controller/ManiphestBulkEditController.php
··· 1 + <?php 2 + 3 + final class ManiphestBulkEditController extends ManiphestController { 4 + 5 + public function handleRequest(AphrontRequest $request) { 6 + $viewer = $this->getViewer(); 7 + 8 + $this->requireApplicationCapability( 9 + ManiphestBulkEditCapability::CAPABILITY); 10 + 11 + $bulk_engine = id(new ManiphestTaskBulkEngine()) 12 + ->setViewer($viewer) 13 + ->setController($this) 14 + ->addContextParameter('board'); 15 + 16 + $board_id = $request->getInt('board'); 17 + if ($board_id) { 18 + $project = id(new PhabricatorProjectQuery()) 19 + ->setViewer($viewer) 20 + ->withIDs(array($board_id)) 21 + ->executeOne(); 22 + if (!$project) { 23 + return new Aphront404Response(); 24 + } 25 + 26 + $bulk_engine->setWorkboard($project); 27 + } 28 + 29 + return $bulk_engine->buildResponse(); 30 + } 31 + 32 + }
+1 -1
src/applications/maniphest/view/ManiphestTaskResultListView.php
··· 255 255 $user, 256 256 array( 257 257 'method' => 'POST', 258 - 'action' => '/maniphest/batch/', 258 + 'action' => '/maniphest/bulk/', 259 259 'id' => 'batch-select-form', 260 260 ), 261 261 $editor);
+16 -7
src/applications/project/controller/PhabricatorProjectBoardViewController.php
··· 230 230 ->addCancelButton($board_uri); 231 231 } 232 232 233 - $batch_ids = mpull($batch_tasks, 'getID'); 234 - $batch_ids = implode(',', $batch_ids); 233 + // Create a saved query to hold the working set. This allows us to get 234 + // around URI length limitations with a long "?ids=..." query string. 235 + // For details, see T10268. 236 + $search_engine = id(new ManiphestTaskSearchEngine()) 237 + ->setViewer($viewer); 238 + 239 + $saved_query = $search_engine->newSavedQuery(); 240 + $saved_query->setParameter('ids', mpull($batch_tasks, 'getID')); 241 + $search_engine->saveQuery($saved_query); 242 + 243 + $query_key = $saved_query->getQueryKey(); 235 244 236 - $batch_uri = new PhutilURI('/maniphest/batch/'); 237 - $batch_uri->setQueryParam('board', $this->id); 238 - $batch_uri->setQueryParam('batch', $batch_ids); 245 + $bulk_uri = new PhutilURI("/maniphest/bulk/query/{$query_key}/"); 246 + $bulk_uri->setQueryParam('board', $this->id); 247 + 239 248 return id(new AphrontRedirectResponse()) 240 - ->setURI($batch_uri); 249 + ->setURI($bulk_uri); 241 250 } 242 251 243 252 $move_id = $request->getStr('move'); ··· 1048 1057 1049 1058 $column_items[] = id(new PhabricatorActionView()) 1050 1059 ->setIcon('fa-list-ul') 1051 - ->setName(pht('Batch Edit Tasks...')) 1060 + ->setName(pht('Bulk Edit Tasks...')) 1052 1061 ->setHref($batch_edit_uri) 1053 1062 ->setDisabled(!$can_batch_edit); 1054 1063
+454
src/applications/transactions/bulk/PhabricatorBulkEngine.php
··· 1 + <?php 2 + 3 + abstract class PhabricatorBulkEngine extends Phobject { 4 + 5 + private $viewer; 6 + private $controller; 7 + private $context = array(); 8 + private $objectList; 9 + private $savedQuery; 10 + private $editableList; 11 + private $targetList; 12 + 13 + abstract public function newSearchEngine(); 14 + 15 + public function getCancelURI() { 16 + $saved_query = $this->savedQuery; 17 + if ($saved_query) { 18 + $path = '/query/'.$saved_query->getQueryKey().'/'; 19 + } else { 20 + $path = '/'; 21 + } 22 + 23 + return $this->getQueryURI($path); 24 + } 25 + 26 + public function getDoneURI() { 27 + if ($this->objectList !== null) { 28 + $ids = mpull($this->objectList, 'getID'); 29 + $path = '/?ids='.implode(',', $ids); 30 + } else { 31 + $path = '/'; 32 + } 33 + 34 + return $this->getQueryURI($path); 35 + } 36 + 37 + protected function getQueryURI($path = '/') { 38 + $viewer = $this->getViewer(); 39 + 40 + $engine = id($this->newSearchEngine()) 41 + ->setViewer($viewer); 42 + 43 + return $engine->getQueryBaseURI().ltrim($path, '/'); 44 + } 45 + 46 + protected function getBulkURI() { 47 + $saved_query = $this->savedQuery; 48 + if ($saved_query) { 49 + $path = '/query/'.$saved_query->getQueryKey().'/'; 50 + } else { 51 + $path = '/'; 52 + } 53 + 54 + return $this->getBulkBaseURI($path); 55 + } 56 + 57 + protected function getBulkBaseURI($path) { 58 + return $this->getQueryURI('bulk/'.ltrim($path, '/')); 59 + } 60 + 61 + final public function setViewer(PhabricatorUser $viewer) { 62 + $this->viewer = $viewer; 63 + return $this; 64 + } 65 + 66 + final public function getViewer() { 67 + return $this->viewer; 68 + } 69 + 70 + final public function setController(PhabricatorController $controller) { 71 + $this->controller = $controller; 72 + return $this; 73 + } 74 + 75 + final public function getController() { 76 + return $this->controller; 77 + } 78 + 79 + final public function addContextParameter($key) { 80 + $this->context[$key] = true; 81 + return $this; 82 + } 83 + 84 + final public function buildResponse() { 85 + $viewer = $this->getViewer(); 86 + $controller = $this->getController(); 87 + $request = $controller->getRequest(); 88 + 89 + $response = $this->loadObjectList(); 90 + if ($response) { 91 + return $response; 92 + } 93 + 94 + if ($request->isFormPost() && $request->getBool('bulkEngine')) { 95 + return $this->buildEditResponse(); 96 + } 97 + 98 + $list_view = $this->newBulkObjectList(); 99 + 100 + $header = id(new PHUIHeaderView()) 101 + ->setHeader(pht('Bulk Editor')) 102 + ->setHeaderIcon('fa-pencil-square-o'); 103 + 104 + $list_box = id(new PHUIObjectBoxView()) 105 + ->setHeaderText(pht('Working Set')) 106 + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 107 + ->setObjectList($list_view); 108 + 109 + $form_view = $this->newBulkActionForm(); 110 + 111 + $form_box = id(new PHUIObjectBoxView()) 112 + ->setHeaderText(pht('Actions')) 113 + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) 114 + ->setForm($form_view); 115 + 116 + $complete_form = phabricator_form( 117 + $viewer, 118 + array( 119 + 'action' => $this->getBulkURI(), 120 + 'method' => 'POST', 121 + 'id' => 'maniphest-batch-edit-form', 122 + ), 123 + array( 124 + $this->newContextInputs(), 125 + $list_box, 126 + $form_box, 127 + )); 128 + 129 + $column_view = id(new PHUITwoColumnView()) 130 + ->setHeader($header) 131 + ->setFooter($complete_form); 132 + 133 + // TODO: This is a bit hacky and inflexible. 134 + $crumbs = $controller->buildApplicationCrumbsForEditEngine(); 135 + $crumbs->addTextCrumb(pht('Query'), $this->getCancelURI()); 136 + $crumbs->addTextCrumb(pht('Bulk Editor')); 137 + 138 + return $controller->newPage() 139 + ->setTitle(pht('Bulk Edit')) 140 + ->setCrumbs($crumbs) 141 + ->appendChild($column_view); 142 + } 143 + 144 + private function loadObjectList() { 145 + $viewer = $this->getViewer(); 146 + $controller = $this->getController(); 147 + $request = $controller->getRequest(); 148 + 149 + $search_engine = id($this->newSearchEngine()) 150 + ->setViewer($viewer); 151 + 152 + $query_key = $request->getURIData('queryKey'); 153 + if (strlen($query_key)) { 154 + if ($search_engine->isBuiltinQuery($query_key)) { 155 + $saved = $search_engine->buildSavedQueryFromBuiltin($query_key); 156 + } else { 157 + $saved = id(new PhabricatorSavedQueryQuery()) 158 + ->setViewer($viewer) 159 + ->withQueryKeys(array($query_key)) 160 + ->executeOne(); 161 + if (!$saved) { 162 + return new Aphront404Response(); 163 + } 164 + } 165 + } else { 166 + // TODO: For now, since we don't deal gracefully with queries which 167 + // match a huge result set, just bail if we don't have any query 168 + // parameters instead of querying for a trillion tasks and timing out. 169 + $request_data = $request->getPassthroughRequestData(); 170 + if (!$request_data) { 171 + throw new Exception( 172 + pht( 173 + 'Expected a query key or a set of query constraints.')); 174 + } 175 + 176 + $saved = $search_engine->buildSavedQueryFromRequest($request); 177 + $search_engine->saveQuery($saved); 178 + } 179 + 180 + $object_query = $search_engine->buildQueryFromSavedQuery($saved) 181 + ->setViewer($viewer); 182 + $object_list = $object_query->execute(); 183 + $object_list = mpull($object_list, null, 'getPHID'); 184 + 185 + // If the user has submitted the bulk edit form, select only the objects 186 + // they checked. 187 + if ($request->getBool('bulkEngine')) { 188 + $target_phids = $request->getArr('bulkTargetPHIDs'); 189 + 190 + // NOTE: It's possible that the underlying query result set has changed 191 + // between the time we ran the query initially and now: for example, the 192 + // query was for "Open Tasks" and some tasks were closed while the user 193 + // was making action selections. 194 + 195 + // This could result in some objects getting dropped from the working set 196 + // here: we'll have target PHIDs for them, but they will no longer be 197 + // part of the object list. For now, just go with this since it doesn't 198 + // seem like a big problem and may even be desirable. 199 + 200 + $this->targetList = array_select_keys($object_list, $target_phids); 201 + } else { 202 + $this->targetList = $object_list; 203 + } 204 + 205 + $this->objectList = $object_list; 206 + $this->savedQuery = $saved; 207 + 208 + // Filter just the editable objects. We show all the objects which the 209 + // query matches whether they're editable or not, but indicate which ones 210 + // can not be edited to the user. 211 + 212 + $editable_list = id(new PhabricatorPolicyFilter()) 213 + ->setViewer($viewer) 214 + ->requireCapabilities( 215 + array( 216 + PhabricatorPolicyCapability::CAN_VIEW, 217 + PhabricatorPolicyCapability::CAN_EDIT, 218 + )) 219 + ->apply($object_list); 220 + $this->editableList = mpull($editable_list, null, 'getPHID'); 221 + 222 + return null; 223 + } 224 + 225 + private function newBulkObjectList() { 226 + $viewer = $this->getViewer(); 227 + 228 + $objects = $this->objectList; 229 + $objects = mpull($objects, null, 'getPHID'); 230 + 231 + $handles = $viewer->loadHandles(array_keys($objects)); 232 + 233 + $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; 234 + 235 + $list = id(new PHUIObjectItemListView()) 236 + ->setViewer($viewer) 237 + ->setFlush(true); 238 + 239 + foreach ($objects as $phid => $object) { 240 + $handle = $handles[$phid]; 241 + 242 + $is_closed = ($handle->getStatus() === $status_closed); 243 + $can_edit = isset($this->editableList[$phid]); 244 + $is_disabled = ($is_closed || !$can_edit); 245 + $is_selected = isset($this->targetList[$phid]); 246 + 247 + $item = id(new PHUIObjectItemView()) 248 + ->setHeader($handle->getFullName()) 249 + ->setHref($handle->getURI()) 250 + ->setDisabled($is_disabled) 251 + ->setSelectable('bulkTargetPHIDs[]', $phid, $is_selected, !$can_edit); 252 + 253 + if (!$can_edit) { 254 + $item->addIcon('fa-pencil red', pht('Not Editable')); 255 + } 256 + 257 + $list->addItem($item); 258 + } 259 + 260 + return $list; 261 + } 262 + 263 + private function newContextInputs() { 264 + $viewer = $this->getViewer(); 265 + $controller = $this->getController(); 266 + $request = $controller->getRequest(); 267 + 268 + $parameters = array(); 269 + foreach ($this->context as $key => $value) { 270 + $parameters[$key] = $request->getStr($key); 271 + } 272 + 273 + $parameters = array( 274 + 'bulkEngine' => 1, 275 + ) + $parameters; 276 + 277 + $result = array(); 278 + foreach ($parameters as $key => $value) { 279 + $result[] = phutil_tag( 280 + 'input', 281 + array( 282 + 'type' => 'hidden', 283 + 'name' => $key, 284 + 'value' => $value, 285 + )); 286 + } 287 + 288 + return $result; 289 + } 290 + 291 + private function newBulkActionForm() { 292 + $viewer = $this->getViewer(); 293 + 294 + $cancel_uri = $this->getCancelURI(); 295 + 296 + $template = new AphrontTokenizerTemplateView(); 297 + $template = $template->render(); 298 + 299 + $projects_source = new PhabricatorProjectDatasource(); 300 + $mailable_source = new PhabricatorMetaMTAMailableDatasource(); 301 + $mailable_source->setViewer($viewer); 302 + $owner_source = new ManiphestAssigneeDatasource(); 303 + $owner_source->setViewer($viewer); 304 + $spaces_source = id(new PhabricatorSpacesNamespaceDatasource()) 305 + ->setViewer($viewer); 306 + 307 + require_celerity_resource('maniphest-batch-editor'); 308 + 309 + Javelin::initBehavior( 310 + 'maniphest-batch-editor', 311 + array( 312 + 'root' => 'maniphest-batch-edit-form', 313 + 'tokenizerTemplate' => $template, 314 + 'sources' => array( 315 + 'project' => array( 316 + 'src' => $projects_source->getDatasourceURI(), 317 + 'placeholder' => $projects_source->getPlaceholderText(), 318 + 'browseURI' => $projects_source->getBrowseURI(), 319 + ), 320 + 'owner' => array( 321 + 'src' => $owner_source->getDatasourceURI(), 322 + 'placeholder' => $owner_source->getPlaceholderText(), 323 + 'browseURI' => $owner_source->getBrowseURI(), 324 + 'limit' => 1, 325 + ), 326 + 'cc' => array( 327 + 'src' => $mailable_source->getDatasourceURI(), 328 + 'placeholder' => $mailable_source->getPlaceholderText(), 329 + 'browseURI' => $mailable_source->getBrowseURI(), 330 + ), 331 + 'spaces' => array( 332 + 'src' => $spaces_source->getDatasourceURI(), 333 + 'placeholder' => $spaces_source->getPlaceholderText(), 334 + 'browseURI' => $spaces_source->getBrowseURI(), 335 + 'limit' => 1, 336 + ), 337 + ), 338 + 'input' => 'batch-form-actions', 339 + 'priorityMap' => ManiphestTaskPriority::getTaskPriorityMap(), 340 + 'statusMap' => ManiphestTaskStatus::getTaskStatusMap(), 341 + )); 342 + 343 + $form = id(new PHUIFormLayoutView()) 344 + ->setUser($viewer); 345 + 346 + $form->appendChild( 347 + phutil_tag( 348 + 'input', 349 + array( 350 + 'type' => 'hidden', 351 + 'name' => 'actions', 352 + 'id' => 'batch-form-actions', 353 + ))); 354 + 355 + $form->appendChild( 356 + id(new PHUIFormInsetView()) 357 + ->setTitle(pht('Bulk Edit Actions')) 358 + ->setRightButton( 359 + javelin_tag( 360 + 'a', 361 + array( 362 + 'href' => '#', 363 + 'class' => 'button button-green', 364 + 'sigil' => 'add-action', 365 + 'mustcapture' => true, 366 + ), 367 + pht('Add Another Action'))) 368 + ->setContent( 369 + javelin_tag( 370 + 'table', 371 + array( 372 + 'sigil' => 'maniphest-batch-actions', 373 + 'class' => 'maniphest-batch-actions-table', 374 + ), 375 + ''))) 376 + ->appendChild( 377 + id(new AphrontFormSubmitControl()) 378 + ->setValue(pht('Apply Bulk Edit')) 379 + ->addCancelButton($cancel_uri)); 380 + 381 + return $form; 382 + } 383 + 384 + private function buildEditResponse() { 385 + $viewer = $this->getViewer(); 386 + $controller = $this->getController(); 387 + $request = $controller->getRequest(); 388 + 389 + if (!$this->objectList) { 390 + throw new Exception(pht('Query does not match any objects.')); 391 + } 392 + 393 + if (!$this->editableList) { 394 + throw new Exception( 395 + pht( 396 + 'Query does not match any objects you have permission to edit.')); 397 + } 398 + 399 + // Restrict the selection set to objects the user can actually edit. 400 + $objects = array_intersect_key($this->editableList, $this->targetList); 401 + 402 + if (!$objects) { 403 + throw new Exception( 404 + pht( 405 + 'You have not selected any objects to edit.')); 406 + } 407 + 408 + $raw_actions = $request->getStr('actions'); 409 + if ($raw_actions) { 410 + $actions = phutil_json_decode($raw_actions); 411 + } else { 412 + $actions = array(); 413 + } 414 + 415 + if (!$actions) { 416 + throw new Exception( 417 + pht( 418 + 'You have not chosen any edits to apply.')); 419 + } 420 + 421 + $cancel_uri = $this->getCancelURI(); 422 + $done_uri = $this->getDoneURI(); 423 + 424 + $job = PhabricatorWorkerBulkJob::initializeNewJob( 425 + $viewer, 426 + // TODO: This is a Maniphest-specific job type for now, but will become 427 + // a generic one so it gets to live here for now instead of in the task 428 + // specific BulkEngine subclass. 429 + new ManiphestTaskEditBulkJobType(), 430 + array( 431 + 'taskPHIDs' => mpull($objects, 'getPHID'), 432 + 'actions' => $actions, 433 + 'cancelURI' => $cancel_uri, 434 + 'doneURI' => $done_uri, 435 + )); 436 + 437 + $type_status = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS; 438 + 439 + $xactions = array(); 440 + $xactions[] = id(new PhabricatorWorkerBulkJobTransaction()) 441 + ->setTransactionType($type_status) 442 + ->setNewValue(PhabricatorWorkerBulkJob::STATUS_CONFIRM); 443 + 444 + $editor = id(new PhabricatorWorkerBulkJobEditor()) 445 + ->setActor($viewer) 446 + ->setContentSourceFromRequest($request) 447 + ->setContinueOnMissingFields(true) 448 + ->applyTransactions($job, $xactions); 449 + 450 + return id(new AphrontRedirectResponse()) 451 + ->setURI($job->getMonitorURI()); 452 + } 453 + 454 + }
+25 -11
src/view/phui/PHUIObjectItemView.php
··· 32 32 private $selectableName; 33 33 private $selectableValue; 34 34 private $isSelected; 35 + private $isForbidden; 35 36 36 37 public function setDisabled($disabled) { 37 38 $this->disabled = $disabled; ··· 164 165 return $this; 165 166 } 166 167 167 - public function setSelectable($name, $value, $is_selected) { 168 + public function setSelectable( 169 + $name, 170 + $value, 171 + $is_selected, 172 + $is_forbidden = false) { 173 + 168 174 $this->selectableName = $name; 169 175 $this->selectableValue = $value; 170 176 $this->isSelected = $is_selected; 177 + $this->isForbidden = $is_forbidden; 178 + 171 179 return $this; 172 180 } 173 181 ··· 299 307 throw new Exception(pht('Invalid effect!')); 300 308 } 301 309 302 - if ($this->isSelected) { 310 + if ($this->isForbidden) { 311 + $item_classes[] = 'phui-oi-forbidden'; 312 + } else if ($this->isSelected) { 303 313 $item_classes[] = 'phui-oi-selected'; 304 314 } 305 315 306 - if ($this->selectableName !== null) { 316 + if ($this->selectableName !== null && !$this->isForbidden) { 307 317 $item_classes[] = 'phui-oi-selectable'; 308 318 $sigils[] = 'phui-oi-selectable'; 309 319 ··· 654 664 } 655 665 656 666 if ($this->selectableName !== null) { 657 - $checkbox = phutil_tag( 658 - 'input', 659 - array( 660 - 'type' => 'checkbox', 661 - 'name' => $this->selectableName, 662 - 'value' => $this->selectableValue, 663 - 'checked' => ($this->isSelected ? 'checked' : null), 664 - )); 667 + if (!$this->isForbidden) { 668 + $checkbox = phutil_tag( 669 + 'input', 670 + array( 671 + 'type' => 'checkbox', 672 + 'name' => $this->selectableName, 673 + 'value' => $this->selectableValue, 674 + 'checked' => ($this->isSelected ? 'checked' : null), 675 + )); 676 + } else { 677 + $checkbox = null; 678 + } 665 679 666 680 $column0 = phutil_tag( 667 681 'div',
+4
webroot/rsrc/css/phui/object-item/phui-oi-list-view.css
··· 455 455 border-color: {$sh-blueborder}; 456 456 } 457 457 458 + .phui-oi-forbidden { 459 + background: {$sh-redbackground}; 460 + } 461 + 458 462 459 463 /* - Handle Icons -------------------------------------------------------------- 460 464
+7 -4
webroot/rsrc/js/application/maniphest/behavior-batch-selector.js
··· 157 157 'submit', 158 158 null, 159 159 function() { 160 - var inputs = []; 160 + var ids = []; 161 161 for (var k in selected) { 162 - inputs.push( 163 - JX.$N('input', {type: 'hidden', name: 'batch[]', value: k})); 162 + ids.push(k); 164 163 } 165 - JX.DOM.setContent(JX.$(config.idContainer), inputs); 164 + ids = ids.join(','); 165 + 166 + var input = JX.$N('input', {type: 'hidden', name: 'ids', value: ids}); 167 + 168 + JX.DOM.setContent(JX.$(config.idContainer), input); 166 169 }); 167 170 168 171 update();