1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-22 14:52:41 +01:00

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
This commit is contained in:
epriestley 2017-11-30 05:57:39 -08:00
parent ad659627b3
commit 7f91c8c4ac
13 changed files with 612 additions and 293 deletions

View file

@ -9,7 +9,7 @@ return array(
'names' => array( 'names' => array(
'conpherence.pkg.css' => 'e68cf1fa', 'conpherence.pkg.css' => 'e68cf1fa',
'conpherence.pkg.js' => '15191c65', 'conpherence.pkg.js' => '15191c65',
'core.pkg.css' => '5be8063f', 'core.pkg.css' => '075f9867',
'core.pkg.js' => '4c79d74f', 'core.pkg.js' => '4c79d74f',
'darkconsole.pkg.js' => '1f9a31bc', 'darkconsole.pkg.js' => '1f9a31bc',
'differential.pkg.css' => '45951e9e', 'differential.pkg.css' => '45951e9e',
@ -18,7 +18,7 @@ return array(
'diffusion.pkg.js' => '6134c5a1', 'diffusion.pkg.js' => '6134c5a1',
'favicon.ico' => '30672e08', 'favicon.ico' => '30672e08',
'maniphest.pkg.css' => '4845691a', 'maniphest.pkg.css' => '4845691a',
'maniphest.pkg.js' => '5ab2753f', 'maniphest.pkg.js' => '4d7e79c8',
'rsrc/audio/basic/alert.mp3' => '98461568', 'rsrc/audio/basic/alert.mp3' => '98461568',
'rsrc/audio/basic/bing.mp3' => 'ab8603a5', 'rsrc/audio/basic/bing.mp3' => 'ab8603a5',
'rsrc/audio/basic/pock.mp3' => '0cc772f5', '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-color.css' => 'cd2b9b77',
'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => '08f4ccc3', '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-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/object-item/phui-oi-simple-ui.css' => 'a8beebea',
'rsrc/css/phui/phui-action-list.css' => 'f7f61a34', 'rsrc/css/phui/phui-action-list.css' => 'f7f61a34',
'rsrc/css/phui/phui-action-panel.css' => 'b4798122', '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/PathTypeahead.js' => 'f7fc67ec',
'rsrc/js/application/herald/herald-rule-editor.js' => '7ebaeed3', 'rsrc/js/application/herald/herald-rule-editor.js' => '7ebaeed3',
'rsrc/js/application/maniphest/behavior-batch-editor.js' => '782ab6e7', '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-line-chart.js' => 'e4232876',
'rsrc/js/application/maniphest/behavior-list-edit.js' => 'a9f88de2', 'rsrc/js/application/maniphest/behavior-list-edit.js' => 'a9f88de2',
'rsrc/js/application/maniphest/behavior-subpriorityeditor.js' => '71237763', 'rsrc/js/application/maniphest/behavior-subpriorityeditor.js' => '71237763',
@ -643,7 +643,7 @@ return array(
'javelin-behavior-line-chart' => 'e4232876', 'javelin-behavior-line-chart' => 'e4232876',
'javelin-behavior-load-blame' => '42126667', 'javelin-behavior-load-blame' => '42126667',
'javelin-behavior-maniphest-batch-editor' => '782ab6e7', '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-list-editor' => 'a9f88de2',
'javelin-behavior-maniphest-subpriority-editor' => '71237763', 'javelin-behavior-maniphest-subpriority-editor' => '71237763',
'javelin-behavior-owners-path-editor' => '7a68dda3', 'javelin-behavior-owners-path-editor' => '7a68dda3',
@ -862,7 +862,7 @@ return array(
'phui-oi-color-css' => 'cd2b9b77', 'phui-oi-color-css' => 'cd2b9b77',
'phui-oi-drag-ui-css' => '08f4ccc3', 'phui-oi-drag-ui-css' => '08f4ccc3',
'phui-oi-flush-ui-css' => '9d9685d6', '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-oi-simple-ui-css' => 'a8beebea',
'phui-pager-css' => 'edcbc226', 'phui-pager-css' => 'edcbc226',
'phui-pinboard-view-css' => '2495140e', 'phui-pinboard-view-css' => '2495140e',
@ -960,12 +960,6 @@ return array(
'javelin-stratcom', 'javelin-stratcom',
'javelin-workflow', 'javelin-workflow',
), ),
'0825c27a' => array(
'javelin-behavior',
'javelin-dom',
'javelin-stratcom',
'javelin-util',
),
'08f4ccc3' => array( '08f4ccc3' => array(
'phui-oi-list-view-css', 'phui-oi-list-view-css',
), ),
@ -1815,6 +1809,12 @@ return array(
'phuix-autocomplete', 'phuix-autocomplete',
'javelin-mask', 'javelin-mask',
), ),
'ad54037e' => array(
'javelin-behavior',
'javelin-dom',
'javelin-stratcom',
'javelin-util',
),
'b003d4fb' => array( 'b003d4fb' => array(
'javelin-behavior', 'javelin-behavior',
'javelin-stratcom', 'javelin-stratcom',

View file

@ -1487,8 +1487,8 @@ phutil_register_library_map(array(
'MacroQueryConduitAPIMethod' => 'applications/macro/conduit/MacroQueryConduitAPIMethod.php', 'MacroQueryConduitAPIMethod' => 'applications/macro/conduit/MacroQueryConduitAPIMethod.php',
'ManiphestAssignEmailCommand' => 'applications/maniphest/command/ManiphestAssignEmailCommand.php', 'ManiphestAssignEmailCommand' => 'applications/maniphest/command/ManiphestAssignEmailCommand.php',
'ManiphestAssigneeDatasource' => 'applications/maniphest/typeahead/ManiphestAssigneeDatasource.php', 'ManiphestAssigneeDatasource' => 'applications/maniphest/typeahead/ManiphestAssigneeDatasource.php',
'ManiphestBatchEditController' => 'applications/maniphest/controller/ManiphestBatchEditController.php',
'ManiphestBulkEditCapability' => 'applications/maniphest/capability/ManiphestBulkEditCapability.php', 'ManiphestBulkEditCapability' => 'applications/maniphest/capability/ManiphestBulkEditCapability.php',
'ManiphestBulkEditController' => 'applications/maniphest/controller/ManiphestBulkEditController.php',
'ManiphestClaimEmailCommand' => 'applications/maniphest/command/ManiphestClaimEmailCommand.php', 'ManiphestClaimEmailCommand' => 'applications/maniphest/command/ManiphestClaimEmailCommand.php',
'ManiphestCloseEmailCommand' => 'applications/maniphest/command/ManiphestCloseEmailCommand.php', 'ManiphestCloseEmailCommand' => 'applications/maniphest/command/ManiphestCloseEmailCommand.php',
'ManiphestConduitAPIMethod' => 'applications/maniphest/conduit/ManiphestConduitAPIMethod.php', 'ManiphestConduitAPIMethod' => 'applications/maniphest/conduit/ManiphestConduitAPIMethod.php',
@ -1547,6 +1547,7 @@ phutil_register_library_map(array(
'ManiphestTaskAttachTransaction' => 'applications/maniphest/xaction/ManiphestTaskAttachTransaction.php', 'ManiphestTaskAttachTransaction' => 'applications/maniphest/xaction/ManiphestTaskAttachTransaction.php',
'ManiphestTaskAuthorHeraldField' => 'applications/maniphest/herald/ManiphestTaskAuthorHeraldField.php', 'ManiphestTaskAuthorHeraldField' => 'applications/maniphest/herald/ManiphestTaskAuthorHeraldField.php',
'ManiphestTaskAuthorPolicyRule' => 'applications/maniphest/policyrule/ManiphestTaskAuthorPolicyRule.php', 'ManiphestTaskAuthorPolicyRule' => 'applications/maniphest/policyrule/ManiphestTaskAuthorPolicyRule.php',
'ManiphestTaskBulkEngine' => 'applications/maniphest/bulk/ManiphestTaskBulkEngine.php',
'ManiphestTaskCloseAsDuplicateRelationship' => 'applications/maniphest/relationship/ManiphestTaskCloseAsDuplicateRelationship.php', 'ManiphestTaskCloseAsDuplicateRelationship' => 'applications/maniphest/relationship/ManiphestTaskCloseAsDuplicateRelationship.php',
'ManiphestTaskClosedStatusDatasource' => 'applications/maniphest/typeahead/ManiphestTaskClosedStatusDatasource.php', 'ManiphestTaskClosedStatusDatasource' => 'applications/maniphest/typeahead/ManiphestTaskClosedStatusDatasource.php',
'ManiphestTaskCoverImageTransaction' => 'applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php', 'ManiphestTaskCoverImageTransaction' => 'applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php',
@ -2198,6 +2199,7 @@ phutil_register_library_map(array(
'PhabricatorBuiltinFileCachePurger' => 'applications/cache/purger/PhabricatorBuiltinFileCachePurger.php', 'PhabricatorBuiltinFileCachePurger' => 'applications/cache/purger/PhabricatorBuiltinFileCachePurger.php',
'PhabricatorBuiltinPatchList' => 'infrastructure/storage/patch/PhabricatorBuiltinPatchList.php', 'PhabricatorBuiltinPatchList' => 'infrastructure/storage/patch/PhabricatorBuiltinPatchList.php',
'PhabricatorBulkContentSource' => 'infrastructure/daemon/contentsource/PhabricatorBulkContentSource.php', 'PhabricatorBulkContentSource' => 'infrastructure/daemon/contentsource/PhabricatorBulkContentSource.php',
'PhabricatorBulkEngine' => 'applications/transactions/bulk/PhabricatorBulkEngine.php',
'PhabricatorCacheDAO' => 'applications/cache/storage/PhabricatorCacheDAO.php', 'PhabricatorCacheDAO' => 'applications/cache/storage/PhabricatorCacheDAO.php',
'PhabricatorCacheEngine' => 'applications/system/engine/PhabricatorCacheEngine.php', 'PhabricatorCacheEngine' => 'applications/system/engine/PhabricatorCacheEngine.php',
'PhabricatorCacheEngineExtension' => 'applications/system/engine/PhabricatorCacheEngineExtension.php', 'PhabricatorCacheEngineExtension' => 'applications/system/engine/PhabricatorCacheEngineExtension.php',
@ -6678,8 +6680,8 @@ phutil_register_library_map(array(
'MacroQueryConduitAPIMethod' => 'MacroConduitAPIMethod', 'MacroQueryConduitAPIMethod' => 'MacroConduitAPIMethod',
'ManiphestAssignEmailCommand' => 'ManiphestEmailCommand', 'ManiphestAssignEmailCommand' => 'ManiphestEmailCommand',
'ManiphestAssigneeDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 'ManiphestAssigneeDatasource' => 'PhabricatorTypeaheadCompositeDatasource',
'ManiphestBatchEditController' => 'ManiphestController',
'ManiphestBulkEditCapability' => 'PhabricatorPolicyCapability', 'ManiphestBulkEditCapability' => 'PhabricatorPolicyCapability',
'ManiphestBulkEditController' => 'ManiphestController',
'ManiphestClaimEmailCommand' => 'ManiphestEmailCommand', 'ManiphestClaimEmailCommand' => 'ManiphestEmailCommand',
'ManiphestCloseEmailCommand' => 'ManiphestEmailCommand', 'ManiphestCloseEmailCommand' => 'ManiphestEmailCommand',
'ManiphestConduitAPIMethod' => 'ConduitAPIMethod', 'ManiphestConduitAPIMethod' => 'ConduitAPIMethod',
@ -6761,6 +6763,7 @@ phutil_register_library_map(array(
'ManiphestTaskAttachTransaction' => 'ManiphestTaskTransactionType', 'ManiphestTaskAttachTransaction' => 'ManiphestTaskTransactionType',
'ManiphestTaskAuthorHeraldField' => 'ManiphestTaskHeraldField', 'ManiphestTaskAuthorHeraldField' => 'ManiphestTaskHeraldField',
'ManiphestTaskAuthorPolicyRule' => 'PhabricatorPolicyRule', 'ManiphestTaskAuthorPolicyRule' => 'PhabricatorPolicyRule',
'ManiphestTaskBulkEngine' => 'PhabricatorBulkEngine',
'ManiphestTaskCloseAsDuplicateRelationship' => 'ManiphestTaskRelationship', 'ManiphestTaskCloseAsDuplicateRelationship' => 'ManiphestTaskRelationship',
'ManiphestTaskClosedStatusDatasource' => 'PhabricatorTypeaheadDatasource', 'ManiphestTaskClosedStatusDatasource' => 'PhabricatorTypeaheadDatasource',
'ManiphestTaskCoverImageTransaction' => 'ManiphestTaskTransactionType', 'ManiphestTaskCoverImageTransaction' => 'ManiphestTaskTransactionType',
@ -7487,6 +7490,7 @@ phutil_register_library_map(array(
'PhabricatorBuiltinFileCachePurger' => 'PhabricatorCachePurger', 'PhabricatorBuiltinFileCachePurger' => 'PhabricatorCachePurger',
'PhabricatorBuiltinPatchList' => 'PhabricatorSQLPatchList', 'PhabricatorBuiltinPatchList' => 'PhabricatorSQLPatchList',
'PhabricatorBulkContentSource' => 'PhabricatorContentSource', 'PhabricatorBulkContentSource' => 'PhabricatorContentSource',
'PhabricatorBulkEngine' => 'Phobject',
'PhabricatorCacheDAO' => 'PhabricatorLiskDAO', 'PhabricatorCacheDAO' => 'PhabricatorLiskDAO',
'PhabricatorCacheEngine' => 'Phobject', 'PhabricatorCacheEngine' => 'Phobject',
'PhabricatorCacheEngineExtension' => 'Phobject', 'PhabricatorCacheEngineExtension' => 'Phobject',

View file

@ -618,6 +618,10 @@ abstract class PhabricatorApplication
')?'; ')?';
} }
protected function getBulkRoutePattern($base = null) {
return $base.'(?:query/(?P<queryKey>[^/]+)/)?';
}
protected function getQueryRoutePattern($base = null) { protected function getQueryRoutePattern($base = null) {
return $base.'(?:query/(?P<queryKey>[^/]+)/)?'; return $base.'(?:query/(?P<queryKey>[^/]+)/)?';
} }

View file

@ -52,7 +52,7 @@ final class PhabricatorManiphestApplication extends PhabricatorApplication {
'/maniphest/' => array( '/maniphest/' => array(
'(?:query/(?P<queryKey>[^/]+)/)?' => 'ManiphestTaskListController', '(?:query/(?P<queryKey>[^/]+)/)?' => 'ManiphestTaskListController',
'report/(?:(?P<view>\w+)/)?' => 'ManiphestReportController', 'report/(?:(?P<view>\w+)/)?' => 'ManiphestReportController',
'batch/' => 'ManiphestBatchEditController', $this->getBulkRoutePattern('bulk/') => 'ManiphestBulkEditController',
'task/' => array( 'task/' => array(
$this->getEditRoutePattern('edit/') $this->getEditRoutePattern('edit/')
=> 'ManiphestTaskEditController', => 'ManiphestTaskEditController',

View file

@ -0,0 +1,50 @@
<?php
final class ManiphestTaskBulkEngine
extends PhabricatorBulkEngine {
private $workboard;
public function setWorkboard(PhabricatorProject $workboard) {
$this->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;
}
}

View file

@ -1,255 +0,0 @@
<?php
final class ManiphestBatchEditController extends ManiphestController {
public function handleRequest(AphrontRequest $request) {
$viewer = $this->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;
}
}

View file

@ -0,0 +1,32 @@
<?php
final class ManiphestBulkEditController extends ManiphestController {
public function handleRequest(AphrontRequest $request) {
$viewer = $this->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();
}
}

View file

@ -255,7 +255,7 @@ final class ManiphestTaskResultListView extends ManiphestView {
$user, $user,
array( array(
'method' => 'POST', 'method' => 'POST',
'action' => '/maniphest/batch/', 'action' => '/maniphest/bulk/',
'id' => 'batch-select-form', 'id' => 'batch-select-form',
), ),
$editor); $editor);

View file

@ -230,14 +230,23 @@ final class PhabricatorProjectBoardViewController
->addCancelButton($board_uri); ->addCancelButton($board_uri);
} }
$batch_ids = mpull($batch_tasks, 'getID'); // Create a saved query to hold the working set. This allows us to get
$batch_ids = implode(',', $batch_ids); // 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()) return id(new AphrontRedirectResponse())
->setURI($batch_uri); ->setURI($bulk_uri);
} }
$move_id = $request->getStr('move'); $move_id = $request->getStr('move');
@ -1048,7 +1057,7 @@ final class PhabricatorProjectBoardViewController
$column_items[] = id(new PhabricatorActionView()) $column_items[] = id(new PhabricatorActionView())
->setIcon('fa-list-ul') ->setIcon('fa-list-ul')
->setName(pht('Batch Edit Tasks...')) ->setName(pht('Bulk Edit Tasks...'))
->setHref($batch_edit_uri) ->setHref($batch_edit_uri)
->setDisabled(!$can_batch_edit); ->setDisabled(!$can_batch_edit);

View file

@ -0,0 +1,454 @@
<?php
abstract class PhabricatorBulkEngine extends Phobject {
private $viewer;
private $controller;
private $context = array();
private $objectList;
private $savedQuery;
private $editableList;
private $targetList;
abstract public function newSearchEngine();
public function getCancelURI() {
$saved_query = $this->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());
}
}

View file

@ -32,6 +32,7 @@ final class PHUIObjectItemView extends AphrontTagView {
private $selectableName; private $selectableName;
private $selectableValue; private $selectableValue;
private $isSelected; private $isSelected;
private $isForbidden;
public function setDisabled($disabled) { public function setDisabled($disabled) {
$this->disabled = $disabled; $this->disabled = $disabled;
@ -164,10 +165,17 @@ final class PHUIObjectItemView extends AphrontTagView {
return $this; return $this;
} }
public function setSelectable($name, $value, $is_selected) { public function setSelectable(
$name,
$value,
$is_selected,
$is_forbidden = false) {
$this->selectableName = $name; $this->selectableName = $name;
$this->selectableValue = $value; $this->selectableValue = $value;
$this->isSelected = $is_selected; $this->isSelected = $is_selected;
$this->isForbidden = $is_forbidden;
return $this; return $this;
} }
@ -299,11 +307,13 @@ final class PHUIObjectItemView extends AphrontTagView {
throw new Exception(pht('Invalid effect!')); 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'; $item_classes[] = 'phui-oi-selected';
} }
if ($this->selectableName !== null) { if ($this->selectableName !== null && !$this->isForbidden) {
$item_classes[] = 'phui-oi-selectable'; $item_classes[] = 'phui-oi-selectable';
$sigils[] = 'phui-oi-selectable'; $sigils[] = 'phui-oi-selectable';
@ -654,14 +664,18 @@ final class PHUIObjectItemView extends AphrontTagView {
} }
if ($this->selectableName !== null) { if ($this->selectableName !== null) {
$checkbox = phutil_tag( if (!$this->isForbidden) {
'input', $checkbox = phutil_tag(
array( 'input',
'type' => 'checkbox', array(
'name' => $this->selectableName, 'type' => 'checkbox',
'value' => $this->selectableValue, 'name' => $this->selectableName,
'checked' => ($this->isSelected ? 'checked' : null), 'value' => $this->selectableValue,
)); 'checked' => ($this->isSelected ? 'checked' : null),
));
} else {
$checkbox = null;
}
$column0 = phutil_tag( $column0 = phutil_tag(
'div', 'div',

View file

@ -455,6 +455,10 @@ ul.phui-oi-list-view .phui-oi-selected
border-color: {$sh-blueborder}; border-color: {$sh-blueborder};
} }
.phui-oi-forbidden {
background: {$sh-redbackground};
}
/* - Handle Icons -------------------------------------------------------------- /* - Handle Icons --------------------------------------------------------------

View file

@ -157,12 +157,15 @@ JX.behavior('maniphest-batch-selector', function(config) {
'submit', 'submit',
null, null,
function() { function() {
var inputs = []; var ids = [];
for (var k in selected) { for (var k in selected) {
inputs.push( ids.push(k);
JX.$N('input', {type: 'hidden', name: 'batch[]', value: 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(); update();