diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -407,6 +407,7 @@ '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' => '2cd5917e', '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 @@ 'javelin-behavior-audio-source' => '59b251eb', 'javelin-behavior-audit-preview' => 'be81801d', 'javelin-behavior-balanced-payment-form' => '3b3e1664', + 'javelin-behavior-boards-filter' => '2cd5917e', 'javelin-behavior-config-reorder-fields' => '938aed89', 'javelin-behavior-conpherence-menu' => '7ee23816', 'javelin-behavior-conpherence-pontificate' => '53f6f2dd', @@ -1039,6 +1041,14 @@ 3 => 'javelin-workflow', 4 => 'javelin-json', ), + '2cd5917e' => + array( + 0 => 'javelin-behavior', + 1 => 'javelin-dom', + 2 => 'javelin-util', + 3 => 'javelin-stratcom', + 4 => 'javelin-workflow', + ), '2f2e18aa' => array( 0 => 'javelin-behavior', diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php --- a/src/applications/maniphest/query/ManiphestTaskQuery.php +++ b/src/applications/maniphest/query/ManiphestTaskQuery.php @@ -95,6 +95,22 @@ 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 --- a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php +++ b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php @@ -4,6 +4,26 @@ 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 @@ ->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 @@ ->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 @@ '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 --- a/src/applications/project/application/PhabricatorApplicationProject.php +++ b/src/applications/project/application/PhabricatorApplicationProject.php @@ -51,7 +51,10 @@ '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 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -5,6 +5,8 @@ private $id; private $handles; + private $queryKey; + private $filter; public function shouldAllowPublic() { return true; @@ -12,6 +14,8 @@ 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,62 @@ ksort($columns); - $tasks = id(new ManiphestTaskQuery()) + $board_uri = $this->getApplicationURI('board/'.$project->getID().'/'); + + $engine = id(new ManiphestTaskSearchEngine()) + ->setViewer($viewer) + ->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())) ->setViewer($viewer) - ->withAllProjects(array($project->getPHID())) - ->withStatuses(ManiphestTaskStatus::getOpenStatusConstants()) - ->setOrderBy(ManiphestTaskQuery::ORDER_PRIORITY) ->execute(); + $tasks = mpull($tasks, null, 'getPHID'); $task_phids = array_keys($tasks); @@ -166,6 +220,83 @@ ->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 ($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 +310,7 @@ ->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 --- a/src/applications/project/view/ProjectBoardTaskCard.php +++ b/src/applications/project/view/ProjectBoardTaskCard.php @@ -53,6 +53,7 @@ ->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 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -90,7 +90,7 @@ 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 @@ // 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 @@ )); } - 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 --- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php +++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php @@ -35,6 +35,18 @@ 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 --- a/src/view/layout/PhabricatorActionView.php +++ b/src/view/layout/PhabricatorActionView.php @@ -12,6 +12,16 @@ 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 @@ $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 --- /dev/null +++ b/webroot/rsrc/js/application/projects/behavior-boards-filter.js @@ -0,0 +1,36 @@ +/** + * @provides javelin-behavior-boards-filter + * @requires javelin-behavior + * javelin-dom + * javelin-util + * javelin-stratcom + * javelin-workflow + */ + +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(); + }); + }); + + +});