diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php index 66d7e1e0de..f7d28ed57c 100644 --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -1,171 +1,218 @@ 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); - $next_link = phutil_tag( - 'a', - array( - 'href' => id(new PhutilURI($request->getRequestURI())) - ->setQueryParam('offset', $offset + $limit), - ), - pht('Next Page')); + 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( + 'div', + array( + 'class' => 'typeahead-browse-frame', + ), + $markup); + return $this->newDialog() ->setWidth(AphrontDialogView::WIDTH_FORM) ->setTitle(get_class($source)) // TODO: Provide nice names. - ->appendChild($items) - ->appendChild($next_link) + ->appendChild($markup) ->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 new file mode 100644 index 0000000000..c50c693bd4 --- /dev/null +++ b/webroot/rsrc/css/aphront/typeahead-browse.css @@ -0,0 +1,34 @@ +/** + * @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}; +} diff --git a/webroot/rsrc/js/application/typeahead/behavior-typeahead-browse.js b/webroot/rsrc/js/application/typeahead/behavior-typeahead-browse.js new file mode 100644 index 0000000000..4af387f091 --- /dev/null +++ b/webroot/rsrc/js/application/typeahead/behavior-typeahead-browse.js @@ -0,0 +1,31 @@ +/** + * @provides javelin-behavior-typeahead-browse + * @requires javelin-behavior + * javelin-stratcom + * javelin-workflow + * javelin-dom + */ + +JX.behavior('typeahead-browse', function() { + var loading = false; + + JX.Stratcom.listen('click', 'typeahead-browse-more', function(e) { + e.kill(); + + if (loading) { + return; + } + var link = e.getTarget(); + + loading = true; + JX.DOM.alterClass(link, 'loading', true); + + JX.Workflow.newFromLink(link) + .setHandler(function(r) { + loading = false; + JX.DOM.replace(link, JX.$H(r.markup)); + }) + .start(); + }); + +});