diff --git a/src/applications/maniphest/ManiphestTaskQuery.php b/src/applications/maniphest/ManiphestTaskQuery.php index 8ee667ca68..c67d89ea7d 100644 --- a/src/applications/maniphest/ManiphestTaskQuery.php +++ b/src/applications/maniphest/ManiphestTaskQuery.php @@ -1,605 +1,620 @@ <?php /* * Copyright 2012 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Query tasks by specific criteria. This class uses the higher-performance * but less-general Maniphest indexes to satisfy queries. * * @group maniphest */ final class ManiphestTaskQuery { private $taskIDs = array(); private $authorPHIDs = array(); private $ownerPHIDs = array(); private $includeUnowned = null; private $projectPHIDs = array(); private $xprojectPHIDs = array(); private $subscriberPHIDs = array(); private $anyProject = false; private $includeNoProject = null; private $fullTextSearch = ''; private $status = 'status-any'; const STATUS_ANY = 'status-any'; const STATUS_OPEN = 'status-open'; const STATUS_CLOSED = 'status-closed'; const STATUS_RESOLVED = 'status-resolved'; const STATUS_WONTFIX = 'status-wontfix'; const STATUS_INVALID = 'status-invalid'; const STATUS_SPITE = 'status-spite'; const STATUS_DUPLICATE = 'status-duplicate'; private $priority = null; + private $minPriority = null; + private $maxPriority = null; + private $groupBy = 'group-none'; const GROUP_NONE = 'group-none'; const GROUP_PRIORITY = 'group-priority'; const GROUP_OWNER = 'group-owner'; const GROUP_STATUS = 'group-status'; const GROUP_PROJECT = 'group-project'; private $orderBy = 'order-modified'; const ORDER_PRIORITY = 'order-priority'; const ORDER_CREATED = 'order-created'; const ORDER_MODIFIED = 'order-modified'; const ORDER_TITLE = 'order-title'; private $limit = null; const DEFAULT_PAGE_SIZE = 1000; private $offset = 0; private $calculateRows = false; private $rowCount = null; public function withAuthors(array $authors) { $this->authorPHIDs = $authors; return $this; } public function withTaskIDs(array $ids) { $this->taskIDs = $ids; return $this; } public function withOwners(array $owners) { $this->includeUnowned = false; foreach ($owners as $k => $phid) { if ($phid == ManiphestTaskOwner::OWNER_UP_FOR_GRABS) { $this->includeUnowned = true; unset($owners[$k]); break; } } $this->ownerPHIDs = $owners; return $this; } public function withProjects(array $projects) { $this->includeNoProject = false; foreach ($projects as $k => $phid) { if ($phid == ManiphestTaskOwner::PROJECT_NO_PROJECT) { $this->includeNoProject = true; unset($projects[$k]); } } $this->projectPHIDs = $projects; return $this; } public function withoutProjects(array $projects) { $this->xprojectPHIDs = $projects; return $this; } public function withStatus($status) { $this->status = $status; return $this; } public function withPriority($priority) { $this->priority = $priority; return $this; } + public function withPrioritiesBetween($min, $max) { + $this->minPriority = $min; + $this->maxPriority = $max; + return $this; + } + public function withSubscribers(array $subscribers) { $this->subscriberPHIDs = $subscribers; return $this; } public function withFullTextSearch($fulltext_search) { $this->fullTextSearch = $fulltext_search; return $this; } public function setGroupBy($group) { $this->groupBy = $group; return $this; } public function setOrderBy($order) { $this->orderBy = $order; return $this; } public function setLimit($limit) { $this->limit = $limit; return $this; } public function setOffset($offset) { $this->offset = $offset; return $this; } public function setCalculateRows($calculate_rows) { $this->calculateRows = $calculate_rows; return $this; } public function getRowCount() { if ($this->rowCount === null) { throw new Exception( "You must execute a query with setCalculateRows() before you can ". "retrieve a row count."); } return $this->rowCount; } public function withAnyProject($any_project) { $this->anyProject = $any_project; return $this; } public function execute() { $task_dao = new ManiphestTask(); $conn = $task_dao->establishConnection('r'); if ($this->calculateRows) { $calc = 'SQL_CALC_FOUND_ROWS'; } else { $calc = ''; } $where = array(); $where[] = $this->buildTaskIDsWhereClause($conn); $where[] = $this->buildStatusWhereClause($conn); $where[] = $this->buildPriorityWhereClause($conn); $where[] = $this->buildAuthorWhereClause($conn); $where[] = $this->buildOwnerWhereClause($conn); $where[] = $this->buildSubscriberWhereClause($conn); $where[] = $this->buildProjectWhereClause($conn); $where[] = $this->buildXProjectWhereClause($conn); $where[] = $this->buildFullTextWhereClause($conn); $where = array_filter($where); if ($where) { $where = 'WHERE ('.implode(') AND (', $where).')'; } else { $where = ''; } $join = array(); $join[] = $this->buildProjectJoinClause($conn); $join[] = $this->buildXProjectJoinClause($conn); $join[] = $this->buildSubscriberJoinClause($conn); $join = array_filter($join); if ($join) { $join = implode(' ', $join); } else { $join = ''; } $having = ''; $count = ''; $group = ''; if (count($this->projectPHIDs) > 1) { // If we're searching for more than one project: // - We'll get multiple rows for tasks when they join the project table // multiple times. We use GROUP BY to make them distinct again. // - We want to treat the query as an intersection query, not a union // query. We sum the project count and require it be the same as the // number of projects we're searching for. (If 'anyProject' is set, // we do union instead.) $group = 'GROUP BY task.id'; if (!$this->anyProject) { $count = ', COUNT(project.projectPHID) projectCount'; $having = qsprintf( $conn, 'HAVING projectCount = %d', count($this->projectPHIDs)); } } $order = $this->buildOrderClause($conn); $offset = (int)nonempty($this->offset, 0); $limit = (int)nonempty($this->limit, self::DEFAULT_PAGE_SIZE); if ($this->groupBy == self::GROUP_PROJECT) { $limit = PHP_INT_MAX; $offset = 0; } $data = queryfx_all( $conn, 'SELECT %Q * %Q FROM %T task %Q %Q %Q %Q %Q LIMIT %d, %d', $calc, $count, $task_dao->getTableName(), $join, $where, $group, $having, $order, $offset, $limit); if ($this->calculateRows) { $count = queryfx_one( $conn, 'SELECT FOUND_ROWS() N'); $this->rowCount = $count['N']; } else { $this->rowCount = null; } $tasks = $task_dao->loadAllFromArray($data); if ($this->groupBy == self::GROUP_PROJECT) { $tasks = $this->applyGroupByProject($tasks); } return $tasks; } private function buildTaskIDsWhereClause($conn) { if (!$this->taskIDs) { return null; } return qsprintf( $conn, 'id in (%Ld)', $this->taskIDs); } private function buildStatusWhereClause($conn) { static $map = array( self::STATUS_RESOLVED => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, self::STATUS_WONTFIX => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX, self::STATUS_INVALID => ManiphestTaskStatus::STATUS_CLOSED_INVALID, self::STATUS_SPITE => ManiphestTaskStatus::STATUS_CLOSED_SPITE, self::STATUS_DUPLICATE => ManiphestTaskStatus::STATUS_CLOSED_DUPLICATE, ); switch ($this->status) { case self::STATUS_ANY: return null; case self::STATUS_OPEN: return 'status = 0'; case self::STATUS_CLOSED: return 'status > 0'; default: $constant = idx($map, $this->status); if (!$constant) { throw new Exception("Unknown status query '{$this->status}'!"); } return qsprintf( $conn, 'status = %d', $constant); } } private function buildPriorityWhereClause($conn) { - if ($this->priority === null) { - return null; + if ($this->priority !== null) { + return qsprintf( + $conn, + 'priority = %d', + $this->priority); + } elseif ($this->minPriority !== null && $this->maxPriority !== null) { + return qsprintf( + $conn, + 'priority >= %d AND priority <= %d', + $this->minPriority, + $this->maxPriority); } - return qsprintf( - $conn, - 'priority = %d', - $this->priority); + return null; } private function buildAuthorWhereClause($conn) { if (!$this->authorPHIDs) { return null; } return qsprintf( $conn, 'authorPHID in (%Ls)', $this->authorPHIDs); } private function buildOwnerWhereClause($conn) { if (!$this->ownerPHIDs) { if ($this->includeUnowned === null) { return null; } else if ($this->includeUnowned) { return qsprintf( $conn, 'ownerPHID IS NULL'); } else { return qsprintf( $conn, 'ownerPHID IS NOT NULL'); } } if ($this->includeUnowned) { return qsprintf( $conn, 'ownerPHID IN (%Ls) OR ownerPHID IS NULL', $this->ownerPHIDs); } else { return qsprintf( $conn, 'ownerPHID IN (%Ls)', $this->ownerPHIDs); } } private function buildFullTextWhereClause($conn) { if (!$this->fullTextSearch) { return null; } // In doing a fulltext search, we first find all the PHIDs that match the // fulltext search, and then use that to limit the rest of the search $fulltext_query = new PhabricatorSearchQuery(); $fulltext_query->setQuery($this->fullTextSearch); $fulltext_query->setParameter('limit', PHP_INT_MAX); $engine = PhabricatorSearchEngineSelector::newSelector()->newEngine(); $fulltext_results = $engine->executeSearch($fulltext_query); if (empty($fulltext_results)) { $fulltext_results = array(null); } return qsprintf( $conn, 'phid IN (%Ls)', $fulltext_results); } private function buildSubscriberWhereClause($conn) { if (!$this->subscriberPHIDs) { return null; } return qsprintf( $conn, 'subscriber.subscriberPHID IN (%Ls)', $this->subscriberPHIDs); } private function buildProjectWhereClause($conn) { if (!$this->projectPHIDs && !$this->includeNoProject) { return null; } $parts = array(); if ($this->projectPHIDs) { $parts[] = qsprintf( $conn, 'project.projectPHID in (%Ls)', $this->projectPHIDs); } if ($this->includeNoProject) { $parts[] = qsprintf( $conn, 'project.projectPHID IS NULL'); } return '('.implode(') OR (', $parts).')'; } private function buildProjectJoinClause($conn) { if (!$this->projectPHIDs && !$this->includeNoProject) { return null; } $project_dao = new ManiphestTaskProject(); return qsprintf( $conn, '%Q JOIN %T project ON project.taskPHID = task.phid', ($this->includeNoProject ? 'LEFT' : ''), $project_dao->getTableName()); } private function buildXProjectWhereClause($conn) { if (!$this->xprojectPHIDs) { return null; } return qsprintf( $conn, 'xproject.projectPHID IS NULL'); } private function buildXProjectJoinClause($conn) { if (!$this->xprojectPHIDs) { return null; } $project_dao = new ManiphestTaskProject(); return qsprintf( $conn, 'LEFT JOIN %T xproject ON xproject.taskPHID = task.phid AND xproject.projectPHID IN (%Ls)', $project_dao->getTableName(), $this->xprojectPHIDs); } private function buildSubscriberJoinClause($conn) { if (!$this->subscriberPHIDs) { return null; } $subscriber_dao = new ManiphestTaskSubscriber(); return qsprintf( $conn, 'JOIN %T subscriber ON subscriber.taskPHID = task.phid', $subscriber_dao->getTableName()); } private function buildOrderClause($conn) { $order = array(); switch ($this->groupBy) { case self::GROUP_NONE: break; case self::GROUP_PRIORITY: $order[] = 'priority'; break; case self::GROUP_OWNER: $order[] = 'ownerOrdering'; break; case self::GROUP_STATUS: $order[] = 'status'; break; case self::GROUP_PROJECT: // NOTE: We have to load the entire result set and apply this grouping // in the PHP process for now. break; default: throw new Exception("Unknown group query '{$this->groupBy}'!"); } switch ($this->orderBy) { case self::ORDER_PRIORITY: $order[] = 'priority'; $order[] = 'subpriority'; $order[] = 'dateModified'; break; case self::ORDER_CREATED: $order[] = 'id'; break; case self::ORDER_MODIFIED: $order[] = 'dateModified'; break; case self::ORDER_TITLE: $order[] = 'title'; break; default: throw new Exception("Unknown order query '{$this->orderBy}'!"); } $order = array_unique($order); if (empty($order)) { return null; } foreach ($order as $k => $column) { switch ($column) { case 'subpriority': case 'ownerOrdering': case 'title': $order[$k] = "task.{$column} ASC"; break; default: $order[$k] = "task.{$column} DESC"; break; } } return 'ORDER BY '.implode(', ', $order); } /** * To get paging to work for "group by project", we need to do a bunch of * server-side magic since there's currently no way to sort by project name on * the database. * * TODO: Move this all to the database. */ private function applyGroupByProject(array $tasks) { assert_instances_of($tasks, 'ManiphestTask'); $project_phids = array(); foreach ($tasks as $task) { foreach ($task->getProjectPHIDs() as $phid) { $project_phids[$phid] = true; } } $handles = id(new PhabricatorObjectHandleData(array_keys($project_phids))) ->loadHandles(); $max = 1; foreach ($handles as $handle) { $max = max($max, strlen($handle->getName())); } $items = array(); $ii = 0; foreach ($tasks as $key => $task) { $phids = $task->getProjectPHIDs(); if (!$this->anyProject && $this->projectPHIDs) { $phids = array_diff($phids, $this->projectPHIDs); } if ($phids) { foreach ($phids as $phid) { $items[] = array( 'key' => $key, 'seq' => sprintf( '%'.$max.'s%d', $handles[$phid]->getName(), $ii), ); } } else { // Sort "no project" tasks first. $items[] = array( 'key' => $key, 'seq' => '', ); } ++$ii; } $items = isort($items, 'seq'); $items = array_slice( $items, nonempty($this->offset), nonempty($this->limit, self::DEFAULT_PAGE_SIZE)); $result = array(); foreach ($items as $item) { $result[] = $tasks[$item['key']]; } return $result; } } diff --git a/src/applications/maniphest/constants/ManiphestTaskPriority.php b/src/applications/maniphest/constants/ManiphestTaskPriority.php index 71d69edbd4..4b8185f218 100644 --- a/src/applications/maniphest/constants/ManiphestTaskPriority.php +++ b/src/applications/maniphest/constants/ManiphestTaskPriority.php @@ -1,68 +1,107 @@ <?php /* * Copyright 2012 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @group maniphest */ final class ManiphestTaskPriority extends ManiphestConstants { const PRIORITY_UNBREAK_NOW = 100; const PRIORITY_TRIAGE = 90; const PRIORITY_HIGH = 80; const PRIORITY_NORMAL = 50; const PRIORITY_LOW = 25; const PRIORITY_WISH = 0; + /** + * Get the priorities and their full descriptions. + * + * @return map Priorities to descriptions. + */ public static function getTaskPriorityMap() { return array( self::PRIORITY_UNBREAK_NOW => 'Unbreak Now!', self::PRIORITY_TRIAGE => 'Needs Triage', self::PRIORITY_HIGH => 'High', self::PRIORITY_NORMAL => 'Normal', self::PRIORITY_LOW => 'Low', self::PRIORITY_WISH => 'Wishlist', ); } + /** + * Get the priorities and their related short (one-word) descriptions. + * + * @return map Priorities to brief descriptions. + */ public static function getTaskBriefPriorityMap() { return array( self::PRIORITY_UNBREAK_NOW => 'Unbreak!', self::PRIORITY_TRIAGE => 'Triage', self::PRIORITY_HIGH => 'High', self::PRIORITY_NORMAL => 'Normal', self::PRIORITY_LOW => 'Low', self::PRIORITY_WISH => 'Wish', ); } - + /** + * Get the priorities and some bits for bitwise fun. + * + * @return map Priorities to bits. + */ public static function getLoadMap() { return array( self::PRIORITY_UNBREAK_NOW => 16, self::PRIORITY_TRIAGE => 8, self::PRIORITY_HIGH => 4, self::PRIORITY_NORMAL => 2, self::PRIORITY_LOW => 1, self::PRIORITY_WISH => 0, ); } + /** + * Get the lowest defined priority. + * + * @return int The value of the lowest priority constant. + */ + public static function getLowestPriority() { + return self::PRIORITY_WISH; + } + + /** + * Get the highest defined priority. + * + * @return int The value of the highest priority constant. + */ + public static function getHighestPriority() { + return self::PRIORITY_UNBREAK_NOW; + } + + /** + * Retrieve the full name of the priority level provided. + * + * @param int A priority level. + * @return string The priority name if the level is a valid one, + * or `???` if it is not. + */ public static function getTaskPriorityName($priority) { return idx(self::getTaskPriorityMap(), $priority, '???'); } } diff --git a/src/applications/maniphest/controller/ManiphestTaskListController.php b/src/applications/maniphest/controller/ManiphestTaskListController.php index 1eff255477..69fdb0ce6f 100644 --- a/src/applications/maniphest/controller/ManiphestTaskListController.php +++ b/src/applications/maniphest/controller/ManiphestTaskListController.php @@ -1,858 +1,910 @@ <?php /* * Copyright 2012 Facebook, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * @group maniphest */ final class ManiphestTaskListController extends ManiphestController { const DEFAULT_PAGE_SIZE = 1000; private $view; public function willProcessRequest(array $data) { $this->view = idx($data, 'view'); } private function getArrToStrList($key) { $arr = $this->getRequest()->getArr($key); $arr = implode(',', $arr); return nonempty($arr, null); } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); if ($request->isFormPost()) { // Redirect to GET so URIs can be copy/pasted. $task_ids = $request->getStr('set_tasks'); $task_ids = nonempty($task_ids, null); $search_text = $request->getStr('set_search'); $search_text = nonempty($search_text, null); + $min_priority = $request->getInt('set_lpriority'); + $min_priority = nonempty($min_priority, null); + + $max_priority = $request->getInt('set_hpriority'); + $max_priority = nonempty($max_priority, null); + $uri = $request->getRequestURI() ->alter('users', $this->getArrToStrList('set_users')) ->alter('projects', $this->getArrToStrList('set_projects')) ->alter('xprojects', $this->getArrToStrList('set_xprojects')) ->alter('owners', $this->getArrToStrList('set_owners')) ->alter('authors', $this->getArrToStrList('set_authors')) + ->alter('lpriority', $min_priority) + ->alter('hpriority', $max_priority) ->alter('tasks', $task_ids) ->alter('search', $search_text); return id(new AphrontRedirectResponse())->setURI($uri); } $nav = $this->buildBaseSideNav(); $has_filter = array( 'action' => true, 'created' => true, 'subscribed' => true, 'triage' => true, 'projecttriage' => true, 'projectall' => true, ); $query = null; $key = $request->getStr('key'); if (!$key && !$this->view) { if ($this->getDefaultQuery()) { $key = $this->getDefaultQuery()->getQueryKey(); } } if ($key) { $query = id(new PhabricatorSearchQuery())->loadOneWhere( 'queryKey = %s', $key); } // If the user is running a saved query, load query parameters from that // query. Otherwise, build a new query object from the HTTP request. if ($query) { $nav->selectFilter('Q:'.$query->getQueryKey(), 'custom'); $this->view = 'custom'; } else { $this->view = $nav->selectFilter($this->view, 'action'); $query = $this->buildQueryFromRequest(); } // Execute the query. list($tasks, $handles, $total_count) = self::loadTasks($query); // Extract information we need to render the filters from the query. $search_text = $query->getParameter('fullTextSearch'); $user_phids = $query->getParameter('userPHIDs', array()); $task_ids = $query->getParameter('taskIDs', array()); $owner_phids = $query->getParameter('ownerPHIDs', array()); $author_phids = $query->getParameter('authorPHIDs', array()); $project_phids = $query->getParameter('projectPHIDs', array()); $exclude_project_phids = $query->getParameter( 'excludeProjectPHIDs', array()); + $low_priority = $query->getParameter('lowPriority'); + $high_priority = $query->getParameter('highPriority'); + $page_size = $query->getParameter('limit'); $page = $query->getParameter('offset'); $q_status = $query->getParameter('status'); $q_group = $query->getParameter('group'); $q_order = $query->getParameter('order'); $form = id(new AphrontFormView()) ->setUser($user) ->setAction( $request->getRequestURI() ->alter('key', null) ->alter( $this->getStatusRequestKey(), $this->getStatusRequestValue($q_status)) ->alter( $this->getOrderRequestKey(), $this->getOrderRequestValue($q_order)) ->alter( $this->getGroupRequestKey(), $this->getGroupRequestValue($q_group))); if (isset($has_filter[$this->view])) { $tokens = array(); foreach ($user_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/searchowner/') ->setName('set_users') ->setLabel('Users') ->setValue($tokens)); } if ($this->view == 'custom') { $form->appendChild( id(new AphrontFormTextControl()) ->setName('set_search') ->setLabel('Search') ->setValue($search_text) ); $form->appendChild( id(new AphrontFormTextControl()) ->setName('set_tasks') ->setLabel('Task IDs') ->setValue(join(',', $task_ids)) ); $tokens = array(); foreach ($owner_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/searchowner/') ->setName('set_owners') ->setLabel('Owners') ->setValue($tokens)); $tokens = array(); foreach ($author_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/users/') ->setName('set_authors') ->setLabel('Authors') ->setValue($tokens)); } $tokens = array(); foreach ($project_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } if ($this->view != 'projectall' && $this->view != 'projecttriage') { $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/searchproject/') ->setName('set_projects') ->setLabel('Projects') ->setValue($tokens)); } if ($this->view == 'custom') { $tokens = array(); foreach ($exclude_project_phids as $phid) { $tokens[$phid] = $handles[$phid]->getFullName(); } $form->appendChild( id(new AphrontFormTokenizerControl()) ->setDatasource('/typeahead/common/projects/') ->setName('set_xprojects') ->setLabel('Exclude Projects') ->setValue($tokens)); + + $priority = ManiphestTaskPriority::getLowestPriority(); + if ($low_priority) { + $priority = $low_priority; + } + + $form->appendChild( + id(new AphrontFormSelectControl()) + ->setLabel('Min Priority') + ->setName('set_lpriority') + ->setValue($priority) + ->setOptions(array_reverse( + ManiphestTaskPriority::getTaskPriorityMap(), true))); + + $priority = ManiphestTaskPriority::getHighestPriority(); + if ($high_priority) { + $priority = $high_priority; + } + + $form->appendChild( + id(new AphrontFormSelectControl()) + ->setLabel('Max Priority') + ->setName('set_hpriority') + ->setValue($priority) + ->setOptions(ManiphestTaskPriority::getTaskPriorityMap())); + } $form ->appendChild($this->renderStatusControl($q_status)) ->appendChild($this->renderGroupControl($q_group)) ->appendChild($this->renderOrderControl($q_order)); $submit = id(new AphrontFormSubmitControl()) ->setValue('Filter Tasks'); // Only show "Save..." for novel queries which have some kind of query // parameters set. if ($this->view === 'custom' && empty($key) && $request->getRequestURI()->getQueryParams()) { $submit->addCancelButton( '/maniphest/custom/edit/?key='.$query->getQueryKey(), 'Save Custom Query...'); } $form->appendChild($submit); $create_uri = new PhutilURI('/maniphest/task/create/'); if ($project_phids) { // If we have project filters selected, use them as defaults for task // creation. $create_uri->setQueryParam('projects', implode(';', $project_phids)); } $filter = new AphrontListFilterView(); $filter->addButton( phutil_render_tag( 'a', array( 'href' => (string)$create_uri, 'class' => 'green button', ), 'Create New Task')); if (empty($key)) { $filter->appendChild($form); } $nav->appendChild($filter); $have_tasks = false; foreach ($tasks as $group => $list) { if (count($list)) { $have_tasks = true; break; } } require_celerity_resource('maniphest-task-summary-css'); $list_container = new AphrontNullView(); $list_container->appendChild('<div class="maniphest-list-container">'); if (!$have_tasks) { $list_container->appendChild( '<h1 class="maniphest-task-group-header">'. 'No matching tasks.'. '</h1>'); } else { $pager = new AphrontPagerView(); $pager->setURI($request->getRequestURI(), 'offset'); $pager->setPageSize($page_size); $pager->setOffset($page); $pager->setCount($total_count); $cur = ($pager->getOffset() + 1); $max = min($pager->getOffset() + $page_size, $total_count); $tot = $total_count; $cur = number_format($cur); $max = number_format($max); $tot = number_format($tot); $list_container->appendChild( '<div class="maniphest-total-result-count">'. "Displaying tasks {$cur} - {$max} of {$tot}.". '</div>'); $selector = new AphrontNullView(); $group = $query->getParameter('group'); $order = $query->getParameter('order'); $is_draggable = ($group == 'priority') || ($group == 'none' && $order == 'priority'); $lists = new AphrontNullView(); $lists->appendChild('<div class="maniphest-group-container">'); foreach ($tasks as $group => $list) { $task_list = new ManiphestTaskListView(); $task_list->setShowBatchControls(true); if ($is_draggable) { $task_list->setShowSubpriorityControls(true); } $task_list->setUser($user); $task_list->setTasks($list); $task_list->setHandles($handles); $count = number_format(count($list)); $lists->appendChild( javelin_render_tag( 'h1', array( 'class' => 'maniphest-task-group-header', 'sigil' => 'task-group', 'meta' => array( 'priority' => head($list)->getPriority(), ), ), phutil_escape_html($group).' ('.$count.')')); $lists->appendChild($task_list); } $lists->appendChild('</div>'); $selector->appendChild($lists); $selector->appendChild($this->renderBatchEditor($query)); $form_id = celerity_generate_unique_node_id(); $selector = phabricator_render_form( $user, array( 'method' => 'POST', 'action' => '/maniphest/batch/', 'id' => $form_id, ), $selector->render()); $list_container->appendChild($selector); $list_container->appendChild($pager); Javelin::initBehavior( 'maniphest-subpriority-editor', array( 'root' => $form_id, 'uri' => '/maniphest/subpriority/', )); } $list_container->appendChild('</div>'); $nav->appendChild($list_container); return $this->buildStandardPageResponse( $nav, array( 'title' => 'Task List', )); } public static function loadTasks(PhabricatorSearchQuery $search_query) { $any_project = false; $search_text = $search_query->getParameter('fullTextSearch'); $user_phids = $search_query->getParameter('userPHIDs', array()); $project_phids = $search_query->getParameter('projectPHIDs', array()); $task_ids = $search_query->getParameter('taskIDs', array()); $xproject_phids = $search_query->getParameter( 'excludeProjectPHIDs', array()); $owner_phids = $search_query->getParameter('ownerPHIDs', array()); $author_phids = $search_query->getParameter('authorPHIDs', array()); + $low_priority = $search_query->getParameter('lowPriority'); + $low_priority = nonempty($low_priority, + ManiphestTaskPriority::getLowestPriority()); + $high_priority = $search_query->getParameter('highPriority'); + $high_priority = nonempty($high_priority, + ManiphestTaskPriority::getHighestPriority()); + $query = new ManiphestTaskQuery(); $query->withProjects($project_phids); $query->withTaskIDs($task_ids); if ($xproject_phids) { $query->withoutProjects($xproject_phids); } if ($owner_phids) { $query->withOwners($owner_phids); } if ($author_phids) { $query->withAuthors($author_phids); } $status = $search_query->getParameter('status', 'all'); if (!empty($status['open']) && !empty($status['closed'])) { $query->withStatus(ManiphestTaskQuery::STATUS_ANY); } else if (!empty($status['open'])) { $query->withStatus(ManiphestTaskQuery::STATUS_OPEN); } else { $query->withStatus(ManiphestTaskQuery::STATUS_CLOSED); } switch ($search_query->getParameter('view')) { case 'action': $query->withOwners($user_phids); break; case 'created': $query->withAuthors($user_phids); break; case 'subscribed': $query->withSubscribers($user_phids); break; case 'triage': $query->withOwners($user_phids); $query->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE); break; case 'alltriage': $query->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE); break; case 'all': break; case 'projecttriage': $query->withPriority(ManiphestTaskPriority::PRIORITY_TRIAGE); $any_project = true; break; case 'projectall': $any_project = true; break; + case 'custom': + $query->withPrioritiesBetween($low_priority, $high_priority); + break; } $query->withAnyProject($any_project); $query->withFullTextSearch($search_text); $order_map = array( 'priority' => ManiphestTaskQuery::ORDER_PRIORITY, 'created' => ManiphestTaskQuery::ORDER_CREATED, 'title' => ManiphestTaskQuery::ORDER_TITLE, ); $query->setOrderBy( idx( $order_map, $search_query->getParameter('order'), ManiphestTaskQuery::ORDER_MODIFIED)); $group_map = array( 'priority' => ManiphestTaskQuery::GROUP_PRIORITY, 'owner' => ManiphestTaskQuery::GROUP_OWNER, 'status' => ManiphestTaskQuery::GROUP_STATUS, 'project' => ManiphestTaskQuery::GROUP_PROJECT, ); $query->setGroupBy( idx( $group_map, $search_query->getParameter('group'), ManiphestTaskQuery::GROUP_NONE)); $query->setCalculateRows(true); $query->setLimit($search_query->getParameter('limit')); $query->setOffset($search_query->getParameter('offset')); $data = $query->execute(); $total_row_count = $query->getRowCount(); $project_group_phids = array(); if ($search_query->getParameter('group') == 'project') { foreach ($data as $task) { foreach ($task->getProjectPHIDs() as $phid) { $project_group_phids[] = $phid; } } } $handle_phids = mpull($data, 'getOwnerPHID'); $handle_phids = array_merge( $handle_phids, $project_phids, $user_phids, $xproject_phids, $owner_phids, $author_phids, $project_group_phids, array_mergev(mpull($data, 'getProjectPHIDs'))); $handles = id(new PhabricatorObjectHandleData($handle_phids)) ->loadHandles(); switch ($search_query->getParameter('group')) { case 'priority': $data = mgroup($data, 'getPriority'); // If we have invalid priorities, they'll all map to "???". Merge // arrays to prevent them from overwriting each other. $out = array(); foreach ($data as $pri => $tasks) { $out[ManiphestTaskPriority::getTaskPriorityName($pri)][] = $tasks; } foreach ($out as $pri => $tasks) { $out[$pri] = array_mergev($tasks); } $data = $out; break; case 'status': $data = mgroup($data, 'getStatus'); $out = array(); foreach ($data as $status => $tasks) { $out[ManiphestTaskStatus::getTaskStatusFullName($status)] = $tasks; } $data = $out; break; case 'owner': $data = mgroup($data, 'getOwnerPHID'); $out = array(); foreach ($data as $phid => $tasks) { if ($phid) { $out[$handles[$phid]->getFullName()] = $tasks; } else { $out['Unassigned'] = $tasks; } } $data = $out; ksort($data); // Move "Unassigned" to the top of the list. if (isset($data['Unassigned'])) { $data = array('Unassigned' => $out['Unassigned']) + $out; } break; case 'project': $grouped = array(); foreach ($data as $task) { $phids = $task->getProjectPHIDs(); if ($project_phids && $any_project !== true) { // If the user is filtering on "Bugs", don't show a "Bugs" group // with every result since that's silly (the query also does this // on the backend). $phids = array_diff($phids, $project_phids); } if ($phids) { foreach ($phids as $phid) { $grouped[$handles[$phid]->getName()][$task->getID()] = $task; } } else { $grouped['No Project'][$task->getID()] = $task; } } $data = $grouped; ksort($data); // Move "No Project" to the end of the list. if (isset($data['No Project'])) { $noproject = $data['No Project']; unset($data['No Project']); $data += array('No Project' => $noproject); } break; default: $data = array( 'Tasks' => $data, ); break; } return array($data, $handles, $total_row_count); } private function renderBatchEditor(PhabricatorSearchQuery $search_query) { Javelin::initBehavior( 'maniphest-batch-selector', array( 'selectAll' => 'batch-select-all', 'selectNone' => 'batch-select-none', 'submit' => 'batch-select-submit', 'status' => 'batch-select-status-cell', )); $select_all = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'class' => 'grey button', 'id' => 'batch-select-all', ), 'Select All'); $select_none = javelin_render_tag( 'a', array( 'href' => '#', 'mustcapture' => true, 'class' => 'grey button', 'id' => 'batch-select-none', ), 'Clear Selection'); $submit = phutil_render_tag( 'button', array( 'id' => 'batch-select-submit', 'disabled' => 'disabled', 'class' => 'disabled', ), 'Batch Edit Selected Tasks »'); $export = javelin_render_tag( 'a', array( 'href' => '/maniphest/export/'.$search_query->getQueryKey().'/', 'class' => 'grey button', ), 'Export Tasks to Excel...'); return '<div class="maniphest-batch-editor">'. '<div class="batch-editor-header">Batch Task Editor</div>'. '<table class="maniphest-batch-editor-layout">'. '<tr>'. '<td>'. $select_all. $select_none. '</td>'. '<td>'. $export. '</td>'. '<td id="batch-select-status-cell">'. '0 Selected Tasks'. '</td>'. '<td class="batch-select-submit-cell">'.$submit.'</td>'. '</tr>'. '</table>'. '</table>'; } private function buildQueryFromRequest() { - $request = $this->getRequest(); - $user = $request->getUser(); + $request = $this->getRequest(); + $user = $request->getUser(); $status = $this->getStatusValueFromRequest(); $group = $this->getGroupValueFromRequest(); $order = $this->getOrderValueFromRequest(); $user_phids = $request->getStrList( 'users', array($user->getPHID())); if ($this->view == 'projecttriage' || $this->view == 'projectall') { $project_query = new PhabricatorProjectQuery(); $project_query->setMembers($user_phids); $projects = $project_query->execute(); $project_phids = mpull($projects, 'getPHID'); } else { $project_phids = $request->getStrList('projects'); } $exclude_project_phids = $request->getStrList('xprojects'); $task_ids = $request->getStrList('tasks'); if ($task_ids) { // We only need the integer portion of each task ID, so get rid of any // non-numeric elements $numeric_task_ids = array(); foreach ($task_ids as $task_id) { $task_id = preg_replace('/[a-zA-Z]+/', '', $task_id); if (!empty($task_id)) { $numeric_task_ids[] = $task_id; } } if (empty($numeric_task_ids)) { $numeric_task_ids = array(null); } $task_ids = $numeric_task_ids; } - $owner_phids = $request->getStrList('owners'); - $author_phids = $request->getStrList('authors'); + $owner_phids = $request->getStrList('owners'); + $author_phids = $request->getStrList('authors'); + + $search_string = $request->getStr('search'); - $search_string = $request->getStr('search'); + $low_priority = $request->getInt('lpriority'); + $high_priority = $request->getInt('hpriority'); $page = $request->getInt('offset'); $page_size = self::DEFAULT_PAGE_SIZE; $query = new PhabricatorSearchQuery(); $query->setQuery('<<maniphest>>'); $query->setParameters( array( 'fullTextSearch' => $search_string, 'view' => $this->view, 'userPHIDs' => $user_phids, 'projectPHIDs' => $project_phids, 'excludeProjectPHIDs' => $exclude_project_phids, 'ownerPHIDs' => $owner_phids, 'authorPHIDs' => $author_phids, 'taskIDs' => $task_ids, + 'lowPriority' => $low_priority, + 'highPriority' => $high_priority, 'group' => $group, 'order' => $order, 'offset' => $page, 'limit' => $page_size, 'status' => $status, )); $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); $query->save(); unset($unguarded); return $query; } /* -( Toggle Button Controls )--------------------------------------------- These are a giant mess since we have several different values: the request key (GET param used in requests), the request value (short names used in requests to keep URIs readable), and the query value (complex value stored in the query). */ private function getStatusValueFromRequest() { $map = $this->getStatusMap(); $val = $this->getRequest()->getStr($this->getStatusRequestKey()); return idx($map, $val, head($map)); } private function getGroupValueFromRequest() { $map = $this->getGroupMap(); $val = $this->getRequest()->getStr($this->getGroupRequestKey()); return idx($map, $val, head($map)); } private function getOrderValueFromRequest() { $map = $this->getOrderMap(); $val = $this->getRequest()->getStr($this->getOrderRequestKey()); return idx($map, $val, head($map)); } private function getStatusRequestKey() { return 's'; } private function getGroupRequestKey() { return 'g'; } private function getOrderRequestKey() { return 'o'; } private function getStatusRequestValue($value) { return array_search($value, $this->getStatusMap()); } private function getGroupRequestValue($value) { return array_search($value, $this->getGroupMap()); } private function getOrderRequestValue($value) { return array_search($value, $this->getOrderMap()); } private function getStatusMap() { return array( 'o' => array( 'open' => true, ), 'c' => array( 'closed' => true, ), 'oc' => array( 'open' => true, 'closed' => true, ), ); } private function getGroupMap() { return array( 'p' => 'priority', 'o' => 'owner', 's' => 'status', 'j' => 'project', 'n' => 'none', ); } private function getOrderMap() { return array( 'p' => 'priority', 'u' => 'updated', 'c' => 'created', 't' => 'title', ); } private function getStatusButtonMap() { return array( 'o' => 'Open', 'c' => 'Closed', 'oc' => 'All', ); } private function getGroupButtonMap() { return array( 'p' => 'Priority', 'o' => 'Owner', 's' => 'Status', 'j' => 'Project', 'n' => 'None', ); } private function getOrderButtonMap() { return array( 'p' => 'Priority', 'u' => 'Updated', 'c' => 'Created', 't' => 'Title', ); } public function renderStatusControl($value) { $request = $this->getRequest(); return id(new AphrontFormToggleButtonsControl()) ->setLabel('Status') ->setValue($this->getStatusRequestValue($value)) ->setBaseURI($request->getRequestURI(), $this->getStatusRequestKey()) ->setButtons($this->getStatusButtonMap()); } public function renderOrderControl($value) { $request = $this->getRequest(); return id(new AphrontFormToggleButtonsControl()) ->setLabel('Order') ->setValue($this->getOrderRequestValue($value)) ->setBaseURI($request->getRequestURI(), $this->getOrderRequestKey()) ->setButtons($this->getOrderButtonMap()); } public function renderGroupControl($value) { $request = $this->getRequest(); return id(new AphrontFormToggleButtonsControl()) ->setLabel('Group') ->setValue($this->getGroupRequestValue($value)) ->setBaseURI($request->getRequestURI(), $this->getGroupRequestKey()) ->setButtons($this->getGroupButtonMap()); } }