diff --git a/src/applications/maniphest/constants/ManiphestTaskPriority.php b/src/applications/maniphest/constants/ManiphestTaskPriority.php index 4ec7643683..38a56f767b 100644 --- a/src/applications/maniphest/constants/ManiphestTaskPriority.php +++ b/src/applications/maniphest/constants/ManiphestTaskPriority.php @@ -1,111 +1,114 @@ $spec) { $map[$key] = idx($spec, 'name', $key); } return $map; } /** * Get the priorities and their command keywords. * * @return map Priorities to lists of command keywords. */ public static function getTaskPriorityKeywordsMap() { $map = self::getConfig(); foreach ($map as $key => $spec) { $words = idx($spec, 'keywords', array()); if (!is_array($words)) { $words = array($words); } foreach ($words as $word_key => $word) { $words[$word_key] = phutil_utf8_strtolower($word); } $words = array_unique($words); $map[$key] = $words; } return $map; } /** * Get the priorities and their related short (one-word) descriptions. * * @return map Priorities to short descriptions. */ public static function getShortNameMap() { $map = self::getConfig(); foreach ($map as $key => $spec) { $map[$key] = idx($spec, 'short', idx($spec, 'name', $key)); } return $map; } /** * Get a map from priority constants to their colors. * * @return map Priorities to colors. */ public static function getColorMap() { $map = self::getConfig(); foreach ($map as $key => $spec) { $map[$key] = idx($spec, 'color', 'grey'); } return $map; } /** * Return the default priority for this instance of Phabricator. * * @return int The value of the default priority constant. */ public static function getDefaultPriority() { return PhabricatorEnv::getEnvConfig('maniphest.default-priority'); } /** * 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. */ public static function getTaskPriorityName($priority) { return idx(self::getTaskPriorityMap(), $priority, $priority); } /** * Retrieve the color of the priority level given * * @param int A priority level. * @return string The color of the priority if the level is valid, * or black if it is not. */ public static function getTaskPriorityColor($priority) { return idx(self::getColorMap(), $priority, 'black'); } + public static function getTaskPriorityIcon($priority) { + return 'fa-arrow-right'; + } private static function getConfig() { $config = PhabricatorEnv::getEnvConfig('maniphest.priorities'); krsort($config); return $config; } } diff --git a/src/applications/maniphest/constants/ManiphestTaskStatus.php b/src/applications/maniphest/constants/ManiphestTaskStatus.php index abbb1b1d18..b021dae40f 100644 --- a/src/applications/maniphest/constants/ManiphestTaskStatus.php +++ b/src/applications/maniphest/constants/ManiphestTaskStatus.php @@ -1,344 +1,355 @@ $status) { if ($is_serious && !empty($status['silly'])) { unset($spec[$const]); continue; } } return $spec; } public static function getTaskStatusMap() { return ipull(self::getEnabledStatusMap(), 'name'); } /** * Get the statuses and their command keywords. * * @return map Statuses to lists of command keywords. */ public static function getTaskStatusKeywordsMap() { $map = self::getEnabledStatusMap(); foreach ($map as $key => $spec) { $words = idx($spec, 'keywords', array()); if (!is_array($words)) { $words = array($words); } // For statuses, we include the status name because it's usually // at least somewhat meaningful. $words[] = $key; foreach ($words as $word_key => $word) { $words[$word_key] = phutil_utf8_strtolower($word); } $words = array_unique($words); $map[$key] = $words; } return $map; } public static function getTaskStatusName($status) { return self::getStatusAttribute($status, 'name', pht('Unknown Status')); } public static function getTaskStatusFullName($status) { $name = self::getStatusAttribute($status, 'name.full'); if ($name !== null) { return $name; } return self::getStatusAttribute($status, 'name', pht('Unknown Status')); } public static function renderFullDescription($status) { if (self::isOpenStatus($status)) { $color = 'status'; - $icon = 'fa-square-o bluegrey'; + $icon_color = 'bluegrey'; } else { $color = 'status-dark'; - $icon = 'fa-check-square-o'; + $icon_color = ''; } + $icon = self::getStatusIcon($status); + $img = id(new PHUIIconView()) - ->setIconFont($icon); + ->setIconFont($icon.' '.$icon_color); $tag = phutil_tag( 'span', array( 'class' => 'phui-header-'.$color.' plr', ), array( $img, self::getTaskStatusFullName($status), )); return $tag; } private static function getSpecialStatus($special) { foreach (self::getStatusConfig() as $const => $status) { if (idx($status, 'special') == $special) { return $const; } } return null; } public static function getDefaultStatus() { return self::getSpecialStatus(self::SPECIAL_DEFAULT); } public static function getDefaultClosedStatus() { return self::getSpecialStatus(self::SPECIAL_CLOSED); } public static function getDuplicateStatus() { return self::getSpecialStatus(self::SPECIAL_DUPLICATE); } public static function getOpenStatusConstants() { $result = array(); foreach (self::getEnabledStatusMap() as $const => $status) { if (empty($status['closed'])) { $result[] = $const; } } return $result; } public static function getClosedStatusConstants() { $all = array_keys(self::getTaskStatusMap()); $open = self::getOpenStatusConstants(); return array_diff($all, $open); } public static function isOpenStatus($status) { foreach (self::getOpenStatusConstants() as $constant) { if ($status == $constant) { return true; } } return false; } public static function isClosedStatus($status) { return !self::isOpenStatus($status); } public static function getStatusActionName($status) { return self::getStatusAttribute($status, 'name.action'); } public static function getStatusColor($status) { return self::getStatusAttribute($status, 'transaction.color'); } public static function getStatusIcon($status) { - return self::getStatusAttribute($status, 'transaction.icon'); + $icon = self::getStatusAttribute($status, 'transaction.icon'); + if ($icon) { + return $icon; + } + + if (self::isOpenStatus($status)) { + return 'fa-square-o'; + } else { + return 'fa-check-square-o'; + } } public static function getStatusPrefixMap() { $map = array(); foreach (self::getEnabledStatusMap() as $const => $status) { foreach (idx($status, 'prefixes', array()) as $prefix) { $map[$prefix] = $const; } } $map += array( 'ref' => null, 'refs' => null, 'references' => null, 'cf.' => null, ); return $map; } public static function getStatusSuffixMap() { $map = array(); foreach (self::getEnabledStatusMap() as $const => $status) { foreach (idx($status, 'suffixes', array()) as $prefix) { $map[$prefix] = $const; } } return $map; } private static function getStatusAttribute($status, $key, $default = null) { $config = self::getStatusConfig(); $spec = idx($config, $status); if ($spec) { return idx($spec, $key, $default); } return $default; } /* -( Configuration Validation )------------------------------------------- */ /** * @task validate */ public static function isValidStatusConstant($constant) { if (strlen($constant) > 12) { return false; } if (!preg_match('/^[a-z0-9]+\z/', $constant)) { return false; } return true; } /** * @task validate */ public static function validateConfiguration(array $config) { foreach ($config as $key => $value) { if (!self::isValidStatusConstant($key)) { throw new Exception( pht( 'Key "%s" is not a valid status constant. Status constants must '. 'be 1-12 characters long and contain only lowercase letters (a-z) '. 'and digits (0-9). For example, "%s" or "%s" are reasonable '. 'choices.', $key, 'open', 'closed')); } if (!is_array($value)) { throw new Exception( pht( 'Value for key "%s" should be a dictionary.', $key)); } PhutilTypeSpec::checkMap( $value, array( 'name' => 'string', 'name.full' => 'optional string', 'name.action' => 'optional string', 'closed' => 'optional bool', 'special' => 'optional string', 'transaction.icon' => 'optional string', 'transaction.color' => 'optional string', 'silly' => 'optional bool', 'prefixes' => 'optional list', 'suffixes' => 'optional list', 'keywords' => 'optional list', )); } $special_map = array(); foreach ($config as $key => $value) { $special = idx($value, 'special'); if (!$special) { continue; } if (isset($special_map[$special])) { throw new Exception( pht( 'Configuration has two statuses both marked with the special '. 'attribute "%s" ("%s" and "%s"). There should be only one.', $special, $special_map[$special], $key)); } switch ($special) { case self::SPECIAL_DEFAULT: if (!empty($value['closed'])) { throw new Exception( pht( 'Status "%s" is marked as default, but it is a closed '. 'status. The default status should be an open status.', $key)); } break; case self::SPECIAL_CLOSED: if (empty($value['closed'])) { throw new Exception( pht( 'Status "%s" is marked as the default status for closing '. 'tasks, but is not a closed status. It should be a closed '. 'status.', $key)); } break; case self::SPECIAL_DUPLICATE: if (empty($value['closed'])) { throw new Exception( pht( 'Status "%s" is marked as the status for closing tasks as '. 'duplicates, but it is not a closed status. It should '. 'be a closed status.', $key)); } break; } $special_map[$special] = $key; } // NOTE: We're not explicitly validating that we have at least one open // and one closed status, because the DEFAULT and CLOSED specials imply // that to be true. If those change in the future, that might become a // reasonable thing to validate. $required = array( self::SPECIAL_DEFAULT, self::SPECIAL_CLOSED, self::SPECIAL_DUPLICATE, ); foreach ($required as $required_special) { if (!isset($special_map[$required_special])) { throw new Exception( pht( 'Configuration defines no task status with special attribute '. '"%s", but you must specify a status which fills this special '. 'role.', $required_special)); } } } } diff --git a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php index adeced3031..030d5dfdb3 100644 --- a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php +++ b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php @@ -1,529 +1,518 @@ 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; return $this; } public function getResultTypeDescription() { return pht('Tasks'); } public function getApplicationClassName() { return 'PhabricatorManiphestApplication'; } public function getCustomFieldObject() { return new ManiphestTask(); } public function buildSavedQueryFromRequest(AphrontRequest $request) { $saved = new PhabricatorSavedQuery(); $saved->setParameter( 'assignedPHIDs', $this->readUsersFromRequest($request, 'assigned')); $saved->setParameter( 'authorPHIDs', $this->readUsersFromRequest($request, 'authors')); $saved->setParameter( 'subscriberPHIDs', $this->readSubscribersFromRequest($request, 'subscribers')); $saved->setParameter( 'statuses', $this->readListFromRequest($request, 'statuses')); $saved->setParameter( 'priorities', $this->readListFromRequest($request, 'priorities')); $saved->setParameter( 'blocking', $this->readBoolFromRequest($request, 'blocking')); $saved->setParameter( 'blocked', $this->readBoolFromRequest($request, 'blocked')); $saved->setParameter('group', $request->getStr('group')); $saved->setParameter('order', $request->getStr('order')); $ids = $request->getStrList('ids'); foreach ($ids as $key => $id) { $id = trim($id, ' Tt'); if (!$id || !is_numeric($id)) { unset($ids[$key]); } else { $ids[$key] = $id; } } $saved->setParameter('ids', $ids); $saved->setParameter('fulltext', $request->getStr('fulltext')); $saved->setParameter( 'projects', $this->readProjectsFromRequest($request, 'projects')); $saved->setParameter('createdStart', $request->getStr('createdStart')); $saved->setParameter('createdEnd', $request->getStr('createdEnd')); $saved->setParameter('modifiedStart', $request->getStr('modifiedStart')); $saved->setParameter('modifiedEnd', $request->getStr('modifiedEnd')); $limit = $request->getInt('limit'); if ($limit > 0) { $saved->setParameter('limit', $limit); } $this->readCustomFieldsFromRequest($request, $saved); return $saved; } public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) { $query = id(new ManiphestTaskQuery()) ->needProjectPHIDs(true); $viewer = $this->requireViewer(); $datasource = id(new PhabricatorTypeaheadUserParameterizedDatasource()) ->setViewer($viewer); $author_phids = $saved->getParameter('authorPHIDs', array()); $author_phids = $datasource->evaluateTokens($author_phids); if ($author_phids) { $query->withAuthors($author_phids); } $datasource = id(new PhabricatorMetaMTAMailableFunctionDatasource()) ->setViewer($viewer); $subscriber_phids = $saved->getParameter('subscriberPHIDs', array()); $subscriber_phids = $datasource->evaluateTokens($subscriber_phids); if ($subscriber_phids) { $query->withSubscribers($subscriber_phids); } $datasource = id(new PhabricatorPeopleOwnerDatasource()) ->setViewer($this->requireViewer()); $assigned_phids = $this->readAssignedPHIDs($saved); $assigned_phids = $datasource->evaluateTokens($assigned_phids); if ($assigned_phids) { $query->withOwners($assigned_phids); } $statuses = $saved->getParameter('statuses'); if ($statuses) { $query->withStatuses($statuses); } $priorities = $saved->getParameter('priorities'); if ($priorities) { $query->withPriorities($priorities); } $query->withBlockingTasks($saved->getParameter('blocking')); $query->withBlockedTasks($saved->getParameter('blocked')); $this->applyOrderByToQuery( $query, $this->getOrderValues(), $saved->getParameter('order')); $group = $saved->getParameter('group'); $group = idx($this->getGroupValues(), $group); if ($group) { $query->setGroupBy($group); } else { $query->setGroupBy(head($this->getGroupValues())); } $ids = $saved->getParameter('ids'); if ($ids) { $query->withIDs($ids); } $fulltext = $saved->getParameter('fulltext'); if (strlen($fulltext)) { $query->withFullTextSearch($fulltext); } $projects = $this->readProjectTokens($saved); $adjusted = id(clone $saved)->setParameter('projects', $projects); $this->setQueryProjects($query, $adjusted); $start = $this->parseDateTime($saved->getParameter('createdStart')); $end = $this->parseDateTime($saved->getParameter('createdEnd')); if ($start) { $query->withDateCreatedAfter($start); } if ($end) { $query->withDateCreatedBefore($end); } $mod_start = $this->parseDateTime($saved->getParameter('modifiedStart')); $mod_end = $this->parseDateTime($saved->getParameter('modifiedEnd')); if ($mod_start) { $query->withDateModifiedAfter($mod_start); } if ($mod_end) { $query->withDateModifiedBefore($mod_end); } $this->applyCustomFieldsToQuery($query, $saved); return $query; } public function buildSearchForm( AphrontFormView $form, PhabricatorSavedQuery $saved) { $assigned_phids = $this->readAssignedPHIDs($saved); $author_phids = $saved->getParameter('authorPHIDs', array()); $projects = $this->readProjectTokens($saved); $subscriber_phids = $saved->getParameter('subscriberPHIDs', array()); $statuses = $saved->getParameter('statuses', array()); - $statuses = array_fuse($statuses); - $status_control = id(new AphrontFormCheckboxControl()) - ->setLabel(pht('Status')); - foreach (ManiphestTaskStatus::getTaskStatusMap() as $status => $name) { - $status_control->addCheckbox( - 'statuses[]', - $status, - $name, - isset($statuses[$status])); - } - $priorities = $saved->getParameter('priorities', array()); - $priorities = array_fuse($priorities); - $priority_control = id(new AphrontFormCheckboxControl()) - ->setLabel(pht('Priority')); - foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $name) { - $priority_control->addCheckbox( - 'priorities[]', - $pri, - $name, - isset($priorities[$pri])); - } $blocking_control = id(new AphrontFormSelectControl()) ->setLabel(pht('Blocking')) ->setName('blocking') ->setValue($this->getBoolFromQuery($saved, 'blocking')) ->setOptions(array( '' => pht('Show All Tasks'), 'true' => pht('Show Tasks Blocking Other Tasks'), 'false' => pht('Show Tasks Not Blocking Other Tasks'), )); $blocked_control = id(new AphrontFormSelectControl()) ->setLabel(pht('Blocked')) ->setName('blocked') ->setValue($this->getBoolFromQuery($saved, 'blocked')) ->setOptions(array( '' => pht('Show All Tasks'), 'true' => pht('Show Tasks Blocked By Other Tasks'), 'false' => pht('Show Tasks Not Blocked By Other Tasks'), )); $ids = $saved->getParameter('ids', array()); $builtin_orders = $this->getOrderOptions(); $custom_orders = $this->getCustomFieldOrderOptions(); $all_orders = $builtin_orders + $custom_orders; $form ->appendControl( id(new AphrontFormTokenizerControl()) ->setDatasource(new PhabricatorPeopleOwnerDatasource()) ->setName('assigned') ->setLabel(pht('Assigned To')) ->setValue($assigned_phids)) ->appendControl( id(new AphrontFormTokenizerControl()) ->setDatasource(new PhabricatorProjectLogicalDatasource()) ->setName('projects') ->setLabel(pht('Projects')) ->setValue($projects)) ->appendControl( id(new AphrontFormTokenizerControl()) ->setDatasource(new PhabricatorTypeaheadUserParameterizedDatasource()) ->setName('authors') ->setLabel(pht('Authors')) ->setValue($author_phids)) ->appendControl( id(new AphrontFormTokenizerControl()) ->setDatasource(new PhabricatorMetaMTAMailableFunctionDatasource()) ->setName('subscribers') ->setLabel(pht('Subscribers')) ->setValue($subscriber_phids)) + ->appendControl( + id(new AphrontFormTokenizerControl()) + ->setDatasource(new ManiphestTaskStatusDatasource()) + ->setLabel(pht('Statuses')) + ->setName('statuses') + ->setValue($statuses)) + ->appendControl( + id(new AphrontFormTokenizerControl()) + ->setDatasource(new ManiphestTaskPriorityDatasource()) + ->setLabel(pht('Priorities')) + ->setName('priorities') + ->setValue($priorities)) ->appendChild( id(new AphrontFormTextControl()) ->setName('fulltext') ->setLabel(pht('Contains Words')) ->setValue($saved->getParameter('fulltext'))) - ->appendChild($status_control) - ->appendChild($priority_control) ->appendChild($blocking_control) ->appendChild($blocked_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($all_orders)); } $form ->appendChild( id(new AphrontFormTextControl()) ->setName('ids') ->setLabel(pht('Task IDs')) ->setValue(implode(', ', $ids))); $this->appendCustomFieldsToForm($form, $saved); $this->buildDateRange( $form, $saved, 'createdStart', pht('Created After'), 'createdEnd', pht('Created Before')); $this->buildDateRange( $form, $saved, 'modifiedStart', pht('Updated After'), 'modifiedEnd', pht('Updated Before')); 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; } protected function getBuiltinQueryNames() { $names = array(); if ($this->requireViewer()->isLoggedIn()) { $names['assigned'] = pht('Assigned'); $names['authored'] = pht('Authored'); $names['subscribed'] = pht('Subscribed'); } $names['open'] = pht('Open Tasks'); $names['all'] = pht('All Tasks'); return $names; } public function buildSavedQueryFromBuiltin($query_key) { $query = $this->newSavedQuery(); $query->setQueryKey($query_key); $viewer_phid = $this->requireViewer()->getPHID(); switch ($query_key) { case 'all': return $query; case 'assigned': return $query ->setParameter('assignedPHIDs', array($viewer_phid)) ->setParameter( 'statuses', ManiphestTaskStatus::getOpenStatusConstants()); case 'subscribed': return $query ->setParameter('subscriberPHIDs', array($viewer_phid)) ->setParameter( 'statuses', ManiphestTaskStatus::getOpenStatusConstants()); case 'open': return $query ->setParameter( 'statuses', ManiphestTaskStatus::getOpenStatusConstants()); case 'authored': return $query ->setParameter('authorPHIDs', array($viewer_phid)) ->setParameter('order', 'created') ->setParameter('group', 'none'); } return parent::buildSavedQueryFromBuiltin($query_key); } private function getOrderOptions() { return array( 'priority' => pht('Priority'), 'updated' => pht('Date Updated'), 'created' => pht('Date Created'), 'title' => pht('Title'), ); } private function getOrderValues() { return array( 'priority' => ManiphestTaskQuery::ORDER_PRIORITY, 'updated' => ManiphestTaskQuery::ORDER_MODIFIED, 'created' => ManiphestTaskQuery::ORDER_CREATED, 'title' => ManiphestTaskQuery::ORDER_TITLE, ); } private function getGroupOptions() { return array( 'priority' => pht('Priority'), 'assigned' => pht('Assigned'), 'status' => pht('Status'), 'project' => pht('Project'), 'none' => pht('None'), ); } private function getGroupValues() { return array( 'priority' => ManiphestTaskQuery::GROUP_PRIORITY, 'assigned' => ManiphestTaskQuery::GROUP_OWNER, 'status' => ManiphestTaskQuery::GROUP_STATUS, 'project' => ManiphestTaskQuery::GROUP_PROJECT, 'none' => ManiphestTaskQuery::GROUP_NONE, ); } protected function renderResultList( array $tasks, PhabricatorSavedQuery $saved, array $handles) { $viewer = $this->requireViewer(); if ($this->isPanelContext()) { $can_edit_priority = false; $can_bulk_edit = false; } else { $can_edit_priority = PhabricatorPolicyFilter::hasCapability( $viewer, $this->getApplication(), ManiphestEditPriorityCapability::CAPABILITY); $can_bulk_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $this->getApplication(), ManiphestBulkEditCapability::CAPABILITY); } return id(new ManiphestTaskResultListView()) ->setUser($viewer) ->setTasks($tasks) ->setSavedQuery($saved) ->setCanEditPriority($can_edit_priority) ->setCanBatchEdit($can_bulk_edit) ->setShowBatchControls($this->showBatchControls); } private function readAssignedPHIDs(PhabricatorSavedQuery $saved) { $assigned_phids = $saved->getParameter('assignedPHIDs', array()); // This may be present in old saved queries from before parameterized // typeaheads, and is retained for compatibility. We could remove it by // migrating old saved queries. if ($saved->getParameter('withUnassigned')) { $assigned_phids[] = PhabricatorPeopleNoOwnerDatasource::FUNCTION_TOKEN; } return $assigned_phids; } private function readProjectTokens(PhabricatorSavedQuery $saved) { $projects = $saved->getParameter('projects', array()); $all = $saved->getParameter('allProjectPHIDs', array()); foreach ($all as $phid) { $projects[] = $phid; } $any = $saved->getParameter('anyProjectPHIDs', array()); foreach ($any as $phid) { $projects[] = 'any('.$phid.')'; } $not = $saved->getParameter('excludeProjectPHIDs', array()); foreach ($not as $phid) { $projects[] = 'not('.$phid.')'; } $users = $saved->getParameter('userProjectPHIDs', array()); foreach ($users as $phid) { $projects[] = 'projects('.$phid.')'; } $no = $saved->getParameter('withNoProject'); if ($no) { $projects[] = 'null()'; } return $projects; } } diff --git a/src/applications/maniphest/typeahead/ManiphestTaskPriorityDatasource.php b/src/applications/maniphest/typeahead/ManiphestTaskPriorityDatasource.php index 59c9a08042..4e7dd639d6 100644 --- a/src/applications/maniphest/typeahead/ManiphestTaskPriorityDatasource.php +++ b/src/applications/maniphest/typeahead/ManiphestTaskPriorityDatasource.php @@ -1,35 +1,41 @@ getViewer(); - $raw_query = $this->getRawQuery(); + $results = $this->buildResults(); + return $this->filterResultsAgainstTokens($results); + } + public function renderTokens(array $values) { + return $this->renderTokensFromResults($this->buildResults(), $values); + } + + private function buildResults() { $results = array(); $priority_map = ManiphestTaskPriority::getTaskPriorityMap(); foreach ($priority_map as $value => $name) { - // NOTE: $value is not a PHID but is unique. This'll work. - $results[] = id(new PhabricatorTypeaheadResult()) + $results[$value] = id(new PhabricatorTypeaheadResult()) + ->setIcon(ManiphestTaskPriority::getTaskPriorityIcon($value)) ->setPHID($value) ->setName($name); } - return $this->filterResultsAgainstTokens($results); + return $results; } } diff --git a/src/applications/maniphest/typeahead/ManiphestTaskStatusDatasource.php b/src/applications/maniphest/typeahead/ManiphestTaskStatusDatasource.php index 7ef047e72e..7b8ed6ca76 100644 --- a/src/applications/maniphest/typeahead/ManiphestTaskStatusDatasource.php +++ b/src/applications/maniphest/typeahead/ManiphestTaskStatusDatasource.php @@ -1,34 +1,41 @@ getViewer(); - $raw_query = $this->getRawQuery(); + $results = $this->buildResults(); + return $this->filterResultsAgainstTokens($results); + } + + public function renderTokens(array $values) { + return $this->renderTokensFromResults($this->buildResults(), $values); + } + private function buildResults() { $results = array(); $status_map = ManiphestTaskStatus::getTaskStatusMap(); foreach ($status_map as $value => $name) { - // NOTE: $value is not a PHID but is unique. This'll work. - $results[] = id(new PhabricatorTypeaheadResult()) + $results[$value] = id(new PhabricatorTypeaheadResult()) + ->setIcon(ManiphestTaskStatus::getStatusIcon($value)) ->setPHID($value) ->setName($name); } - return $this->filterResultsAgainstTokens($results); + return $results; } + } diff --git a/src/applications/search/typeahead/PhabricatorSearchDocumentTypeDatasource.php b/src/applications/search/typeahead/PhabricatorSearchDocumentTypeDatasource.php index 511363e7ed..407ab1cad4 100644 --- a/src/applications/search/typeahead/PhabricatorSearchDocumentTypeDatasource.php +++ b/src/applications/search/typeahead/PhabricatorSearchDocumentTypeDatasource.php @@ -1,56 +1,47 @@ buildResults(); return $this->filterResultsAgainstTokens($results); } public function renderTokens(array $values) { - $results = $this->buildResults(); - $results = array_select_keys($results, $values); - - $tokens = array(); - foreach ($results as $result) { - $tokens[] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult( - $result); - } - - return $tokens; + return $this->renderTokensFromResults($this->buildResults(), $values); } private function buildResults() { $types = PhabricatorSearchApplicationSearchEngine::getIndexableDocumentTypes(); $icons = mpull( PhabricatorPHIDType::getAllTypes(), 'getTypeIcon', 'getTypeConstant'); $results = array(); foreach ($types as $type => $name) { $results[$type] = id(new PhabricatorTypeaheadResult()) ->setPHID($type) ->setName($name) ->setIcon(idx($icons, $type)); } return $results; } } diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php index c4813aab18..f0f274b894 100644 --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -1,342 +1,342 @@ getRequest(); $viewer = $request->getUser(); $query = $request->getStr('q'); $offset = $request->getInt('offset'); $select_phid = null; $is_browse = ($request->getURIData('action') == 'browse'); $select = $request->getStr('select'); if ($select) { $select = phutil_json_decode($select); $query = idx($select, 'q'); $offset = idx($select, 'offset'); $select_phid = idx($select, 'phid'); } // Default this to the query string to make debugging a little bit easier. $raw_query = nonempty($request->getStr('raw'), $query); // This makes form submission easier in the debug view. $class = nonempty($request->getURIData('class'), $request->getStr('class')); $sources = id(new PhutilSymbolLoader()) ->setAncestorClass('PhabricatorTypeaheadDatasource') ->loadObjects(); if (isset($sources[$class])) { $source = $sources[$class]; $source->setParameters($request->getRequestData()); $source->setViewer($viewer); // NOTE: Wrapping the source in a Composite datasource ensures we perform // application visibility checks for the viewer, so we do not need to do // those separately. $composite = new PhabricatorTypeaheadRuntimeCompositeDatasource(); $composite->addDatasource($source); $hard_limit = 1000; $limit = 100; $composite ->setViewer($viewer) ->setQuery($query) ->setRawQuery($raw_query) ->setLimit($limit + 1); if ($is_browse) { if (!$composite->isBrowsable()) { return new Aphront404Response(); } if (($offset + $limit) >= $hard_limit) { // Offset-based paging is intrinsically slow; hard-cap how far we're // willing to go with it. return new Aphront404Response(); } $composite ->setOffset($offset); } $results = $composite->loadResults(); if ($is_browse) { // If this is a request for a specific token after the user clicks // "Select", return the token in wire format so it can be added to // the tokenizer. - if ($select_phid) { + if ($select_phid !== null) { $map = mpull($results, null, 'getPHID'); $token = idx($map, $select_phid); if (!$token) { return new Aphront404Response(); } $payload = array( 'key' => $token->getPHID(), 'token' => $token->getWireFormat(), ); return id(new AphrontAjaxResponse())->setContent($payload); } $format = $request->getStr('format'); switch ($format) { case 'html': case 'dialog': // These are the acceptable response formats. break; default: // Return a dialog if format information is missing or invalid. $format = 'dialog'; break; } $next_link = null; if (count($results) > $limit) { $results = array_slice($results, 0, $limit, $preserve_keys = true); if (($offset + (2 * $limit)) < $hard_limit) { $next_uri = id(new PhutilURI($request->getRequestURI())) ->setQueryParam('offset', $offset + $limit) ->setQueryParam('format', 'html'); $next_link = javelin_tag( 'a', array( 'href' => $next_uri, 'class' => 'typeahead-browse-more', 'sigil' => 'typeahead-browse-more', 'mustcapture' => true, ), pht('More Results')); } else { // If the user has paged through more than 1K results, don't // offer to page any further. $next_link = javelin_tag( 'div', array( 'class' => 'typeahead-browse-hard-limit', ), pht('You reach the edge of the abyss.')); } } $exclude = $request->getStrList('exclude'); $exclude = array_fuse($exclude); $select = array( 'offset' => $offset, 'q' => $query, ); $items = array(); foreach ($results as $result) { $token = PhabricatorTypeaheadTokenView::newFromTypeaheadResult( $result); // Disable already-selected tokens. $disabled = isset($exclude[$result->getPHID()]); $value = $select + array('phid' => $result->getPHID()); $value = json_encode($value); $button = phutil_tag( 'button', array( 'class' => 'small grey', 'name' => 'select', 'value' => $value, 'disabled' => $disabled ? 'disabled' : null, ), pht('Select')); $items[] = phutil_tag( 'div', array( 'class' => 'typeahead-browse-item grouped', ), array( $token, $button, )); } $markup = array( $items, $next_link, ); if ($format == 'html') { $content = array( 'markup' => hsprintf('%s', $markup), ); return id(new AphrontAjaxResponse())->setContent($content); } $this->requireResource('typeahead-browse-css'); $this->initBehavior('typeahead-browse'); $input_id = celerity_generate_unique_node_id(); $frame_id = celerity_generate_unique_node_id(); $config = array( 'inputID' => $input_id, 'frameID' => $frame_id, 'uri' => (string)$request->getRequestURI(), ); $this->initBehavior('typeahead-search', $config); $search = javelin_tag( 'input', array( 'type' => 'text', 'id' => $input_id, 'class' => 'typeahead-browse-input', 'autocomplete' => 'off', 'placeholder' => $source->getPlaceholderText(), )); $frame = phutil_tag( 'div', array( 'class' => 'typeahead-browse-frame', 'id' => $frame_id, ), $markup); $browser = array( phutil_tag( 'div', array( 'class' => 'typeahead-browse-header', ), $search), $frame, ); $function_help = null; if ($source->getAllDatasourceFunctions()) { $reference_uri = '/typeahead/help/'.get_class($source).'/'; $reference_link = phutil_tag( 'a', array( 'href' => $reference_uri, 'target' => '_blank', ), pht('Reference: Advanced Functions')); $function_help = array( id(new PHUIIconView()) ->setIconFont('fa-book'), ' ', $reference_link, ); } return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FORM) ->setRenderDialogAsDiv(true) ->setTitle($source->getBrowseTitle()) ->appendChild($browser) ->addFooter($function_help) ->addCancelButton('/', pht('Close')); } } else if ($is_browse) { return new Aphront404Response(); } else { $results = array(); } $content = mpull($results, 'getWireFormat'); $content = array_values($content); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent($content); } // If there's a non-Ajax request to this endpoint, show results in a tabular // format to make it easier to debug typeahead output. foreach ($sources as $key => $source) { // This can happen with composite or generic sources. if (!$source->getDatasourceApplicationClass()) { continue; } if (!PhabricatorApplication::isClassInstalledForViewer( $source->getDatasourceApplicationClass(), $viewer)) { unset($sources[$key]); } } $options = array_fuse(array_keys($sources)); asort($options); $form = id(new AphrontFormView()) ->setUser($viewer) ->setAction('/typeahead/class/') ->appendChild( id(new AphrontFormSelectControl()) ->setLabel(pht('Source Class')) ->setName('class') ->setValue($class) ->setOptions($options)) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Query')) ->setName('q') ->setValue($request->getStr('q'))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Raw Query')) ->setName('raw') ->setValue($request->getStr('raw'))) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Query'))); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Token Query')) ->setForm($form); $table = new AphrontTableView($content); $table->setHeaders( array( pht('Name'), pht('URI'), pht('PHID'), pht('Priority'), pht('Display Name'), pht('Display Type'), pht('Image URI'), pht('Priority Type'), pht('Icon'), pht('Closed'), pht('Sprite'), )); $result_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Token Results (%s)', $class)) ->appendChild($table); return $this->buildApplicationPage( array( $form_box, $result_box, ), array( 'title' => pht('Typeahead Results'), 'device' => false, )); } } diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php index caca8351c7..cdf7208f25 100644 --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php @@ -1,426 +1,438 @@ limit = $limit; return $this; } public function getLimit() { return $this->limit; } public function setOffset($offset) { $this->offset = $offset; return $this; } public function getOffset() { return $this->offset; } public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; return $this; } public function getViewer() { return $this->viewer; } public function setRawQuery($raw_query) { $this->rawQuery = $raw_query; return $this; } public function getRawQuery() { return $this->rawQuery; } public function setQuery($query) { $this->query = $query; return $this; } public function getQuery() { return $this->query; } public function setParameters(array $params) { $this->parameters = $params; return $this; } public function getParameters() { return $this->parameters; } public function getParameter($name, $default = null) { return idx($this->parameters, $name, $default); } public function getDatasourceURI() { $uri = new PhutilURI('/typeahead/class/'.get_class($this).'/'); $uri->setQueryParams($this->parameters); return (string)$uri; } public function getBrowseURI() { if (!$this->isBrowsable()) { return null; } $uri = new PhutilURI('/typeahead/browse/'.get_class($this).'/'); $uri->setQueryParams($this->parameters); return (string)$uri; } abstract public function getPlaceholderText(); public function getBrowseTitle() { return get_class($this); } abstract public function getDatasourceApplicationClass(); abstract public function loadResults(); protected function didLoadResults(array $results) { return $results; } public static function tokenizeString($string) { $string = phutil_utf8_strtolower($string); $string = trim($string); if (!strlen($string)) { return array(); } $tokens = preg_split('/\s+|[-\[\]]/', $string); return array_unique($tokens); } public function getTokens() { return self::tokenizeString($this->getRawQuery()); } protected function executeQuery( PhabricatorCursorPagedPolicyAwareQuery $query) { return $query ->setViewer($this->getViewer()) ->setOffset($this->getOffset()) ->setLimit($this->getLimit()) ->execute(); } /** * Can the user browse through results from this datasource? * * Browsable datasources allow the user to switch from typeahead mode to * a browse mode where they can scroll through all results. * * By default, datasources are browsable, but some datasources can not * generate a meaningful result set or can't filter results on the server. * * @return bool */ public function isBrowsable() { return true; } /** * Filter a list of results, removing items which don't match the query * tokens. * * This is useful for datasources which return a static list of hard-coded * or configured results and can't easily do query filtering in a real * query class. Instead, they can just build the entire result set and use * this method to filter it. * * For datasources backed by database objects, this is often much less * efficient than filtering at the query level. * * @param list List of typeahead results. * @return list Filtered results. */ protected function filterResultsAgainstTokens(array $results) { $tokens = $this->getTokens(); if (!$tokens) { return $results; } $map = array(); foreach ($tokens as $token) { $map[$token] = strlen($token); } foreach ($results as $key => $result) { $rtokens = self::tokenizeString($result->getName()); // For each token in the query, we need to find a match somewhere // in the result name. foreach ($map as $token => $length) { // Look for a match. $match = false; foreach ($rtokens as $rtoken) { if (!strncmp($rtoken, $token, $length)) { // This part of the result name has the query token as a prefix. $match = true; break; } } if (!$match) { // We didn't find a match for this query token, so throw the result // away. Try with the next result. unset($results[$key]); break; } } } return $results; } protected function newFunctionResult() { return id(new PhabricatorTypeaheadResult()) ->setTokenType(PhabricatorTypeaheadTokenView::TYPE_FUNCTION) ->setIcon('fa-asterisk'); } public function newInvalidToken($name) { return id(new PhabricatorTypeaheadTokenView()) ->setValue($name) ->setIcon('fa-exclamation-circle') ->setTokenType(PhabricatorTypeaheadTokenView::TYPE_INVALID); } public function renderTokens(array $values) { $phids = array(); $setup = array(); $tokens = array(); foreach ($values as $key => $value) { if (!self::isFunctionToken($value)) { $phids[$key] = $value; } else { $function = $this->parseFunction($value); if ($function) { $setup[$function['name']][$key] = $function; } else { $name = pht('Invalid Function: %s', $value); $tokens[$key] = $this->newInvalidToken($name) ->setKey($value); } } } if ($phids) { $handles = $this->getViewer()->loadHandles($phids); foreach ($phids as $key => $phid) { $handle = $handles[$phid]; $tokens[$key] = PhabricatorTypeaheadTokenView::newFromHandle($handle); } } if ($setup) { foreach ($setup as $function_name => $argv_list) { // Render the function tokens. $function_tokens = $this->renderFunctionTokens( $function_name, ipull($argv_list, 'argv')); // Rekey the function tokens using the original array keys. $function_tokens = array_combine( array_keys($argv_list), $function_tokens); // For any functions which were invalid, set their value to the // original input value before it was parsed. foreach ($function_tokens as $key => $token) { $type = $token->getTokenType(); if ($type == PhabricatorTypeaheadTokenView::TYPE_INVALID) { $token->setKey($values[$key]); } } $tokens += $function_tokens; } } return array_select_keys($tokens, array_keys($values)); } /* -( Token Functions )---------------------------------------------------- */ /** * @task functions */ public function getDatasourceFunctions() { return array(); } /** * @task functions */ public function getAllDatasourceFunctions() { return $this->getDatasourceFunctions(); } /** * @task functions */ protected function canEvaluateFunction($function) { return $this->shouldStripFunction($function); } /** * @task functions */ protected function shouldStripFunction($function) { $functions = $this->getDatasourceFunctions(); return isset($functions[$function]); } /** * @task functions */ protected function evaluateFunction($function, array $argv_list) { throw new PhutilMethodNotImplementedException(); } /** * @task functions */ public function evaluateTokens(array $tokens) { $results = array(); $evaluate = array(); foreach ($tokens as $token) { if (!self::isFunctionToken($token)) { $results[] = $token; } else { $evaluate[] = $token; } } foreach ($evaluate as $function) { $function = self::parseFunction($function); if (!$function) { throw new PhabricatorTypeaheadInvalidTokenException(); } $name = $function['name']; $argv = $function['argv']; foreach ($this->evaluateFunction($name, array($argv)) as $phid) { $results[] = $phid; } } $results = $this->didEvaluateTokens($results); return $results; } /** * @task functions */ protected function didEvaluateTokens(array $results) { return $results; } /** * @task functions */ public static function isFunctionToken($token) { // We're looking for a "(" so that a string like "members(q" is identified // and parsed as a function call. This allows us to start generating // results immeidately, before the user fully types out "members(quack)". return (strpos($token, '(') !== false); } /** * @task functions */ public function parseFunction($token, $allow_partial = false) { $matches = null; if ($allow_partial) { $ok = preg_match('/^([^(]+)\((.*?)\)?$/', $token, $matches); } else { $ok = preg_match('/^([^(]+)\((.*)\)$/', $token, $matches); } if (!$ok) { return null; } $function = trim($matches[1]); if (!$this->canEvaluateFunction($function)) { return null; } return array( 'name' => $function, 'argv' => array(trim($matches[2])), ); } /** * @task functions */ public function renderFunctionTokens($function, array $argv_list) { throw new PhutilMethodNotImplementedException(); } /** * @task functions */ public function setFunctionStack(array $function_stack) { $this->functionStack = $function_stack; return $this; } /** * @task functions */ public function getFunctionStack() { return $this->functionStack; } /** * @task functions */ protected function getCurrentFunction() { return nonempty(last($this->functionStack), null); } + protected function renderTokensFromResults(array $results, array $values) { + $results = array_select_keys($results, $values); + + $tokens = array(); + foreach ($results as $result) { + $tokens[] = PhabricatorTypeaheadTokenView::newFromTypeaheadResult( + $result); + } + + return $tokens; + } + }