diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php index 533e5aaa88..2742f68409 100644 --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php @@ -1,301 +1,357 @@ 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(); abstract public function getDatasourceApplicationClass(); abstract public function loadResults(); 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 */ protected function canEvaluateFunction($function) { return false; } /** * @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; } } 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(); } } diff --git a/src/view/form/control/AphrontFormTokenizerControl.php b/src/view/form/control/AphrontFormTokenizerControl.php index 2e37c3838d..841c1006e5 100644 --- a/src/view/form/control/AphrontFormTokenizerControl.php +++ b/src/view/form/control/AphrontFormTokenizerControl.php @@ -1,154 +1,131 @@ datasource = $datasource; return $this; } public function setDisableBehavior($disable) { $this->disableBehavior = $disable; return $this; } protected function getCustomControlClass() { return 'aphront-form-control-tokenizer'; } public function setLimit($limit) { $this->limit = $limit; return $this; } public function setPlaceholder($placeholder) { $this->placeholder = $placeholder; return $this; } public function willRender() { // Load the handles now so we'll get a bulk load later on when we actually // render them. $this->loadHandles(); } protected function renderInput() { $name = $this->getName(); $handles = $this->loadHandles(); $handles = iterator_to_array($handles); if ($this->getID()) { $id = $this->getID(); } else { $id = celerity_generate_unique_node_id(); } $datasource = $this->datasource; if (!$datasource) { throw new Exception( pht('You must set a datasource to use a TokenizerControl.')); } $datasource->setViewer($this->getUser()); $placeholder = null; if (!strlen($this->placeholder)) { $placeholder = $datasource->getPlaceholderText(); } - $tokens = array(); $values = nonempty($this->getValue(), array()); - foreach ($values as $value) { - if (isset($handles[$value])) { - $token = PhabricatorTypeaheadTokenView::newFromHandle($handles[$value]); - } else { - $token = null; - - $function = $datasource->parseFunction($value); - if ($function) { - $token_list = $datasource->renderFunctionTokens( - $function['name'], - array($function['argv'])); - $token = head($token_list); - } - - if (!$token) { - $name = pht('Invalid Function: %s', $value); - $token = $datasource->newInvalidToken($name); - } + $tokens = $datasource->renderTokens($values); - $type = $token->getTokenType(); - if ($type == PhabricatorTypeaheadTokenView::TYPE_INVALID) { - $token->setKey($value); - } - } + foreach ($tokens as $token) { $token->setInputName($this->getName()); - $tokens[] = $token; } $template = new AphrontTokenizerTemplateView(); $template->setName($name); $template->setID($id); $template->setValue($tokens); $username = null; if ($this->user) { $username = $this->user->getUsername(); } $datasource_uri = $datasource->getDatasourceURI(); $browse_uri = $datasource->getBrowseURI(); if ($browse_uri) { $template->setBrowseURI($browse_uri); } if (!$this->disableBehavior) { Javelin::initBehavior('aphront-basic-tokenizer', array( 'id' => $id, 'src' => $datasource_uri, 'value' => mpull($tokens, 'getValue', 'getKey'), 'icons' => mpull($tokens, 'getIcon', 'getKey'), 'types' => mpull($tokens, 'getTokenType', 'getKey'), 'colors' => mpull($tokens, 'getColor', 'getKey'), 'limit' => $this->limit, 'username' => $username, 'placeholder' => $placeholder, 'browseURI' => $browse_uri, )); } return $template->render(); } private function loadHandles() { if ($this->handles === null) { $viewer = $this->getUser(); if (!$viewer) { throw new Exception( pht( 'Call setUser() before rendering tokenizers. Use appendControl() '. 'on AphrontFormView to do this easily.')); } $values = nonempty($this->getValue(), array()); $phids = array(); foreach ($values as $value) { if (!PhabricatorTypeaheadDatasource::isFunctionToken($value)) { $phids[] = $value; } } $this->handles = $viewer->loadHandles($phids); } return $this->handles; } }