diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -7,8 +7,8 @@ */ return array( 'names' => array( - 'core.pkg.css' => 'a2a90172', - 'core.pkg.js' => 'd57952b0', + 'core.pkg.css' => 'f7d01efc', + 'core.pkg.js' => 'a1f9db42', 'darkconsole.pkg.js' => '8ab24e01', 'differential.pkg.css' => '3500921f', 'differential.pkg.js' => 'c0506961', @@ -34,7 +34,7 @@ 'rsrc/css/aphront/typeahead.css' => '0e403212', 'rsrc/css/application/almanac/almanac.css' => 'dbb9b3af', 'rsrc/css/application/auth/auth.css' => '1e655982', - 'rsrc/css/application/base/main-menu-view.css' => 'c648b2f5', + 'rsrc/css/application/base/main-menu-view.css' => '31e66da9', 'rsrc/css/application/base/notification-menu.css' => '3c9d8aa1', 'rsrc/css/application/base/phabricator-application-launch-view.css' => '16ca323f', 'rsrc/css/application/base/standard-page-view.css' => 'd3e1abe9', @@ -486,7 +486,7 @@ 'rsrc/js/core/behavior-reorder-applications.js' => '76b9fc3e', 'rsrc/js/core/behavior-reveal-content.js' => '60821bc7', 'rsrc/js/core/behavior-scrollbar.js' => '834a1173', - 'rsrc/js/core/behavior-search-typeahead.js' => '724b1247', + 'rsrc/js/core/behavior-search-typeahead.js' => 'bc965352', 'rsrc/js/core/behavior-select-on-click.js' => '4e3e79a6', 'rsrc/js/core/behavior-toggle-class.js' => 'e566f52c', 'rsrc/js/core/behavior-tokenizer.js' => 'b3a4b884', @@ -628,7 +628,7 @@ 'javelin-behavior-phabricator-oncopy' => '2926fff2', 'javelin-behavior-phabricator-remarkup-assist' => 'e32d14ab', 'javelin-behavior-phabricator-reveal-content' => '60821bc7', - 'javelin-behavior-phabricator-search-typeahead' => '724b1247', + 'javelin-behavior-phabricator-search-typeahead' => 'bc965352', 'javelin-behavior-phabricator-show-older-transactions' => 'dbbf48b6', 'javelin-behavior-phabricator-tooltips' => '3ee3408b', 'javelin-behavior-phabricator-transaction-comment-form' => '9f7309fb', @@ -737,7 +737,7 @@ 'phabricator-hovercard-view-css' => '44394670', 'phabricator-keyboard-shortcut' => '1ae869f2', 'phabricator-keyboard-shortcut-manager' => 'c1700f6f', - 'phabricator-main-menu-view' => 'c648b2f5', + 'phabricator-main-menu-view' => '31e66da9', 'phabricator-nav-view-css' => '7aeaf435', 'phabricator-notification' => '0c6946e7', 'phabricator-notification-css' => '9c279160', @@ -1367,16 +1367,6 @@ 'javelin-vector', 'javelin-util', ), - '724b1247' => array( - 'javelin-behavior', - 'javelin-typeahead-ondemand-source', - 'javelin-typeahead', - 'javelin-dom', - 'javelin-uri', - 'javelin-util', - 'javelin-stratcom', - 'phabricator-prefab', - ), '7319e029' => array( 'javelin-behavior', 'javelin-dom', @@ -1749,6 +1739,16 @@ 'javelin-mask', 'phabricator-drag-and-drop-file-upload', ), + 'bc965352' => array( + 'javelin-behavior', + 'javelin-typeahead-ondemand-source', + 'javelin-typeahead', + 'javelin-dom', + 'javelin-uri', + 'javelin-util', + 'javelin-stratcom', + 'phabricator-prefab', + ), 'bd4c8dca' => array( 'javelin-install', 'javelin-util', diff --git a/src/applications/base/PhabricatorApplication.php b/src/applications/base/PhabricatorApplication.php --- a/src/applications/base/PhabricatorApplication.php +++ b/src/applications/base/PhabricatorApplication.php @@ -582,4 +582,8 @@ } } + public function getApplicationSearchDocumentTypes() { + return array(); + } + } diff --git a/src/applications/maniphest/application/PhabricatorManiphestApplication.php b/src/applications/maniphest/application/PhabricatorManiphestApplication.php --- a/src/applications/maniphest/application/PhabricatorManiphestApplication.php +++ b/src/applications/maniphest/application/PhabricatorManiphestApplication.php @@ -158,4 +158,10 @@ ); } + public function getApplicationSearchDocumentTypes() { + return array( + ManiphestTaskPHIDType::TYPECONST, + ); + } + } diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -233,6 +233,15 @@ $saved_query); } + $messages = $engine->getMessages(); + if ($messages) { + $nav->appendChild( + id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_NOTICE) + ->setErrors($messages)); + } + + $nav->appendChild($list); // TODO: This is a bit hacky. diff --git a/src/applications/search/controller/PhabricatorSearchController.php b/src/applications/search/controller/PhabricatorSearchController.php --- a/src/applications/search/controller/PhabricatorSearchController.php +++ b/src/applications/search/controller/PhabricatorSearchController.php @@ -3,6 +3,8 @@ final class PhabricatorSearchController extends PhabricatorSearchBaseController { + const SCOPE_CURRENT_APPLICATION = 'application'; + private $queryKey; public function shouldAllowPublic() { @@ -32,49 +34,69 @@ $engine = new PhabricatorSearchApplicationSearchEngine(); $engine->setViewer($viewer); - // NOTE: This is a little weird. If we're coming from primary search, we - // load the user's first search filter and overwrite the "query" part of - // it, then send them to that result page. This is sort of odd, but lets - // users choose a default query like "Open Tasks" in a reasonable way, - // with only this piece of somewhat-sketchy code. See discussion in T4365. - + // If we're coming from primary search, do some special handling to + // interpret the scope selector and query. if ($request->getBool('search:primary')) { + + // If there's no query, just take the user to advanced search. if (!strlen($request->getStr('query'))) { $advanced_uri = '/search/query/advanced/'; return id(new AphrontRedirectResponse())->setURI($advanced_uri); } - $named_queries = $engine->loadEnabledNamedQueries(); - if ($named_queries) { - $named = head($named_queries); - - $query_key = $named->getQueryKey(); - $saved = null; - if ($engine->isBuiltinQuery($query_key)) { - $saved = $engine->buildSavedQueryFromBuiltin($query_key); - } else { - $saved = id(new PhabricatorSavedQueryQuery()) - ->setViewer($viewer) - ->withQueryKeys(array($query_key)) - ->executeOne(); + // First, load or construct a template for the search by examining + // the current search scope. + $scope = $request->getStr('search:scope'); + $saved = null; + + $app_scope_error = false; + if ($scope == self::SCOPE_CURRENT_APPLICATION) { + $app_scope_error = true; + $application = id(new PhabricatorApplicationQuery()) + ->setViewer($viewer) + ->withClasses(array($request->getStr('search:application'))) + ->executeOne(); + if ($application) { + $types = $application->getApplicationSearchDocumentTypes(); + if ($types) { + $app_scope_error = false; + $saved = id(new PhabricatorSavedQuery()) + ->setEngineClassName(get_class($engine)) + ->setParameter('types', $types); + } } + } - if ($saved) { - $saved->setParameter('query', $request->getStr('query')); - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - try { - $saved->setID(null)->save(); - } catch (AphrontDuplicateKeyQueryException $ex) { - // Ignore, this is just a repeated search. - } - unset($unguarded); - - $results_uri = $engine->getQueryResultsPageURI( - $saved->getQueryKey()).'#R'; + if (!$saved && !$engine->isBuiltinQuery($scope)) { + $saved = id(new PhabricatorSavedQueryQuery()) + ->setViewer($viewer) + ->withQueryKeys(array($scope)) + ->executeOne(); + } - return id(new AphrontRedirectResponse())->setURI($results_uri); + if (!$saved) { + if (!$engine->isBuiltinQuery($scope)) { + $scope = 'all'; } + $saved = $engine->buildSavedQueryFromBuiltin($scope); } + + // Add the user's query, then save this as a new saved query and send + // the user to the results page. + $saved->setParameter('query', $request->getStr('query')); + $saved->setParameter('app.scope.error', $app_scope_error); + + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + try { + $saved->setID(null)->save(); + } catch (AphrontDuplicateKeyQueryException $ex) { + // Ignore, this is just a repeated search. + } + unset($unguarded); + + $query_key = $saved->getQueryKey(); + $results_uri = $engine->getQueryResultsPageURI($query_key).'#R'; + return id(new AphrontRedirectResponse())->setURI($results_uri); } $controller = id(new PhabricatorApplicationSearchController()) diff --git a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php --- a/src/applications/search/engine/PhabricatorApplicationSearchEngine.php +++ b/src/applications/search/engine/PhabricatorApplicationSearchEngine.php @@ -19,6 +19,7 @@ private $application; private $viewer; private $errors = array(); + private $messages = array(); private $customFields = false; private $request; private $context; @@ -101,6 +102,15 @@ return $this; } + public function getMessages() { + return $this->messages; + } + + public function addMessage($message) { + $this->messages[] = $message; + return $this; + } + /** * Return an application URI corresponding to the results page of a query. * Normally, this is something like `/application/query/QUERYKEY/`. diff --git a/src/applications/search/query/PhabricatorSearchApplicationSearchEngine.php b/src/applications/search/query/PhabricatorSearchApplicationSearchEngine.php --- a/src/applications/search/query/PhabricatorSearchApplicationSearchEngine.php +++ b/src/applications/search/query/PhabricatorSearchApplicationSearchEngine.php @@ -55,6 +55,14 @@ AphrontFormView $form, PhabricatorSavedQuery $saved) { + if ($saved->getParameter('app.scope.error')) { + $this->addMessage( + pht( + 'You ran a "Current Application" search, but the current '. + 'application does not have any searchable document types. '. + 'Showing results for all documents.')); + } + $options = array(); $author_value = null; $owner_value = null; diff --git a/src/applications/settings/storage/PhabricatorUserPreferences.php b/src/applications/settings/storage/PhabricatorUserPreferences.php --- a/src/applications/settings/storage/PhabricatorUserPreferences.php +++ b/src/applications/settings/storage/PhabricatorUserPreferences.php @@ -19,6 +19,7 @@ const PREFERENCE_SEARCHBAR_JUMP = 'searchbar-jump'; const PREFERENCE_SEARCH_SHORTCUT = 'search-shortcut'; + const PREFERENCE_SEARCH_SCOPE = 'search-scope'; const PREFERENCE_DIFFUSION_BLAME = 'diffusion-blame'; const PREFERENCE_DIFFUSION_COLOR = 'diffusion-color'; diff --git a/src/view/page/menu/PhabricatorMainMenuSearchView.php b/src/view/page/menu/PhabricatorMainMenuSearchView.php --- a/src/view/page/menu/PhabricatorMainMenuSearchView.php +++ b/src/view/page/menu/PhabricatorMainMenuSearchView.php @@ -3,6 +3,16 @@ final class PhabricatorMainMenuSearchView extends AphrontView { private $id; + private $application; + + public function setApplication(PhabricatorApplication $application) { + $this->application = $application; + return $this; + } + + public function getApplication() { + return $this->application; + } public function getID() { if (!$this->id) { @@ -36,6 +46,7 @@ ''); $search_datasource = new PhabricatorSearchDatasource(); + $scope_key = PhabricatorUserPreferences::PREFERENCE_SEARCH_SCOPE; Javelin::initBehavior( 'phabricator-search-typeahead', @@ -46,6 +57,7 @@ 'src' => $search_datasource->getDatasourceURI(), 'limit' => 10, 'placeholder' => pht('Search'), + 'scopeUpdateURI' => '/settings/adjust/?key='.$scope_key, )); $primary_input = phutil_tag( @@ -63,6 +75,8 @@ ), pht('Search')); + $selector = $this->buildModeSelector(); + $form = phabricator_form( $user, array( @@ -78,6 +92,7 @@ 'class' => 'phui-icon-view phui-font-fa fa-search', ), $search_text), + $selector, $primary_input, $target, ))); @@ -85,4 +100,124 @@ return $form; } + private function buildModeSelector() { + $viewer = $this->getUser(); + + $items = array(); + $items[] = array( + 'name' => pht('Search'), + ); + + $items[] = array( + 'icon' => 'fa-globe', + 'name' => pht('Search All Documents'), + 'value' => 'all', + ); + + $application_value = null; + $application_icon = 'fa-file-o'; + $application = $this->getApplication(); + if ($application) { + $application_value = get_class($application); + if ($application->getApplicationSearchDocumentTypes()) { + $application_icon = $application->getFontIcon(); + } + } + + $items[] = array( + 'icon' => $application_icon, + 'name' => pht('Search Current Application'), + 'value' => PhabricatorSearchController::SCOPE_CURRENT_APPLICATION, + ); + + $items[] = array( + 'name' => pht('Saved Queries'), + ); + + + $engine = id(new PhabricatorSearchApplicationSearchEngine()) + ->setViewer($viewer); + $engine_queries = $engine->loadEnabledNamedQueries(); + $query_map = mpull($engine_queries, 'getQueryName', 'getQueryKey'); + foreach ($query_map as $query_key => $query_name) { + if ($query_key == 'all') { + // Skip the builtin "All" query since it's redundant with the default + // setting. + continue; + } + + $items[] = array( + 'icon' => 'fa-search', + 'name' => $query_name, + 'value' => $query_key, + ); + } + + $items[] = array( + 'name' => pht('More Options'), + ); + + $items[] = array( + 'icon' => 'fa-search-plus', + 'name' => pht('Advanced Search'), + 'href' => '/search/query/advanced/', + ); + + /* TODO: Write this. + $items[] = array( + 'icon' => 'fa-book', + 'name' => pht('User Guide: Search'), + 'href' => PhabricatorEnv::getDoclink('User Guide: Search'), + ); + */ + + $scope_key = PhabricatorUserPreferences::PREFERENCE_SEARCH_SCOPE; + $current_value = $viewer->loadPreferences()->getPreference( + $scope_key, + 'all'); + + $current_icon = 'fa-globe'; + foreach ($items as $item) { + if (idx($item, 'value') == $current_value) { + $current_icon = $item['icon']; + break; + } + } + + $selector = id(new PHUIButtonView()) + ->addClass('phabricator-main-menu-search-dropdown') + ->addSigil('global-search-dropdown') + ->setMetadata( + array( + 'items' => $items, + 'icon' => $current_icon, + 'value' => $current_value, + )) + ->setIcon( + id(new PHUIIconView()) + ->addSigil('global-search-dropdown-icon') + ->setIconFont($current_icon)) + ->setDropdown(true); + + $input = javelin_tag( + 'input', + array( + 'type' => 'hidden', + 'sigil' => 'global-search-dropdown-input', + 'name' => 'search:scope', + 'value' => $current_value, + )); + + $application_input = javelin_tag( + 'input', + array( + 'type' => 'hidden', + 'sigil' => 'global-search-dropdown-app', + 'name' => 'search:application', + 'value' => $application_value, + )); + + return array($selector, $input, $application_input); + } + } diff --git a/src/view/page/menu/PhabricatorMainMenuView.php b/src/view/page/menu/PhabricatorMainMenuView.php --- a/src/view/page/menu/PhabricatorMainMenuView.php +++ b/src/view/page/menu/PhabricatorMainMenuView.php @@ -114,6 +114,16 @@ if ($show_search) { $search = new PhabricatorMainMenuSearchView(); $search->setUser($user); + + $application = null; + $controller = $this->getController(); + if ($controller) { + $application = $controller->getCurrentApplication(); + } + if ($application) { + $search->setApplication($application); + } + $result = $search; $pref_shortcut = PhabricatorUserPreferences::PREFERENCE_SEARCH_SHORTCUT; diff --git a/webroot/rsrc/css/application/base/main-menu-view.css b/webroot/rsrc/css/application/base/main-menu-view.css --- a/webroot/rsrc/css/application/base/main-menu-view.css +++ b/webroot/rsrc/css/application/base/main-menu-view.css @@ -160,10 +160,9 @@ height: 28px; line-height: 12px; box-shadow: 0px 1px 1px rgba(128, 128, 128, 0.25); - padding: 6px 30px 6px 6px; + padding: 6px 30px 6px 46px; float: left; width: 205px; - left: 0; } .phabricator-main-menu.main-header-dark .phabricator-main-menu-search input { @@ -207,6 +206,25 @@ border-radius: 0; } +.phabricator-main-menu-search button.phabricator-main-menu-search-dropdown { + position: absolute; + right: auto; + left: 0; + width: 40px; +} + +.phabricator-main-menu-search button.phabricator-main-menu-search-dropdown + .phui-icon-view { + color: rgba(255,255,255,.8); +} + +.phabricator-main-menu-search-dropdown .caret { + position: absolute; + right: 4px; + top: 3px; +} + + .phabricator-main-menu-search button:hover { color: #fff; } diff --git a/webroot/rsrc/js/core/behavior-search-typeahead.js b/webroot/rsrc/js/core/behavior-search-typeahead.js --- a/webroot/rsrc/js/core/behavior-search-typeahead.js +++ b/webroot/rsrc/js/core/behavior-search-typeahead.js @@ -143,4 +143,86 @@ typeahead.setPlaceholder(''); typeahead.updatePlaceHolder(); }); + + // TODO: Quicksand needs to update the application search input as we change + // applications; we should register a listener. + // TODO: Quicksand also needs to update the application search icon on the + // button itself and in the menu. + + // Implement the scope selector menu for the global search. + JX.Stratcom.listen('click', 'global-search-dropdown', function(e) { + var data = e.getNodeData('global-search-dropdown'); + var button = e.getNode('global-search-dropdown'); + if (data.menu) { + return; + } + + e.kill(); + + function updateValue(spec) { + if (data.value == spec.value) { + return; + } + + // Swap out the icon. + var icon = JX.DOM.find(button, 'span', 'global-search-dropdown-icon'); + JX.DOM.alterClass(icon, data.icon, false); + data.icon = spec.icon; + JX.DOM.alterClass(icon, data.icon, true); + + // Update the value. + data.value = spec.value; + + // Update the form input. + var frame = button.parentNode; + var input = JX.DOM.find(frame, 'input', 'global-search-dropdown-input'); + input.value = data.value; + + new JX.Request(config.scopeUpdateURI) + .setData({value: data.value}) + .send(); + } + + var menu = new JX.PHUIXDropdownMenu(button) + .setAlign('left'); + data.menu = menu; + + menu.listen('open', function() { + var list = new JX.PHUIXActionListView(); + + for (var ii = 0; ii < data.items.length; ii++) { + var spec = data.items[ii]; + var item = new JX.PHUIXActionView() + .setName(spec.name) + .setIcon(spec.icon); + + if (spec.value) { + if (spec.value == data.value) { + item.setSelected(true); + } + + var handler = function(spec, e) { + e.prevent(); + menu.close(); + updateValue(spec); + }; + + item.setHandler(JX.bind(null, handler, spec)); + } else if (spec.href) { + item.setHref(spec.href); + item.setHandler(function() { menu.close(); }); + } else { + item.setDisabled(true); + } + + list.addItem(item); + } + + menu.setContent(list.getNode()); + }); + + menu.open(); + }); + + });