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: