From f0147fd8add12dc66f5d4c149ac12f29b4b78cc7 Mon Sep 17 00:00:00 2001 From: epriestley Date: Tue, 20 May 2014 11:42:05 -0700 Subject: [PATCH] Allow workboards to be filtered with ApplicationSearch Summary: Ref T4673. IMPORTANT: I had to break one thing (see TODO) to get this working. Not sure how you want to deal with that. I might be able to put the element //inside// the workboard, or I could write some JS. But I figured I'd get feedback first. General areas for improvement: - It would be nice to give you some feedback that you have a filter applied. - It would be nice to let you save and quickly select common filters. - These would probably both be covered by a dropdown menu instead of a button, but that's more JS than I want to sign up for right now. - Managing custom filters is also a significant amount of extra UI to build. - Also, maybe these filters should be sticky per-board? Or across all boards? Or have a "make this my default view"? I tend to dislike implicit stickiness. Test Plan: Before: {F157543} Apply Filter: {F157544} Filtered: {F157545} Reviewers: chad, btrahan Reviewed By: btrahan Subscribers: qgil, swisspol, epriestley Maniphest Tasks: T4673 Differential Revision: https://secure.phabricator.com/D9211 --- resources/celerity/map.php | 9 ++ .../maniphest/query/ManiphestTaskQuery.php | 16 ++ .../query/ManiphestTaskSearchEngine.php | 91 +++++++---- .../PhabricatorApplicationProject.php | 5 +- .../PhabricatorProjectBoardViewController.php | 143 +++++++++++++++++- .../project/view/ProjectBoardTaskCard.php | 1 + ...PhabricatorApplicationSearchController.php | 16 +- .../PhabricatorApplicationSearchEngine.php | 12 ++ src/view/layout/PhabricatorActionView.php | 14 ++ .../projects/behavior-boards-filter.js | 35 +++++ 10 files changed, 297 insertions(+), 45 deletions(-) create mode 100644 webroot/rsrc/js/application/projects/behavior-boards-filter.js diff --git a/resources/celerity/map.php b/resources/celerity/map.php index 8c360e269c..93c6eecd09 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -407,6 +407,7 @@ return array( 'rsrc/js/application/policy/behavior-policy-control.js' => '71b4cbcc', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '263aeb8c', 'rsrc/js/application/ponder/behavior-votebox.js' => '327dbe61', + 'rsrc/js/application/projects/behavior-boards-filter.js' => '22f113af', 'rsrc/js/application/projects/behavior-project-boards.js' => 'd8e135db', 'rsrc/js/application/projects/behavior-project-create.js' => '065227cc', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '9eb2cedb', @@ -544,6 +545,7 @@ return array( 'javelin-behavior-audio-source' => '59b251eb', 'javelin-behavior-audit-preview' => 'be81801d', 'javelin-behavior-balanced-payment-form' => '3b3e1664', + 'javelin-behavior-boards-filter' => '22f113af', 'javelin-behavior-config-reorder-fields' => '938aed89', 'javelin-behavior-conpherence-menu' => '7ee23816', 'javelin-behavior-conpherence-pontificate' => '53f6f2dd', @@ -1004,6 +1006,13 @@ return array( 5 => 'javelin-json', 6 => 'phabricator-prefab', ), + '22f113af' => + array( + 0 => 'javelin-behavior', + 1 => 'javelin-dom', + 2 => 'javelin-stratcom', + 3 => 'phuix-dropdown-menu', + ), '263aeb8c' => array( 0 => 'javelin-behavior', diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php index 3b002f8db8..a890926822 100644 --- a/src/applications/maniphest/query/ManiphestTaskQuery.php +++ b/src/applications/maniphest/query/ManiphestTaskQuery.php @@ -95,6 +95,22 @@ final class ManiphestTaskQuery return $this; } + /** + * Add an additional "all projects" constraint to existing filters. + * + * This is used by boards to supplement queries. + * + * @param list List of project PHIDs to add to any existing constriant. + * @return this + */ + public function addWithAllProjects(array $projects) { + if ($this->projectPHIDs === null) { + $this->projectPHIDs = array(); + } + + return $this->withAllProjects(array_merge($this->projectPHIDs, $projects)); + } + public function withoutProjects(array $projects) { $this->xprojectPHIDs = $projects; return $this; diff --git a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php index 9224709f02..eaf5f75aa2 100644 --- a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php +++ b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php @@ -4,6 +4,26 @@ final class ManiphestTaskSearchEngine extends PhabricatorApplicationSearchEngine { private $showBatchControls; + private $baseURI; + private $isBoardView; + + public function setIsBoardView($is_board_view) { + $this->isBoardView = $is_board_view; + return $this; + } + + public function getIsBoardView() { + return $this->isBoardView; + } + + public function setBaseURI($base_uri) { + $this->baseURI = $base_uri; + return $this; + } + + public function getBaseURI() { + return $this->baseURI; + } public function setShowBatchControls($show_batch_controls) { $this->showBatchControls = $show_batch_controls; @@ -301,14 +321,20 @@ final class ManiphestTaskSearchEngine ->setDatasource('/typeahead/common/projects/') ->setName('allProjects') ->setLabel(pht('In All Projects')) - ->setValue($all_project_handles)) - ->appendChild( - id(new AphrontFormCheckboxControl()) - ->addCheckbox( - 'withNoProject', - 1, - pht('Show only tasks with no projects.'), - $with_no_projects)) + ->setValue($all_project_handles)); + + if (!$this->getIsBoardView()) { + $form + ->appendChild( + id(new AphrontFormCheckboxControl()) + ->addCheckbox( + 'withNoProject', + 1, + pht('Show only tasks with no projects.'), + $with_no_projects)); + } + + $form ->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/projects/') @@ -340,19 +366,25 @@ final class ManiphestTaskSearchEngine ->setLabel(pht('Subscribers')) ->setValue($subscriber_handles)) ->appendChild($status_control) - ->appendChild($priority_control) - ->appendChild( - id(new AphrontFormSelectControl()) - ->setName('group') - ->setLabel(pht('Group By')) - ->setValue($saved->getParameter('group')) - ->setOptions($this->getGroupOptions())) - ->appendChild( - id(new AphrontFormSelectControl()) - ->setName('order') - ->setLabel(pht('Order By')) - ->setValue($saved->getParameter('order')) - ->setOptions($this->getOrderOptions())) + ->appendChild($priority_control); + + if (!$this->getIsBoardView()) { + $form + ->appendChild( + id(new AphrontFormSelectControl()) + ->setName('group') + ->setLabel(pht('Group By')) + ->setValue($saved->getParameter('group')) + ->setOptions($this->getGroupOptions())) + ->appendChild( + id(new AphrontFormSelectControl()) + ->setName('order') + ->setLabel(pht('Order By')) + ->setValue($saved->getParameter('order')) + ->setOptions($this->getOrderOptions())); + } + + $form ->appendChild( id(new AphrontFormTextControl()) ->setName('fulltext') @@ -382,15 +414,20 @@ final class ManiphestTaskSearchEngine 'modifiedEnd', pht('Updated Before')); - $form - ->appendChild( - id(new AphrontFormTextControl()) - ->setName('limit') - ->setLabel(pht('Page Size')) - ->setValue($saved->getParameter('limit', 100))); + if (!$this->getIsBoardView()) { + $form + ->appendChild( + id(new AphrontFormTextControl()) + ->setName('limit') + ->setLabel(pht('Page Size')) + ->setValue($saved->getParameter('limit', 100))); + } } protected function getURI($path) { + if ($this->baseURI) { + return $this->baseURI.$path; + } return '/maniphest/'.$path; } diff --git a/src/applications/project/application/PhabricatorApplicationProject.php b/src/applications/project/application/PhabricatorApplicationProject.php index fe5108327e..494bd9fa1b 100644 --- a/src/applications/project/application/PhabricatorApplicationProject.php +++ b/src/applications/project/application/PhabricatorApplicationProject.php @@ -51,7 +51,10 @@ final class PhabricatorApplicationProject extends PhabricatorApplication { 'picture/(?P[1-9]\d*)/' => 'PhabricatorProjectEditPictureController', 'create/' => 'PhabricatorProjectCreateController', - 'board/(?P[1-9]\d*)/' => 'PhabricatorProjectBoardViewController', + 'board/(?P[1-9]\d*)/'. + '(?Pfilter/)?'. + '(?:query/(?P[^/]+)/)?' => + 'PhabricatorProjectBoardViewController', 'move/(?P[1-9]\d*)/' => 'PhabricatorProjectMoveController', 'board/(?P[1-9]\d*)/edit/(?:(?P\d+)/)?' => 'PhabricatorProjectBoardEditController', diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 95c59d6f5a..3936e1565f 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -5,6 +5,8 @@ final class PhabricatorProjectBoardViewController private $id; private $handles; + private $queryKey; + private $filter; public function shouldAllowPublic() { return true; @@ -12,6 +14,8 @@ final class PhabricatorProjectBoardViewController public function willProcessRequest(array $data) { $this->id = $data['id']; + $this->queryKey = idx($data, 'queryKey'); + $this->filter = (bool)idx($data, 'filter'); } public function processRequest() { @@ -50,12 +54,63 @@ final class PhabricatorProjectBoardViewController ksort($columns); - $tasks = id(new ManiphestTaskQuery()) + $board_uri = $this->getApplicationURI('board/'.$project->getID().'/'); + + $engine = id(new ManiphestTaskSearchEngine()) ->setViewer($viewer) - ->withAllProjects(array($project->getPHID())) - ->withStatuses(ManiphestTaskStatus::getOpenStatusConstants()) + ->setBaseURI($board_uri) + ->setIsBoardView(true); + + if ($request->isFormPost()) { + $saved = $engine->buildSavedQueryFromRequest($request); + $engine->saveQuery($saved); + return id(new AphrontRedirectResponse())->setURI( + $engine->getQueryResultsPageURI($saved->getQueryKey())); + } + + $query_key = $this->queryKey; + if (!$query_key) { + $query_key = 'open'; + } + + $custom_query = null; + if ($engine->isBuiltinQuery($query_key)) { + $saved = $engine->buildSavedQueryFromBuiltin($query_key); + } else { + $saved = id(new PhabricatorSavedQueryQuery()) + ->setViewer($viewer) + ->withQueryKeys(array($query_key)) + ->executeOne(); + + if (!$saved) { + return new Aphront404Response(); + } + + $custom_query = $saved; + } + + if ($this->filter) { + $filter_form = id(new AphrontFormView()) + ->setUser($viewer); + $engine->buildSearchForm($filter_form, $saved); + + return $this->newDialog() + ->setWidth(AphrontDialogView::WIDTH_FULL) + ->setTitle(pht('Advanced Filter')) + ->appendChild($filter_form->buildLayoutView()) + ->setSubmitURI($board_uri) + ->addSubmitButton(pht('Apply Filter')) + ->addCancelButton($board_uri); + } + + $task_query = $engine->buildQueryFromSavedQuery($saved); + + $tasks = $task_query + ->addWithAllProjects(array($project->getPHID())) ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY) + ->setViewer($viewer) ->execute(); + $tasks = mpull($tasks, null, 'getPHID'); $task_phids = array_keys($tasks); @@ -166,6 +221,87 @@ final class PhabricatorProjectBoardViewController ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit); + Javelin::initBehavior( + 'boards-filter', + array( + )); + + $filter_icon = id(new PHUIIconView()) + ->setIconFont('fa-search-plus bluegrey'); + + $named = array( + 'open' => pht('Open Tasks'), + 'all' => pht('All Tasks'), + ); + + if ($viewer->isLoggedIn()) { + $named['assigned'] = pht('Assigned to Me'); + } + + if ($custom_query) { + $named[$custom_query->getQueryKey()] = pht('Custom Filter'); + } + + $items = array(); + foreach ($named as $key => $name) { + $is_selected = ($key == $query_key); + if ($is_selected) { + $active_filter = $name; + } + + $is_custom = false; + if ($custom_query) { + $is_custom = ($key == $custom_query->getQueryKey()); + } + + $item = id(new PhabricatorActionView()) + ->setIcon('fa-search') + ->setSelected($is_selected) + ->setName($name); + + if ($is_custom) { + $item->setHref( + $this->getApplicationURI( + 'board/'.$this->id.'/filter/query/'.$key.'/')); + $item->setWorkflow(true); + } else { + $item->setHref($engine->getQueryResultsPageURI($key)); + } + + $items[] = $item; + } + + $items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cog') + ->setHref($this->getApplicationURI('board/'.$this->id.'/filter/')) + ->setWorkflow(true) + ->setName(pht('Advanced Filter...')); + + + + $filter_menu = id(new PhabricatorActionListView()) + ->setUser($viewer); + foreach ($items as $item) { + $filter_menu->addAction($item); + } + + $filter_button = id(new PHUIButtonView()) + ->setText(pht('Filter: %s', $active_filter)) + ->setIcon($filter_icon) + ->setTag('a') + ->setHref('#') + ->addSigil('boards-filter-menu') + +/* + TODO: @chad, this looks really gnarly right now, at least in Safari. + ->setDropdown(true) +*/ + + ->setMetadata( + array( + 'items' => hsprintf('%s', $filter_menu), + )); + $header_link = phutil_tag( 'a', array( @@ -179,6 +315,7 @@ final class PhabricatorProjectBoardViewController ->setNoBackground(true) ->setImage($project->getProfileImageURI()) ->setImageURL($this->getApplicationURI('view/'.$project->getID().'/')) + ->addActionLink($filter_button) ->addActionLink($add_button) ->setPolicyObject($project); diff --git a/src/applications/project/view/ProjectBoardTaskCard.php b/src/applications/project/view/ProjectBoardTaskCard.php index 498bbc346c..f148351794 100644 --- a/src/applications/project/view/ProjectBoardTaskCard.php +++ b/src/applications/project/view/ProjectBoardTaskCard.php @@ -53,6 +53,7 @@ final class ProjectBoardTaskCard { ->setGrippable($can_edit) ->setHref('/T'.$task->getID()) ->addSigil('project-card') + ->setDisabled($task->isClosed()) ->setMetadata( array( 'objectPHID' => $task->getPHID(), diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index 9366323742..8518822b47 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -90,7 +90,7 @@ final class PhabricatorApplicationSearchController if ($request->isFormPost()) { $saved_query = $engine->buildSavedQueryFromRequest($request); - $this->saveQuery($saved_query); + $engine->saveQuery($saved_query); return id(new AphrontRedirectResponse())->setURI( $engine->getQueryResultsPageURI($saved_query->getQueryKey()).'#R'); } @@ -145,7 +145,7 @@ final class PhabricatorApplicationSearchController // Save the query to generate a query key, so "Save Custom Query..." and // other features like Maniphest's "Export..." work correctly. - $this->saveQuery($saved_query); + $engine->saveQuery($saved_query); } $nav->selectFilter( @@ -353,18 +353,6 @@ final class PhabricatorApplicationSearchController )); } - private function saveQuery(PhabricatorSavedQuery $query) { - $query->setEngineClassName(get_class($this->getSearchEngine())); - - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - try { - $query->save(); - } catch (AphrontQueryDuplicateKeyException $ex) { - // Ignore, this is just a repeated search. - } - unset($unguarded); - } - protected function buildApplicationMenu() { return $this->getDelegatingController()->buildApplicationMenu(); } diff --git a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php index 5fe1dd6587..2bed70537f 100644 --- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php +++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php @@ -35,6 +35,18 @@ abstract class PhabricatorApplicationSearchEngine { return $this->viewer; } + public function saveQuery(PhabricatorSavedQuery $query) { + $query->setEngineClassName(get_class($this)); + + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + try { + $query->save(); + } catch (AphrontQueryDuplicateKeyException $ex) { + // Ignore, this is just a repeated search. + } + unset($unguarded); + } + /** * Create a saved query object from the request. * diff --git a/src/view/layout/PhabricatorActionView.php b/src/view/layout/PhabricatorActionView.php index 44a0544520..befef4d2bf 100644 --- a/src/view/layout/PhabricatorActionView.php +++ b/src/view/layout/PhabricatorActionView.php @@ -12,6 +12,16 @@ final class PhabricatorActionView extends AphrontView { private $objectURI; private $sigils = array(); private $metadata; + private $selected; + + public function setSelected($selected) { + $this->selected = $selected; + return $this; + } + + public function getSelected() { + return $this->selected; + } public function setMetadata($metadata) { $this->metadata = $metadata; @@ -167,6 +177,10 @@ final class PhabricatorActionView extends AphrontView { $classes[] = 'phabricator-action-view-disabled'; } + if ($this->selected) { + $classes[] = 'phabricator-action-view-selected'; + } + return phutil_tag( 'li', array( diff --git a/webroot/rsrc/js/application/projects/behavior-boards-filter.js b/webroot/rsrc/js/application/projects/behavior-boards-filter.js new file mode 100644 index 0000000000..960e1f39ca --- /dev/null +++ b/webroot/rsrc/js/application/projects/behavior-boards-filter.js @@ -0,0 +1,35 @@ +/** + * @provides javelin-behavior-boards-filter + * @requires javelin-behavior + * javelin-dom + * javelin-stratcom + * phuix-dropdown-menu + */ + +JX.behavior('boards-filter', function(config) { + + JX.Stratcom.listen('click', 'boards-filter-menu', function(e) { + var data = e.getNodeData('boards-filter-menu'); + if (data.menu) { + return; + } + + e.kill(); + + var list = JX.$H(data.items).getFragment().firstChild; + + var button = e.getNode('boards-filter-menu'); + data.menu = new JX.PHUIXDropdownMenu(button); + data.menu.setContent(list); + data.menu.open(); + + JX.DOM.listen(list, 'click', 'tag:a', function(e) { + if (!e.isNormalClick()) { + return; + } + data.menu.close(); + }); + }); + + +});