diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -11,8 +11,18 @@ $request = $this->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); @@ -46,7 +56,6 @@ } $limit = 10; - $offset = $request->getInt('offset'); if (($offset + $limit) >= $hard_limit) { // Offset-based paging is intrinsically slow; hard-cap how far we're @@ -62,13 +71,43 @@ $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', @@ -91,16 +130,44 @@ } } + $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( @@ -108,7 +175,7 @@ $next_link, ); - if ($request->isAjax()) { + if ($format == 'html') { $content = array( 'markup' => hsprintf('%s', $markup), ); diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php @@ -73,6 +73,16 @@ 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(); diff --git a/src/view/control/AphrontTokenizerTemplateView.php b/src/view/control/AphrontTokenizerTemplateView.php --- a/src/view/control/AphrontTokenizerTemplateView.php +++ b/src/view/control/AphrontTokenizerTemplateView.php @@ -5,6 +5,12 @@ private $value; private $name; private $id; + private $browseURI; + + public function setBrowseURI($browse_uri) { + $this->browseURI = $browse_uri; + return $this; + } public function setID($id) { $this->id = $id; @@ -61,13 +67,57 @@ $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) { diff --git a/src/view/form/control/AphrontFormTokenizerControl.php b/src/view/form/control/AphrontFormTokenizerControl.php --- a/src/view/form/control/AphrontFormTokenizerControl.php +++ b/src/view/form/control/AphrontFormTokenizerControl.php @@ -70,8 +70,18 @@ } $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) { @@ -83,6 +93,7 @@ 'limit' => $this->limit, 'username' => $username, 'placeholder' => $placeholder, + 'browseURI' => $browse_uri, )); } diff --git a/webroot/rsrc/css/aphront/tokenizer.css b/webroot/rsrc/css/aphront/tokenizer.css --- a/webroot/rsrc/css/aphront/tokenizer.css +++ b/webroot/rsrc/css/aphront/tokenizer.css @@ -104,3 +104,17 @@ .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 --- a/webroot/rsrc/css/aphront/typeahead-browse.css +++ b/webroot/rsrc/css/aphront/typeahead-browse.css @@ -45,3 +45,16 @@ 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 --- a/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js +++ b/webroot/rsrc/externals/javelin/lib/control/tokenizer/Tokenizer.js @@ -45,12 +45,14 @@ properties : { limit : null, - renderTokenCallback : null + renderTokenCallback : null, + browseURI: null }, members : { _containerNode : null, _root : null, + _frame: null, _focus : null, _orig : null, _typeahead : null, @@ -76,6 +78,20 @@ this._tokens = []; this._tokenMap = {}; + try { + this._frame = JX.DOM.findAbove(this._orig, 'table', 'tokenizer-frame'); + } catch (e) { + // Ignore, this tokenizer doesn't have a frame. + } + + if (this._frame) { + JX.DOM.listen( + this._frame, + 'click', + 'tokenizer-browse', + JX.bind(this, this._onbrowse)); + } + var focus = this.buildInput(this._orig.value); this._focus = focus; @@ -429,6 +445,24 @@ false); this._focus.value = ''; this._redraw(); + }, + + _onbrowse: function(e) { + e.kill(); + + var uri = this.getBrowseURI(); + if (!uri) { + return; + } + + new JX.Workflow(uri, {exclude: JX.keys(this.getTokens()).join(',')}) + .setHandler( + JX.bind(this, function(r) { + this._typeahead.getDatasource().addResult(r.token); + this.addToken(r.key); + this.focus(); + })) + .start(); } } diff --git a/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js b/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js --- a/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js +++ b/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js @@ -31,7 +31,7 @@ } JX.DOM.alterClass(frame, 'loading', true); - new JX.Workflow(config.uri, {q: value}) + new JX.Workflow(config.uri, {q: value, format: 'html'}) .setHandler(function(r) { if (value != input.value) { // The user typed some more stuff while the request was in flight, diff --git a/webroot/rsrc/js/core/Prefab.js b/webroot/rsrc/js/core/Prefab.js --- a/webroot/rsrc/js/core/Prefab.js +++ b/webroot/rsrc/js/core/Prefab.js @@ -194,6 +194,10 @@ tokenizer.setInitialValue(config.value); } + if (config.browseURI) { + tokenizer.setBrowseURI(config.browseURI); + } + JX.Stratcom.addData(root, {'tokenizer' : tokenizer}); return {