diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 0fdf44344f..650d3f7efb 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,7 +9,7 @@ return array( 'names' => array( 'conpherence.pkg.css' => 'e68cf1fa', 'conpherence.pkg.js' => '15191c65', - 'core.pkg.css' => '5be8063f', + 'core.pkg.css' => '075f9867', 'core.pkg.js' => '4c79d74f', 'darkconsole.pkg.js' => '1f9a31bc', 'differential.pkg.css' => '45951e9e', @@ -18,7 +18,7 @@ return array( 'diffusion.pkg.js' => '6134c5a1', 'favicon.ico' => '30672e08', 'maniphest.pkg.css' => '4845691a', - 'maniphest.pkg.js' => '5ab2753f', + 'maniphest.pkg.js' => '4d7e79c8', 'rsrc/audio/basic/alert.mp3' => '98461568', 'rsrc/audio/basic/bing.mp3' => 'ab8603a5', 'rsrc/audio/basic/pock.mp3' => '0cc772f5', @@ -135,7 +135,7 @@ return array( 'rsrc/css/phui/object-item/phui-oi-color.css' => 'cd2b9b77', 'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => '08f4ccc3', 'rsrc/css/phui/object-item/phui-oi-flush-ui.css' => '9d9685d6', - 'rsrc/css/phui/object-item/phui-oi-list-view.css' => '73c5f5c4', + 'rsrc/css/phui/object-item/phui-oi-list-view.css' => '6ae18df0', 'rsrc/css/phui/object-item/phui-oi-simple-ui.css' => 'a8beebea', 'rsrc/css/phui/phui-action-list.css' => 'f7f61a34', 'rsrc/css/phui/phui-action-panel.css' => 'b4798122', @@ -420,7 +420,7 @@ return array( 'rsrc/js/application/herald/PathTypeahead.js' => 'f7fc67ec', 'rsrc/js/application/herald/herald-rule-editor.js' => '7ebaeed3', 'rsrc/js/application/maniphest/behavior-batch-editor.js' => '782ab6e7', - 'rsrc/js/application/maniphest/behavior-batch-selector.js' => '0825c27a', + 'rsrc/js/application/maniphest/behavior-batch-selector.js' => 'ad54037e', 'rsrc/js/application/maniphest/behavior-line-chart.js' => 'e4232876', 'rsrc/js/application/maniphest/behavior-list-edit.js' => 'a9f88de2', 'rsrc/js/application/maniphest/behavior-subpriorityeditor.js' => '71237763', @@ -643,7 +643,7 @@ return array( 'javelin-behavior-line-chart' => 'e4232876', 'javelin-behavior-load-blame' => '42126667', 'javelin-behavior-maniphest-batch-editor' => '782ab6e7', - 'javelin-behavior-maniphest-batch-selector' => '0825c27a', + 'javelin-behavior-maniphest-batch-selector' => 'ad54037e', 'javelin-behavior-maniphest-list-editor' => 'a9f88de2', 'javelin-behavior-maniphest-subpriority-editor' => '71237763', 'javelin-behavior-owners-path-editor' => '7a68dda3', @@ -862,7 +862,7 @@ return array( 'phui-oi-color-css' => 'cd2b9b77', 'phui-oi-drag-ui-css' => '08f4ccc3', 'phui-oi-flush-ui-css' => '9d9685d6', - 'phui-oi-list-view-css' => '73c5f5c4', + 'phui-oi-list-view-css' => '6ae18df0', 'phui-oi-simple-ui-css' => 'a8beebea', 'phui-pager-css' => 'edcbc226', 'phui-pinboard-view-css' => '2495140e', @@ -960,12 +960,6 @@ return array( 'javelin-stratcom', 'javelin-workflow', ), - '0825c27a' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-stratcom', - 'javelin-util', - ), '08f4ccc3' => array( 'phui-oi-list-view-css', ), @@ -1815,6 +1809,12 @@ return array( 'phuix-autocomplete', 'javelin-mask', ), + 'ad54037e' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-stratcom', + 'javelin-util', + ), 'b003d4fb' => array( 'javelin-behavior', 'javelin-stratcom', diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index f4a410ad2e..3ff9ce4fd3 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1487,8 +1487,8 @@ phutil_register_library_map(array( 'MacroQueryConduitAPIMethod' => 'applications/macro/conduit/MacroQueryConduitAPIMethod.php', 'ManiphestAssignEmailCommand' => 'applications/maniphest/command/ManiphestAssignEmailCommand.php', 'ManiphestAssigneeDatasource' => 'applications/maniphest/typeahead/ManiphestAssigneeDatasource.php', - 'ManiphestBatchEditController' => 'applications/maniphest/controller/ManiphestBatchEditController.php', 'ManiphestBulkEditCapability' => 'applications/maniphest/capability/ManiphestBulkEditCapability.php', + 'ManiphestBulkEditController' => 'applications/maniphest/controller/ManiphestBulkEditController.php', 'ManiphestClaimEmailCommand' => 'applications/maniphest/command/ManiphestClaimEmailCommand.php', 'ManiphestCloseEmailCommand' => 'applications/maniphest/command/ManiphestCloseEmailCommand.php', 'ManiphestConduitAPIMethod' => 'applications/maniphest/conduit/ManiphestConduitAPIMethod.php', @@ -1547,6 +1547,7 @@ phutil_register_library_map(array( 'ManiphestTaskAttachTransaction' => 'applications/maniphest/xaction/ManiphestTaskAttachTransaction.php', 'ManiphestTaskAuthorHeraldField' => 'applications/maniphest/herald/ManiphestTaskAuthorHeraldField.php', 'ManiphestTaskAuthorPolicyRule' => 'applications/maniphest/policyrule/ManiphestTaskAuthorPolicyRule.php', + 'ManiphestTaskBulkEngine' => 'applications/maniphest/bulk/ManiphestTaskBulkEngine.php', 'ManiphestTaskCloseAsDuplicateRelationship' => 'applications/maniphest/relationship/ManiphestTaskCloseAsDuplicateRelationship.php', 'ManiphestTaskClosedStatusDatasource' => 'applications/maniphest/typeahead/ManiphestTaskClosedStatusDatasource.php', 'ManiphestTaskCoverImageTransaction' => 'applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php', @@ -2198,6 +2199,7 @@ phutil_register_library_map(array( 'PhabricatorBuiltinFileCachePurger' => 'applications/cache/purger/PhabricatorBuiltinFileCachePurger.php', 'PhabricatorBuiltinPatchList' => 'infrastructure/storage/patch/PhabricatorBuiltinPatchList.php', 'PhabricatorBulkContentSource' => 'infrastructure/daemon/contentsource/PhabricatorBulkContentSource.php', + 'PhabricatorBulkEngine' => 'applications/transactions/bulk/PhabricatorBulkEngine.php', 'PhabricatorCacheDAO' => 'applications/cache/storage/PhabricatorCacheDAO.php', 'PhabricatorCacheEngine' => 'applications/system/engine/PhabricatorCacheEngine.php', 'PhabricatorCacheEngineExtension' => 'applications/system/engine/PhabricatorCacheEngineExtension.php', @@ -6678,8 +6680,8 @@ phutil_register_library_map(array( 'MacroQueryConduitAPIMethod' => 'MacroConduitAPIMethod', 'ManiphestAssignEmailCommand' => 'ManiphestEmailCommand', 'ManiphestAssigneeDatasource' => 'PhabricatorTypeaheadCompositeDatasource', - 'ManiphestBatchEditController' => 'ManiphestController', 'ManiphestBulkEditCapability' => 'PhabricatorPolicyCapability', + 'ManiphestBulkEditController' => 'ManiphestController', 'ManiphestClaimEmailCommand' => 'ManiphestEmailCommand', 'ManiphestCloseEmailCommand' => 'ManiphestEmailCommand', 'ManiphestConduitAPIMethod' => 'ConduitAPIMethod', @@ -6761,6 +6763,7 @@ phutil_register_library_map(array( 'ManiphestTaskAttachTransaction' => 'ManiphestTaskTransactionType', 'ManiphestTaskAuthorHeraldField' => 'ManiphestTaskHeraldField', 'ManiphestTaskAuthorPolicyRule' => 'PhabricatorPolicyRule', + 'ManiphestTaskBulkEngine' => 'PhabricatorBulkEngine', 'ManiphestTaskCloseAsDuplicateRelationship' => 'ManiphestTaskRelationship', 'ManiphestTaskClosedStatusDatasource' => 'PhabricatorTypeaheadDatasource', 'ManiphestTaskCoverImageTransaction' => 'ManiphestTaskTransactionType', @@ -7487,6 +7490,7 @@ phutil_register_library_map(array( 'PhabricatorBuiltinFileCachePurger' => 'PhabricatorCachePurger', 'PhabricatorBuiltinPatchList' => 'PhabricatorSQLPatchList', 'PhabricatorBulkContentSource' => 'PhabricatorContentSource', + 'PhabricatorBulkEngine' => 'Phobject', 'PhabricatorCacheDAO' => 'PhabricatorLiskDAO', 'PhabricatorCacheEngine' => 'Phobject', 'PhabricatorCacheEngineExtension' => 'Phobject', diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php index c25612408c..0ae8e75354 100644 --- a/src/applications/base/PhabricatorApplication.php +++ b/src/applications/base/PhabricatorApplication.php @@ -618,6 +618,10 @@ abstract class PhabricatorApplication ')?'; } + protected function getBulkRoutePattern($base = null) { + return $base.'(?:query/(?P[^/]+)/)?'; + } + protected function getQueryRoutePattern($base = null) { return $base.'(?:query/(?P[^/]+)/)?'; } diff --git a/src/applications/maniphest/application/PhabricatorManiphestApplication.php b/src/applications/maniphest/application/PhabricatorManiphestApplication.php index 3076354599..6e4ac0a8f6 100644 --- a/src/applications/maniphest/application/PhabricatorManiphestApplication.php +++ b/src/applications/maniphest/application/PhabricatorManiphestApplication.php @@ -52,7 +52,7 @@ final class PhabricatorManiphestApplication extends PhabricatorApplication { '/maniphest/' => array( '(?:query/(?P[^/]+)/)?' => 'ManiphestTaskListController', 'report/(?:(?P\w+)/)?' => 'ManiphestReportController', - 'batch/' => 'ManiphestBatchEditController', + $this->getBulkRoutePattern('bulk/') => 'ManiphestBulkEditController', 'task/' => array( $this->getEditRoutePattern('edit/') => 'ManiphestTaskEditController', diff --git a/src/applications/maniphest/bulk/ManiphestTaskBulkEngine.php b/src/applications/maniphest/bulk/ManiphestTaskBulkEngine.php new file mode 100644 index 0000000000..8b62f627d8 --- /dev/null +++ b/src/applications/maniphest/bulk/ManiphestTaskBulkEngine.php @@ -0,0 +1,50 @@ +workboard = $workboard; + return $this; + } + + public function getWorkboard() { + return $this->workboard; + } + + public function newSearchEngine() { + return new ManiphestTaskSearchEngine(); + } + + public function getDoneURI() { + $board_uri = $this->getBoardURI(); + if ($board_uri) { + return $board_uri; + } + + return parent::getDoneURI(); + } + + public function getCancelURI() { + $board_uri = $this->getBoardURI(); + if ($board_uri) { + return $board_uri; + } + + return parent::getCancelURI(); + } + + private function getBoardURI() { + $workboard = $this->getWorkboard(); + + if ($workboard) { + $project_id = $workboard->getID(); + return "/project/board/{$project_id}/"; + } + + return null; + } + +} diff --git a/src/applications/maniphest/controller/ManiphestBatchEditController.php b/src/applications/maniphest/controller/ManiphestBatchEditController.php deleted file mode 100644 index c3438a6cee..0000000000 --- a/src/applications/maniphest/controller/ManiphestBatchEditController.php +++ /dev/null @@ -1,255 +0,0 @@ -getViewer(); - - $this->requireApplicationCapability( - ManiphestBulkEditCapability::CAPABILITY); - - $project = null; - $board_id = $request->getInt('board'); - if ($board_id) { - $project = id(new PhabricatorProjectQuery()) - ->setViewer($viewer) - ->withIDs(array($board_id)) - ->executeOne(); - if (!$project) { - return new Aphront404Response(); - } - } - - $task_ids = $request->getArr('batch'); - if (!$task_ids) { - $task_ids = $request->getStrList('batch'); - } - - if (!$task_ids) { - throw new Exception( - pht( - 'No tasks are selected.')); - } - - $tasks = id(new ManiphestTaskQuery()) - ->setViewer($viewer) - ->withIDs($task_ids) - ->requireCapabilities( - array( - PhabricatorPolicyCapability::CAN_VIEW, - PhabricatorPolicyCapability::CAN_EDIT, - )) - ->needSubscriberPHIDs(true) - ->needProjectPHIDs(true) - ->execute(); - - if (!$tasks) { - throw new Exception( - pht("You don't have permission to edit any of the selected tasks.")); - } - - if ($project) { - $cancel_uri = '/project/board/'.$project->getID().'/'; - $redirect_uri = $cancel_uri; - } else { - $cancel_uri = '/maniphest/'; - $redirect_uri = '/maniphest/?ids='.implode(',', mpull($tasks, 'getID')); - } - - $actions = $request->getStr('actions'); - if ($actions) { - $actions = phutil_json_decode($actions); - } - - if ($request->isFormPost() && $actions) { - $job = PhabricatorWorkerBulkJob::initializeNewJob( - $viewer, - new ManiphestTaskEditBulkJobType(), - array( - 'taskPHIDs' => mpull($tasks, 'getPHID'), - 'actions' => $actions, - 'cancelURI' => $cancel_uri, - 'doneURI' => $redirect_uri, - )); - - $type_status = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS; - - $xactions = array(); - $xactions[] = id(new PhabricatorWorkerBulkJobTransaction()) - ->setTransactionType($type_status) - ->setNewValue(PhabricatorWorkerBulkJob::STATUS_CONFIRM); - - $editor = id(new PhabricatorWorkerBulkJobEditor()) - ->setActor($viewer) - ->setContentSourceFromRequest($request) - ->setContinueOnMissingFields(true) - ->applyTransactions($job, $xactions); - - return id(new AphrontRedirectResponse()) - ->setURI($job->getMonitorURI()); - } - - $list = $this->newBulkObjectList($tasks); - - $template = new AphrontTokenizerTemplateView(); - $template = $template->render(); - - $projects_source = new PhabricatorProjectDatasource(); - $mailable_source = new PhabricatorMetaMTAMailableDatasource(); - $mailable_source->setViewer($viewer); - $owner_source = new ManiphestAssigneeDatasource(); - $owner_source->setViewer($viewer); - $spaces_source = id(new PhabricatorSpacesNamespaceDatasource()) - ->setViewer($viewer); - - require_celerity_resource('maniphest-batch-editor'); - Javelin::initBehavior( - 'maniphest-batch-editor', - array( - 'root' => 'maniphest-batch-edit-form', - 'tokenizerTemplate' => $template, - 'sources' => array( - 'project' => array( - 'src' => $projects_source->getDatasourceURI(), - 'placeholder' => $projects_source->getPlaceholderText(), - 'browseURI' => $projects_source->getBrowseURI(), - ), - 'owner' => array( - 'src' => $owner_source->getDatasourceURI(), - 'placeholder' => $owner_source->getPlaceholderText(), - 'browseURI' => $owner_source->getBrowseURI(), - 'limit' => 1, - ), - 'cc' => array( - 'src' => $mailable_source->getDatasourceURI(), - 'placeholder' => $mailable_source->getPlaceholderText(), - 'browseURI' => $mailable_source->getBrowseURI(), - ), - 'spaces' => array( - 'src' => $spaces_source->getDatasourceURI(), - 'placeholder' => $spaces_source->getPlaceholderText(), - 'browseURI' => $spaces_source->getBrowseURI(), - 'limit' => 1, - ), - ), - 'input' => 'batch-form-actions', - 'priorityMap' => ManiphestTaskPriority::getTaskPriorityMap(), - 'statusMap' => ManiphestTaskStatus::getTaskStatusMap(), - )); - - $form = id(new PHUIFormLayoutView()) - ->setUser($viewer); - - $form->appendChild( - phutil_tag( - 'input', - array( - 'type' => 'hidden', - 'name' => 'actions', - 'id' => 'batch-form-actions', - ))); - - $form->appendChild( - id(new PHUIFormInsetView()) - ->setTitle(pht('Actions')) - ->setRightButton(javelin_tag( - 'a', - array( - 'href' => '#', - 'class' => 'button button-green', - 'sigil' => 'add-action', - 'mustcapture' => true, - ), - pht('Add Another Action'))) - ->setContent(javelin_tag( - 'table', - array( - 'sigil' => 'maniphest-batch-actions', - 'class' => 'maniphest-batch-actions-table', - ), - ''))) - ->appendChild( - id(new AphrontFormSubmitControl()) - ->setValue(pht('Update Tasks')) - ->addCancelButton($cancel_uri)); - - $title = pht('Batch Editor'); - - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb($title); - $crumbs->setBorder(true); - - $header = id(new PHUIHeaderView()) - ->setHeader(pht('Batch Editor')) - ->setHeaderIcon('fa-pencil-square-o'); - - $task_box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Selected Tasks')) - ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->setObjectList($list); - - $form_box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Actions')) - ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->setForm($form); - - - $complete_form = phabricator_form( - $viewer, - array( - 'action' => $request->getRequestURI(), - 'method' => 'POST', - 'id' => 'maniphest-batch-edit-form', - ), - array( - phutil_tag( - 'input', - array( - 'type' => 'hidden', - 'name' => 'board', - 'value' => $board_id, - )), - $task_box, - $form_box, - )); - - $view = id(new PHUITwoColumnView()) - ->setHeader($header) - ->setFooter($complete_form); - - return $this->newPage() - ->setTitle($title) - ->setCrumbs($crumbs) - ->appendChild($view); - } - - private function newBulkObjectList(array $objects) { - $viewer = $this->getViewer(); - $objects = mpull($objects, null, 'getPHID'); - - $handles = $viewer->loadHandles(array_keys($objects)); - - $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; - - $list = id(new PHUIObjectItemListView()) - ->setViewer($viewer) - ->setFlush(true); - - foreach ($objects as $phid => $object) { - $handle = $handles[$phid]; - - $is_closed = ($handle->getStatus() === $status_closed); - - $item = id(new PHUIObjectItemView()) - ->setHeader($handle->getFullName()) - ->setHref($handle->getURI()) - ->setDisabled($is_closed) - ->setSelectable('batch[]', $object->getID(), true); - - $list->addItem($item); - } - - return $list; - } - -} diff --git a/src/applications/maniphest/controller/ManiphestBulkEditController.php b/src/applications/maniphest/controller/ManiphestBulkEditController.php new file mode 100644 index 0000000000..b698e54848 --- /dev/null +++ b/src/applications/maniphest/controller/ManiphestBulkEditController.php @@ -0,0 +1,32 @@ +getViewer(); + + $this->requireApplicationCapability( + ManiphestBulkEditCapability::CAPABILITY); + + $bulk_engine = id(new ManiphestTaskBulkEngine()) + ->setViewer($viewer) + ->setController($this) + ->addContextParameter('board'); + + $board_id = $request->getInt('board'); + if ($board_id) { + $project = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->withIDs(array($board_id)) + ->executeOne(); + if (!$project) { + return new Aphront404Response(); + } + + $bulk_engine->setWorkboard($project); + } + + return $bulk_engine->buildResponse(); + } + +} diff --git a/src/applications/maniphest/view/ManiphestTaskResultListView.php b/src/applications/maniphest/view/ManiphestTaskResultListView.php index 0144df0e33..2203db8bfc 100644 --- a/src/applications/maniphest/view/ManiphestTaskResultListView.php +++ b/src/applications/maniphest/view/ManiphestTaskResultListView.php @@ -255,7 +255,7 @@ final class ManiphestTaskResultListView extends ManiphestView { $user, array( 'method' => 'POST', - 'action' => '/maniphest/batch/', + 'action' => '/maniphest/bulk/', 'id' => 'batch-select-form', ), $editor); diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 793fe5603d..9396d1873e 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -230,14 +230,23 @@ final class PhabricatorProjectBoardViewController ->addCancelButton($board_uri); } - $batch_ids = mpull($batch_tasks, 'getID'); - $batch_ids = implode(',', $batch_ids); + // Create a saved query to hold the working set. This allows us to get + // around URI length limitations with a long "?ids=..." query string. + // For details, see T10268. + $search_engine = id(new ManiphestTaskSearchEngine()) + ->setViewer($viewer); + + $saved_query = $search_engine->newSavedQuery(); + $saved_query->setParameter('ids', mpull($batch_tasks, 'getID')); + $search_engine->saveQuery($saved_query); + + $query_key = $saved_query->getQueryKey(); + + $bulk_uri = new PhutilURI("/maniphest/bulk/query/{$query_key}/"); + $bulk_uri->setQueryParam('board', $this->id); - $batch_uri = new PhutilURI('/maniphest/batch/'); - $batch_uri->setQueryParam('board', $this->id); - $batch_uri->setQueryParam('batch', $batch_ids); return id(new AphrontRedirectResponse()) - ->setURI($batch_uri); + ->setURI($bulk_uri); } $move_id = $request->getStr('move'); @@ -1048,7 +1057,7 @@ final class PhabricatorProjectBoardViewController $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-list-ul') - ->setName(pht('Batch Edit Tasks...')) + ->setName(pht('Bulk Edit Tasks...')) ->setHref($batch_edit_uri) ->setDisabled(!$can_batch_edit); diff --git a/src/applications/transactions/bulk/PhabricatorBulkEngine.php b/src/applications/transactions/bulk/PhabricatorBulkEngine.php new file mode 100644 index 0000000000..82dc7f2510 --- /dev/null +++ b/src/applications/transactions/bulk/PhabricatorBulkEngine.php @@ -0,0 +1,454 @@ +savedQuery; + if ($saved_query) { + $path = '/query/'.$saved_query->getQueryKey().'/'; + } else { + $path = '/'; + } + + return $this->getQueryURI($path); + } + + public function getDoneURI() { + if ($this->objectList !== null) { + $ids = mpull($this->objectList, 'getID'); + $path = '/?ids='.implode(',', $ids); + } else { + $path = '/'; + } + + return $this->getQueryURI($path); + } + + protected function getQueryURI($path = '/') { + $viewer = $this->getViewer(); + + $engine = id($this->newSearchEngine()) + ->setViewer($viewer); + + return $engine->getQueryBaseURI().ltrim($path, '/'); + } + + protected function getBulkURI() { + $saved_query = $this->savedQuery; + if ($saved_query) { + $path = '/query/'.$saved_query->getQueryKey().'/'; + } else { + $path = '/'; + } + + return $this->getBulkBaseURI($path); + } + + protected function getBulkBaseURI($path) { + return $this->getQueryURI('bulk/'.ltrim($path, '/')); + } + + final public function setViewer(PhabricatorUser $viewer) { + $this->viewer = $viewer; + return $this; + } + + final public function getViewer() { + return $this->viewer; + } + + final public function setController(PhabricatorController $controller) { + $this->controller = $controller; + return $this; + } + + final public function getController() { + return $this->controller; + } + + final public function addContextParameter($key) { + $this->context[$key] = true; + return $this; + } + + final public function buildResponse() { + $viewer = $this->getViewer(); + $controller = $this->getController(); + $request = $controller->getRequest(); + + $response = $this->loadObjectList(); + if ($response) { + return $response; + } + + if ($request->isFormPost() && $request->getBool('bulkEngine')) { + return $this->buildEditResponse(); + } + + $list_view = $this->newBulkObjectList(); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Bulk Editor')) + ->setHeaderIcon('fa-pencil-square-o'); + + $list_box = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Working Set')) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setObjectList($list_view); + + $form_view = $this->newBulkActionForm(); + + $form_box = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Actions')) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setForm($form_view); + + $complete_form = phabricator_form( + $viewer, + array( + 'action' => $this->getBulkURI(), + 'method' => 'POST', + 'id' => 'maniphest-batch-edit-form', + ), + array( + $this->newContextInputs(), + $list_box, + $form_box, + )); + + $column_view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setFooter($complete_form); + + // TODO: This is a bit hacky and inflexible. + $crumbs = $controller->buildApplicationCrumbsForEditEngine(); + $crumbs->addTextCrumb(pht('Query'), $this->getCancelURI()); + $crumbs->addTextCrumb(pht('Bulk Editor')); + + return $controller->newPage() + ->setTitle(pht('Bulk Edit')) + ->setCrumbs($crumbs) + ->appendChild($column_view); + } + + private function loadObjectList() { + $viewer = $this->getViewer(); + $controller = $this->getController(); + $request = $controller->getRequest(); + + $search_engine = id($this->newSearchEngine()) + ->setViewer($viewer); + + $query_key = $request->getURIData('queryKey'); + if (strlen($query_key)) { + if ($search_engine->isBuiltinQuery($query_key)) { + $saved = $search_engine->buildSavedQueryFromBuiltin($query_key); + } else { + $saved = id(new PhabricatorSavedQueryQuery()) + ->setViewer($viewer) + ->withQueryKeys(array($query_key)) + ->executeOne(); + if (!$saved) { + return new Aphront404Response(); + } + } + } else { + // TODO: For now, since we don't deal gracefully with queries which + // match a huge result set, just bail if we don't have any query + // parameters instead of querying for a trillion tasks and timing out. + $request_data = $request->getPassthroughRequestData(); + if (!$request_data) { + throw new Exception( + pht( + 'Expected a query key or a set of query constraints.')); + } + + $saved = $search_engine->buildSavedQueryFromRequest($request); + $search_engine->saveQuery($saved); + } + + $object_query = $search_engine->buildQueryFromSavedQuery($saved) + ->setViewer($viewer); + $object_list = $object_query->execute(); + $object_list = mpull($object_list, null, 'getPHID'); + + // If the user has submitted the bulk edit form, select only the objects + // they checked. + if ($request->getBool('bulkEngine')) { + $target_phids = $request->getArr('bulkTargetPHIDs'); + + // NOTE: It's possible that the underlying query result set has changed + // between the time we ran the query initially and now: for example, the + // query was for "Open Tasks" and some tasks were closed while the user + // was making action selections. + + // This could result in some objects getting dropped from the working set + // here: we'll have target PHIDs for them, but they will no longer be + // part of the object list. For now, just go with this since it doesn't + // seem like a big problem and may even be desirable. + + $this->targetList = array_select_keys($object_list, $target_phids); + } else { + $this->targetList = $object_list; + } + + $this->objectList = $object_list; + $this->savedQuery = $saved; + + // Filter just the editable objects. We show all the objects which the + // query matches whether they're editable or not, but indicate which ones + // can not be edited to the user. + + $editable_list = id(new PhabricatorPolicyFilter()) + ->setViewer($viewer) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->apply($object_list); + $this->editableList = mpull($editable_list, null, 'getPHID'); + + return null; + } + + private function newBulkObjectList() { + $viewer = $this->getViewer(); + + $objects = $this->objectList; + $objects = mpull($objects, null, 'getPHID'); + + $handles = $viewer->loadHandles(array_keys($objects)); + + $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; + + $list = id(new PHUIObjectItemListView()) + ->setViewer($viewer) + ->setFlush(true); + + foreach ($objects as $phid => $object) { + $handle = $handles[$phid]; + + $is_closed = ($handle->getStatus() === $status_closed); + $can_edit = isset($this->editableList[$phid]); + $is_disabled = ($is_closed || !$can_edit); + $is_selected = isset($this->targetList[$phid]); + + $item = id(new PHUIObjectItemView()) + ->setHeader($handle->getFullName()) + ->setHref($handle->getURI()) + ->setDisabled($is_disabled) + ->setSelectable('bulkTargetPHIDs[]', $phid, $is_selected, !$can_edit); + + if (!$can_edit) { + $item->addIcon('fa-pencil red', pht('Not Editable')); + } + + $list->addItem($item); + } + + return $list; + } + + private function newContextInputs() { + $viewer = $this->getViewer(); + $controller = $this->getController(); + $request = $controller->getRequest(); + + $parameters = array(); + foreach ($this->context as $key => $value) { + $parameters[$key] = $request->getStr($key); + } + + $parameters = array( + 'bulkEngine' => 1, + ) + $parameters; + + $result = array(); + foreach ($parameters as $key => $value) { + $result[] = phutil_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => $key, + 'value' => $value, + )); + } + + return $result; + } + + private function newBulkActionForm() { + $viewer = $this->getViewer(); + + $cancel_uri = $this->getCancelURI(); + + $template = new AphrontTokenizerTemplateView(); + $template = $template->render(); + + $projects_source = new PhabricatorProjectDatasource(); + $mailable_source = new PhabricatorMetaMTAMailableDatasource(); + $mailable_source->setViewer($viewer); + $owner_source = new ManiphestAssigneeDatasource(); + $owner_source->setViewer($viewer); + $spaces_source = id(new PhabricatorSpacesNamespaceDatasource()) + ->setViewer($viewer); + + require_celerity_resource('maniphest-batch-editor'); + + Javelin::initBehavior( + 'maniphest-batch-editor', + array( + 'root' => 'maniphest-batch-edit-form', + 'tokenizerTemplate' => $template, + 'sources' => array( + 'project' => array( + 'src' => $projects_source->getDatasourceURI(), + 'placeholder' => $projects_source->getPlaceholderText(), + 'browseURI' => $projects_source->getBrowseURI(), + ), + 'owner' => array( + 'src' => $owner_source->getDatasourceURI(), + 'placeholder' => $owner_source->getPlaceholderText(), + 'browseURI' => $owner_source->getBrowseURI(), + 'limit' => 1, + ), + 'cc' => array( + 'src' => $mailable_source->getDatasourceURI(), + 'placeholder' => $mailable_source->getPlaceholderText(), + 'browseURI' => $mailable_source->getBrowseURI(), + ), + 'spaces' => array( + 'src' => $spaces_source->getDatasourceURI(), + 'placeholder' => $spaces_source->getPlaceholderText(), + 'browseURI' => $spaces_source->getBrowseURI(), + 'limit' => 1, + ), + ), + 'input' => 'batch-form-actions', + 'priorityMap' => ManiphestTaskPriority::getTaskPriorityMap(), + 'statusMap' => ManiphestTaskStatus::getTaskStatusMap(), + )); + + $form = id(new PHUIFormLayoutView()) + ->setUser($viewer); + + $form->appendChild( + phutil_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => 'actions', + 'id' => 'batch-form-actions', + ))); + + $form->appendChild( + id(new PHUIFormInsetView()) + ->setTitle(pht('Bulk Edit Actions')) + ->setRightButton( + javelin_tag( + 'a', + array( + 'href' => '#', + 'class' => 'button button-green', + 'sigil' => 'add-action', + 'mustcapture' => true, + ), + pht('Add Another Action'))) + ->setContent( + javelin_tag( + 'table', + array( + 'sigil' => 'maniphest-batch-actions', + 'class' => 'maniphest-batch-actions-table', + ), + ''))) + ->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue(pht('Apply Bulk Edit')) + ->addCancelButton($cancel_uri)); + + return $form; + } + + private function buildEditResponse() { + $viewer = $this->getViewer(); + $controller = $this->getController(); + $request = $controller->getRequest(); + + if (!$this->objectList) { + throw new Exception(pht('Query does not match any objects.')); + } + + if (!$this->editableList) { + throw new Exception( + pht( + 'Query does not match any objects you have permission to edit.')); + } + + // Restrict the selection set to objects the user can actually edit. + $objects = array_intersect_key($this->editableList, $this->targetList); + + if (!$objects) { + throw new Exception( + pht( + 'You have not selected any objects to edit.')); + } + + $raw_actions = $request->getStr('actions'); + if ($raw_actions) { + $actions = phutil_json_decode($raw_actions); + } else { + $actions = array(); + } + + if (!$actions) { + throw new Exception( + pht( + 'You have not chosen any edits to apply.')); + } + + $cancel_uri = $this->getCancelURI(); + $done_uri = $this->getDoneURI(); + + $job = PhabricatorWorkerBulkJob::initializeNewJob( + $viewer, + // TODO: This is a Maniphest-specific job type for now, but will become + // a generic one so it gets to live here for now instead of in the task + // specific BulkEngine subclass. + new ManiphestTaskEditBulkJobType(), + array( + 'taskPHIDs' => mpull($objects, 'getPHID'), + 'actions' => $actions, + 'cancelURI' => $cancel_uri, + 'doneURI' => $done_uri, + )); + + $type_status = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS; + + $xactions = array(); + $xactions[] = id(new PhabricatorWorkerBulkJobTransaction()) + ->setTransactionType($type_status) + ->setNewValue(PhabricatorWorkerBulkJob::STATUS_CONFIRM); + + $editor = id(new PhabricatorWorkerBulkJobEditor()) + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnMissingFields(true) + ->applyTransactions($job, $xactions); + + return id(new AphrontRedirectResponse()) + ->setURI($job->getMonitorURI()); + } + +} diff --git a/src/view/phui/PHUIObjectItemView.php b/src/view/phui/PHUIObjectItemView.php index 4753561124..c1e57632c4 100644 --- a/src/view/phui/PHUIObjectItemView.php +++ b/src/view/phui/PHUIObjectItemView.php @@ -32,6 +32,7 @@ final class PHUIObjectItemView extends AphrontTagView { private $selectableName; private $selectableValue; private $isSelected; + private $isForbidden; public function setDisabled($disabled) { $this->disabled = $disabled; @@ -164,10 +165,17 @@ final class PHUIObjectItemView extends AphrontTagView { return $this; } - public function setSelectable($name, $value, $is_selected) { + public function setSelectable( + $name, + $value, + $is_selected, + $is_forbidden = false) { + $this->selectableName = $name; $this->selectableValue = $value; $this->isSelected = $is_selected; + $this->isForbidden = $is_forbidden; + return $this; } @@ -299,11 +307,13 @@ final class PHUIObjectItemView extends AphrontTagView { throw new Exception(pht('Invalid effect!')); } - if ($this->isSelected) { + if ($this->isForbidden) { + $item_classes[] = 'phui-oi-forbidden'; + } else if ($this->isSelected) { $item_classes[] = 'phui-oi-selected'; } - if ($this->selectableName !== null) { + if ($this->selectableName !== null && !$this->isForbidden) { $item_classes[] = 'phui-oi-selectable'; $sigils[] = 'phui-oi-selectable'; @@ -654,14 +664,18 @@ final class PHUIObjectItemView extends AphrontTagView { } if ($this->selectableName !== null) { - $checkbox = phutil_tag( - 'input', - array( - 'type' => 'checkbox', - 'name' => $this->selectableName, - 'value' => $this->selectableValue, - 'checked' => ($this->isSelected ? 'checked' : null), - )); + if (!$this->isForbidden) { + $checkbox = phutil_tag( + 'input', + array( + 'type' => 'checkbox', + 'name' => $this->selectableName, + 'value' => $this->selectableValue, + 'checked' => ($this->isSelected ? 'checked' : null), + )); + } else { + $checkbox = null; + } $column0 = phutil_tag( 'div', diff --git a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css index 4ee5d41b67..ff79a8d70b 100644 --- a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css +++ b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css @@ -455,6 +455,10 @@ ul.phui-oi-list-view .phui-oi-selected border-color: {$sh-blueborder}; } +.phui-oi-forbidden { + background: {$sh-redbackground}; +} + /* - Handle Icons -------------------------------------------------------------- diff --git a/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js b/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js index b7728abb57..b62f40a589 100644 --- a/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js +++ b/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js @@ -157,12 +157,15 @@ JX.behavior('maniphest-batch-selector', function(config) { 'submit', null, function() { - var inputs = []; + var ids = []; for (var k in selected) { - inputs.push( - JX.$N('input', {type: 'hidden', name: 'batch[]', value: k})); + ids.push(k); } - JX.DOM.setContent(JX.$(config.idContainer), inputs); + ids = ids.join(','); + + var input = JX.$N('input', {type: 'hidden', name: 'ids', value: ids}); + + JX.DOM.setContent(JX.$(config.idContainer), input); }); update();