diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php index 2672e7d79a..2c645dbe38 100644 --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -1,255 +1,322 @@ 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); $composite ->setViewer($viewer) ->setQuery($query) ->setRawQuery($raw_query); $hard_limit = 1000; if ($is_browse) { if (!$composite->isBrowsable()) { return new Aphront404Response(); } $limit = 10; - $offset = $request->getInt('offset'); 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 ->setLimit($limit + 1) ->setOffset($offset); } $results = $composite->loadResults(); if ($is_browse) { - $next_link = null; + // 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) { + $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('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::newForTypeaheadResult( $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' => 'grouped', + 'class' => 'typeahead-browse-item grouped', ), - $token); + array( + $token, + $button, + )); } $markup = array( $items, $next_link, ); - if ($request->isAjax()) { + 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, ); return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FORM) ->setRenderDialogAsDiv(true) ->setTitle(get_class($source)) // TODO: Provide nice names. ->appendChild($browser) ->addCancelButton('/', pht('Close')); } } else if ($is_browse) { return new Aphront404Response(); } else { $results = array(); } $content = mpull($results, 'getWireFormat'); 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 sources like user or project, as well // generic ones like NoOwner 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 a15a248e23..bec3ea6c2c 100644 --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php @@ -1,176 +1,186 @@ 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; } } diff --git a/src/view/control/AphrontTokenizerTemplateView.php b/src/view/control/AphrontTokenizerTemplateView.php index 9d419d905c..009f3e134e 100644 --- a/src/view/control/AphrontTokenizerTemplateView.php +++ b/src/view/control/AphrontTokenizerTemplateView.php @@ -1,81 +1,131 @@ browseURI = $browse_uri; + return $this; + } public function setID($id) { $this->id = $id; return $this; } public function setValue(array $value) { assert_instances_of($value, 'PhabricatorObjectHandle'); $this->value = $value; return $this; } public function getValue() { return $this->value; } public function setName($name) { $this->name = $name; return $this; } public function getName() { return $this->name; } public function render() { require_celerity_resource('aphront-tokenizer-control-css'); $id = $this->id; $name = $this->getName(); $values = nonempty($this->getValue(), array()); $tokens = array(); foreach ($values as $key => $value) { $tokens[] = $this->renderToken( $value->getPHID(), $value->getFullName(), $value->getType()); } $input = javelin_tag( 'input', array( 'mustcapture' => true, 'name' => $name, 'class' => 'jx-tokenizer-input', 'sigil' => 'tokenizer-input', 'style' => 'width: 0px;', 'disabled' => 'disabled', 'type' => 'text', )); $content = $tokens; $content[] = $input; $content[] = phutil_tag('div', array('style' => 'clear: both;'), ''); - return phutil_tag( + $container = phutil_tag( 'div', array( 'id' => $id, 'class' => 'jx-tokenizer-container', ), $content); + + $browse = null; + if ($this->browseURI) { + $icon = id(new PHUIIconView()) + ->setIconFont('fa-list-ul'); + + // TODO: This thing is ugly and the ugliness is not intentional. + // We have to give it text or PHUIButtonView collapses. It should likely + // just be an icon and look more integrated into the input. + $browse = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon($icon) + ->addSigil('tokenizer-browse') + ->setColor(PHUIButtonView::GREY) + ->setSize(PHUIButtonView::SMALL) + ->setText(pht('Browse...')); + } + + $frame = javelin_tag( + 'table', + array( + 'class' => 'jx-tokenizer-frame', + 'sigil' => 'tokenizer-frame', + ), + phutil_tag( + 'tr', + array( + ), + array( + phutil_tag( + 'td', + array( + 'class' => 'jx-tokenizer-frame-input', + ), + $container), + phutil_tag( + 'td', + array( + 'class' => 'jx-tokenizer-frame-browse', + ), + $browse), + ))); + + return $frame; } private function renderToken($key, $value, $icon) { return id(new PhabricatorTypeaheadTokenView()) ->setKey($key) ->setValue($value) ->setIcon($icon) ->setInputName($this->getName()); } } diff --git a/src/view/form/control/AphrontFormTokenizerControl.php b/src/view/form/control/AphrontFormTokenizerControl.php index 6e1352ba91..970436e959 100644 --- a/src/view/form/control/AphrontFormTokenizerControl.php +++ b/src/view/form/control/AphrontFormTokenizerControl.php @@ -1,109 +1,120 @@ 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(); } $placeholder = null; if (!strlen($this->placeholder)) { if ($this->datasource) { $placeholder = $this->datasource->getPlaceholderText(); } } else { $placeholder = $this->placeholder; } $template = new AphrontTokenizerTemplateView(); $template->setName($name); $template->setID($id); $template->setValue($handles); $username = null; if ($this->user) { $username = $this->user->getUsername(); } $datasource_uri = null; - if ($this->datasource) { - $datasource_uri = $this->datasource->getDatasourceURI(); + $browse_uri = null; + + $datasource = $this->datasource; + if ($datasource) { + $datasource->setViewer($this->getUser()); + + $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($handles, 'getFullName', 'getPHID'), 'icons' => mpull($handles, 'getIcon', 'getPHID'), '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()); $this->handles = $viewer->loadHandles($values); } return $this->handles; } } diff --git a/webroot/rsrc/css/aphront/tokenizer.css b/webroot/rsrc/css/aphront/tokenizer.css index ca709d738c..074f54e5d8 100644 --- a/webroot/rsrc/css/aphront/tokenizer.css +++ b/webroot/rsrc/css/aphront/tokenizer.css @@ -1,106 +1,120 @@ /** * @provides aphront-tokenizer-control-css * @requires aphront-typeahead-control-css */ body div.jx-tokenizer { background: transparent; position: relative; width: 100%; } body div.jx-tokenizer-container { position: relative; display: block; padding: 0 0 2px 0; min-height: 30px; height: auto; } var.jx-tokenizer-metrics { position: absolute; left: 20px; top: 20px; } body input.jx-tokenizer-input { border: 1px solid transparent; border-width: 1px 0px; padding: 3px; outline: none; float: left; width: 100%; border-shadow: none; box-shadow: none; -webkit-box-shadow: none; font-size: 13px; color: #333; height: 26px; } body input.jx-tokenizer-input:focus { box-shadow: none; -webkit-box-shadow: none; border-color: transparent; } body input.jx-typeahead-placeholder { margin-left: 4px; color: {$greytext}; } a.jx-tokenizer-x { margin-left: 4px; color: {$bluetext}; } a.jx-tokenizer-x:hover { color: {$darkbluetext}; text-decoration: none; } a.jx-tokenizer-token { padding: 2px 6px 3px; border: 1px solid {$lightblueborder}; margin: 3px 2px 0 4px; background: #dee7f8; float: left; cursor: pointer; border-radius: 3px; color: {$darkbluetext}; min-height: 16px; } a.jx-tokenizer-token:hover { text-decoration: none; border-color: {$blueborder}; background: #CDD9F0; } .jx-tokenizer-token .phui-icon-view { display: inline-block; margin: 2px 4px -3px 0; color: {$bluetext}; } .tokenizer-result { position: relative; padding: 5px 8px 5px 28px; } .tokenizer-result .phui-icon-view { display: inline-block; width: 24px; height: 24px; position: absolute; top: 5px; left: 8px; } .tokenizer-result-closed { color: {$greytext}; } .tokenizer-closed { margin-top: 2px; } + +.jx-tokenizer-frame { + width: 100%; +} + +.jx-tokenizer-frame-input { + width: 100%; +} + +.jx-tokenizer-frame-browse { + width: 100px; + vertical-align: middle; + padding: 0 0 0 4px; +} diff --git a/webroot/rsrc/css/aphront/typeahead-browse.css b/webroot/rsrc/css/aphront/typeahead-browse.css index d8f5381a8f..d5fdabe598 100644 --- a/webroot/rsrc/css/aphront/typeahead-browse.css +++ b/webroot/rsrc/css/aphront/typeahead-browse.css @@ -1,47 +1,60 @@ /** * @provides typeahead-browse-css */ .typeahead-browse-more, .typeahead-browse-hard-limit { display: block; padding: 8px; margin: 8px 0 0; text-align: center; } .typeahead-browse-more { background: {$lightblue}; border: 1px solid {$lightblueborder}; } .typeahead-browse-more.loading { opacity: 0.8; } .typeahead-browse-hard-limit { background: {$lightgreybackground}; border: 1px solid {$lightgreyborder}; color: {$lightgreytext}; } .typeahead-browse-frame { overflow-x: hidden; overflow-y: auto; padding: 4px; height: 260px; border: 1px solid {$lightgreyborder}; } .typeahead-browse-frame.loading { opacity: 0.8; } .typeahead-browse-header { padding: 4px 0; } input.typeahead-browse-input { margin: 0; width: 100%; } + +.typeahead-browse-item { + padding: 2px 0; +} + +.typeahead-browse-item + .typeahead-browse-item { + border-top: 1px solid {$thinblueborder}; +} + +.typeahead-browse-item button { + float: right; + margin: 2px 4px; +} diff --git a/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js b/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js index fd5aae35d8..bdde22fb88 100644 --- a/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js +++ b/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js @@ -1,435 +1,469 @@ /** * @requires javelin-dom * javelin-util * javelin-stratcom * javelin-install * @provides javelin-tokenizer * @javelin */ /** * A tokenizer is a UI component similar to a text input, except that it * allows the user to input a list of items ("tokens"), generally from a fixed * set of results. A familiar example of this UI is the "To:" field of most * email clients, where the control autocompletes addresses from the user's * address book. * * @{JX.Tokenizer} is built on top of @{JX.Typeahead}, and primarily adds the * ability to choose multiple items. * * To build a @{JX.Tokenizer}, you need to do four things: * * 1. Construct it, padding a DOM node for it to attach to. See the constructor * for more information. * 2. Build a {@JX.Typeahead} and configure it with setTypeahead(). * 3. Configure any special options you want. * 4. Call start(). * * If you do this correctly, the input should suggest items and enter them as * tokens as the user types. * * When the tokenizer is focused, the CSS class `jx-tokenizer-container-focused` * is added to the container node. */ JX.install('Tokenizer', { construct : function(containerNode) { this._containerNode = containerNode; }, events : [ /** * Emitted when the value of the tokenizer changes, similar to an 'onchange' * from a