diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php index f7d28ed57c..e2a6de0e43 100644 --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -1,218 +1,250 @@ getRequest(); $viewer = $request->getUser(); $query = $request->getStr('q'); $is_browse = ($request->getURIData('action') == 'browse'); // 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()); // 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) { $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 (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); $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.')); } } $items = array(); foreach ($results as $result) { $token = PhabricatorTypeaheadTokenView::newForTypeaheadResult( $result); $items[] = phutil_tag( 'div', array( 'class' => 'grouped', ), $token); } $markup = array( $items, $next_link, ); if ($request->isAjax()) { $content = array( 'markup' => hsprintf('%s', $markup), ); return id(new AphrontAjaxResponse())->setContent($content); } $this->requireResource('typeahead-browse-css'); $this->initBehavior('typeahead-browse'); - $markup = phutil_tag( + $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($markup) + ->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/webroot/rsrc/css/aphront/typeahead-browse.css b/webroot/rsrc/css/aphront/typeahead-browse.css index c50c693bd4..d8f5381a8f 100644 --- a/webroot/rsrc/css/aphront/typeahead-browse.css +++ b/webroot/rsrc/css/aphront/typeahead-browse.css @@ -1,34 +1,47 @@ /** * @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%; +} diff --git a/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js b/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js new file mode 100644 index 0000000000..93a0c0ef67 --- /dev/null +++ b/webroot/rsrc/js/application/typeahead/behavior-typeahead-search.js @@ -0,0 +1,56 @@ +/** + * @provides javelin-behavior-typeahead-search + * @requires javelin-behavior + * javelin-stratcom + * javelin-workflow + * javelin-dom + */ + +JX.behavior('typeahead-search', function(config) { + var input = JX.$(config.inputID); + var frame = JX.$(config.frameID); + var last = input.value; + + function update() { + if (input.value == last) { + // This is some kind of non-input keypress like an arrow key. Don't + // send a query to the server. + return; + } + + // Call load() in a little while. If the user hasn't typed anything else, + // we'll send a request to get results. + setTimeout(JX.bind(null, load, input.value), 100); + } + + function load(value) { + if (value != input.value) { + // The user has typed some more text, so don't send a request yet. We + // want to wait for them to stop typing. + return; + } + + JX.DOM.alterClass(frame, 'loading', true); + new JX.Workflow(config.uri, {q: value}) + .setHandler(function(r) { + if (value != input.value) { + // The user typed some more stuff while the request was in flight, + // so ignore the response. + return; + } + + last = input.value; + JX.DOM.setContent(frame, JX.$H(r.markup)); + JX.DOM.alterClass(frame, 'loading', false); + }) + .start(); + } + + JX.DOM.listen(input, ['keydown', 'keypress', 'keyup'], null, function() { + // We need to delay this to actually read the value after the keypress. + setTimeout(update, 0); + }); + + JX.DOM.focus(input); + +});