From a2b038ffe7043b6e725c75591dd157c98015c26c Mon Sep 17 00:00:00 2001
From: Isaac Janzen <50783505+janzenisaac@users.noreply.github.com>
Date: Fri, 16 Jun 2023 09:24:07 -0500
Subject: [PATCH] DEV: Upgrade search-menu to glimmer (#20482)
# Top level view
This PR is the first version of converting the search menu and its logic from (deprecated) widgets to glimmer components. The changes are hidden behind a group based feature flag. This will give us the ability to test the new implementation in a production setting before fully committing to the new search menu.
# What has changed
The majority of the logic from the widget implementation has been updated to fit within the context of a glimmer component, but it has not fundamentally changed. Instead of having a single widget - [search-menu.js](https://github.com/discourse/discourse/blob/main/app/assets/javascripts/discourse/app/widgets/search-menu.js) - that built the bulk of the search menu logic, we split the logic into (20+) bite size components. This greatly increases the readability and makes extending a component in the search menu much more straightforward.
That being said, certain pieces needed to be rewritten from scratch as they did not translate from widget -> glimmer, or there was a general code upgraded needed. There are a few of these changes worth noting:
### Search Service
**Search Term** -> In the widget implementation we had a overly complex way of managing the current search term. We tracked the search term across multiple different states (`term`, `opts.term`, `searchData.term`) causing headaches. This PR introduces a single source of truth:
```js
this.search.activeGlobalSearchTerm
```
This tracked value is available anywhere the `search` service is injected. In the case the search term should be needs to be updated you can call
```js
this.search.activeGlobalSearchTerm = "foo"
```
**event listeners** -> In the widget implementation we defined event listeners **only** on the search input to handle things such as
- keyboard navigation / shortcuts
- closing the search menu
- performing a search with "enter"
Having this in one place caused a lot of bloat in our logic as we had to handle multiple different cases in one location. Do _x_ if it is this element, but do _y_ if it is another. This PR updates the event listeners to be attached to individual components, allowing for a more fine tuned set of actions per element. To not duplicate logic across multiple components, we have condensed shared logic to actions on the search service to be reused. For example - `this.search.handleArrowUpOrDown` - to handle keyboard navigation.
### Search Context
We have unique logic based on the current search context (topic / tag / category / user / etc). This context is set within a models route file. We have updated the search service with a tracked value `searchContext` that can be utilized and updated from any component where the search service is injected.
```js
# before
this.searchService.set("searchContext", user.searchContext);
# after
this.searchService.searchContext = user.searchContext;
```
# Views
---
.../discourse/app/components/menu-panel.hbs | 7 +
.../discourse/app/components/search-menu.hbs | 25 +
.../discourse/app/components/search-menu.js | 294 +++++
.../search-menu/advanced-button.hbs | 7 +
.../search-menu/browser-search-tip.hbs | 8 +
.../search-menu/browser-search-tip.js | 9 +
.../components/search-menu/clear-button.hbs | 9 +
.../search-menu/highlighted-search.hbs | 1 +
.../search-menu/highlighted-search.js | 16 +
.../search-menu/menu-panel-contents.hbs | 65 +
.../search-menu/menu-panel-contents.js | 10 +
.../app/components/search-menu/results.hbs | 52 +
.../app/components/search-menu/results.js | 53 +
.../search-menu/results/assistant-item.hbs | 56 +
.../search-menu/results/assistant-item.js | 100 ++
.../search-menu/results/assistant.hbs | 81 ++
.../search-menu/results/assistant.js | 128 ++
.../components/search-menu/results/blurb.hbs | 11 +
.../components/search-menu/results/blurb.js | 7 +
.../search-menu/results/initial-options.hbs | 45 +
.../search-menu/results/initial-options.js | 128 ++
.../search-menu/results/more-link.hbs | 17 +
.../search-menu/results/more-link.js | 37 +
.../search-menu/results/random-quick-tip.hbs | 16 +
.../search-menu/results/random-quick-tip.js | 70 ++
.../search-menu/results/recent-searches.hbs | 23 +
.../search-menu/results/recent-searches.js | 51 +
.../search-menu/results/type/category.hbs | 1 +
.../search-menu/results/type/group.hbs | 19 +
.../search-menu/results/type/post.hbs | 2 +
.../search-menu/results/type/tag.hbs | 2 +
.../search-menu/results/type/topic.hbs | 24 +
.../search-menu/results/type/topic.js | 6 +
.../search-menu/results/type/user.hbs | 14 +
.../components/search-menu/results/types.hbs | 18 +
.../components/search-menu/results/types.js | 35 +
.../components/search-menu/search-term.hbs | 11 +
.../app/components/search-menu/search-term.js | 89 ++
.../discourse/app/lib/plugin-api.js | 2 +
.../javascripts/discourse/app/lib/search.js | 6 +-
.../app/routes/build-category-route.js | 4 +-
.../app/routes/build-group-messages-route.js | 11 +-
.../routes/build-private-messages-route.js | 16 +-
.../discourse/app/routes/tag-show.js | 6 +-
.../javascripts/discourse/app/routes/topic.js | 4 +-
.../javascripts/discourse/app/routes/user.js | 4 +-
.../discourse/app/services/search.js | 122 +-
.../discourse/app/widgets/header.js | 75 +-
.../acceptance/glimmer-search-mobile-test.js | 53 +
.../tests/acceptance/glimmer-search-test.js | 1052 +++++++++++++++++
.../tests/fixtures/search-fixtures.js | 105 ++
.../stylesheets/common/base/menu-panel.scss | 34 +
app/models/user.rb | 4 +
app/serializers/current_user_serializer.rb | 3 +-
config/locales/server.en.yml | 1 +
config/site_settings.yml | 6 +
56 files changed, 3009 insertions(+), 46 deletions(-)
create mode 100644 app/assets/javascripts/discourse/app/components/menu-panel.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/advanced-button.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/clear-button.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/assistant.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/assistant.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/blurb.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/blurb.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/more-link.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/more-link.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/type/category.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/type/group.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/type/post.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/type/tag.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/type/user.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/types.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/results/types.js
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/search-term.hbs
create mode 100644 app/assets/javascripts/discourse/app/components/search-menu/search-term.js
create mode 100644 app/assets/javascripts/discourse/tests/acceptance/glimmer-search-mobile-test.js
create mode 100644 app/assets/javascripts/discourse/tests/acceptance/glimmer-search-test.js
diff --git a/app/assets/javascripts/discourse/app/components/menu-panel.hbs b/app/assets/javascripts/discourse/app/components/menu-panel.hbs
new file mode 100644
index 00000000000..9e77bac711d
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/menu-panel.hbs
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu.hbs b/app/assets/javascripts/discourse/app/components/search-menu.hbs
new file mode 100644
index 00000000000..a8c9ca61da7
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu.hbs
@@ -0,0 +1,25 @@
+
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu.js b/app/assets/javascripts/discourse/app/components/search-menu.js
new file mode 100644
index 00000000000..a1b5c62c726
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu.js
@@ -0,0 +1,294 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import { action } from "@ember/object";
+import { bind } from "discourse-common/utils/decorators";
+import { tracked } from "@glimmer/tracking";
+import {
+ isValidSearchTerm,
+ searchForTerm,
+ updateRecentSearches,
+} from "discourse/lib/search";
+import DiscourseURL from "discourse/lib/url";
+import discourseDebounce from "discourse-common/lib/debounce";
+import getURL from "discourse-common/lib/get-url";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { Promise } from "rsvp";
+import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
+import userSearch from "discourse/lib/user-search";
+import { CANCELLED_STATUS } from "discourse/lib/autocomplete";
+import { cancel } from "@ember/runloop";
+
+const CATEGORY_SLUG_REGEXP = /(\#[a-zA-Z0-9\-:]*)$/gi;
+const USERNAME_REGEXP = /(\@[a-zA-Z0-9\-\_]*)$/gi;
+const SUGGESTIONS_REGEXP = /(in:|status:|order:|:)([a-zA-Z]*)$/gi;
+export const SEARCH_INPUT_ID = "search-term";
+export const SEARCH_BUTTON_ID = "search-button";
+export const MODIFIER_REGEXP = /.*(\#|\@|:).*$/gi;
+export const DEFAULT_TYPE_FILTER = "exclude_topics";
+
+export function focusSearchInput() {
+ document.getElementById(SEARCH_INPUT_ID).focus();
+}
+
+export function focusSearchButton() {
+ document.getElementById(SEARCH_BUTTON_ID).focus();
+}
+
+export default class SearchMenu extends Component {
+ @service search;
+ @service currentUser;
+ @service siteSettings;
+ @service appEvents;
+
+ @tracked inTopicContext = this.args.inTopicContext;
+ @tracked loading = false;
+ @tracked results = {};
+ @tracked noResults = false;
+ @tracked inPMInboxContext =
+ this.search.searchContext?.type === "private_messages";
+ @tracked typeFilter = DEFAULT_TYPE_FILTER;
+ @tracked suggestionKeyword = false;
+ @tracked suggestionResults = [];
+ @tracked invalidTerm = false;
+ _debouncer = null;
+ _activeSearch = null;
+
+ get includesTopics() {
+ return this.typeFilter !== DEFAULT_TYPE_FILTER;
+ }
+
+ get searchContext() {
+ if (this.inTopicContext || this.inPMInboxContext) {
+ return this.search.searchContext;
+ }
+
+ return false;
+ }
+
+ @bind
+ fullSearchUrl(opts) {
+ let url = "/search";
+ let params = new URLSearchParams();
+
+ if (this.search.activeGlobalSearchTerm) {
+ let q = this.search.activeGlobalSearchTerm;
+
+ if (this.searchContext?.type === "topic") {
+ q += ` topic:${this.searchContext.id}`;
+ } else if (this.searchContext?.type === "private_messages") {
+ q += " in:messages";
+ }
+ params.set("q", q);
+ }
+ if (opts?.expanded) {
+ params.set("expanded", "true");
+ }
+ if (params.toString() !== "") {
+ url = `${url}?${params}`;
+ }
+ return getURL(url);
+ }
+
+ @bind
+ clearSearch(e) {
+ e.stopPropagation();
+ e.preventDefault();
+ this.search.activeGlobalSearchTerm = "";
+ focusSearchInput();
+ this.triggerSearch();
+ }
+
+ @action
+ searchTermChanged(term, opts = {}) {
+ this.typeFilter = opts.searchTopics ? null : DEFAULT_TYPE_FILTER;
+ if (opts.setTopicContext) {
+ this.inTopicContext = true;
+ }
+ this.search.activeGlobalSearchTerm = term;
+ this.triggerSearch();
+ }
+
+ @action
+ fullSearch() {
+ this.loading = false;
+ const url = this.fullSearchUrl();
+ if (url) {
+ DiscourseURL.routeTo(url);
+ }
+ }
+
+ @action
+ updateTypeFilter(value) {
+ this.typeFilter = value;
+ }
+
+ @action
+ clearPMInboxContext() {
+ this.inPMInboxContext = false;
+ }
+
+ @action
+ clearTopicContext() {
+ this.inTopicContext = false;
+ }
+
+ // for cancelling debounced search
+ cancel() {
+ if (this._activeSearch) {
+ this._activeSearch.abort();
+ this._activeSearch = null;
+ }
+ }
+
+ async perform() {
+ this.cancel();
+
+ const matchSuggestions = this.matchesSuggestions();
+ if (matchSuggestions) {
+ this.noResults = true;
+ this.results = {};
+ this.loading = false;
+ this.suggestionResults = [];
+
+ if (matchSuggestions.type === "category") {
+ const categorySearchTerm = matchSuggestions.categoriesMatch[0].replace(
+ "#",
+ ""
+ );
+
+ const categoryTagSearch = searchCategoryTag(
+ categorySearchTerm,
+ this.siteSettings
+ );
+ Promise.resolve(categoryTagSearch).then((results) => {
+ if (results !== CANCELLED_STATUS) {
+ this.suggestionResults = results;
+ this.suggestionKeyword = "#";
+ }
+ });
+ } else if (matchSuggestions.type === "username") {
+ const userSearchTerm = matchSuggestions.usernamesMatch[0].replace(
+ "@",
+ ""
+ );
+ const opts = { includeGroups: true, limit: 6 };
+ if (userSearchTerm.length > 0) {
+ opts.term = userSearchTerm;
+ } else {
+ opts.lastSeenUsers = true;
+ }
+
+ userSearch(opts).then((result) => {
+ if (result?.users?.length > 0) {
+ this.suggestionResults = result.users;
+ this.suggestionKeyword = "@";
+ } else {
+ this.noResults = true;
+ this.suggestionKeyword = false;
+ }
+ });
+ } else {
+ this.suggestionKeyword = matchSuggestions[0];
+ }
+ return;
+ }
+
+ this.suggestionKeyword = false;
+
+ if (!this.search.activeGlobalSearchTerm) {
+ this.noResults = false;
+ this.results = {};
+ this.loading = false;
+ this.invalidTerm = false;
+ } else if (
+ !isValidSearchTerm(this.search.activeGlobalSearchTerm, this.siteSettings)
+ ) {
+ this.noResults = true;
+ this.results = {};
+ this.loading = false;
+ this.invalidTerm = true;
+ } else {
+ this.invalidTerm = false;
+
+ this._activeSearch = searchForTerm(this.search.activeGlobalSearchTerm, {
+ typeFilter: this.typeFilter,
+ fullSearchUrl: this.fullSearchUrl,
+ searchContext: this.searchContext,
+ });
+
+ this._activeSearch
+ .then((results) => {
+ // we ensure the current search term is the one used
+ // when starting the query
+ if (results) {
+ if (this.searchContext) {
+ this.appEvents.trigger("post-stream:refresh", {
+ force: true,
+ });
+ }
+
+ this.noResults = results.resultTypes.length === 0;
+ this.results = results;
+ }
+ })
+ .catch(popupAjaxError)
+ .finally(() => {
+ this.loading = false;
+ });
+ }
+ }
+
+ matchesSuggestions() {
+ if (
+ this.search.activeGlobalSearchTerm === undefined ||
+ this.includesTopics
+ ) {
+ return false;
+ }
+
+ const term = this.search.activeGlobalSearchTerm.trim();
+ const categoriesMatch = term.match(CATEGORY_SLUG_REGEXP);
+
+ if (categoriesMatch) {
+ return { type: "category", categoriesMatch };
+ }
+
+ const usernamesMatch = term.match(USERNAME_REGEXP);
+ if (usernamesMatch) {
+ return { type: "username", usernamesMatch };
+ }
+
+ const suggestionsMatch = term.match(SUGGESTIONS_REGEXP);
+ if (suggestionsMatch) {
+ return suggestionsMatch;
+ }
+
+ return false;
+ }
+
+ @action
+ triggerSearch() {
+ this.noResults = false;
+
+ if (this.includesTopics) {
+ if (this.search.contextType === "topic") {
+ this.search.highlightTerm = this.search.activeGlobalSearchTerm;
+ }
+ this.loading = true;
+ cancel(this._debouncer);
+ this.perform();
+
+ if (this.currentUser) {
+ updateRecentSearches(
+ this.currentUser,
+ this.search.activeGlobalSearchTerm
+ );
+ }
+ } else {
+ this.loading = false;
+ if (!this.inTopicContext) {
+ this._debouncer = discourseDebounce(this, this.perform, 400);
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/advanced-button.hbs b/app/assets/javascripts/discourse/app/components/search-menu/advanced-button.hbs
new file mode 100644
index 00000000000..b1b847c430c
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/advanced-button.hbs
@@ -0,0 +1,7 @@
+
+ {{d-icon "sliders-h"}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.hbs b/app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.hbs
new file mode 100644
index 00000000000..4b470452ec4
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.hbs
@@ -0,0 +1,8 @@
+
+
+ {{this.translatedLabel}}
+
+
+ {{i18n "search.browser_tip_description"}}
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.js b/app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.js
new file mode 100644
index 00000000000..904e1db6f27
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/browser-search-tip.js
@@ -0,0 +1,9 @@
+import Component from "@glimmer/component";
+import I18n from "I18n";
+import { translateModKey } from "discourse/lib/utilities";
+
+export default class BrowserSearchTip extends Component {
+ get translatedLabel() {
+ return I18n.t("search.browser_tip", { modifier: translateModKey("Meta+") });
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/clear-button.hbs b/app/assets/javascripts/discourse/app/components/search-menu/clear-button.hbs
new file mode 100644
index 00000000000..0b3b2dce015
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/clear-button.hbs
@@ -0,0 +1,9 @@
+
+ {{d-icon "times"}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.hbs b/app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.hbs
new file mode 100644
index 00000000000..af66c0f837a
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.hbs
@@ -0,0 +1 @@
+{{this.content}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.js b/app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.js
new file mode 100644
index 00000000000..7f892b47ef5
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/highlighted-search.js
@@ -0,0 +1,16 @@
+import Component from "@glimmer/component";
+import highlightSearch from "discourse/lib/highlight-search";
+import { inject as service } from "@ember/service";
+
+export default class HighlightedSearch extends Component {
+ @service search;
+
+ constructor() {
+ super(...arguments);
+ const span = document.createElement("span");
+ span.textContent = this.args.string;
+ this.content = span;
+
+ highlightSearch(span, this.search.activeGlobalSearchTerm);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.hbs b/app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.hbs
new file mode 100644
index 00000000000..04d0941b4da
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.hbs
@@ -0,0 +1,65 @@
+
+
+{{#if (and @inTopicContext (not @includesTopics))}}
+
+{{else}}
+ {{#unless @loading}}
+
+ {{/unless}}
+{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.js b/app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.js
new file mode 100644
index 00000000000..e980911f3a9
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/menu-panel-contents.js
@@ -0,0 +1,10 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+
+export default class MenuPanelContents extends Component {
+ @service search;
+
+ get advancedSearchButtonHref() {
+ return this.args.fullSearchUrl({ expanded: true });
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results.hbs
new file mode 100644
index 00000000000..67032e7281e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results.hbs
@@ -0,0 +1,52 @@
+
+ {{#if @suggestionKeyword}}
+
+ {{else if this.termTooShort}}
+
{{i18n "search.too_short"}}
+ {{else if this.noTopicResults}}
+
{{i18n "search.no_results"}}
+ {{else if this.renderInitialOptions}}
+
+ {{else}}
+ {{#if @searchTopics}}
+ {{! render results after a search has been performed }}
+ {{#if this.resultTypesWithComponent}}
+
+
+ {{/if}}
+ {{else}}
+ {{#unless @inPMInboxContext}}
+ {{! render the first couple suggestions before a search has been performed}}
+
+ {{#if this.resultTypesWithComponent}}
+
+ {{/if}}
+ {{/unless}}
+ {{/if}}
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results.js b/app/assets/javascripts/discourse/app/components/search-menu/results.js
new file mode 100644
index 00000000000..cc08acde99f
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results.js
@@ -0,0 +1,53 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import TopicViewComponent from "./results/type/topic";
+import PostViewComponent from "./results/type/post";
+import UserViewComponent from "./results/type/user";
+import TagViewComponent from "./results/type/tag";
+import GroupViewComponent from "./results/type/group";
+import CategoryViewComponent from "./results/type/category";
+
+const SEARCH_RESULTS_COMPONENT_TYPE = {
+ "search-result-category": CategoryViewComponent,
+ "search-result-topic": TopicViewComponent,
+ "search-result-post": PostViewComponent,
+ "search-result-user": UserViewComponent,
+ "search-result-tag": TagViewComponent,
+ "search-result-group": GroupViewComponent,
+};
+
+export default class Results extends Component {
+ @service search;
+
+ @tracked searchTopics = this.args.searchTopics;
+
+ get renderInitialOptions() {
+ return !this.search.activeGlobalSearchTerm && !this.args.inPMInboxContext;
+ }
+
+ get noTopicResults() {
+ return this.args.searchTopics && this.args.noResults;
+ }
+
+ get termTooShort() {
+ return this.args.searchTopics && this.args.invalidTerm;
+ }
+
+ get resultTypesWithComponent() {
+ let content = [];
+ this.args.results.resultTypes?.map((resultType) => {
+ content.push({
+ ...resultType,
+ component: SEARCH_RESULTS_COMPONENT_TYPE[resultType.componentName],
+ });
+ });
+ return content;
+ }
+
+ @action
+ updateSearchTopics(value) {
+ this.searchTopics = value;
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.hbs
new file mode 100644
index 00000000000..576b47aaab2
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.hbs
@@ -0,0 +1,56 @@
+{{! template-lint-disable no-down-event-binding }}
+{{! template-lint-disable no-invalid-interactive }}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.js b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.js
new file mode 100644
index 00000000000..5e98d2a4bc2
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant-item.js
@@ -0,0 +1,100 @@
+import Component from "@glimmer/component";
+import getURL from "discourse-common/lib/get-url";
+import { inject as service } from "@ember/service";
+import { action } from "@ember/object";
+import { debounce } from "discourse-common/utils/decorators";
+import {
+ focusSearchButton,
+ focusSearchInput,
+} from "discourse/components/search-menu";
+
+export default class AssistantItem extends Component {
+ @service search;
+ @service appEvents;
+
+ icon = this.args.icon || "search";
+
+ get href() {
+ let href = "#";
+ if (this.args.category) {
+ href = this.args.category.url;
+
+ if (this.args.tags && this.args.isIntersection) {
+ href = getURL(`/tag/${this.args.tag}`);
+ }
+ } else if (
+ this.args.tags &&
+ this.args.isIntersection &&
+ this.args.additionalTags?.length
+ ) {
+ href = getURL(`/tag/${this.args.tag}`);
+ }
+
+ return href;
+ }
+
+ get prefix() {
+ let prefix = "";
+ if (this.args.suggestionKeyword !== "+") {
+ prefix =
+ this.search.activeGlobalSearchTerm
+ ?.split(this.args.suggestionKeyword)[0]
+ .trim() || "";
+ if (prefix.length) {
+ prefix = `${prefix} `;
+ }
+ } else {
+ prefix = this.search.activeGlobalSearchTerm;
+ }
+ return prefix;
+ }
+
+ get tagsSlug() {
+ if (!this.args.tag || !this.args.additionalTags) {
+ return;
+ }
+
+ return `tags:${[this.args.tag, ...this.args.additionalTags].join("+")}`;
+ }
+
+ @action
+ onKeydown(e) {
+ if (e.key === "Escape") {
+ focusSearchButton();
+ this.args.closeSearchMenu();
+ e.preventDefault();
+ return false;
+ }
+
+ if (e.key === "Enter") {
+ this.itemSelected();
+ }
+
+ this.search.handleArrowUpOrDown(e);
+ e.stopPropagation();
+ e.preventDefault();
+ }
+
+ @action
+ onClick(e) {
+ this.itemSelected();
+ e.preventDefault();
+ return false;
+ }
+
+ @debounce(100)
+ itemSelected() {
+ let updatedValue = "";
+ if (this.args.slug) {
+ updatedValue = this.prefix.concat(this.args.slug);
+ } else {
+ updatedValue = this.prefix.trim();
+ }
+ const inTopicContext = this.search.searchContext?.type === "topic";
+ this.args.searchTermChanged(updatedValue, {
+ searchTopics: !inTopicContext || this.search.activeGlobalSearchTerm,
+ ...(inTopicContext && { setTopicContext: true }),
+ });
+ focusSearchInput();
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/assistant.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant.hbs
new file mode 100644
index 00000000000..bc29ae86a2b
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant.hbs
@@ -0,0 +1,81 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/assistant.js b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant.js
new file mode 100644
index 00000000000..4421706af87
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/assistant.js
@@ -0,0 +1,128 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+
+const suggestionShortcuts = [
+ "in:title",
+ "in:pinned",
+ "status:open",
+ "status:closed",
+ "status:public",
+ "status:noreplies",
+ "order:latest",
+ "order:views",
+ "order:likes",
+ "order:latest_topic",
+];
+
+const SUGGESTION_KEYWORD_MAP = {
+ "+": "tagIntersection",
+ "#": "categoryOrTag",
+ "@": "user",
+};
+
+export default class Assistant extends Component {
+ @service router;
+ @service currentUser;
+ @service siteSettings;
+ @service search;
+
+ constructor() {
+ super(...arguments);
+
+ if (this.currentUser) {
+ addSearchSuggestion("in:likes");
+ addSearchSuggestion("in:bookmarks");
+ addSearchSuggestion("in:mine");
+ addSearchSuggestion("in:messages");
+ addSearchSuggestion("in:seen");
+ addSearchSuggestion("in:tracking");
+ addSearchSuggestion("in:unseen");
+ addSearchSuggestion("in:watching");
+ }
+
+ if (this.siteSettings.tagging_enabled) {
+ addSearchSuggestion("in:tagged");
+ addSearchSuggestion("in:untagged");
+ }
+ }
+
+ get suggestionShortcuts() {
+ const shortcut = this.search.activeGlobalSearchTerm.split(" ").slice(-1);
+ const suggestions = suggestionShortcuts.filter((suggestion) =>
+ suggestion.includes(shortcut)
+ );
+ return suggestions.slice(0, 8);
+ }
+
+ get userMatchesInTopic() {
+ return (
+ this.args.results.length === 1 &&
+ this.router.currentRouteName.startsWith("topic.")
+ );
+ }
+
+ get suggestionType() {
+ switch (this.args.suggestionKeyword) {
+ case "+":
+ return SUGGESTION_KEYWORD_MAP[this.args.suggestionKeyword];
+ case "#":
+ return SUGGESTION_KEYWORD_MAP[this.args.suggestionKeyword];
+ case "@":
+ return SUGGESTION_KEYWORD_MAP[this.args.suggestionKeyword];
+ }
+ }
+
+ get prefix() {
+ let prefix = "";
+ if (this.args.suggestionKeyword !== "+") {
+ prefix =
+ this.args.slug?.split(this.args.suggestionKeyword)[0].trim() || "";
+ if (prefix.length) {
+ prefix = `${prefix} `;
+ }
+ } else {
+ this.args.results.forEach((result) => {
+ if (result.additionalTags) {
+ prefix =
+ this.args.slug?.split(" ").slice(0, -1).join(" ").trim() || "";
+ } else {
+ prefix = this.args.slug?.split("#")[0].trim() || "";
+ }
+ if (prefix.length) {
+ prefix = `${prefix} `;
+ }
+ });
+ }
+ return prefix;
+ }
+
+ // For all results that are a category we need to assign
+ // a 'fullSlug' for each object. It would place too much logic
+ // to do this on the fly within the view so instead we build
+ // a 'fullSlugForCategoryMap' which we can then
+ // access in the view by 'category.id'
+ get fullSlugForCategoryMap() {
+ const categoryMap = {};
+ this.args.results.forEach((result) => {
+ if (result.model) {
+ const fullSlug = result.model.parentCategory
+ ? `#${result.model.parentCategory.slug}:${result.model.slug}`
+ : `#${result.model.slug}`;
+ categoryMap[result.model.id] = `${this.prefix}${fullSlug}`;
+ }
+ });
+ return categoryMap;
+ }
+
+ get user() {
+ // when only one user matches while in topic
+ // quick suggest user search in the topic or globally
+ return this.args.results[0];
+ }
+}
+
+export function addSearchSuggestion(value) {
+ if (!suggestionShortcuts.includes(value)) {
+ suggestionShortcuts.push(value);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/blurb.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/blurb.hbs
new file mode 100644
index 00000000000..31f6a9f2420
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/blurb.hbs
@@ -0,0 +1,11 @@
+
+ {{format-age @result.created_at}}
+ -
+ {{#if this.siteSettings.use_pg_headlines_for_excerpt}}
+ {{@result.blurb}}
+ {{else}}
+
+
+
+ {{/if}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/blurb.js b/app/assets/javascripts/discourse/app/components/search-menu/results/blurb.js
new file mode 100644
index 00000000000..5e0c15590ca
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/blurb.js
@@ -0,0 +1,7 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+
+export default class Blurb extends Component {
+ @service siteSettings;
+ @service site;
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.hbs
new file mode 100644
index 00000000000..77f8600fb53
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.hbs
@@ -0,0 +1,45 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.js b/app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.js
new file mode 100644
index 00000000000..5c13732ae22
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/initial-options.js
@@ -0,0 +1,128 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import { MODIFIER_REGEXP } from "discourse/components/search-menu";
+import AssistantItem from "./assistant-item";
+import Assistant from "./assistant";
+import I18n from "I18n";
+
+const SEARCH_CONTEXT_TYPE_COMPONENTS = {
+ topic: AssistantItem,
+ private_messages: AssistantItem,
+ category: Assistant,
+ tag: Assistant,
+ tagIntersection: Assistant,
+ user: AssistantItem,
+};
+
+export default class InitialOptions extends Component {
+ @service search;
+ @service siteSettings;
+ @service currentUser;
+
+ constructor() {
+ super(...arguments);
+
+ if (this.search.activeGlobalSearchTerm || this.search.searchContext) {
+ if (this.search.searchContext) {
+ // set the component we will be using to display results
+ this.contextTypeComponent =
+ SEARCH_CONTEXT_TYPE_COMPONENTS[this.search.searchContext.type];
+ // set attributes for the component
+ this.attributesForSearchContextType(this.search.searchContext.type);
+ }
+ }
+ }
+
+ get termMatchesContextTypeKeyword() {
+ return this.search.activeGlobalSearchTerm?.match(MODIFIER_REGEXP)
+ ? true
+ : false;
+ }
+
+ attributesForSearchContextType(type) {
+ switch (type) {
+ case "topic":
+ this.topicContextType();
+ break;
+ case "private_messages":
+ this.privateMessageContextType();
+ break;
+ case "category":
+ this.categoryContextType();
+ break;
+ case "tag":
+ this.tagContextType();
+ break;
+ case "tagIntersection":
+ this.tagIntersectionContextType();
+ break;
+ case "user":
+ this.userContextType();
+ break;
+ }
+ }
+
+ topicContextType() {
+ this.suffix = I18n.t("search.in_this_topic");
+ }
+
+ privateMessageContextType() {
+ this.slug = "in:messages";
+ this.label = "in:messages";
+ }
+
+ categoryContextType() {
+ const searchContextCategory = this.search.searchContext.category;
+ const fullSlug = searchContextCategory.parentCategory
+ ? `#${searchContextCategory.parentCategory.slug}:${searchContextCategory.slug}`
+ : `#${searchContextCategory.slug}`;
+
+ this.slug = fullSlug;
+ this.contextTypeKeyword = "#";
+ this.initialResults = [{ model: this.search.searchContext.category }];
+ this.withInLabel = true;
+ }
+
+ tagContextType() {
+ this.slug = `#${this.search.searchContext.name}`;
+ this.contextTypeKeyword = "#";
+ this.initialResults = [{ name: this.search.searchContext.name }];
+ this.withInLabel = true;
+ }
+
+ tagIntersectionContextType() {
+ const searchContext = this.search.searchContext;
+
+ let tagTerm;
+ if (searchContext.additionalTags) {
+ const tags = [searchContext.tagId, ...searchContext.additionalTags];
+ tagTerm = `tags:${tags.join("+")}`;
+ } else {
+ tagTerm = `#${searchContext.tagId}`;
+ }
+ let suggestionOptions = {
+ tagName: searchContext.tagId,
+ additionalTags: searchContext.additionalTags,
+ };
+ if (searchContext.category) {
+ const categorySlug = searchContext.category.parentCategory
+ ? `#${searchContext.category.parentCategory.slug}:${searchContext.category.slug}`
+ : `#${searchContext.category.slug}`;
+ suggestionOptions.categoryName = categorySlug;
+ suggestionOptions.category = searchContext.category;
+ tagTerm = tagTerm + ` ${categorySlug}`;
+ }
+
+ this.slug = tagTerm;
+ this.contextTypeKeyword = "+";
+ this.initialResults = [suggestionOptions];
+ this.withInLabel = true;
+ }
+
+ userContextType() {
+ this.slug = `@${this.search.searchContext.user.username}`;
+ this.suffix = I18n.t("search.in_posts_by", {
+ username: this.search.searchContext.user.username,
+ });
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/more-link.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/more-link.hbs
new file mode 100644
index 00000000000..a035c876406
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/more-link.hbs
@@ -0,0 +1,17 @@
+{{#if this.topicResults}}
+ {{! template-lint-disable no-invalid-interactive }}
+
+{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/more-link.js b/app/assets/javascripts/discourse/app/components/search-menu/results/more-link.js
new file mode 100644
index 00000000000..994c1a946ef
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/more-link.js
@@ -0,0 +1,37 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { inject as service } from "@ember/service";
+import { focusSearchButton } from "discourse/components/search-menu";
+
+export default class MoreLink extends Component {
+ @service search;
+
+ get topicResults() {
+ const topicResults = this.args.resultTypes.filter(
+ (resultType) => resultType.type === "topic"
+ );
+ return topicResults[0];
+ }
+
+ get moreUrl() {
+ return this.topicResults.moreUrl && this.topicResults.moreUrl();
+ }
+
+ @action
+ moreOfType(type) {
+ this.args.updateTypeFilter(type);
+ this.args.triggerSearch();
+ }
+
+ @action
+ onKeyup(e) {
+ if (e.key === "Escape") {
+ focusSearchButton();
+ this.args.closeSearchMenu();
+ e.preventDefault();
+ return false;
+ }
+
+ this.search.handleArrowUpOrDown(e);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.hbs
new file mode 100644
index 00000000000..2a605efe836
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.hbs
@@ -0,0 +1,16 @@
+
+
+ {{this.randomTip.label}}
+
+
+
+ {{this.randomTip.description}}
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.js b/app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.js
new file mode 100644
index 00000000000..8f0cf9eb258
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/random-quick-tip.js
@@ -0,0 +1,70 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import { action } from "@ember/object";
+import I18n from "I18n";
+import { focusSearchInput } from "discourse/components/search-menu";
+
+const DEFAULT_QUICK_TIPS = [
+ {
+ label: "#",
+ description: I18n.t("search.tips.category_tag"),
+ clickable: true,
+ },
+ {
+ label: "@",
+ description: I18n.t("search.tips.author"),
+ clickable: true,
+ },
+ {
+ label: "in:",
+ description: I18n.t("search.tips.in"),
+ clickable: true,
+ },
+ {
+ label: "status:",
+ description: I18n.t("search.tips.status"),
+ clickable: true,
+ },
+ {
+ label: I18n.t("search.tips.full_search_key", { modifier: "Ctrl" }),
+ description: I18n.t("search.tips.full_search"),
+ },
+ {
+ label: "@me",
+ description: I18n.t("search.tips.me"),
+ },
+];
+
+let QUICK_TIPS = [];
+
+export function addQuickSearchRandomTip(tip) {
+ if (!QUICK_TIPS.includes(tip)) {
+ QUICK_TIPS.push(tip);
+ }
+}
+
+export function resetQuickSearchRandomTips() {
+ QUICK_TIPS = [].concat(DEFAULT_QUICK_TIPS);
+}
+
+resetQuickSearchRandomTips();
+
+export default class RandomQuickTip extends Component {
+ @service search;
+
+ constructor() {
+ super(...arguments);
+ this.randomTip = QUICK_TIPS[Math.floor(Math.random() * QUICK_TIPS.length)];
+ }
+
+ @action
+ tipSelected(e) {
+ if (e.target.classList.contains("tip-clickable")) {
+ this.search.activeGlobalSearchTerm = this.randomTip.label;
+ focusSearchInput();
+
+ e.stopPropagation();
+ e.preventDefault();
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.hbs
new file mode 100644
index 00000000000..158183b3b33
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.hbs
@@ -0,0 +1,23 @@
+{{#if this.currentUser.recent_searches}}
+
+{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.js b/app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.js
new file mode 100644
index 00000000000..2f5c77407b8
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/recent-searches.js
@@ -0,0 +1,51 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import User from "discourse/models/user";
+import { action } from "@ember/object";
+import { focusSearchButton } from "discourse/components/search-menu";
+
+export default class RecentSearches extends Component {
+ @service currentUser;
+ @service siteSettings;
+
+ constructor() {
+ super(...arguments);
+
+ if (
+ this.currentUser &&
+ this.siteSettings.log_search_queries &&
+ !this.currentUser.recent_searches?.length
+ ) {
+ this.loadRecentSearches();
+ }
+ }
+
+ @action
+ clearRecent() {
+ return User.resetRecentSearches().then((result) => {
+ if (result.success) {
+ this.currentUser.recent_searches.clear();
+ }
+ });
+ }
+
+ @action
+ onKeyup(e) {
+ if (e.key === "Escape") {
+ focusSearchButton();
+ this.args.closeSearchMenu();
+ e.preventDefault();
+ return false;
+ }
+
+ this.search.handleArrowUpOrDown(e);
+ }
+
+ loadRecentSearches() {
+ User.loadRecentSearches().then((result) => {
+ if (result.success && result.recent_searches?.length) {
+ this.currentUser.set("recent_searches", result.recent_searches);
+ }
+ });
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/type/category.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/type/category.hbs
new file mode 100644
index 00000000000..64b312acfc5
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/type/category.hbs
@@ -0,0 +1 @@
+{{category-link @result link=false allowUncategorized=true}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/type/group.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/type/group.hbs
new file mode 100644
index 00000000000..e552d6926b5
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/type/group.hbs
@@ -0,0 +1,19 @@
+
+ {{#if @result.flairUrl}}
+
+ {{else}}
+ {{d-icon "users"}}
+ {{/if}}
+
+
{{or @result.fullName @result.name}}
+ {{! show the name of the group if we also show the full name }}
+ {{#if @result.fullName}}
+
{{@result.name}}
+ {{/if}}
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/type/post.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/type/post.hbs
new file mode 100644
index 00000000000..b96d53e4090
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/type/post.hbs
@@ -0,0 +1,2 @@
+{{i18n "search.post_format" @result}}
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/type/tag.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/type/tag.hbs
new file mode 100644
index 00000000000..6d5c636b5f1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/type/tag.hbs
@@ -0,0 +1,2 @@
+{{d-icon "tag"}}
+{{discourse-tag (or @result.id @result) tagName="span"}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.hbs
new file mode 100644
index 00000000000..1038c134d66
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.hbs
@@ -0,0 +1,24 @@
+
+
+
+
+ {{#if
+ (and
+ this.siteSettings.use_pg_headlines_for_excerpt
+ @result.topic_title_headline
+ )
+ }}
+ {{replace-emoji @result.topic_title_headline}}
+ {{else}}
+
+ {{/if}}
+
+
+
+ {{category-link @result.topic.category link=false}}
+ {{#if this.siteSettings.tagging_enabled}}
+ {{discourse-tags @result.topic tagName="span"}}
+ {{/if}}
+
+
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.js b/app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.js
new file mode 100644
index 00000000000..52a77b67a88
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/type/topic.js
@@ -0,0 +1,6 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+
+export default class Results extends Component {
+ @service siteSettings;
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/type/user.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/type/user.hbs
new file mode 100644
index 00000000000..da530613341
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/type/user.hbs
@@ -0,0 +1,14 @@
+{{avatar
+ @result
+ imageSize="small"
+ template=@result.avatar_template
+ username=@result.username
+}}
+
+ {{format-username @result.username}}
+
+{{#if @result.custom_data}}
+ {{#each @result.custom_data as |row|}}
+ {{row.name}}: {{row.value}}
+ {{/each}}
+{{/if}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/types.hbs b/app/assets/javascripts/discourse/app/components/search-menu/results/types.hbs
new file mode 100644
index 00000000000..1a1e0b5f4cb
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/types.hbs
@@ -0,0 +1,18 @@
+{{#each this.filteredResultTypes as |resultType|}}
+
+
+ {{#each resultType.results as |result|}}
+ {{! template-lint-disable no-down-event-binding }}
+ {{! template-lint-disable no-invalid-interactive }}
+
+
+
+
+
+ {{/each}}
+
+
+{{/each}}
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/results/types.js b/app/assets/javascripts/discourse/app/components/search-menu/results/types.js
new file mode 100644
index 00000000000..6f53640045e
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/results/types.js
@@ -0,0 +1,35 @@
+import Component from "@glimmer/component";
+import { inject as service } from "@ember/service";
+import { action } from "@ember/object";
+import { focusSearchButton } from "discourse/components/search-menu";
+
+export default class Types extends Component {
+ @service search;
+
+ get filteredResultTypes() {
+ // return only topic result types
+ if (this.args.topicResultsOnly) {
+ return this.args.resultTypes.filter(
+ (resultType) => resultType.type === "topic"
+ );
+ }
+
+ // return all result types minus topics
+ return this.args.resultTypes.filter(
+ (resultType) => resultType.type !== "topic"
+ );
+ }
+
+ @action
+ onKeydown(e) {
+ if (e.key === "Escape") {
+ focusSearchButton();
+ this.args.closeSearchMenu();
+ e.preventDefault();
+ return false;
+ }
+
+ this.search.handleResultInsertion(e);
+ this.search.handleArrowUpOrDown(e);
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/search-term.hbs b/app/assets/javascripts/discourse/app/components/search-menu/search-term.hbs
new file mode 100644
index 00000000000..a00a0dd1e8a
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/search-term.hbs
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/app/assets/javascripts/discourse/app/components/search-menu/search-term.js b/app/assets/javascripts/discourse/app/components/search-menu/search-term.js
new file mode 100644
index 00000000000..c072f768ab1
--- /dev/null
+++ b/app/assets/javascripts/discourse/app/components/search-menu/search-term.js
@@ -0,0 +1,89 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { tracked } from "@glimmer/tracking";
+import { isiPad } from "discourse/lib/utilities";
+import { inject as service } from "@ember/service";
+import {
+ DEFAULT_TYPE_FILTER,
+ SEARCH_INPUT_ID,
+ focusSearchButton,
+} from "discourse/components/search-menu";
+
+const SECOND_ENTER_MAX_DELAY = 15000;
+
+export default class SearchTerm extends Component {
+ @service search;
+ @service appEvents;
+
+ @tracked lastEnterTimestamp = null;
+
+ // make constant available in template
+ get inputId() {
+ return SEARCH_INPUT_ID;
+ }
+
+ @action
+ updateSearchTerm(input) {
+ this.parseAndUpdateSearchTerm(
+ this.search.activeGlobalSearchTerm,
+ input.target.value
+ );
+ }
+
+ @action
+ focus(element) {
+ element.focus();
+ element.select();
+ }
+
+ @action
+ onKeyup(e) {
+ if (e.key === "Escape") {
+ focusSearchButton();
+ this.args.closeSearchMenu();
+ e.preventDefault();
+ return false;
+ }
+
+ this.search.handleArrowUpOrDown(e);
+
+ if (e.key === "Enter") {
+ const recentEnterHit =
+ this.lastEnterTimestamp &&
+ Date.now() - this.lastEnterTimestamp < SECOND_ENTER_MAX_DELAY;
+
+ // same combination as key-enter-escape mixin
+ if (
+ e.ctrlKey ||
+ e.metaKey ||
+ (isiPad() && e.altKey) ||
+ (this.args.typeFilter !== DEFAULT_TYPE_FILTER && recentEnterHit)
+ ) {
+ this.args.fullSearch();
+ this.args.closeSearchMenu();
+ } else {
+ this.args.updateTypeFilter(null);
+ this.args.triggerSearch();
+ }
+ this.lastEnterTimestamp = Date.now();
+ }
+
+ if (e.key === "Backspace") {
+ if (!e.target.value) {
+ this.args.clearTopicContext();
+ this.args.clearPMInboxContext();
+ this.focus(e.target);
+ }
+ }
+
+ e.preventDefault();
+ }
+
+ parseAndUpdateSearchTerm(originalVal, newVal) {
+ // remove zero-width chars
+ const parsedVal = newVal.replace(/[\u200B-\u200D\uFEFF]/, "");
+ if (parsedVal !== originalVal) {
+ this.args.searchTermChanged(parsedVal);
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/lib/plugin-api.js b/app/assets/javascripts/discourse/app/lib/plugin-api.js
index 6a7979929c8..d80ca1e5e2b 100644
--- a/app/assets/javascripts/discourse/app/lib/plugin-api.js
+++ b/app/assets/javascripts/discourse/app/lib/plugin-api.js
@@ -100,6 +100,7 @@ import {
addSearchSuggestion,
removeDefaultQuickSearchRandomTips,
} from "discourse/widgets/search-menu-results";
+import { addSearchSuggestion as addGlimmerSearchSuggestion } from "discourse/components/search-menu/results/assistant";
import { CUSTOM_USER_SEARCH_OPTIONS } from "select-kit/components/user-chooser";
import { downloadCalendar } from "discourse/lib/download-calendar";
import { consolePrefix } from "discourse/lib/source-identifier";
@@ -1667,6 +1668,7 @@ class PluginApi {
*/
addSearchSuggestion(value) {
addSearchSuggestion(value);
+ addGlimmerSearchSuggestion(value);
}
/**
diff --git a/app/assets/javascripts/discourse/app/lib/search.js b/app/assets/javascripts/discourse/app/lib/search.js
index ac5f529a533..c0fa7c37bf1 100644
--- a/app/assets/javascripts/discourse/app/lib/search.js
+++ b/app/assets/javascripts/discourse/app/lib/search.js
@@ -108,11 +108,13 @@ function translateGroupedSearchResults(results, opts) {
const groupedSearchResult = results.grouped_search_result;
if (groupedSearchResult) {
[
+ // We are defining the order that the result types will be
+ // displayed in. We should make this customizable.
["topic", "posts"],
- ["user", "users"],
- ["group", "groups"],
["category", "categories"],
["tag", "tags"],
+ ["user", "users"],
+ ["group", "groups"],
].forEach(function (pair) {
const type = pair[0];
const name = pair[1];
diff --git a/app/assets/javascripts/discourse/app/routes/build-category-route.js b/app/assets/javascripts/discourse/app/routes/build-category-route.js
index e29def7cc37..0cee1a6a0c3 100644
--- a/app/assets/javascripts/discourse/app/routes/build-category-route.js
+++ b/app/assets/javascripts/discourse/app/routes/build-category-route.js
@@ -206,7 +206,7 @@ export default (filterArg, params) => {
}
this.controllerFor("discovery/topics").setProperties(topicOpts);
- this.searchService.set("searchContext", category.get("searchContext"));
+ this.searchService.searchContext = category.get("searchContext");
this.set("topics", null);
},
@@ -231,7 +231,7 @@ export default (filterArg, params) => {
this._super(...arguments);
this.composer.set("prioritizedCategoryId", null);
- this.searchService.set("searchContext", null);
+ this.searchService.searchContext = null;
},
@action
diff --git a/app/assets/javascripts/discourse/app/routes/build-group-messages-route.js b/app/assets/javascripts/discourse/app/routes/build-group-messages-route.js
index c366a4eb830..0ab0990f531 100644
--- a/app/assets/javascripts/discourse/app/routes/build-group-messages-route.js
+++ b/app/assets/javascripts/discourse/app/routes/build-group-messages-route.js
@@ -39,12 +39,11 @@ export default (type) => {
showPosters: true,
});
- const currentUser = this.currentUser;
- this.searchService.set("searchContext", {
+ this.searchService.searchContext = {
type: "private_messages",
- id: currentUser.get("username_lower"),
- user: currentUser,
- });
+ id: this.currentUser.get("username_lower"),
+ user: this.currentUser,
+ };
},
emptyState() {
@@ -59,7 +58,7 @@ export default (type) => {
},
deactivate() {
- this.searchService.set("searchContext", null);
+ this.searchService.searchContext = null;
},
});
};
diff --git a/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js b/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js
index 200f5a57569..598647bf080 100644
--- a/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js
+++ b/app/assets/javascripts/discourse/app/routes/build-private-messages-route.js
@@ -80,7 +80,16 @@ export default (inboxType, path, filter) => {
group: null,
});
- this.searchService.set("contextType", "private_messages");
+ // Private messages don't have a unique search context instead
+ // it is built upon the user search context and then tweaks the `type`.
+ // Since this is the only model in which we set a custom `type` we don't
+ // want to create a stand-alone `setSearchType` on the search service so
+ // we can instead explicitly set the search context and pass in the `type`
+ const pmSearchContext = {
+ ...this.controllerFor("user").get("model.searchContext"),
+ type: "private_messages",
+ };
+ this.searchService.searchContext = pmSearchContext;
},
emptyState() {
@@ -97,9 +106,8 @@ export default (inboxType, path, filter) => {
deactivate() {
this.controllerFor("user-topics-list").unsubscribe();
- this.searchService.set(
- "searchContext",
- this.controllerFor("user").get("model.searchContext")
+ this.searchService.searchContext = this.controllerFor("user").get(
+ "model.searchContext"
);
},
diff --git a/app/assets/javascripts/discourse/app/routes/tag-show.js b/app/assets/javascripts/discourse/app/routes/tag-show.js
index 8d3d00eb13c..5286b0ad178 100644
--- a/app/assets/javascripts/discourse/app/routes/tag-show.js
+++ b/app/assets/javascripts/discourse/app/routes/tag-show.js
@@ -161,9 +161,9 @@ export default DiscourseRoute.extend(FilterModeMixin, {
category: model.category || null,
};
- this.searchService.set("searchContext", tagIntersectionSearchContext);
+ this.searchService.searchContext = tagIntersectionSearchContext;
} else {
- this.searchService.set("searchContext", model.tag.searchContext);
+ this.searchService.searchContext = model.tag.searchContext;
}
},
@@ -202,7 +202,7 @@ export default DiscourseRoute.extend(FilterModeMixin, {
deactivate() {
this._super(...arguments);
- this.searchService.set("searchContext", null);
+ this.searchService.searchContext = null;
},
@action
diff --git a/app/assets/javascripts/discourse/app/routes/topic.js b/app/assets/javascripts/discourse/app/routes/topic.js
index 861ad01e0f9..1cb1ba31ac1 100644
--- a/app/assets/javascripts/discourse/app/routes/topic.js
+++ b/app/assets/javascripts/discourse/app/routes/topic.js
@@ -325,7 +325,7 @@ const TopicRoute = DiscourseRoute.extend({
deactivate() {
this._super(...arguments);
- this.searchService.set("searchContext", null);
+ this.searchService.searchContext = null;
const topicController = this.controllerFor("topic");
const postStream = topicController.get("model.postStream");
@@ -351,7 +351,7 @@ const TopicRoute = DiscourseRoute.extend({
firstPostExpanded: false,
});
- this.searchService.set("searchContext", model.get("searchContext"));
+ this.searchService.searchContext = model.get("searchContext");
// close the multi select when switching topics
controller.set("multiSelect", false);
diff --git a/app/assets/javascripts/discourse/app/routes/user.js b/app/assets/javascripts/discourse/app/routes/user.js
index aa0af019790..cf9072e1b8c 100644
--- a/app/assets/javascripts/discourse/app/routes/user.js
+++ b/app/assets/javascripts/discourse/app/routes/user.js
@@ -44,7 +44,7 @@ export default DiscourseRoute.extend({
setupController(controller, user) {
controller.set("model", user);
- this.searchService.set("searchContext", user.searchContext);
+ this.searchService.searchContext = user.searchContext;
},
activate() {
@@ -73,7 +73,7 @@ export default DiscourseRoute.extend({
user.stopTrackingStatus();
// Remove the search context
- this.searchService.set("searchContext", null);
+ this.searchService.searchContext = null;
},
@bind
diff --git a/app/assets/javascripts/discourse/app/services/search.js b/app/assets/javascripts/discourse/app/services/search.js
index 2fe2dacaecf..baef9d04aaf 100644
--- a/app/assets/javascripts/discourse/app/services/search.js
+++ b/app/assets/javascripts/discourse/app/services/search.js
@@ -1,21 +1,109 @@
-import Service from "@ember/service";
-import discourseComputed from "discourse-common/utils/decorators";
+import Service, { inject as service } from "@ember/service";
+import { disableImplicitInjections } from "discourse/lib/implicit-injections";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import { focusSearchInput } from "discourse/components/search-menu";
-export default Service.extend({
- searchContextEnabled: false, // checkbox to scope search
- searchContext: null,
- highlightTerm: null,
+@disableImplicitInjections
+export default class Search extends Service {
+ @service appEvents;
- @discourseComputed("searchContext")
- contextType: {
- get(searchContext) {
- return searchContext?.type;
- },
+ @tracked activeGlobalSearchTerm = "";
+ @tracked searchContext;
+ @tracked highlightTerm;
- set(value, searchContext) {
- this.set("searchContext", { ...searchContext, type: value });
+ // only relative for the widget search menu
+ searchContextEnabled = false; // checkbox to scope search
- return value;
- },
- },
-});
+ get contextType() {
+ return this.searchContext?.type || null;
+ }
+
+ // The need to navigate with the keyboard creates a lot shared logic
+ // between multiple components
+ //
+ // - SearchTerm
+ // - Results::AssistantItem
+ // - Results::Types
+ // - Results::MoreLink
+ // - Results::RecentSearches
+ //
+ // To minimze the duplicate logic we will create a shared action here
+ // that can be reused across all of the components
+ @action
+ handleResultInsertion(e) {
+ if (e.keyCode === 65 /* a or A */) {
+ // add a link and focus composer if open
+ if (document.querySelector("#reply-control.open")) {
+ this.appEvents.trigger(
+ "composer:insert-text",
+ document.activeElement.href,
+ {
+ ensureSpace: true,
+ }
+ );
+ this.appEvents.trigger("header:keyboard-trigger", { type: "search" });
+ document.querySelector("#reply-control.open textarea").focus();
+
+ e.stopPropagation();
+ e.preventDefault();
+ return false;
+ }
+ }
+ }
+
+ @action
+ handleArrowUpOrDown(e) {
+ if (e.key === "ArrowUp" || e.key === "ArrowDown") {
+ let focused = e.target.closest(".search-menu") ? e.target : null;
+ if (!focused) {
+ return;
+ }
+
+ let links = document.querySelectorAll(".search-menu .results a");
+ let results = document.querySelectorAll(
+ ".search-menu .results .search-link"
+ );
+
+ if (!results.length) {
+ return;
+ }
+
+ let prevResult;
+ let result;
+
+ links.forEach((item) => {
+ if (item.classList.contains("search-link")) {
+ prevResult = item;
+ }
+
+ if (item === focused) {
+ result = prevResult;
+ }
+ });
+
+ let index = -1;
+ if (result) {
+ index = Array.prototype.indexOf.call(results, result);
+ }
+
+ if (index === -1 && e.key === "ArrowDown") {
+ // change focus from the search input to the first result item
+ const firstResult = results[0] || links[0];
+ firstResult.focus();
+ } else if (index === 0 && e.key === "ArrowUp") {
+ focusSearchInput();
+ } else if (index > -1) {
+ // change focus to the next result item if present
+ index += e.key === "ArrowDown" ? 1 : -1;
+ if (index >= 0 && index < results.length) {
+ results[index].focus();
+ }
+ }
+
+ e.stopPropagation();
+ e.preventDefault();
+ return false;
+ }
+ }
+}
diff --git a/app/assets/javascripts/discourse/app/widgets/header.js b/app/assets/javascripts/discourse/app/widgets/header.js
index e9b4ea2c0b7..687ea37686e 100644
--- a/app/assets/javascripts/discourse/app/widgets/header.js
+++ b/app/assets/javascripts/discourse/app/widgets/header.js
@@ -13,6 +13,7 @@ import { logSearchLinkClick } from "discourse/lib/search";
import RenderGlimmer from "discourse/widgets/render-glimmer";
import { hbs } from "ember-cli-htmlbars";
import { hideUserTip } from "discourse/lib/user-tips";
+import { SEARCH_BUTTON_ID } from "discourse/components/search-menu";
let _extraHeaderIcons = [];
@@ -266,7 +267,7 @@ createWidget("header-icons", {
const search = this.attach("header-dropdown", {
title: "search.title",
icon: "search",
- iconId: "search-button",
+ iconId: SEARCH_BUTTON_ID,
action: "toggleSearchMenu",
active: attrs.searchVisible,
href: getURL("/search"),
@@ -423,6 +424,45 @@ createWidget("revamped-user-menu-wrapper", {
},
});
+createWidget("glimmer-search-menu-wrapper", {
+ buildAttributes() {
+ return { "data-click-outside": true, "aria-live": "polite" };
+ },
+
+ buildClasses() {
+ return ["search-menu"];
+ },
+
+ html() {
+ return [
+ new RenderGlimmer(
+ this,
+ "div.widget-component-connector",
+ hbs` `,
+ {
+ closeSearchMenu: this.closeSearchMenu.bind(this),
+ inTopicContext: this.attrs.inTopicContext,
+ searchVisible: this.attrs.searchVisible,
+ animationClass: this.attrs.animationClass,
+ }
+ ),
+ ];
+ },
+
+ closeSearchMenu() {
+ this.sendWidgetAction("toggleSearchMenu");
+ },
+
+ clickOutside() {
+ this.closeSearchMenu();
+ },
+});
+
export default createWidget("header", {
tagName: "header.d-header.clearfix",
buildKey: () => `header`,
@@ -467,11 +507,21 @@ export default createWidget("header", {
const panels = [this.attach("header-buttons", attrs), headerIcons];
if (state.searchVisible) {
- panels.push(
- this.attach("search-menu", {
- inTopicContext: state.inTopicContext && inTopicRoute,
- })
- );
+ if (this.currentUser?.experimental_search_menu_groups_enabled) {
+ panels.push(
+ this.attach("glimmer-search-menu-wrapper", {
+ inTopicContext: state.inTopicContext && inTopicRoute,
+ searchVisible: state.searchVisible,
+ animationClass: this.animationClass(),
+ })
+ );
+ } else {
+ panels.push(
+ this.attach("search-menu", {
+ inTopicContext: state.inTopicContext && inTopicRoute,
+ })
+ );
+ }
} else if (state.hamburgerVisible) {
if (
attrs.navigationMenuQueryParamOverride === "header_dropdown" ||
@@ -522,6 +572,12 @@ export default createWidget("header", {
}
},
+ animationClass() {
+ return this.site.mobileView || this.site.narrowDesktopView
+ ? "slide-in"
+ : "drop-down";
+ },
+
closeAll() {
this.state.userVisible = false;
this.state.hamburgerVisible = false;
@@ -712,7 +768,12 @@ export default createWidget("header", {
},
focusSearchInput() {
- if (this.state.searchVisible) {
+ // the glimmer search menu handles the focusing of the search
+ // input within the search component
+ if (
+ this.state.searchVisible &&
+ !this.currentUser?.experimental_search_menu_groups_enabled
+ ) {
schedule("afterRender", () => {
const searchInput = document.querySelector("#search-term");
searchInput.focus();
diff --git a/app/assets/javascripts/discourse/tests/acceptance/glimmer-search-mobile-test.js b/app/assets/javascripts/discourse/tests/acceptance/glimmer-search-mobile-test.js
new file mode 100644
index 00000000000..3e0841a1897
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/glimmer-search-mobile-test.js
@@ -0,0 +1,53 @@
+import {
+ acceptance,
+ count,
+ exists,
+ query,
+} from "discourse/tests/helpers/qunit-helpers";
+import { click, fillIn, visit } from "@ember/test-helpers";
+import { test } from "qunit";
+
+acceptance("Search - Glimmer - Mobile", function (needs) {
+ needs.mobileView();
+ needs.user({
+ experimental_search_menu_groups_enabled: true,
+ });
+
+ test("search", async function (assert) {
+ await visit("/");
+
+ await click("#search-button");
+
+ assert.ok(
+ exists("input.full-page-search"),
+ "it shows the full page search form"
+ );
+
+ assert.ok(!exists(".search-results .fps-topic"), "no results by default");
+
+ await click(".advanced-filters summary");
+
+ assert.ok(
+ exists(".advanced-filters[open]"),
+ "it should expand advanced search filters"
+ );
+
+ await fillIn(".search-query", "discourse");
+ await click(".search-cta");
+
+ assert.strictEqual(count(".fps-topic"), 1, "has one post");
+
+ assert.notOk(
+ exists(".advanced-filters[open]"),
+ "it should collapse advanced search filters"
+ );
+
+ await click("#search-button");
+
+ assert.strictEqual(
+ query("input.full-page-search").value,
+ "discourse",
+ "it does not reset input when hitting search icon again"
+ );
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/acceptance/glimmer-search-test.js b/app/assets/javascripts/discourse/tests/acceptance/glimmer-search-test.js
new file mode 100644
index 00000000000..f24cdff9a6e
--- /dev/null
+++ b/app/assets/javascripts/discourse/tests/acceptance/glimmer-search-test.js
@@ -0,0 +1,1052 @@
+import {
+ acceptance,
+ count,
+ exists,
+ query,
+ queryAll,
+ updateCurrentUser,
+} from "discourse/tests/helpers/qunit-helpers";
+import {
+ click,
+ fillIn,
+ settled,
+ triggerKeyEvent,
+ visit,
+} from "@ember/test-helpers";
+import I18n from "I18n";
+import searchFixtures from "discourse/tests/fixtures/search-fixtures";
+import selectKit from "discourse/tests/helpers/select-kit-helper";
+import { test } from "qunit";
+import { DEFAULT_TYPE_FILTER } from "discourse/components/search-menu";
+
+acceptance("Search - Glimmer - Anonymous", function (needs) {
+ needs.user({
+ experimental_search_menu_groups_enabled: true,
+ });
+ needs.hooks.beforeEach(() => {
+ updateCurrentUser({ is_anonymous: true });
+ });
+ needs.pretender((server, helper) => {
+ server.get("/search/query", (request) => {
+ if (request.queryParams.type_filter === DEFAULT_TYPE_FILTER) {
+ // posts/topics are not present in the payload by default
+ return helper.response({
+ users: searchFixtures["search/query"]["users"],
+ categories: searchFixtures["search/query"]["categories"],
+ groups: searchFixtures["search/query"]["groups"],
+ grouped_search_result:
+ searchFixtures["search/query"]["grouped_search_result"],
+ });
+ }
+ return helper.response(searchFixtures["search/query"]);
+ });
+
+ server.get("/u/search/users", () => {
+ return helper.response({
+ users: [
+ {
+ username: "admin",
+ name: "admin",
+ avatar_template: "/images/avatar.png",
+ },
+ ],
+ });
+ });
+
+ server.get("/tag/important/notifications", () => {
+ return helper.response({
+ tag_notification: { id: "important", notification_level: 2 },
+ });
+ });
+ });
+
+ test("search", async function (assert) {
+ await visit("/");
+
+ await click("#search-button");
+
+ assert.ok(exists("#search-term"), "it shows the search input");
+ assert.ok(
+ exists(".show-advanced-search"),
+ "it shows full page search button"
+ );
+ assert.ok(
+ exists(".search-menu .results ul li.search-random-quick-tip"),
+ "shows random quick tip by default"
+ );
+
+ await fillIn("#search-term", "dev");
+
+ assert.ok(
+ !exists(".search-menu .results ul li.search-random-quick-tip"),
+ "quick tip no longer shown"
+ );
+
+ assert.strictEqual(
+ query(
+ ".search-menu .results ul.search-menu-initial-options li:first-child .search-item-prefix"
+ ).innerText.trim(),
+ "dev",
+ "first dropdown item includes correct prefix"
+ );
+
+ assert.strictEqual(
+ query(
+ ".search-menu .results ul.search-menu-initial-options li:first-child .search-item-slug"
+ ).innerText.trim(),
+ I18n.t("search.in_topics_posts"),
+ "first dropdown item includes correct suffix"
+ );
+
+ assert.ok(
+ exists(".search-menu .search-result-category ul li"),
+ "shows matching category results"
+ );
+
+ assert.ok(
+ exists(".search-menu .search-result-user ul li"),
+ "shows matching user results"
+ );
+
+ await triggerKeyEvent("#search-term", "keyup", "ArrowDown");
+ await click(document.activeElement);
+
+ assert.ok(
+ exists(".search-menu .search-result-topic ul li"),
+ "shows topic results"
+ );
+ assert.ok(
+ exists(".search-menu .results ul li .topic-title[data-topic-id]"),
+ "topic has data-topic-id"
+ );
+
+ await click(".show-advanced-search");
+
+ assert.strictEqual(
+ query(".full-page-search").value,
+ "dev",
+ "it goes to full search page and preserves the search term"
+ );
+
+ assert.ok(
+ exists(".search-advanced-options"),
+ "advanced search is expanded"
+ );
+ });
+
+ test("search button toggles search menu", async function (assert) {
+ await visit("/");
+
+ await click("#search-button");
+ assert.ok(exists(".search-menu"));
+
+ await click(".d-header"); // click outside
+ assert.ok(!exists(".search-menu"));
+
+ await click("#search-button");
+ assert.ok(exists(".search-menu"));
+
+ await click("#search-button"); // toggle same button
+ assert.ok(!exists(".search-menu"));
+ });
+
+ test("search scope", async function (assert) {
+ const contextSelector = ".search-menu .results .search-menu-assistant-item";
+
+ await visit("/tag/important");
+ await click("#search-button");
+
+ assert.strictEqual(
+ query(".search-link .label-suffix").textContent.trim(),
+ I18n.t("search.in"),
+ "first option includes suffix for tag search with no term"
+ );
+
+ assert.strictEqual(
+ query(".search-link .search-item-tag").textContent.trim(),
+ "important",
+ "frst option includes tag for tag search with no term"
+ );
+
+ await fillIn("#search-term", "smth");
+
+ const secondOption = queryAll(contextSelector)[1];
+ assert.strictEqual(
+ secondOption.querySelector(".search-item-prefix").textContent.trim(),
+ "smth",
+ "second option includes term for tag-scoped search"
+ );
+
+ assert.strictEqual(
+ secondOption.querySelector(".label-suffix").textContent.trim(),
+ I18n.t("search.in"),
+ "second option includes suffix for tag-scoped search"
+ );
+
+ assert.strictEqual(
+ secondOption.querySelector(".search-item-tag").textContent.trim(),
+ "important",
+ "second option includes tag for tag-scoped search"
+ );
+
+ await visit("/c/bug");
+ await click("#search-button");
+
+ const secondOptionCategory = queryAll(contextSelector)[1];
+ assert.strictEqual(
+ secondOptionCategory
+ .querySelector(".search-item-prefix")
+ .textContent.trim(),
+ "smth",
+ "second option includes term for category-scoped search with no term"
+ );
+
+ assert.strictEqual(
+ secondOptionCategory.querySelector(".label-suffix").textContent.trim(),
+ I18n.t("search.in"),
+ "second option includes suffix for category-scoped search with no term"
+ );
+
+ assert.strictEqual(
+ secondOptionCategory.querySelector(".category-name").textContent.trim(),
+ "bug",
+ "second option includes category slug for category-scoped search with no term"
+ );
+
+ assert.ok(
+ exists(`${contextSelector} span.badge-wrapper`),
+ "category badge is a span (i.e. not a link)"
+ );
+
+ await visit("/t/internationalization-localization/280");
+ await click("#search-button");
+
+ const secondOptionTopic = queryAll(contextSelector)[1];
+ assert.strictEqual(
+ secondOptionTopic.querySelector(".search-item-prefix").textContent.trim(),
+ "smth",
+ "second option includes term for topic-scoped search with no term"
+ );
+
+ assert.strictEqual(
+ secondOptionTopic.querySelector(".label-suffix").textContent.trim(),
+ I18n.t("search.in_this_topic"),
+ "second option includes suffix for topic-scoped search with no term"
+ );
+
+ await visit("/u/eviltrout");
+ await click("#search-button");
+
+ const secondOptionUser = queryAll(contextSelector)[1];
+ assert.strictEqual(
+ secondOptionUser.querySelector(".search-item-prefix").textContent.trim(),
+ "smth",
+ "second option includes term for user-scoped search with no term"
+ );
+
+ assert.strictEqual(
+ secondOptionUser.querySelector(".label-suffix").textContent.trim(),
+ I18n.t("search.in_posts_by", { username: "eviltrout" }),
+ "second option includes suffix for user-scoped search with no term"
+ );
+ });
+
+ test("search scope for topics", async function (assert) {
+ await visit("/t/internationalization-localization/280/1");
+ await click("#search-button");
+
+ const firstResult =
+ ".search-menu .results .search-menu-assistant-item:first-child";
+ assert.strictEqual(
+ query(firstResult).textContent.trim(),
+ I18n.t("search.in_this_topic"),
+ "contextual topic search is first available option with no search term"
+ );
+
+ await fillIn("#search-term", "a proper");
+ await query("input#search-term").focus();
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+
+ await click(document.activeElement);
+ assert.ok(
+ exists(".search-menu .search-result-post ul li"),
+ "clicking second option scopes search to current topic"
+ );
+
+ assert.strictEqual(
+ query("#post_7 span.highlighted").textContent.trim(),
+ "a proper",
+ "highlights the post correctly"
+ );
+
+ assert.ok(
+ exists(".search-menu .search-context"),
+ "search context indicator is visible"
+ );
+ await click(".clear-search");
+ assert.strictEqual(
+ query("#search-term").textContent.trim(),
+ "",
+ "clear button works"
+ );
+
+ await click(".search-context");
+ assert.ok(
+ !exists(".search-menu .search-context"),
+ "search context indicator is no longer visible"
+ );
+
+ await fillIn("#search-term", "dev");
+ await query("#search-term").focus();
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+ await click(document.activeElement);
+
+ assert.ok(
+ exists(".search-menu .search-context"),
+ "search context indicator is visible"
+ );
+
+ await fillIn("#search-term", "");
+ await query("#search-term").focus();
+ await triggerKeyEvent("#search-term", "keyup", "Backspace");
+
+ assert.ok(
+ !exists(".search-menu .search-context"),
+ "backspace resets search context"
+ );
+ });
+
+ test("topic search scope - keep 'in this topic' filter in full page search", async function (assert) {
+ await visit("/t/internationalization-localization/280/1");
+ await click("#search-button");
+
+ await fillIn("#search-term", "proper");
+ await query("input#search-term").focus();
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+ await click(document.activeElement);
+
+ await click(".show-advanced-search");
+
+ assert.strictEqual(
+ query(".full-page-search").value,
+ "proper topic:280",
+ "it goes to full search page and preserves search term + context"
+ );
+
+ assert.ok(
+ exists(".search-advanced-options"),
+ "advanced search is expanded"
+ );
+ });
+
+ test("topic search scope - special case when matching a single user", async function (assert) {
+ await visit("/t/internationalization-localization/280/1");
+ await click("#search-button");
+ await fillIn("#search-term", "@admin");
+
+ assert.strictEqual(count(".search-menu-assistant-item"), 2);
+ assert.strictEqual(
+ query(
+ ".search-menu-assistant-item:first-child .search-item-slug .label-suffix"
+ ).textContent.trim(),
+ I18n.t("search.in_topics_posts"),
+ "first result hints at global search"
+ );
+
+ assert.strictEqual(
+ query(
+ ".search-menu-assistant-item:nth-child(2) .search-item-slug .label-suffix"
+ ).textContent.trim(),
+ I18n.t("search.in_this_topic"),
+ "second result hints at search within current topic"
+ );
+ });
+});
+
+acceptance("Search - Glimmer - Authenticated", function (needs) {
+ needs.user({
+ experimental_search_menu_groups_enabled: true,
+ });
+ needs.settings({
+ log_search_queries: true,
+ allow_uncategorized_topics: true,
+ });
+
+ needs.pretender((server, helper) => {
+ server.get("/search/query", (request) => {
+ if (request.queryParams.term.includes("empty")) {
+ return helper.response({
+ posts: [],
+ users: [],
+ categories: [],
+ tags: [],
+ groups: [],
+ grouped_search_result: {
+ more_posts: null,
+ more_users: null,
+ more_categories: null,
+ term: "plans test",
+ search_log_id: 1,
+ more_full_page_results: null,
+ can_create_topic: true,
+ error: null,
+ type_filter: null,
+ post_ids: [],
+ user_ids: [],
+ category_ids: [],
+ tag_ids: [],
+ group_ids: [],
+ },
+ });
+ }
+
+ return helper.response(searchFixtures["search/query"]);
+ });
+
+ server.get("/inline-onebox", () =>
+ helper.response({
+ "inline-oneboxes": [
+ {
+ url: "http://www.something.com",
+ title: searchFixtures["search/query"].topics[0].title,
+ },
+ ],
+ })
+ );
+ });
+
+ test("Right filters are shown in full page search", async function (assert) {
+ const inSelector = selectKit(".select-kit#in");
+
+ await visit("/search?expanded=true");
+
+ await inSelector.expand();
+
+ assert.ok(inSelector.rowByValue("first").exists());
+ assert.ok(inSelector.rowByValue("pinned").exists());
+ assert.ok(inSelector.rowByValue("wiki").exists());
+ assert.ok(inSelector.rowByValue("images").exists());
+
+ assert.ok(inSelector.rowByValue("unseen").exists());
+ assert.ok(inSelector.rowByValue("posted").exists());
+ assert.ok(inSelector.rowByValue("watching").exists());
+ assert.ok(inSelector.rowByValue("tracking").exists());
+ assert.ok(inSelector.rowByValue("bookmarks").exists());
+
+ assert.ok(exists(".search-advanced-options .in-likes"));
+ assert.ok(exists(".search-advanced-options .in-private"));
+ assert.ok(exists(".search-advanced-options .in-seen"));
+ });
+
+ test("Works with empty result sets", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click("#search-button");
+ await fillIn("#search-term", "plans");
+ await query("input#search-term").focus();
+ await triggerKeyEvent(".search-menu", "keyup", "ArrowDown");
+ await click(document.activeElement);
+
+ assert.notStrictEqual(count(".search-menu .results .item"), 0);
+
+ await fillIn("#search-term", "plans empty");
+ await triggerKeyEvent("#search-term", "keyup", 13);
+
+ assert.strictEqual(count(".search-menu .results .item"), 0);
+ assert.strictEqual(count(".search-menu .results .no-results"), 1);
+ });
+
+ test("search dropdown keyboard navigation", async function (assert) {
+ const container = ".search-menu .results";
+
+ await visit("/");
+ await click("#search-button");
+ await fillIn("#search-term", "dev");
+
+ assert.ok(exists(query(`${container} ul li`)), "has a list of items");
+
+ await triggerKeyEvent("#search-term", "keyup", "Enter");
+ assert.ok(
+ exists(query(`${container} .search-result-topic`)),
+ "has topic results"
+ );
+
+ await triggerKeyEvent("#search-term", "keyup", "ArrowDown");
+ assert.strictEqual(
+ document.activeElement.getAttribute("href"),
+ query(`${container} li:first-child a`).getAttribute("href"),
+ "arrow down selects first element"
+ );
+
+ await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
+ assert.strictEqual(
+ document.activeElement.getAttribute("href"),
+ query(`${container} li:nth-child(2) a`).getAttribute("href"),
+ "arrow down selects next element"
+ );
+
+ // navigate to the `more link`
+ await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keydown", "ArrowDown");
+
+ assert.strictEqual(
+ document.activeElement.getAttribute("href"),
+ "/search?q=dev",
+ "arrow down sets focus to more results link"
+ );
+
+ await triggerKeyEvent("#search-term", "keyup", "Escape");
+ assert.strictEqual(
+ document.activeElement,
+ query("#search-button"),
+ "Escaping search returns focus to search button"
+ );
+ assert.ok(!exists(".search-menu:visible"), "Esc removes search dropdown");
+
+ await click("#search-button");
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keydown", "ArrowUp");
+
+ assert.strictEqual(
+ document.activeElement.tagName.toLowerCase(),
+ "input",
+ "arrow up sets focus to search term input"
+ );
+
+ await triggerKeyEvent("#search-term", "keyup", "Escape");
+ await click("#create-topic");
+ await click("#search-button");
+
+ await triggerKeyEvent("#search-term", "keyup", "Enter");
+ await triggerKeyEvent("#search-term", "keyup", "ArrowDown");
+ const firstLink = document.activeElement.getAttribute("href");
+ await triggerKeyEvent(document.activeElement, "keydown", "A");
+ await settled();
+
+ assert.strictEqual(
+ query("#reply-control textarea").value,
+ `${window.location.origin}${firstLink}`,
+ "hitting A when focused on a search result copies link to composer"
+ );
+
+ await click("#search-button");
+ await triggerKeyEvent("#search-term", "keyup", "Enter");
+
+ assert.ok(
+ exists(query(`${container} .search-result-topic`)),
+ "has topic results"
+ );
+
+ await triggerKeyEvent("#search-term", "keyup", "Enter");
+
+ assert.ok(
+ exists(query(`.search-container`)),
+ "second Enter hit goes to full page search"
+ );
+ assert.ok(
+ !exists(query(`.search-menu`)),
+ "search dropdown is collapsed after second Enter hit"
+ );
+
+ //new search launched, Enter key should be reset
+ await click("#search-button");
+ assert.ok(exists(query(`${container} ul li`)), "has a list of items");
+ await triggerKeyEvent("#search-term", "keyup", "Enter");
+ assert.ok(exists(query(`.search-menu`)), "search dropdown is visible");
+ });
+
+ test("search while composer is open", async function (assert) {
+ await visit("/t/internationalization-localization/280");
+ await click(".reply");
+ await fillIn(".d-editor-input", "a link");
+ await click("#search-button");
+ await fillIn("#search-term", "dev");
+
+ await triggerKeyEvent("#search-term", "keyup", "Enter");
+ await triggerKeyEvent(document.activeElement, "keyup", "ArrowDown");
+ await triggerKeyEvent(document.activeElement, "keydown", 65); // maps to lowercase a
+
+ assert.ok(
+ query(".d-editor-input").value.includes("a link"),
+ "still has the original composer content"
+ );
+
+ assert.ok(
+ query(".d-editor-input").value.includes(
+ searchFixtures["search/query"].topics[0].slug
+ ),
+ "adds link from search to composer"
+ );
+ });
+
+ test("Shows recent search results", async function (assert) {
+ await visit("/");
+ await click("#search-button");
+
+ assert.strictEqual(
+ query(
+ ".search-menu .search-menu-recent li:nth-of-type(1) .search-link"
+ ).textContent.trim(),
+ "yellow",
+ "shows first recent search"
+ );
+
+ assert.strictEqual(
+ query(
+ ".search-menu .search-menu-recent li:nth-of-type(2) .search-link"
+ ).textContent.trim(),
+ "blue",
+ "shows second recent search"
+ );
+ });
+});
+
+acceptance("Search - Glimmer - with tagging enabled", function (needs) {
+ needs.user({
+ experimental_search_menu_groups_enabled: true,
+ });
+ needs.settings({ tagging_enabled: true });
+
+ test("displays tags", async function (assert) {
+ await visit("/");
+ await click("#search-button");
+ await fillIn("#search-term", "dev");
+ await triggerKeyEvent("#search-term", "keyup", 13);
+
+ assert.strictEqual(
+ query(
+ ".search-menu .results ul li:nth-of-type(1) .discourse-tags"
+ ).textContent.trim(),
+ "dev slow",
+ "tags displayed in search results"
+ );
+ });
+
+ test("displays tag shortcuts", async function (assert) {
+ await visit("/");
+
+ await click("#search-button");
+
+ await fillIn("#search-term", "dude #monk");
+ await triggerKeyEvent("#search-term", "keyup", 51);
+
+ const firstItem =
+ ".search-menu .results ul.search-menu-assistant .search-link";
+ assert.ok(exists(query(firstItem)));
+
+ const firstTag = query(`${firstItem} .search-item-tag`).textContent.trim();
+ assert.strictEqual(firstTag, "monkey");
+ });
+});
+
+acceptance("Search - Glimmer - assistant", function (needs) {
+ needs.user({
+ experimental_search_menu_groups_enabled: true,
+ });
+
+ needs.pretender((server, helper) => {
+ server.get("/search/query", (request) => {
+ if (request.queryParams["search_context[type]"] === "private_messages") {
+ // return only one result for PM search
+ return helper.response({
+ posts: [
+ {
+ id: 3833,
+ name: "Bill Dudney",
+ username: "bdudney",
+ avatar_template:
+ "/user_avatar/meta.discourse.org/bdudney/{size}/8343_1.png",
+ uploaded_avatar_id: 8343,
+ created_at: "2013-02-07T17:46:57.469Z",
+ cooked:
+ "I've gotten vagrant up and running with a development environment but it's taking forever to load.
\n\nFor example http://192.168.10.200:3000/ takes tens of seconds to load.
\n\nI'm running the whole stack on a new rMBP with OS X 10.8.2.
\n\nAny ideas of what I've done wrong? Or is this just a function of being on the bleeding edge?
\n\nThanks,
\n\n-bd
",
+ post_number: 1,
+ post_type: 1,
+ updated_at: "2013-02-07T17:46:57.469Z",
+ like_count: 0,
+ reply_count: 1,
+ reply_to_post_number: null,
+ quote_count: 0,
+ incoming_link_count: 4422,
+ reads: 327,
+ score: 21978.4,
+ yours: false,
+ topic_id: 2179,
+ topic_slug: "development-mode-super-slow",
+ display_username: "Bill Dudney",
+ primary_group_name: null,
+ version: 2,
+ can_edit: false,
+ can_delete: false,
+ can_recover: false,
+ user_title: null,
+ actions_summary: [
+ {
+ id: 2,
+ count: 0,
+ hidden: false,
+ can_act: false,
+ },
+ {
+ id: 3,
+ count: 0,
+ hidden: false,
+ can_act: false,
+ },
+ {
+ id: 4,
+ count: 0,
+ hidden: false,
+ can_act: false,
+ },
+ {
+ id: 5,
+ count: 0,
+ hidden: true,
+ can_act: false,
+ },
+ {
+ id: 6,
+ count: 0,
+ hidden: false,
+ can_act: false,
+ },
+ {
+ id: 7,
+ count: 0,
+ hidden: false,
+ can_act: false,
+ },
+ {
+ id: 8,
+ count: 0,
+ hidden: false,
+ can_act: false,
+ },
+ ],
+ moderator: false,
+ admin: false,
+ staff: false,
+ user_id: 1828,
+ hidden: false,
+ hidden_reason_id: null,
+ trust_level: 1,
+ deleted_at: null,
+ user_deleted: false,
+ edit_reason: null,
+ can_view_edit_history: true,
+ wiki: false,
+ blurb:
+ "I've gotten vagrant up and running with a development environment but it's taking forever to load. For example http://192.168.10.200:3000/ takes...",
+ },
+ ],
+ topics: [
+ {
+ id: 2179,
+ title: "Development mode super slow",
+ fancy_title: "Development mode super slow",
+ slug: "development-mode-super-slow",
+ posts_count: 72,
+ reply_count: 53,
+ highest_post_number: 73,
+ image_url: null,
+ created_at: "2013-02-07T17:46:57.262Z",
+ last_posted_at: "2015-04-17T08:08:26.671Z",
+ bumped: true,
+ bumped_at: "2015-04-17T08:08:26.671Z",
+ unseen: false,
+ pinned: false,
+ unpinned: null,
+ visible: true,
+ closed: false,
+ archived: false,
+ bookmarked: null,
+ liked: null,
+ views: 9538,
+ like_count: 45,
+ has_summary: true,
+ archetype: "regular",
+ last_poster_username: null,
+ category_id: 7,
+ pinned_globally: false,
+ posters: [],
+ tags: ["dev", "slow"],
+ tags_descriptions: {
+ dev: "dev description",
+ slow: "slow description",
+ },
+ },
+ ],
+ grouped_search_result: {
+ term: "emoji",
+ post_ids: [3833],
+ },
+ });
+ }
+ return helper.response(searchFixtures["search/query"]);
+ });
+
+ server.get("/tag/dev/notifications", () => {
+ return helper.response({
+ tag_notification: { id: "dev", notification_level: 2 },
+ });
+ });
+
+ server.get("/tags/c/bug/1/dev/l/latest.json", () => {
+ return helper.response({
+ users: [],
+ primary_groups: [],
+ topic_list: {
+ can_create_topic: true,
+ draft: null,
+ draft_key: "new_topic",
+ draft_sequence: 1,
+ per_page: 30,
+ tags: [
+ {
+ id: 1,
+ name: "dev",
+ topic_count: 1,
+ },
+ ],
+ topics: [],
+ },
+ });
+ });
+
+ server.get("/tags/intersection/dev/foo.json", () => {
+ return helper.response({
+ topic_list: {
+ can_create_topic: true,
+ draft: null,
+ draft_key: "new_topic",
+ draft_sequence: 1,
+ per_page: 30,
+ topics: [],
+ },
+ });
+ });
+
+ server.get("/u/search/users", () => {
+ return helper.response({
+ users: [
+ {
+ username: "TeaMoe",
+ name: "TeaMoe",
+ avatar_template:
+ "https://avatars.discourse.org/v3/letter/t/41988e/{size}.png",
+ },
+ {
+ username: "TeamOneJ",
+ name: "J Cobb",
+ avatar_template:
+ "https://avatars.discourse.org/v3/letter/t/3d9bf3/{size}.png",
+ },
+ {
+ username: "kudos",
+ name: "Team Blogeto.com",
+ avatar_template:
+ "/user_avatar/meta.discourse.org/kudos/{size}/62185_1.png",
+ },
+ ],
+ });
+ });
+ });
+
+ test("shows category shortcuts when typing #", async function (assert) {
+ await visit("/");
+
+ await click("#search-button");
+
+ await fillIn("#search-term", "#");
+ await triggerKeyEvent("#search-term", "keyup", 51);
+
+ const firstCategory =
+ ".search-menu .results ul.search-menu-assistant .search-link";
+ assert.ok(exists(query(firstCategory)));
+
+ const firstResultSlug = query(
+ `${firstCategory} .category-name`
+ ).textContent.trim();
+
+ await click(firstCategory);
+ assert.strictEqual(query("#search-term").value, `#${firstResultSlug}`);
+
+ await fillIn("#search-term", "sam #");
+ await triggerKeyEvent("#search-term", "keyup", 51);
+
+ assert.ok(exists(query(firstCategory)));
+ assert.strictEqual(
+ query(
+ ".search-menu .results ul.search-menu-assistant .search-item-prefix"
+ ).innerText,
+ "sam"
+ );
+
+ await click(firstCategory);
+ assert.strictEqual(query("#search-term").value, `sam #${firstResultSlug}`);
+ });
+
+ test("Shows category / tag combination shortcut when both are present", async function (assert) {
+ await visit("/tags/c/bug/dev");
+ await click("#search-button");
+
+ assert.strictEqual(
+ query(".search-menu .results ul.search-menu-assistant .category-name")
+ .innerText,
+ "bug",
+ "Category is displayed"
+ );
+
+ assert.strictEqual(
+ query(".search-menu .results ul.search-menu-assistant .search-item-tag")
+ .innerText,
+ "dev",
+ "Tag is displayed"
+ );
+ });
+
+ test("Updates tag / category combination search suggestion when typing", async function (assert) {
+ await visit("/tags/c/bug/dev");
+ await click("#search-button");
+ await fillIn("#search-term", "foo bar");
+
+ assert.strictEqual(
+ query(
+ ".search-menu .results ul.search-menu-assistant .search-item-prefix"
+ ).innerText,
+ "foo bar",
+ "Input is applied to search query"
+ );
+
+ assert.strictEqual(
+ query(".search-menu .results ul.search-menu-assistant .category-name")
+ .innerText,
+ "bug"
+ );
+
+ assert.strictEqual(
+ query(".search-menu .results ul.search-menu-assistant .search-item-tag")
+ .innerText,
+ "dev",
+ "Tag is displayed"
+ );
+ });
+
+ test("Shows tag combination shortcut when visiting tag intersection", async function (assert) {
+ await visit("/tags/intersection/dev/foo");
+ await click("#search-button");
+
+ assert.strictEqual(
+ query(".search-menu .results ul.search-menu-assistant .search-item-tag")
+ .innerText,
+ "tags:dev+foo",
+ "Tags are displayed"
+ );
+ });
+
+ test("Updates tag intersection search suggestion when typing", async function (assert) {
+ await visit("/tags/intersection/dev/foo");
+ await click("#search-button");
+ await fillIn("#search-term", "foo bar");
+
+ assert.strictEqual(
+ query(
+ ".search-menu .results ul.search-menu-assistant .search-item-prefix"
+ ).innerText,
+ "foo bar",
+ "Input is applied to search query"
+ );
+
+ assert.strictEqual(
+ query(".search-menu .results ul.search-menu-assistant .search-item-tag")
+ .innerText,
+ "tags:dev+foo",
+ "Tags are displayed"
+ );
+ });
+
+ test("shows in: shortcuts", async function (assert) {
+ await visit("/");
+ await click("#search-button");
+
+ const firstTarget =
+ ".search-menu .results ul.search-menu-assistant .search-link ";
+
+ await fillIn("#search-term", "in:");
+ await triggerKeyEvent("#search-term", "keydown", 51);
+ assert.strictEqual(
+ query(firstTarget.concat(".search-item-slug")).innerText,
+ "in:title",
+ "keyword is present in suggestion"
+ );
+
+ await fillIn("#search-term", "sam in:");
+ await triggerKeyEvent("#search-term", "keydown", 51);
+ assert.strictEqual(
+ query(firstTarget.concat(".search-item-prefix")).innerText,
+ "sam",
+ "term is present in suggestion"
+ );
+ assert.strictEqual(
+ query(firstTarget.concat(".search-item-slug")).innerText,
+ "in:title",
+ "keyword is present in suggestion"
+ );
+
+ await fillIn("#search-term", "in:mess");
+ await triggerKeyEvent("#search-term", "keydown", 51);
+ assert.strictEqual(query(firstTarget).innerText, "in:messages");
+ });
+
+ test("shows users when typing @", async function (assert) {
+ await visit("/");
+
+ await click("#search-button");
+
+ await fillIn("#search-term", "@");
+ await triggerKeyEvent("#search-term", "keyup", 51);
+
+ const firstUser =
+ ".search-menu .results ul.search-menu-assistant .search-item-user";
+ const firstUsername = query(firstUser).innerText.trim();
+ assert.strictEqual(firstUsername, "TeaMoe");
+
+ await click(query(firstUser));
+ assert.strictEqual(query("#search-term").value, `@${firstUsername}`);
+ });
+
+ test("shows 'in messages' button when in an inbox", async function (assert) {
+ await visit("/u/charlie/messages");
+ await click("#search-button");
+
+ assert.ok(exists(".btn.search-context"), "it shows the button");
+
+ await fillIn("#search-term", "");
+ await query("input#search-term").focus();
+ await triggerKeyEvent("input#search-term", "keyup", "Backspace");
+
+ assert.notOk(exists(".btn.search-context"), "it removes the button");
+
+ await click(".d-header");
+ await click("#search-button");
+ assert.ok(
+ exists(".btn.search-context"),
+ "it shows the button when reinvoking search"
+ );
+
+ await fillIn("#search-term", "emoji");
+ await query("input#search-term").focus();
+ await triggerKeyEvent("#search-term", "keyup", "Enter");
+
+ assert.strictEqual(
+ count(".search-menu .search-result-topic"),
+ 1,
+ "it passes the PM search context to the search query"
+ );
+ });
+});
diff --git a/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js
index 78747bbaeb0..eb7ab45dba8 100644
--- a/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js
+++ b/app/assets/javascripts/discourse/tests/fixtures/search-fixtures.js
@@ -709,6 +709,111 @@ export default {
"/letter_avatar/devmach/{size}/5_fcf819f9b3791cb8c87edf29c8984f83.png",
},
],
+ "/tag/important/notifications.json": {
+ users: [{ id: 1, username: "sam", avatar_template: "/images/avatar.png" }],
+ primary_groups: [],
+ topic_list: {
+ can_create_topic: true,
+ draft: null,
+ draft_key: "new_topic",
+ draft_sequence: 4,
+ per_page: 30,
+ tags: [
+ {
+ id: 1,
+ name: "important",
+ topic_count: 2,
+ staff: false,
+ },
+ ],
+ topics: [
+ {
+ id: 16,
+ title: "Dinosaurs are the best",
+ fancy_title: "Dinosaurs are the best",
+ slug: "dinosaurs-are-the-best",
+ posts_count: 1,
+ reply_count: 0,
+ highest_post_number: 1,
+ image_url: null,
+ created_at: "2019-11-12T05:19:52.300Z",
+ last_posted_at: "2019-11-12T05:19:52.848Z",
+ bumped: true,
+ bumped_at: "2019-11-12T05:19:52.848Z",
+ unseen: false,
+ last_read_post_number: 1,
+ unread_posts: 0,
+ pinned: false,
+ unpinned: null,
+ visible: true,
+ closed: false,
+ archived: false,
+ notification_level: 3,
+ bookmarked: false,
+ liked: false,
+ tags: ["test"],
+ views: 2,
+ like_count: 0,
+ has_summary: false,
+ archetype: "regular",
+ last_poster_username: "sam",
+ category_id: 1,
+ pinned_globally: false,
+ featured_link: null,
+ posters: [
+ {
+ extras: "latest single",
+ description: "Original Poster, Most Recent Poster",
+ user_id: 1,
+ primary_group_id: null,
+ },
+ ],
+ },
+ {
+ id: 15,
+ title: "This is a test tagged post",
+ fancy_title: "This is a test tagged post",
+ slug: "this-is-a-test-tagged-post",
+ posts_count: 1,
+ reply_count: 0,
+ highest_post_number: 1,
+ image_url: null,
+ created_at: "2019-11-12T05:19:32.032Z",
+ last_posted_at: "2019-11-12T05:19:32.516Z",
+ bumped: true,
+ bumped_at: "2019-11-12T05:19:32.516Z",
+ unseen: false,
+ last_read_post_number: 1,
+ unread_posts: 0,
+ pinned: false,
+ unpinned: null,
+ visible: true,
+ closed: false,
+ archived: false,
+ notification_level: 3,
+ bookmarked: false,
+ liked: false,
+ tags: ["test"],
+ views: 1,
+ like_count: 0,
+ has_summary: false,
+ archetype: "regular",
+ last_poster_username: "sam",
+ category_id: 3,
+ pinned_globally: false,
+ featured_link: null,
+ posters: [
+ {
+ extras: "latest single",
+ description: "Original Poster, Most Recent Poster",
+ user_id: 1,
+ primary_group_id: null,
+ },
+ ],
+ },
+ ],
+ },
+ },
categories: [
{
id: 7,
diff --git a/app/assets/stylesheets/common/base/menu-panel.scss b/app/assets/stylesheets/common/base/menu-panel.scss
index 202d7d87843..763f610c907 100644
--- a/app/assets/stylesheets/common/base/menu-panel.scss
+++ b/app/assets/stylesheets/common/base/menu-panel.scss
@@ -257,11 +257,17 @@
}
.hamburger-panel {
+ // remove once glimmer search menu in place
a.widget-link {
width: 100%;
box-sizing: border-box;
@include ellipsis;
}
+ a.search-link {
+ width: 100%;
+ box-sizing: border-box;
+ @include ellipsis;
+ }
.panel-body {
overflow-y: auto;
}
@@ -279,6 +285,7 @@
}
.menu-panel {
+ // remove once glimmer search menu in place
.widget-link,
.categories-link {
padding: 0.25em 0.5em;
@@ -306,6 +313,33 @@
}
}
+ .search-link,
+ .categories-link {
+ padding: 0.25em 0.5em;
+ display: block;
+ color: var(--primary);
+ &:hover,
+ &:focus {
+ background-color: var(--d-hover);
+ outline: none;
+ }
+
+ .d-icon {
+ color: var(--primary-medium);
+ }
+
+ .new {
+ font-size: var(--font-down-1);
+ margin-left: 0.5em;
+ color: var(--primary-med-or-secondary-med);
+ }
+
+ &.show-help,
+ &.filter {
+ color: var(--tertiary);
+ }
+ }
+
li.category-link {
float: left;
background-color: transparent;
diff --git a/app/models/user.rb b/app/models/user.rb
index 9eab6333f69..991974c2243 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1831,6 +1831,10 @@ class User < ActiveRecord::Base
in_any_groups?(SiteSetting.new_edit_sidebar_categories_tags_interface_groups_map)
end
+ def experimental_search_menu_groups_enabled?
+ in_any_groups?(SiteSetting.experimental_search_menu_groups_map)
+ end
+
protected
def badge_grant
diff --git a/app/serializers/current_user_serializer.rb b/app/serializers/current_user_serializer.rb
index fcfb09a600c..a4333679c52 100644
--- a/app/serializers/current_user_serializer.rb
+++ b/app/serializers/current_user_serializer.rb
@@ -69,7 +69,8 @@ class CurrentUserSerializer < BasicUserSerializer
:sidebar_list_destination,
:sidebar_sections,
:new_new_view_enabled?,
- :new_edit_sidebar_categories_tags_interface_groups_enabled?
+ :new_edit_sidebar_categories_tags_interface_groups_enabled?,
+ :experimental_search_menu_groups_enabled?
delegate :user_stat, to: :object, private: true
delegate :any_posts, :draft_count, :pending_posts_count, :read_faq?, to: :user_stat
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index fcf08869b76..63d364b9e7b 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -2431,6 +2431,7 @@ en:
enable_custom_sidebar_sections: "EXPERIMENTAL: Enable custom sidebar sections"
experimental_topics_filter: "EXPERIMENTAL: Enables the experimental topics filter route at /filter"
experimental_post_image_grid: "EXPERIMENTAL: Enables a [grid] tag in posts to display images in a grid layout."
+ experimental_search_menu_groups: "EXPERIMENTAL: Enables the new search menu that has been upgraded to use glimmer"
errors:
invalid_css_color: "Invalid color. Enter a color name or hex value."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index 8ee006f7c28..f21a88baeeb 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -2121,6 +2121,12 @@ developer:
default: ""
allow_any: false
hidden: true
+ experimental_search_menu_groups:
+ type: group_list
+ list_type: compact
+ default: ""
+ allow_any: false
+ refresh: true
navigation:
navigation_menu: