mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-26 00:32:42 +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:
parent
ad659627b3
commit
7f91c8c4ac
13 changed files with 612 additions and 293 deletions
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -618,6 +618,10 @@ abstract class PhabricatorApplication
|
|||
')?';
|
||||
}
|
||||
|
||||
protected function getBulkRoutePattern($base = null) {
|
||||
return $base.'(?:query/(?P<queryKey>[^/]+)/)?';
|
||||
}
|
||||
|
||||
protected function getQueryRoutePattern($base = null) {
|
||||
return $base.'(?:query/(?P<queryKey>[^/]+)/)?';
|
||||
}
|
||||
|
|
|
@ -52,7 +52,7 @@ final class PhabricatorManiphestApplication extends PhabricatorApplication {
|
|||
'/maniphest/' => array(
|
||||
'(?:query/(?P<queryKey>[^/]+)/)?' => 'ManiphestTaskListController',
|
||||
'report/(?:(?P<view>\w+)/)?' => 'ManiphestReportController',
|
||||
'batch/' => 'ManiphestBatchEditController',
|
||||
$this->getBulkRoutePattern('bulk/') => 'ManiphestBulkEditController',
|
||||
'task/' => array(
|
||||
$this->getEditRoutePattern('edit/')
|
||||
=> 'ManiphestTaskEditController',
|
||||
|
|
50
src/applications/maniphest/bulk/ManiphestTaskBulkEngine.php
Normal file
50
src/applications/maniphest/bulk/ManiphestTaskBulkEngine.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -255,7 +255,7 @@ final class ManiphestTaskResultListView extends ManiphestView {
|
|||
$user,
|
||||
array(
|
||||
'method' => 'POST',
|
||||
'action' => '/maniphest/batch/',
|
||||
'action' => '/maniphest/bulk/',
|
||||
'id' => 'batch-select-form',
|
||||
),
|
||||
$editor);
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
454
src/applications/transactions/bulk/PhabricatorBulkEngine.php
Normal file
454
src/applications/transactions/bulk/PhabricatorBulkEngine.php
Normal 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());
|
||||
}
|
||||
|
||||
}
|
|
@ -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,6 +664,7 @@ final class PHUIObjectItemView extends AphrontTagView {
|
|||
}
|
||||
|
||||
if ($this->selectableName !== null) {
|
||||
if (!$this->isForbidden) {
|
||||
$checkbox = phutil_tag(
|
||||
'input',
|
||||
array(
|
||||
|
@ -662,6 +673,9 @@ final class PHUIObjectItemView extends AphrontTagView {
|
|||
'value' => $this->selectableValue,
|
||||
'checked' => ($this->isSelected ? 'checked' : null),
|
||||
));
|
||||
} else {
|
||||
$checkbox = null;
|
||||
}
|
||||
|
||||
$column0 = phutil_tag(
|
||||
'div',
|
||||
|
|
|
@ -455,6 +455,10 @@ ul.phui-oi-list-view .phui-oi-selected
|
|||
border-color: {$sh-blueborder};
|
||||
}
|
||||
|
||||
.phui-oi-forbidden {
|
||||
background: {$sh-redbackground};
|
||||
}
|
||||
|
||||
|
||||
/* - Handle Icons --------------------------------------------------------------
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
Loading…
Reference in a new issue