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 @@ +
+
+
+ {{yield}} +
+
+
\ 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 @inTopicContext}} + + {{else if @inPMInboxContext}} + + {{/if}} + + + + {{#if @loading}} +
+ {{loading-spinner}} +
+ {{else}} +
+ {{#if this.search.activeGlobalSearchTerm}} + + {{/if}} + +
+ {{/if}} +
+ +{{#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 }} +
  • + + + {{d-icon (or @icon "search")}} + + + {{#if this.prefix}} + + {{this.prefix}} + + {{/if}} + + {{#if @withInLabel}} + {{i18n "search.in"}} + {{/if}} + + {{#if @category}} + + {{#if (and @tag @isIntersection)}} + + {{d-icon "tag"}}{{@tag}} + + {{/if}} + {{else if @tag}} + {{#if (and @isIntersection @additionalTags.length)}} + {{this.tagsSlug}} + {{else}} + + + + {{/if}} + {{else if @user}} + + + + {{/if}} + + + {{#if @suffix}} + {{@suffix}} + {{/if}} + {{@label}} + + {{#if @extraHint}} + + {{i18n "search.enter_hint"}} + + {{/if}} + +
  • \ 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 this.moreUrl}} + + {{i18n "more"}}... + + {{else if this.topicResults.more}} + + {{i18n "more"}}... + + {{/if}} +
    +{{/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}} +
    +
    +

    {{i18n "search.recent"}}

    + +
    + + {{#each this.currentUser.recent_searches as |slug|}} + + {{/each}} +
    +{{/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\n

    For example http://192.168.10.200:3000/ takes tens of seconds to load.

    \n\n

    I'm running the whole stack on a new rMBP with OS X 10.8.2.

    \n\n

    Any ideas of what I've done wrong? Or is this just a function of being on the bleeding edge?

    \n\n

    Thanks,

    \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: